1 // Copyright 2024 Onde Inference (Splitfire AB). All rights reserved.
2 // Use of this source code is governed by the MIT license found in LICENSE.
3 //
4 // example/lib/main.dart
5 //
6 // Complete Material 3 Flutter chat app demonstrating the onde_inference SDK:
7 // * Synchronous OndeChatEngine() factory constructor (no await, no null)
8 // * Platform-aware default model loading via loadDefaultModel() extension
9 // * Multi-turn streaming chat via streamMessage(message:)
10 // * EngineInfo display using EngineInfoX.historyLengthInt extension
11 // * OndeError sealed-class error handling (not OndeException)
12 // * Sampling preset selector (creative / precise / fast)
13 // * Unload / reload model flow with live status bar
14
15 import 'dart:async';
16
17 import 'dart:io' show Platform;
18
19 import 'package:flutter/material.dart';
20 import 'package:onde_inference/onde_inference.dart';
21 import 'package:path_provider/path_provider.dart'
22 show getApplicationSupportDirectory;
23
24 // ── Onde app credentials ──────────────────────────────────────────────────
25 // Register your app at https://ondeinference.com to get these.
26 // The dashboard lets you assign a model to your app — the SDK fetches
27 // it automatically. If no model is assigned, the platform default is used.
28 const String ondeAppId = String.fromEnvironment('ONDE_APP_ID');
29 const String ondeAppSecret = String.fromEnvironment('ONDE_APP_SECRET');
30
31 // ---------------------------------------------------------------------------
32 // Entry point
33 // ---------------------------------------------------------------------------
34
35 void main() async {
36 WidgetsFlutterBinding.ensureInitialized();
37 await OndeInference.init();
38
39 // Resolve the model cache directory for sandboxed platforms.
40 //
41 // On iOS/macOS this tries the App Group shared container first
42 // (group.com.ondeinference.apps) so all Onde-powered apps share
43 // downloaded models. If the App Group is unavailable it falls back
44 // to the app's private Application Support directory.
45 //
46 // On Android there is no App Group — the fallback is always used.
47 //
48 // On desktop Linux/Windows this is a no-op (default ~/.cache works).
49 String? fallback;
50 if (Platform.isIOS || Platform.isAndroid) {
51 final dir = await getApplicationSupportDirectory();
52 fallback = dir.path;
53 }
54 await OndeInference.setupCacheDir(fallbackDir: fallback);
55
56 runApp(const OndeInferenceApp());
57 }
58
59 // ---------------------------------------------------------------------------
60 // Root app
61 // ---------------------------------------------------------------------------
62
63 class OndeInferenceApp extends StatelessWidget {
64 const OndeInferenceApp({super.key});
65
66 @override
67 Widget build(BuildContext context) {
68 return MaterialApp(
69 title: 'Onde Inference',
70 debugShowCheckedModeBanner: false,
71 theme: ThemeData(
72 colorScheme: ColorScheme.fromSeed(
73 seedColor: const Color(0xFF6750A4),
74 brightness: Brightness.light,
75 ),
76 useMaterial3: true,
77 ),
78 darkTheme: ThemeData(
79 colorScheme: ColorScheme.fromSeed(
80 seedColor: const Color(0xFF6750A4),
81 brightness: Brightness.dark,
82 ),
83 useMaterial3: true,
84 ),
85 home: const ChatScreen(),
86 );
87 }
88 }
89
90 // ---------------------------------------------------------------------------
91 // Chat message model
92 // ---------------------------------------------------------------------------
93
94 enum _Role { user, assistant }
95
96 class _Message {
97 final _Role role;
98 final String text;
99 final bool isStreaming;
100
101 const _Message({
102 required this.role,
103 required this.text,
104 this.isStreaming = false,
105 });
106
107 _Message copyWith({String? text, bool? isStreaming}) => _Message(
108 role: role,
109 text: text ?? this.text,
110 isStreaming: isStreaming ?? this.isStreaming,
111 );
112 }
113
114 // ---------------------------------------------------------------------------
115 // Sampling preset enum
116 // ---------------------------------------------------------------------------
117
118 enum _SamplingPreset { creative, precise, fast }
119
120 extension _SamplingPresetExt on _SamplingPreset {
121 String get label => switch (this) {
122 _SamplingPreset.creative => 'Creative',
123 _SamplingPreset.precise => 'Precise',
124 _SamplingPreset.fast => 'Fast',
125 };
126
127 SamplingConfig get config => switch (this) {
128 _SamplingPreset.creative => OndeInference.defaultSamplingConfig(),
129 _SamplingPreset.precise => OndeInference.deterministicSamplingConfig(),
130 _SamplingPreset.fast => OndeInference.mobileSamplingConfig(),
131 };
132 }
133
134 // ---------------------------------------------------------------------------
135 // ChatScreen
136 // ---------------------------------------------------------------------------
137
138 class ChatScreen extends StatefulWidget {
139 const ChatScreen({super.key});
140
141 @override
142 State<ChatScreen> createState() => _ChatScreenState();
143 }
144
145 class _ChatScreenState extends State<ChatScreen> {
146 // OndeChatEngine() is a synchronous factory constructor -- no Future, no null.
147 final OndeChatEngine _engine = OndeChatEngine();
148
149 EngineInfo _engineInfo = EngineInfo(
150 status: EngineStatus.unloaded,
151 historyLength: BigInt.zero,
152 );
153
154 final List<_Message> _messages = [];
155 final TextEditingController _inputController = TextEditingController();
156 final ScrollController _scrollController = ScrollController();
157
158 bool _isModelLoading = false;
159 String _loadingStatus = 'Tap "Load model" to begin.';
160 String? _errorBanner;
161 bool _isGenerating = false;
162 _SamplingPreset _samplingPreset = _SamplingPreset.creative;
163
164 @override
165 void initState() {
166 super.initState();
167 _loadModel();
168 }
169
170 @override
171 void dispose() {
172 _inputController.dispose();
173 _scrollController.dispose();
174 super.dispose();
175 }
176
177 // --------------------------------------------------------------------------
178 // Scroll helper
179 // --------------------------------------------------------------------------
180
181 void _scrollToBottom() {
182 WidgetsBinding.instance.addPostFrameCallback((_) {
183 if (_scrollController.hasClients) {
184 _scrollController.animateTo(
185 _scrollController.position.maxScrollExtent,
186 duration: const Duration(milliseconds: 200),
187 curve: Curves.easeOut,
188 );
189 }
190 });
191 }
192
193 // --------------------------------------------------------------------------
194 // Engine management
195 // --------------------------------------------------------------------------
196
197 Future<void> _loadModel() async {
198 setState(() {
199 _isModelLoading = true;
200 _loadingStatus = 'Downloading / loading model…';
201 _errorBanner = null;
202 });
203 try {
204 final double elapsed;
205 if (ondeAppId.isNotEmpty && ondeAppSecret.isNotEmpty) {
206 elapsed = await _engine.loadAssignedModel(
207 appId: ondeAppId,
208 appSecret: ondeAppSecret,
209 systemPrompt: 'You are a helpful, concise assistant.',
210 sampling: _samplingPreset.config,
211 );
212 } else {
213 elapsed = await _engine.loadDefaultModel(
214 systemPrompt: 'You are a helpful, concise assistant.',
215 sampling: _samplingPreset.config,
216 );
217 }
218 final info = await _engine.info();
219 setState(() {
220 _isModelLoading = false;
221 _engineInfo = info;
222 _loadingStatus =
223 'Loaded ${info.modelName ?? "model"} in ${elapsed.toStringAsFixed(1)}s';
224 });
225 } on OndeError catch (e) {
226 setState(() {
227 _isModelLoading = false;
228 _loadingStatus = 'Load failed.';
229 _errorBanner = e.toString();
230 });
231 }
232 }
233
234 Future<void> _unloadModel() async {
235 await _engine.unloadModel();
236 final info = await _engine.info();
237 setState(() {
238 _engineInfo = info;
239 _messages.clear();
240 _loadingStatus = 'Model unloaded.';
241 });
242 }
243
244 Future<void> _clearHistory() async {
245 final removed = await _engine.clearHistoryCount();
246 final info = await _engine.info();
247 setState(() {
248 _messages.clear();
249 _engineInfo = info;
250 });
251 if (mounted) {
252 ScaffoldMessenger.of(context).showSnackBar(
253 SnackBar(content: Text('Cleared $removed messages.')),
254 );
255 }
256 }
257
258 Future<void> _sendMessage() async {
259 final text = _inputController.text.trim();
260 if (text.isEmpty ||
261 _isGenerating ||
262 _engineInfo.status != EngineStatus.ready) {
263 return;
264 }
265 _inputController.clear();
266 setState(() {
267 _messages.add(_Message(role: _Role.user, text: text));
268 _messages.add(const _Message(
269 role: _Role.assistant,
270 text: '',
271 isStreaming: true,
272 ));
273 _isGenerating = true;
274 _errorBanner = null;
275 });
276 _scrollToBottom();
277
278 final buffer = StringBuffer();
279 try {
280 await for (final chunk in _engine.streamMessage(message: text)) {
281 if (!mounted) break;
282 buffer.write(chunk.delta);
283 setState(() {
284 _messages[_messages.length - 1] =
285 _messages.last.copyWith(text: buffer.toString());
286 });
287 _scrollToBottom();
288 if (chunk.done) break;
289 }
290 final info = await _engine.info();
291 setState(() {
292 _messages[_messages.length - 1] =
293 _messages.last.copyWith(isStreaming: false);
294 _isGenerating = false;
295 _engineInfo = info;
296 });
297 } on OndeError catch (e) {
298 setState(() {
299 _messages[_messages.length - 1] = _messages.last.copyWith(
300 text: '⚠ ${e.toString()}',
301 isStreaming: false,
302 );
303 _isGenerating = false;
304 _errorBanner = e.toString();
305 });
306 }
307 _scrollToBottom();
308 }
309
310 // --------------------------------------------------------------------------
311 // Build
312 // --------------------------------------------------------------------------
313
314 @override
315 Widget build(BuildContext context) {
316 final isReady = _engineInfo.status == EngineStatus.ready;
317 return Scaffold(
318 appBar: AppBar(
319 title: const Text('Onde Inference'),
320 centerTitle: false,
321 actions: [
322 if (isReady)
323 IconButton(
324 icon: const Icon(Icons.delete_sweep_outlined),
325 tooltip: 'Clear history',
326 onPressed: _clearHistory,
327 ),
328 PopupMenuButton<_SamplingPreset>(
329 tooltip: 'Sampling preset',
330 icon: const Icon(Icons.tune),
331 initialValue: _samplingPreset,
332 onSelected: (preset) => setState(() => _samplingPreset = preset),
333 itemBuilder: (context) => _SamplingPreset.values
334 .map(
335 (p) => PopupMenuItem(
336 value: p,
337 child: Text(p.label),
338 ),
339 )
340 .toList(),
341 ),
342 const SizedBox(width: 8),
343 ],
344 ),
345 body: Column(
346 children: [
347 _EngineStatusBar(
348 info: _engineInfo,
349 isLoading: _isModelLoading,
350 loadingStatus: _loadingStatus,
351 onLoad: (!_isModelLoading &&
352 _engineInfo.status == EngineStatus.unloaded)
353 ? _loadModel
354 : null,
355 onUnload: isReady ? _unloadModel : null,
356 ),
357 if (_errorBanner != null)
358 _ErrorBanner(
359 message: _errorBanner!,
360 onDismiss: () => setState(() => _errorBanner = null),
361 ),
362 Expanded(
363 child: _messages.isEmpty
364 ? const _EmptyState()
365 : ListView.builder(
366 controller: _scrollController,
367 padding: const EdgeInsets.symmetric(
368 horizontal: 12,
369 vertical: 8,
370 ),
371 itemCount: _messages.length,
372 itemBuilder: (context, index) =>
373 _MessageBubble(message: _messages[index]),
374 ),
375 ),
376 _InputBar(
377 controller: _inputController,
378 isEnabled: isReady && !_isGenerating,
379 onSend: _sendMessage,
380 ),
381 ],
382 ),
383 );
384 }
385 }
386
387 // ---------------------------------------------------------------------------
388 // _EngineStatusBar
389 // ---------------------------------------------------------------------------
390
391 class _EngineStatusBar extends StatelessWidget {
392 final EngineInfo info;
393 final bool isLoading;
394 final String loadingStatus;
395 final VoidCallback? onLoad;
396 final VoidCallback? onUnload;
397
398 const _EngineStatusBar({
399 required this.info,
400 required this.isLoading,
401 required this.loadingStatus,
402 this.onLoad,
403 this.onUnload,
404 });
405
406 @override
407 Widget build(BuildContext context) {
408 final cs = Theme.of(context).colorScheme;
409 final tt = Theme.of(context).textTheme;
410 return Material(
411 color: cs.surfaceContainerHighest,
412 child: Padding(
413 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
414 child: Row(
415 children: [
416 if (isLoading)
417 Padding(
418 padding: const EdgeInsets.only(right: 10),
419 child: SizedBox(
420 width: 16,
421 height: 16,
422 child: CircularProgressIndicator(
423 strokeWidth: 2,
424 color: cs.primary,
425 ),
426 ),
427 ),
428 Expanded(
429 child: Column(
430 crossAxisAlignment: CrossAxisAlignment.start,
431 mainAxisSize: MainAxisSize.min,
432 children: [
433 Text(
434 loadingStatus,
435 style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
436 maxLines: 1,
437 overflow: TextOverflow.ellipsis,
438 ),
439 if (info.status == EngineStatus.ready)
440 Text(
441 '${info.status.label}'
442 ' · ${info.historyLengthInt} msgs'
443 '${info.approxMemory != null ? " · ${info.approxMemory}" : ""}',
444 style: tt.labelSmall?.copyWith(color: cs.outline),
445 maxLines: 1,
446 overflow: TextOverflow.ellipsis,
447 ),
448 ],
449 ),
450 ),
451 const SizedBox(width: 8),
452 if (onLoad != null)
453 FilledButton.tonal(
454 onPressed: onLoad,
455 child: const Text('Load model'),
456 ),
457 if (onUnload != null)
458 TextButton(
459 onPressed: onUnload,
460 child: const Text('Unload'),
461 ),
462 ],
463 ),
464 ),
465 );
466 }
467 }
468
469 // ---------------------------------------------------------------------------
470 // _ErrorBanner
471 // ---------------------------------------------------------------------------
472
473 class _ErrorBanner extends StatelessWidget {
474 final String message;
475 final VoidCallback onDismiss;
476
477 const _ErrorBanner({required this.message, required this.onDismiss});
478
479 @override
480 Widget build(BuildContext context) {
481 final cs = Theme.of(context).colorScheme;
482 return MaterialBanner(
483 backgroundColor: cs.errorContainer,
484 leading: Icon(Icons.error_outline, color: cs.onErrorContainer),
485 content: Text(
486 message,
487 style: TextStyle(color: cs.onErrorContainer),
488 maxLines: 3,
489 overflow: TextOverflow.ellipsis,
490 ),
491 actions: [
492 TextButton(
493 onPressed: onDismiss,
494 child: Text(
495 'Dismiss',
496 style: TextStyle(color: cs.onErrorContainer),
497 ),
498 ),
499 ],
500 );
501 }
502 }
503
504 // ---------------------------------------------------------------------------
505 // _MessageBubble
506 // ---------------------------------------------------------------------------
507
508 class _MessageBubble extends StatelessWidget {
509 final _Message message;
510
511 const _MessageBubble({required this.message});
512
513 @override
514 Widget build(BuildContext context) {
515 final cs = Theme.of(context).colorScheme;
516 final tt = Theme.of(context).textTheme;
517 final isUser = message.role == _Role.user;
518 return Padding(
519 padding: const EdgeInsets.symmetric(vertical: 4),
520 child: Row(
521 mainAxisAlignment:
522 isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
523 crossAxisAlignment: CrossAxisAlignment.end,
524 children: [
525 if (!isUser)
526 CircleAvatar(
527 radius: 14,
528 backgroundColor: cs.primaryContainer,
529 child: Icon(
530 Icons.auto_awesome,
531 size: 14,
532 color: cs.onPrimaryContainer,
533 ),
534 ),
535 if (!isUser) const SizedBox(width: 6),
536 Flexible(
537 child: Container(
538 padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
539 decoration: BoxDecoration(
540 color: isUser ? cs.primary : cs.surfaceContainerHigh,
541 borderRadius: BorderRadius.only(
542 topLeft: const Radius.circular(18),
543 topRight: const Radius.circular(18),
544 bottomLeft: Radius.circular(isUser ? 18 : 4),
545 bottomRight: Radius.circular(isUser ? 4 : 18),
546 ),
547 ),
548 child: Row(
549 mainAxisSize: MainAxisSize.min,
550 crossAxisAlignment: CrossAxisAlignment.end,
551 children: [
552 Flexible(
553 child: Text(
554 message.text.isEmpty && message.isStreaming
555 ? ' '
556 : message.text,
557 style: tt.bodyMedium?.copyWith(
558 color: isUser ? cs.onPrimary : cs.onSurface,
559 ),
560 ),
561 ),
562 if (message.isStreaming) ...[
563 const SizedBox(width: 4),
564 _BlinkingCursor(
565 color: isUser ? cs.onPrimary : cs.primary,
566 ),
567 ],
568 ],
569 ),
570 ),
571 ),
572 if (isUser) const SizedBox(width: 6),
573 if (isUser)
574 CircleAvatar(
575 radius: 14,
576 backgroundColor: cs.secondaryContainer,
577 child: Icon(
578 Icons.person,
579 size: 14,
580 color: cs.onSecondaryContainer,
581 ),
582 ),
583 ],
584 ),
585 );
586 }
587 }
588
589 // ---------------------------------------------------------------------------
590 // _EmptyState
591 // ---------------------------------------------------------------------------
592
593 class _EmptyState extends StatelessWidget {
594 const _EmptyState();
595
596 @override
597 Widget build(BuildContext context) {
598 final cs = Theme.of(context).colorScheme;
599 final tt = Theme.of(context).textTheme;
600 return Center(
601 child: Column(
602 mainAxisSize: MainAxisSize.min,
603 children: [
604 Icon(
605 Icons.chat_bubble_outline_rounded,
606 size: 64,
607 color: cs.outlineVariant,
608 ),
609 const SizedBox(height: 16),
610 Text(
611 'No messages yet',
612 style: tt.titleMedium?.copyWith(color: cs.onSurfaceVariant),
613 ),
614 const SizedBox(height: 6),
615 Text(
616 'Load the model, then say hello.',
617 style: tt.bodySmall?.copyWith(color: cs.outline),
618 ),
619 ],
620 ),
621 );
622 }
623 }
624
625 // ---------------------------------------------------------------------------
626 // _InputBar
627 // ---------------------------------------------------------------------------
628
629 class _InputBar extends StatelessWidget {
630 final TextEditingController controller;
631 final bool isEnabled;
632 final VoidCallback onSend;
633
634 const _InputBar({
635 required this.controller,
636 required this.isEnabled,
637 required this.onSend,
638 });
639
640 @override
641 Widget build(BuildContext context) {
642 final cs = Theme.of(context).colorScheme;
643 return SafeArea(
644 top: false,
645 child: Padding(
646 padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
647 child: Row(
648 children: [
649 Expanded(
650 child: TextField(
651 controller: controller,
652 enabled: isEnabled,
653 minLines: 1,
654 maxLines: 5,
655 textInputAction: TextInputAction.send,
656 onSubmitted: isEnabled ? (_) => onSend() : null,
657 decoration: InputDecoration(
658 hintText: isEnabled ? 'Message…' : 'Load a model first…',
659 border: const OutlineInputBorder(
660 borderRadius: BorderRadius.all(Radius.circular(24)),
661 ),
662 contentPadding: const EdgeInsets.symmetric(
663 horizontal: 16,
664 vertical: 10,
665 ),
666 filled: true,
667 fillColor: cs.surfaceContainerHigh,
668 ),
669 ),
670 ),
671 const SizedBox(width: 8),
672 FilledButton(
673 onPressed: isEnabled ? onSend : null,
674 style: FilledButton.styleFrom(
675 padding: const EdgeInsets.all(14),
676 shape: const CircleBorder(),
677 ),
678 child: const Icon(Icons.send_rounded, size: 20),
679 ),
680 ],
681 ),
682 ),
683 );
684 }
685 }
686
687 // ---------------------------------------------------------------------------
688 // _BlinkingCursor
689 // ---------------------------------------------------------------------------
690
691 class _BlinkingCursor extends StatefulWidget {
692 final Color color;
693
694 const _BlinkingCursor({required this.color});
695
696 @override
697 State<_BlinkingCursor> createState() => _BlinkingCursorState();
698 }
699
700 class _BlinkingCursorState extends State<_BlinkingCursor>
701 with SingleTickerProviderStateMixin {
702 late final AnimationController _controller = AnimationController(
703 vsync: this,
704 duration: const Duration(milliseconds: 530),
705 )..repeat(reverse: true);
706
707 late final Animation<double> _opacity = CurvedAnimation(
708 parent: _controller,
709 curve: Curves.easeInOut,
710 );
711
712 @override
713 void dispose() {
714 _controller.dispose();
715 super.dispose();
716 }
717
718 @override
719 Widget build(BuildContext context) {
720 return FadeTransition(
721 opacity: _opacity,
722 child: Container(
723 width: 2,
724 height: 14,
725 decoration: BoxDecoration(
726 color: widget.color,
727 borderRadius: BorderRadius.circular(1),
728 ),
729 ),
730 );
731 }
732 }