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