@tduncan / Cortana / commits / 4771556

feat: add built-in AI support validation and localized error handling logic

abdulmominsakib committed Sep 1, 2026 at 15:43 UTC 4771556b19f0244023d8d37193ad0bba7a5df19f
34 files changed +758 -197
lib/bootstrap/bootstrap_host.dart
+9
@@ -10,6 +10,7 @@ import '../core/providers/highlighter_provider.dart';
10 import '../core/providers/storage_providers.dart';
11 import '../core/storage/objectbox_store.dart';
12 import '../features/on_device/data/on_device_gemma_service.dart';
13 +import '../features/on_device/providers/on_device_providers.dart';
14 import '../core/models/enums.dart';
15 import '../features/personas/providers/personas_providers.dart';
16 import '../features/servers/data/models/server.dart';
@@ -107,6 +108,14 @@ class _BootstrapHostState extends State<BootstrapHost> {
108 await container.read(serversProvider.notifier).addServer(server);
109 }
110
111 + // Eagerly resolve foundational state providers so their initial state
112 + // is stable before UncontrolledProviderScope mounts the widget tree.
113 + container.read(settingsProvider);
114 + container.read(activeServerIdProvider);
115 + container.read(activeServerProvider);
116 + container.read(connectionStatusProvider);
117 + container.read(onDeviceEngineProvider);
118 +
119 _container = container;
120 _updateStage(BootstrapStage.done, 'Ready');
121 } catch (e) {
lib/features/chat/providers/model_selection_providers.dart
+1 -2
@@ -255,8 +255,7 @@ final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
255 if (hasDefaultForServer) {
256 final defaultModel = typedModels
257 .where(
258 - (m) =>
259 - m.id == defaultModelId && downloadedAsync.contains(m.id),
258 + (m) => m.id == defaultModelId && downloadedAsync.contains(m.id),
259 )
260 .firstOrNull;
261 if (defaultModel != null && ref.mounted) {
lib/features/conversations/views/chat_history_screen.dart
+2 -1
@@ -127,7 +127,8 @@ class ChatHistoryScreen extends ConsumerWidget {
127 icon: const HugeIcon(
128 icon: HugeIcons.strokeRoundedMenu01,
129 ),
130 - onPressed: () => Scaffold.maybeOf(context)?.openDrawer(),
130 + onPressed: () =>
131 + Scaffold.maybeOf(context)?.openDrawer(),
132 ),
133 ),
134 const SizedBox(width: 8),
lib/features/on_device/components/model_card.dart
+85 -10
@@ -1,3 +1,4 @@
1 +import 'package:flutter_gemma_builtin_ai/flutter_gemma_builtin_ai.dart';
2 import 'package:hugeicons/hugeicons.dart';
3 import 'dart:io';
4
@@ -421,12 +422,22 @@ class _ModelCardActions extends ConsumerWidget {
422 );
423 }
424
425 + final builtInAvailability = model.isBuiltIn
426 + ? ref.watch(builtInAiAvailabilityProvider).value
427 + : null;
428 + final isBuiltInUnsupported =
429 + model.isBuiltIn &&
430 + (builtInAvailability ==
431 + BuiltInAiAvailability.unavailableDeviceUnsupported ||
432 + builtInAvailability == BuiltInAiAvailability.unavailableOther);
433 +
434 if (downloadProgress?.status == DownloadStatus.failed) {
435 return _FailedDownloadActions(
436 model: model,
437 downloadProgress: downloadProgress,
438 theme: theme,
439 l10n: l10n,
440 + isUnsupported: isBuiltInUnsupported,
441 );
442 }
443
@@ -439,6 +450,10 @@ class _ModelCardActions extends ConsumerWidget {
450 );
451 }
452
453 + if (isBuiltInUnsupported) {
454 + return _BuiltInUnsupportedActions(model: model, theme: theme, l10n: l10n);
455 + }
456 +
457 return _NotDownloadedActions(
458 model: model,
459 deviceMemory: deviceMemory,
@@ -661,30 +676,89 @@ class _FailedDownloadActions extends ConsumerWidget {
676 this.downloadProgress,
677 required this.theme,
678 required this.l10n,
679 + this.isUnsupported = false,
680 });
681
682 final OnDeviceModel model;
683 final DownloadProgressInfo? downloadProgress;
684 final ThemeData theme;
685 final AppLocalizations l10n;
686 + final bool isUnsupported;
687
688 @override
689 Widget build(BuildContext context, WidgetRef ref) {
690 + final rawError = downloadProgress?.error;
691 + final isDeviceUnsupported =
692 + isUnsupported ||
693 + (model.isBuiltIn &&
694 + (rawError != null &&
695 + (rawError.contains('unavailableDeviceUnsupported') ||
696 + rawError.contains('BuiltInAiUnavailableException') ||
697 + rawError.contains('unavailableOther'))));
698 +
699 + final displayError = isDeviceUnsupported
700 + ? l10n.builtin_ai_not_supported
701 + : (rawError ?? l10n.download_failed);
702 +
703 return Row(
704 children: [
705 + HugeIcon(
706 + icon: isDeviceUnsupported
707 + ? HugeIcons.strokeRoundedUnavailable
708 + : HugeIcons.strokeRoundedAlertCircle,
709 + color: Colors.red[400]!,
710 + size: 18,
711 + ),
712 + const SizedBox(width: 6),
713 Expanded(
714 child: Text(
677 - downloadProgress?.error ?? l10n.download_failed,
678 - style: theme.textTheme.bodySmall?.copyWith(color: Colors.red),
715 + displayError,
716 + style: theme.textTheme.bodySmall?.copyWith(color: Colors.red[400]),
717 ),
718 ),
681 - const SizedBox(width: 8),
682 - ShadButton.outline(
683 - size: ShadButtonSize.sm,
684 - onPressed: () => ref
685 - .read(foregroundDownloadNotifierProvider.notifier)
686 - .retryDownload(model.id),
687 - child: Text(l10n.retry),
719 + if (!isDeviceUnsupported) ...[
720 + const SizedBox(width: 8),
721 + ShadButton.outline(
722 + size: ShadButtonSize.sm,
723 + onPressed: () => ref
724 + .read(foregroundDownloadNotifierProvider.notifier)
725 + .retryDownload(model.id),
726 + child: Text(l10n.retry),
727 + ),
728 + ],
729 + ],
730 + );
731 + }
732 +}
733 +
734 +class _BuiltInUnsupportedActions extends StatelessWidget {
735 + const _BuiltInUnsupportedActions({
736 + required this.model,
737 + required this.theme,
738 + required this.l10n,
739 + });
740 +
741 + final OnDeviceModel model;
742 + final ThemeData theme;
743 + final AppLocalizations l10n;
744 +
745 + @override
746 + Widget build(BuildContext context) {
747 + return Row(
748 + children: [
749 + HugeIcon(
750 + icon: HugeIcons.strokeRoundedUnavailable,
751 + color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
752 + size: 18,
753 + ),
754 + const SizedBox(width: 6),
755 + Expanded(
756 + child: Text(
757 + l10n.builtin_ai_not_supported,
758 + style: theme.textTheme.bodySmall?.copyWith(
759 + color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
760 + ),
761 + ),
762 ),
763 ],
764 );
@@ -759,13 +833,14 @@ class _NotDownloadedActions extends ConsumerWidget {
833
834 @override
835 Widget build(BuildContext context, WidgetRef ref) {
836 + final label = model.isBuiltIn ? l10n.builtin_ai_enable : l10n.download;
837 return Row(
838 children: [
839 const Spacer(),
840 ShadButton.outline(
841 size: ShadButtonSize.sm,
842 onPressed: () => _startDownload(context, ref),
768 - child: Text(l10n.download),
843 + child: Text(label),
844 ),
845 ],
846 );
lib/features/on_device/components/on_device_picker_section.dart
+80 -36
@@ -1,3 +1,4 @@
1 +import 'package:flutter_gemma_builtin_ai/flutter_gemma_builtin_ai.dart';
2 import 'package:hugeicons/hugeicons.dart';
3 import 'dart:io';
4
@@ -236,9 +237,26 @@ class _OnDeviceModelTile extends ConsumerWidget {
237 (downloadProgress.status == DownloadStatus.running ||
238 downloadProgress.status == DownloadStatus.pending);
239 final isPaused = downloadProgress?.status == DownloadStatus.paused;
240 + final builtInAvailability = model.isBuiltIn
241 + ? ref.watch(builtInAiAvailabilityProvider).value
242 + : null;
243 + final isBuiltInUnsupported =
244 + model.isBuiltIn &&
245 + (builtInAvailability ==
246 + BuiltInAiAvailability.unavailableDeviceUnsupported ||
247 + builtInAvailability == BuiltInAiAvailability.unavailableOther);
248
249 return InkWell(
250 onTap: () {
251 + if (isBuiltInUnsupported) {
252 + ScaffoldMessenger.of(context).showSnackBar(
253 + SnackBar(
254 + content: Text(l10n.builtin_ai_not_supported),
255 + duration: const Duration(seconds: 4),
256 + ),
257 + );
258 + return;
259 + }
260 if (isLoaded) {
261 _selectModel(context, ref);
262 } else if (isDownloaded && !isCurrentlyLoading && !isDownloading) {
@@ -436,10 +454,7 @@ class _OnDeviceModelTile extends ConsumerWidget {
454 isDark: isDark,
455 ),
456 if (model.isBuiltIn)
439 - MetadataChip(
440 - label: '⚡ Built-in',
441 - isDark: isDark,
442 - ),
457 + MetadataChip(label: '⚡ Built-in', isDark: isDark),
458 if (model.format == OnDeviceModelFormat.gguf)
459 MetadataChip(
460 label: l10n.gguf_format_label,
@@ -531,44 +546,73 @@ class _OnDeviceModelTile extends ConsumerWidget {
546 .cancelDownload(model.id),
547 ),
548 ] else if (!isCurrentlyLoading) ...[
534 - _IconButton(
535 - icon: HugeIcon(
536 - icon: HugeIcons.strokeRoundedCloudDownload,
537 - size: 18,
549 + if (isBuiltInUnsupported) ...[
550 + HugeIcon(
551 + icon: HugeIcons.strokeRoundedUnavailable,
552 + size: 16,
553 color: isDark
554 ? AppColors.darkMutedText
555 : AppColors.lightMutedText,
556 ),
542 - tooltip: l10n.download,
543 - onPressed: () async {
544 - final result = await ref
545 - .read(foregroundDownloadNotifierProvider.notifier)
546 - .startDownload(model.id);
547 - if (result == 'missing_huggingface_token' &&
548 - context.mounted) {
549 - ScaffoldMessenger.of(context).showSnackBar(
550 - SnackBar(
551 - content: Text(l10n.model_missing_huggingface_token),
552 - duration: const Duration(seconds: 6),
553 - action: SnackBarAction(
554 - label: l10n.settings_title,
555 - onPressed: () => context.push(AppRoutes.settings),
557 + const SizedBox(width: 4),
558 + Text(
559 + l10n.builtin_ai_unsupported_chip,
560 + style: TextStyle(
561 + fontSize: 11,
562 + color: isDark
563 + ? AppColors.darkMutedText
564 + : AppColors.lightMutedText,
565 + ),
566 + ),
567 + ] else ...[
568 + _IconButton(
569 + icon: HugeIcon(
570 + icon: model.isBuiltIn
571 + ? HugeIcons.strokeRoundedFlash
572 + : HugeIcons.strokeRoundedCloudDownload,
573 + size: 18,
574 + color: isDark
575 + ? AppColors.darkMutedText
576 + : AppColors.lightMutedText,
577 + ),
578 + tooltip: model.isBuiltIn
579 + ? l10n.builtin_ai_enable
580 + : l10n.download,
581 + onPressed: () async {
582 + final result = await ref
583 + .read(foregroundDownloadNotifierProvider.notifier)
584 + .startDownload(model.id);
585 + if (result == 'missing_huggingface_token' &&
586 + context.mounted) {
587 + ScaffoldMessenger.of(context).showSnackBar(
588 + SnackBar(
589 + content: Text(
590 + l10n.model_missing_huggingface_token,
591 + ),
592 + duration: const Duration(seconds: 6),
593 + action: SnackBarAction(
594 + label: l10n.settings_title,
595 + onPressed: () =>
596 + context.push(AppRoutes.settings),
597 + ),
598 ),
557 - ),
558 - );
559 - }
560 - },
561 - ),
562 - const SizedBox(width: 4),
563 - Text(
564 - l10n.not_downloaded,
565 - style: TextStyle(
566 - fontSize: 11,
567 - color: isDark
568 - ? AppColors.darkMutedText
569 - : AppColors.lightMutedText,
599 + );
600 + }
601 + },
602 ),
571 - ),
603 + const SizedBox(width: 4),
604 + Text(
605 + model.isBuiltIn
606 + ? l10n.builtin_ai_enable
607 + : l10n.not_downloaded,
608 + style: TextStyle(
609 + fontSize: 11,
610 + color: isDark
611 + ? AppColors.darkMutedText
612 + : AppColors.lightMutedText,
613 + ),
614 + ),
615 + ],
616 ],
617 if (isSelected) ...[
618 const SizedBox(width: 4),
lib/features/on_device/data/on_device_gemma_service.dart
+17 -12
@@ -83,10 +83,7 @@ class OnDeviceGemmaService implements OnDeviceInferenceService {
83
84 static Future<void> initialize({String? huggingFaceToken}) async {
85 await FlutterGemma.initialize(
86 - inferenceEngines: const [
87 - LiteRtLmEngine(),
88 - BuiltInAiEngine(),
89 - ],
86 + inferenceEngines: const [LiteRtLmEngine(), BuiltInAiEngine()],
87 huggingFaceToken: huggingFaceToken,
88 maxDownloadRetries: 10,
89 );
@@ -99,9 +96,7 @@ class OnDeviceGemmaService implements OnDeviceInferenceService {
96 }) async {
97 if (model.isBuiltIn) {
98 Log.info('Installing built-in model ${model.id}');
102 - await BuiltInAi.ensureReady(
103 - onProgress: onProgress,
104 - );
99 + await BuiltInAi.ensureReady(onProgress: onProgress);
100 await FlutterGemma.installModel(
101 modelType: ModelType.general,
102 fileType: ModelFileType.builtIn,
@@ -282,11 +277,21 @@ class OnDeviceGemmaService implements OnDeviceInferenceService {
277 required String? systemInstruction,
278 required List<Tool> tools,
279 }) async {
285 - final chat = await _model!.openChat(
286 - systemInstruction: systemInstruction,
287 - tools: tools,
288 - supportsFunctionCalls: tools.isNotEmpty,
289 - );
280 + final model = _model!;
281 + final InferenceChat chat;
282 + if (model.fileType == ModelFileType.builtIn) {
283 + chat = await model.createChat(
284 + systemInstruction: systemInstruction,
285 + tools: tools,
286 + supportsFunctionCalls: tools.isNotEmpty,
287 + );
288 + } else {
289 + chat = await model.openChat(
290 + systemInstruction: systemInstruction,
291 + tools: tools,
292 + supportsFunctionCalls: tools.isNotEmpty,
293 + );
294 + }
295 return _GemmaInferenceSession(chat);
296 }
297
lib/features/on_device/providers/foreground_download_providers.dart
+9 -2
@@ -10,6 +10,7 @@ import '../../../core/providers/storage_providers.dart';
10 import '../data/models/download_progress_info.dart';
11 import '../data/models/download_status.dart';
12 import '../data/models/on_device_model.dart';
13 +import '../utils/on_device_error_formatter.dart';
14 import 'on_device_providers.dart';
15
16 final foregroundDownloadNotifierProvider =
@@ -179,7 +180,9 @@ class ForegroundDownloadNotifier
180 modelId: modelId,
181 status: DownloadStatus.complete,
182 progress: 1.0,
182 - receivedBytes: model.fileSizeBytes > 0 ? model.fileSizeBytes : 100,
183 + receivedBytes: model.fileSizeBytes > 0
184 + ? model.fileSizeBytes
185 + : 100,
186 totalBytes: model.fileSizeBytes > 0 ? model.fileSizeBytes : 100,
187 ),
188 };
@@ -199,13 +202,17 @@ class ForegroundDownloadNotifier
202 } catch (e) {
203 Log.error('Download failed for $modelId: $e');
204 if (_activeDownloads[modelId] == true && ref.mounted) {
205 + final formattedError = formatOnDeviceError(
206 + e,
207 + isBuiltIn: model.isBuiltIn,
208 + );
209 state = {
210 ...state,
211 modelId: DownloadProgressInfo(
212 modelId: modelId,
213 status: DownloadStatus.failed,
214 progress: state[modelId]?.progress ?? 0.0,
208 - error: 'Download failed: ${e.toString()}',
215 + error: formattedError,
216 ),
217 };
218 _saveState();
lib/features/on_device/providers/on_device_providers.dart
+17 -12
@@ -20,6 +20,7 @@ import '../data/notification_permission_service.dart';
20 import '../data/on_device_gemma_service.dart';
21 import '../data/on_device_llama_service.dart';
22 import '../data/on_device_mlx_service.dart';
23 +import '../utils/on_device_error_formatter.dart';
24 import '../../servers/providers/server_providers.dart';
25
26 final notificationPermissionServiceProvider =
@@ -88,7 +89,8 @@ List<OnDeviceModel> availableCuratedMlxModels({
89 bool? isIOS,
90 bool? isMacOS,
91 }) {
91 - final applePlatform = isApplePlatform ??
92 + final applePlatform =
93 + isApplePlatform ??
94 ((isIOS ?? false) ||
95 (isMacOS ?? false) ||
96 (!kIsWeb && (Platform.isIOS || Platform.isMacOS)));
@@ -98,14 +100,15 @@ List<OnDeviceModel> availableCuratedMlxModels({
100 return OnDeviceModel.curatedMlxModels;
101 }
102
101 -final builtInAiAvailabilityProvider =
102 - FutureProvider<BuiltInAiAvailability>((ref) async {
103 - try {
104 - return await BuiltInAi.availability();
105 - } catch (e) {
106 - return BuiltInAiAvailability.unavailableOther;
107 - }
108 - });
103 +final builtInAiAvailabilityProvider = FutureProvider<BuiltInAiAvailability>((
104 + ref,
105 +) async {
106 + try {
107 + return await BuiltInAi.availability();
108 + } catch (e) {
109 + return BuiltInAiAvailability.unavailableOther;
110 + }
111 +});
112
113 final onDeviceModelsProvider = Provider<List<OnDeviceModel>>((ref) {
114 final imported = ref.watch(importedGgufModelsProvider);
@@ -131,8 +134,9 @@ final downloadedModelsProvider = FutureProvider<Set<String>>((ref) async {
134
135 final builtInIds = <String>{};
136 try {
134 - final builtInAvailability =
135 - await ref.watch(builtInAiAvailabilityProvider.future);
137 + final builtInAvailability = await ref.watch(
138 + builtInAiAvailabilityProvider.future,
139 + );
140 if (builtInAvailability == BuiltInAiAvailability.available) {
141 for (final model in availableCuratedBuiltInModels()) {
142 builtInIds.add(model.id);
@@ -458,11 +462,12 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
462 } catch (e) {
463 if (!ref.mounted) return;
464 Log.error('Failed to load model $modelId: $e');
465 + final formattedError = formatOnDeviceError(e, isBuiltIn: model.isBuiltIn);
466 state = state.copyWith(
467 status: OnDeviceEngineStatus.error,
468 loadedModelId: null,
469 loadedRuntime: null,
465 - error: 'Failed to load model: ${e.toString()}',
470 + error: formattedError,
471 );
472 } finally {
473 await bgService.stop();
lib/features/on_device/utils/on_device_error_formatter.dart new
+57
@@ -0,0 +1,57 @@
1 +import '../../../../l10n/app_localizations.dart';
2 +
3 +/// Formats raw on-device engine, download, or initialization errors into
4 +/// clean, user-friendly, and localized error messages.
5 +String formatOnDeviceError(
6 + Object error, {
7 + bool isBuiltIn = false,
8 + AppLocalizations? l10n,
9 +}) {
10 + final raw = error.toString();
11 +
12 + // 1. Built-in AI / System AI availability errors
13 + if (isBuiltIn ||
14 + raw.contains('BuiltInAiUnavailableException') ||
15 + raw.contains('unavailableDeviceUnsupported') ||
16 + raw.contains('unavailableOther') ||
17 + raw.contains('BuiltInAiAvailability')) {
18 + return l10n?.builtin_ai_not_supported ??
19 + 'Built-in AI is not supported on this device.';
20 + }
21 +
22 + // 2. Missing or invalid Hugging Face token
23 + if (raw.contains('missing_huggingface_token') ||
24 + raw.contains('GatedRepo') ||
25 + raw.contains('401') ||
26 + raw.contains('403')) {
27 + return l10n?.model_missing_huggingface_token ??
28 + 'Hugging Face token required to download this model.';
29 + }
30 +
31 + // 3. Disk space errors
32 + if (raw.contains('ENOSPC') ||
33 + raw.contains('No space left') ||
34 + raw.contains('not enough storage')) {
35 + return 'Not enough storage space to download this model.';
36 + }
37 +
38 + // 4. Network / Connection errors
39 + if (raw.contains('SocketException') ||
40 + raw.contains('Connection closed') ||
41 + raw.contains('Failed host lookup') ||
42 + raw.contains('TimeoutException')) {
43 + return 'Network connection failed. Please check your internet connection.';
44 + }
45 +
46 + // 5. Clean up standard Exception/StateError prefixes
47 + var clean = raw;
48 + if (clean.startsWith('Exception: ')) {
49 + clean = clean.substring('Exception: '.length);
50 + } else if (clean.startsWith('StateError: ')) {
51 + clean = clean.substring('StateError: '.length);
52 + } else if (clean.startsWith('StateError (')) {
53 + clean = clean.replaceAll(RegExp(r'^StateError \((.*)\)$'), r'$1');
54 + }
55 +
56 + return clean;
57 +}
lib/features/onboarding/screens/onboarding_model_download_screen.dart
+64 -1
@@ -1,3 +1,4 @@
1 +import 'package:flutter_gemma_builtin_ai/flutter_gemma_builtin_ai.dart';
2 import 'package:hugeicons/hugeicons.dart';
3 import 'dart:async';
4 import 'dart:io';
@@ -334,6 +335,14 @@ class _ModelCard extends ConsumerWidget {
335 final isPaused =
336 progressInfo?.status == DownloadStatus.paused ||
337 progressInfo?.status == DownloadStatus.canceled;
338 + final builtInAvailability = model.isBuiltIn
339 + ? ref.watch(builtInAiAvailabilityProvider).value
340 + : null;
341 + final isBuiltInUnsupported =
342 + model.isBuiltIn &&
343 + (builtInAvailability ==
344 + BuiltInAiAvailability.unavailableDeviceUnsupported ||
345 + builtInAvailability == BuiltInAiAvailability.unavailableOther);
346
347 return Container(
348 padding: const EdgeInsets.all(16),
@@ -520,6 +529,39 @@ class _ModelCard extends ConsumerWidget {
529 ),
530 ],
531 )
532 + else if (progressInfo?.status == DownloadStatus.failed)
533 + Row(
534 + children: [
535 + HugeIcon(
536 + icon: isBuiltInUnsupported
537 + ? HugeIcons.strokeRoundedUnavailable
538 + : HugeIcons.strokeRoundedAlertCircle,
539 + color: Colors.red[400]!,
540 + size: 18,
541 + ),
542 + const SizedBox(width: 6),
543 + Expanded(
544 + child: Text(
545 + isBuiltInUnsupported
546 + ? l10n.builtin_ai_not_supported
547 + : (progressInfo?.error ?? l10n.download_failed),
548 + style: theme.textTheme.bodySmall?.copyWith(
549 + color: Colors.red[400],
550 + ),
551 + ),
552 + ),
553 + if (!isBuiltInUnsupported) ...[
554 + const SizedBox(width: 8),
555 + ShadButton.outline(
556 + size: ShadButtonSize.sm,
557 + onPressed: () => ref
558 + .read(foregroundDownloadNotifierProvider.notifier)
559 + .retryDownload(model.id),
560 + child: Text(l10n.retry),
561 + ),
562 + ],
563 + ],
564 + )
565 else if (isPaused)
566 Row(
567 children: [
@@ -537,11 +579,32 @@ class _ModelCard extends ConsumerWidget {
579 ),
580 ],
581 )
582 + else if (isBuiltInUnsupported)
583 + Row(
584 + children: [
585 + HugeIcon(
586 + icon: HugeIcons.strokeRoundedUnavailable,
587 + color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
588 + size: 18,
589 + ),
590 + const SizedBox(width: 6),
591 + Expanded(
592 + child: Text(
593 + l10n.builtin_ai_not_supported,
594 + style: theme.textTheme.bodySmall?.copyWith(
595 + color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
596 + ),
597 + ),
598 + ),
599 + ],
600 + )
601 else
602 ShadButton.outline(
603 size: ShadButtonSize.sm,
604 onPressed: () => _startDownload(context, ref),
544 - child: Text(l10n.download),
605 + child: Text(
606 + model.isBuiltIn ? l10n.builtin_ai_enable : l10n.download,
607 + ),
608 ),
609 ],
610 ),
lib/features/saved_messages/views/saved_messages_screen.dart
+2 -1
@@ -90,7 +90,8 @@ class SavedMessagesScreen extends ConsumerWidget {
90 icon: const HugeIcon(
91 icon: HugeIcons.strokeRoundedMenu01,
92 ),
93 - onPressed: () => Scaffold.maybeOf(context)?.openDrawer(),
93 + onPressed: () =>
94 + Scaffold.maybeOf(context)?.openDrawer(),
95 ),
96 ),
97 const SizedBox(width: 8),
lib/features/servers/providers/server_providers.dart
+1 -12
@@ -385,23 +385,12 @@ class ConnectionStatusNotifier extends Notifier<ConnectionStatus> {
385 try {
386 final isConnected = await apiService.testConnection(server);
387 if (!ref.mounted || generation != _checkGeneration) return;
388 - final status = isConnected
388 + state = isConnected
389 ? ConnectionStatus.connected
390 : ConnectionStatus.disconnected;
391 - state = status;
392 - if (ref.mounted) {
393 - ref
394 - .read(serversProvider.notifier)
395 - .updateServerStatus(server.id, status);
396 - }
391 } catch (e) {
392 if (!ref.mounted || generation != _checkGeneration) return;
393 state = ConnectionStatus.error;
400 - if (ref.mounted) {
401 - ref
402 - .read(serversProvider.notifier)
403 - .updateServerStatus(server.id, ConnectionStatus.disconnected);
404 - }
394 }
395 }
396
lib/l10n/app_en.arb
+16
@@ -3368,6 +3368,22 @@
3368 "calendar_permission_denied": "Calendar permission denied. Please grant calendar access in your device settings.",
3369 "@calendar_permission_denied": {
3370 "description": "Snackbar message when calendar permission is denied"
3371 + },
3372 + "builtin_ai_not_supported": "Built-in AI is not supported on this device.",
3373 + "@builtin_ai_not_supported": {
3374 + "description": "Error shown when built-in AI (Gemini Nano / Apple Intelligence) is not supported on the user's hardware or OS"
3375 + },
3376 + "builtin_ai_unsupported_desc": "Your device hardware or OS does not support on-device system AI (e.g. Gemini Nano / Apple Intelligence). Please choose a downloadable model instead.",
3377 + "@builtin_ai_unsupported_desc": {
3378 + "description": "Explanation shown when built-in AI is unavailable on this device"
3379 + },
3380 + "builtin_ai_unsupported_chip": "Not Supported",
3381 + "@builtin_ai_unsupported_chip": {
3382 + "description": "Chip or badge indicating the built-in model is not supported on this device"
3383 + },
3384 + "builtin_ai_enable": "Enable",
3385 + "@builtin_ai_enable": {
3386 + "description": "Button label to enable or initialize built-in on-device AI"
3387 }
3388 }
3389
lib/l10n/app_localizations.dart
+24
@@ -5656,6 +5656,30 @@ abstract class AppLocalizations {
5656 /// In en, this message translates to:
5657 /// **'Calendar permission denied. Please grant calendar access in your device settings.'**
5658 String get calendar_permission_denied;
5659 +
5660 + /// Error shown when built-in AI (Gemini Nano / Apple Intelligence) is not supported on the user's hardware or OS
5661 + ///
5662 + /// In en, this message translates to:
5663 + /// **'Built-in AI is not supported on this device.'**
5664 + String get builtin_ai_not_supported;
5665 +
5666 + /// Explanation shown when built-in AI is unavailable on this device
5667 + ///
5668 + /// In en, this message translates to:
5669 + /// **'Your device hardware or OS does not support on-device system AI (e.g. Gemini Nano / Apple Intelligence). Please choose a downloadable model instead.'**
5670 + String get builtin_ai_unsupported_desc;
5671 +
5672 + /// Chip or badge indicating the built-in model is not supported on this device
5673 + ///
5674 + /// In en, this message translates to:
5675 + /// **'Not Supported'**
5676 + String get builtin_ai_unsupported_chip;
5677 +
5678 + /// Button label to enable or initialize built-in on-device AI
5679 + ///
5680 + /// In en, this message translates to:
5681 + /// **'Enable'**
5682 + String get builtin_ai_enable;
5683 }
5684
5685 class _AppLocalizationsDelegate
lib/l10n/app_localizations_ar.dart
+14
@@ -3025,4 +3025,18 @@ class AppLocalizationsAr extends AppLocalizations {
3025 @override
3026 String get calendar_permission_denied =>
3027 'Calendar permission denied. Please grant calendar access in your device settings.';
3028 +
3029 + @override
3030 + String get builtin_ai_not_supported =>
3031 + 'Built-in AI is not supported on this device.';
3032 +
3033 + @override
3034 + String get builtin_ai_unsupported_desc =>
3035 + 'Your device hardware or OS does not support on-device system AI (e.g. Gemini Nano / Apple Intelligence). Please choose a downloadable model instead.';
3036 +
3037 + @override
3038 + String get builtin_ai_unsupported_chip => 'Not Supported';
3039 +
3040 + @override
3041 + String get builtin_ai_enable => 'Enable';
3042 }
lib/l10n/app_localizations_bn.dart
+14
@@ -3051,4 +3051,18 @@ class AppLocalizationsBn extends AppLocalizations {
3051 @override
3052 String get calendar_permission_denied =>
3053 'Calendar permission denied. Please grant calendar access in your device settings.';
3054 +
3055 + @override
3056 + String get builtin_ai_not_supported =>
3057 + 'Built-in AI is not supported on this device.';
3058 +
3059 + @override
3060 + String get builtin_ai_unsupported_desc =>
3061 + 'Your device hardware or OS does not support on-device system AI (e.g. Gemini Nano / Apple Intelligence). Please choose a downloadable model instead.';
3062 +
3063 + @override
3064 + String get builtin_ai_unsupported_chip => 'Not Supported';
3065 +
3066 + @override
3067 + String get builtin_ai_enable => 'Enable';
3068 }
lib/l10n/app_localizations_de.dart
+14
@@ -3040,4 +3040,18 @@ class AppLocalizationsDe extends AppLocalizations {
3040 @override
3041 String get calendar_permission_denied =>
3042 'Calendar permission denied. Please grant calendar access in your device settings.';
3043 +
3044 + @override
3045 + String get builtin_ai_not_supported =>
3046 + 'Built-in AI is not supported on this device.';
3047 +
3048 + @override
3049 + String get builtin_ai_unsupported_desc =>
3050 + 'Your device hardware or OS does not support on-device system AI (e.g. Gemini Nano / Apple Intelligence). Please choose a downloadable model instead.';
3051 +
3052 + @override
3053 + String get builtin_ai_unsupported_chip => 'Not Supported';
3054 +
3055 + @override
3056 + String get builtin_ai_enable => 'Enable';
3057 }
lib/l10n/app_localizations_en.dart
+14
@@ -3030,4 +3030,18 @@ class AppLocalizationsEn extends AppLocalizations {
3030 @override
3031 String get calendar_permission_denied =>
3032 'Calendar permission denied. Please grant calendar access in your device settings.';
3033 +
3034 + @override
3035 + String get builtin_ai_not_supported =>
3036 + 'Built-in AI is not supported on this device.';
3037 +
3038 + @override
3039 + String get builtin_ai_unsupported_desc =>
3040 + 'Your device hardware or OS does not support on-device system AI (e.g. Gemini Nano / Apple Intelligence). Please choose a downloadable model instead.';
3041 +
3042 + @override
3043 + String get builtin_ai_unsupported_chip => 'Not Supported';
3044 +
3045 + @override
3046 + String get builtin_ai_enable => 'Enable';
3047 }
lib/l10n/app_localizations_es.dart
+14
@@ -3054,4 +3054,18 @@ class AppLocalizationsEs extends AppLocalizations {
3054 @override
3055 String get calendar_permission_denied =>
3056 'Calendar permission denied. Please grant calendar access in your device settings.';
3057 +
3058 + @override
3059 + String get builtin_ai_not_supported =>
3060 + 'Built-in AI is not supported on this device.';
3061 +
3062 + @override
3063 + String get builtin_ai_unsupported_desc =>
3064 + 'Your device hardware or OS does not support on-device system AI (e.g. Gemini Nano / Apple Intelligence). Please choose a downloadable model instead.';
3065 +
3066 + @override
3067 + String get builtin_ai_unsupported_chip => 'Not Supported';
3068 +
3069 + @override
3070 + String get builtin_ai_enable => 'Enable';
3071 }
lib/l10n/app_localizations_fr.dart
+14
@@ -3043,4 +3043,18 @@ class AppLocalizationsFr extends AppLocalizations {
3043 @override
3044 String get calendar_permission_denied =>
3045 'Calendar permission denied. Please grant calendar access in your device settings.';
3046 +
3047 + @override
3048 + String get builtin_ai_not_supported =>
3049 + 'Built-in AI is not supported on this device.';
3050 +
3051 + @override
3052 + String get builtin_ai_unsupported_desc =>
3053 + 'Your device hardware or OS does not support on-device system AI (e.g. Gemini Nano / Apple Intelligence). Please choose a downloadable model instead.';
3054 +
3055 + @override
3056 + String get builtin_ai_unsupported_chip => 'Not Supported';
3057 +
3058 + @override
3059 + String get builtin_ai_enable => 'Enable';
3060 }
lib/l10n/app_localizations_hi.dart
+14
@@ -3041,4 +3041,18 @@ class AppLocalizationsHi extends AppLocalizations {
3041 @override
3042 String get calendar_permission_denied =>
3043 'Calendar permission denied. Please grant calendar access in your device settings.';
3044 +
3045 + @override
3046 + String get builtin_ai_not_supported =>
3047 + 'Built-in AI is not supported on this device.';
3048 +
3049 + @override
3050 + String get builtin_ai_unsupported_desc =>
3051 + 'Your device hardware or OS does not support on-device system AI (e.g. Gemini Nano / Apple Intelligence). Please choose a downloadable model instead.';
3052 +
3053 + @override
3054 + String get builtin_ai_unsupported_chip => 'Not Supported';
3055 +
3056 + @override
3057 + String get builtin_ai_enable => 'Enable';
3058 }
lib/l10n/app_localizations_it.dart
+14
@@ -3057,4 +3057,18 @@ class AppLocalizationsIt extends AppLocalizations {
3057 @override
3058 String get calendar_permission_denied =>
3059 'Calendar permission denied. Please grant calendar access in your device settings.';
3060 +
3061 + @override
3062 + String get builtin_ai_not_supported =>
3063 + 'Built-in AI is not supported on this device.';
3064 +
3065 + @override
3066 + String get builtin_ai_unsupported_desc =>
3067 + 'Your device hardware or OS does not support on-device system AI (e.g. Gemini Nano / Apple Intelligence). Please choose a downloadable model instead.';
3068 +
3069 + @override
3070 + String get builtin_ai_unsupported_chip => 'Not Supported';
3071 +
3072 + @override
3073 + String get builtin_ai_enable => 'Enable';
3074 }
lib/l10n/app_localizations_ja.dart
+14
@@ -2980,4 +2980,18 @@ class AppLocalizationsJa extends AppLocalizations {
2980 @override
2981 String get calendar_permission_denied =>
2982 'Calendar permission denied. Please grant calendar access in your device settings.';
2983 +
2984 + @override
2985 + String get builtin_ai_not_supported =>
2986 + 'Built-in AI is not supported on this device.';
2987 +
2988 + @override
2989 + String get builtin_ai_unsupported_desc =>
2990 + 'Your device hardware or OS does not support on-device system AI (e.g. Gemini Nano / Apple Intelligence). Please choose a downloadable model instead.';
2991 +
2992 + @override
2993 + String get builtin_ai_unsupported_chip => 'Not Supported';
2994 +
2995 + @override
2996 + String get builtin_ai_enable => 'Enable';
2997 }
lib/l10n/app_localizations_ru.dart
+14
@@ -3055,4 +3055,18 @@ class AppLocalizationsRu extends AppLocalizations {
3055 @override
3056 String get calendar_permission_denied =>
3057 'Calendar permission denied. Please grant calendar access in your device settings.';
3058 +
3059 + @override
3060 + String get builtin_ai_not_supported =>
3061 + 'Built-in AI is not supported on this device.';
3062 +
3063 + @override
3064 + String get builtin_ai_unsupported_desc =>
3065 + 'Your device hardware or OS does not support on-device system AI (e.g. Gemini Nano / Apple Intelligence). Please choose a downloadable model instead.';
3066 +
3067 + @override
3068 + String get builtin_ai_unsupported_chip => 'Not Supported';
3069 +
3070 + @override
3071 + String get builtin_ai_enable => 'Enable';
3072 }
lib/l10n/app_localizations_tr.dart
+14
@@ -3060,4 +3060,18 @@ class AppLocalizationsTr extends AppLocalizations {
3060 @override
3061 String get calendar_permission_denied =>
3062 'Calendar permission denied. Please grant calendar access in your device settings.';
3063 +
3064 + @override
3065 + String get builtin_ai_not_supported =>
3066 + 'Built-in AI is not supported on this device.';
3067 +
3068 + @override
3069 + String get builtin_ai_unsupported_desc =>
3070 + 'Your device hardware or OS does not support on-device system AI (e.g. Gemini Nano / Apple Intelligence). Please choose a downloadable model instead.';
3071 +
3072 + @override
3073 + String get builtin_ai_unsupported_chip => 'Not Supported';
3074 +
3075 + @override
3076 + String get builtin_ai_enable => 'Enable';
3077 }
lib/l10n/app_localizations_zh.dart
+14
@@ -2956,6 +2956,20 @@ class AppLocalizationsZh extends AppLocalizations {
2956 @override
2957 String get calendar_permission_denied =>
2958 'Calendar permission denied. Please grant calendar access in your device settings.';
2959 +
2960 + @override
2961 + String get builtin_ai_not_supported =>
2962 + 'Built-in AI is not supported on this device.';
2963 +
2964 + @override
2965 + String get builtin_ai_unsupported_desc =>
2966 + 'Your device hardware or OS does not support on-device system AI (e.g. Gemini Nano / Apple Intelligence). Please choose a downloadable model instead.';
2967 +
2968 + @override
2969 + String get builtin_ai_unsupported_chip => 'Not Supported';
2970 +
2971 + @override
2972 + String get builtin_ai_enable => 'Enable';
2973 }
2974
2975 /// The translations for Chinese, as used in Taiwan (`zh_TW`).
lib/l10n/untranslated.txt
+60 -12
@@ -2,7 +2,11 @@
2 "ar": [
3 "calendar_access",
4 "calendar_access_desc",
5 - "calendar_permission_denied"
5 + "calendar_permission_denied",
6 + "builtin_ai_not_supported",
7 + "builtin_ai_unsupported_desc",
8 + "builtin_ai_unsupported_chip",
9 + "builtin_ai_enable"
10 ],
11
12 "bn": [
@@ -12,7 +16,11 @@
16 "image_pick_failed",
17 "calendar_access",
18 "calendar_access_desc",
15 - "calendar_permission_denied"
19 + "calendar_permission_denied",
20 + "builtin_ai_not_supported",
21 + "builtin_ai_unsupported_desc",
22 + "builtin_ai_unsupported_chip",
23 + "builtin_ai_enable"
24 ],
25
26 "de": [
@@ -22,7 +30,11 @@
30 "image_pick_failed",
31 "calendar_access",
32 "calendar_access_desc",
25 - "calendar_permission_denied"
33 + "calendar_permission_denied",
34 + "builtin_ai_not_supported",
35 + "builtin_ai_unsupported_desc",
36 + "builtin_ai_unsupported_chip",
37 + "builtin_ai_enable"
38 ],
39
40 "es": [
@@ -32,7 +44,11 @@
44 "image_pick_failed",
45 "calendar_access",
46 "calendar_access_desc",
35 - "calendar_permission_denied"
47 + "calendar_permission_denied",
48 + "builtin_ai_not_supported",
49 + "builtin_ai_unsupported_desc",
50 + "builtin_ai_unsupported_chip",
51 + "builtin_ai_enable"
52 ],
53
54 "fr": [
@@ -42,7 +58,11 @@
58 "image_pick_failed",
59 "calendar_access",
60 "calendar_access_desc",
45 - "calendar_permission_denied"
61 + "calendar_permission_denied",
62 + "builtin_ai_not_supported",
63 + "builtin_ai_unsupported_desc",
64 + "builtin_ai_unsupported_chip",
65 + "builtin_ai_enable"
66 ],
67
68 "hi": [
@@ -52,7 +72,11 @@
72 "image_pick_failed",
73 "calendar_access",
74 "calendar_access_desc",
55 - "calendar_permission_denied"
75 + "calendar_permission_denied",
76 + "builtin_ai_not_supported",
77 + "builtin_ai_unsupported_desc",
78 + "builtin_ai_unsupported_chip",
79 + "builtin_ai_enable"
80 ],
81
82 "it": [
@@ -62,7 +86,11 @@
86 "image_pick_failed",
87 "calendar_access",
88 "calendar_access_desc",
65 - "calendar_permission_denied"
89 + "calendar_permission_denied",
90 + "builtin_ai_not_supported",
91 + "builtin_ai_unsupported_desc",
92 + "builtin_ai_unsupported_chip",
93 + "builtin_ai_enable"
94 ],
95
96 "ja": [
@@ -72,7 +100,11 @@
100 "image_pick_failed",
101 "calendar_access",
102 "calendar_access_desc",
75 - "calendar_permission_denied"
103 + "calendar_permission_denied",
104 + "builtin_ai_not_supported",
105 + "builtin_ai_unsupported_desc",
106 + "builtin_ai_unsupported_chip",
107 + "builtin_ai_enable"
108 ],
109
110 "ru": [
@@ -82,7 +114,11 @@
114 "image_pick_failed",
115 "calendar_access",
116 "calendar_access_desc",
85 - "calendar_permission_denied"
117 + "calendar_permission_denied",
118 + "builtin_ai_not_supported",
119 + "builtin_ai_unsupported_desc",
120 + "builtin_ai_unsupported_chip",
121 + "builtin_ai_enable"
122 ],
123
124 "tr": [
@@ -92,7 +128,11 @@
128 "image_pick_failed",
129 "calendar_access",
130 "calendar_access_desc",
95 - "calendar_permission_denied"
131 + "calendar_permission_denied",
132 + "builtin_ai_not_supported",
133 + "builtin_ai_unsupported_desc",
134 + "builtin_ai_unsupported_chip",
135 + "builtin_ai_enable"
136 ],
137
138 "zh": [
@@ -102,7 +142,11 @@
142 "image_pick_failed",
143 "calendar_access",
144 "calendar_access_desc",
105 - "calendar_permission_denied"
145 + "calendar_permission_denied",
146 + "builtin_ai_not_supported",
147 + "builtin_ai_unsupported_desc",
148 + "builtin_ai_unsupported_chip",
149 + "builtin_ai_enable"
150 ],
151
152 "zh_TW": [
@@ -112,6 +156,10 @@
156 "image_pick_failed",
157 "calendar_access",
158 "calendar_access_desc",
115 - "calendar_permission_denied"
159 + "calendar_permission_denied",
160 + "builtin_ai_not_supported",
161 + "builtin_ai_unsupported_desc",
162 + "builtin_ai_unsupported_chip",
163 + "builtin_ai_enable"
164 ]
165 }
pubspec.yaml
+1 -1
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17 # In Windows, build-name is used as the major, minor, and patch parts
18 # of the product and file versions while build-number is used as the build suffix.
19 -version: 1.7.6+81
19 +version: 1.7.7+82
20
21 environment:
22 sdk: ">=3.12.0 <4.0.0"
test/features/chat/providers/chat_notifier_disposed_ref_test.dart
+1 -1
@@ -73,4 +73,4 @@ class _ConnectedServerNotifier extends ActiveServerNotifier {
73 class _DisposedChatNotifier extends ChatNotifier {
74 @override
75 ChatState build() => const ChatState();
76 -}
\ No newline at end of file
76 +}
test/features/chat/providers/model_selection_providers_test.dart
+1 -3
@@ -62,9 +62,7 @@ void main() {
62 overrides: [
63 sharedPreferencesProvider.overrideWithValue(prefs),
64 activeServerProvider.overrideWith(_StubActiveServerNotifier.new),
65 - downloadedModelsProvider.overrideWith(
66 - (ref) async => {'model-b'},
67 - ),
65 + downloadedModelsProvider.overrideWith((ref) async => {'model-b'}),
66 availableModelsProvider(
67 'server-1',
68 ).overrideWith((ref) async => _models()),
test/features/on_device/builtin_ai_model_test.dart
+16
@@ -72,5 +72,21 @@ void main() {
72 );
73 expect(webModels, [OnDeviceModel.geminiNanoBuiltIn]);
74 });
75 +
76 + test('Gemini Nano is built-in format and general model type', () {
77 + const model = OnDeviceModel.geminiNanoBuiltIn;
78 + expect(model.isBuiltIn, isTrue);
79 + expect(model.flutterGemmaModelType.name, 'general');
80 + });
81 +
82 + test(
83 + 'Apple Foundation Models is built-in format and general model type',
84 + () {
85 + const model = OnDeviceModel.appleFoundationModelsBuiltIn;
86 + expect(model.isBuiltIn, isTrue);
87 + expect(model.format, OnDeviceModelFormat.builtIn);
88 + expect(model.flutterGemmaModelType.name, 'general');
89 + },
90 + );
91 });
92 }
test/features/on_device/on_device_chat_service_lifecycle_test.dart
+72 -87
@@ -431,88 +431,81 @@ void main() {
431 },
432 );
433
434 - test(
435 - 'retained sessions are closed after the TTL elapses',
436 - () async {
437 - final ttlService = OnDeviceChatService(
438 - inferenceService,
439 - retainedSessionTtl: const Duration(milliseconds: 50),
434 + test('retained sessions are closed after the TTL elapses', () async {
435 + final ttlService = OnDeviceChatService(
436 + inferenceService,
437 + retainedSessionTtl: const Duration(milliseconds: 50),
438 + );
439 + try {
440 + final firstDone = ttlService
441 + .sendMessage(
442 + server: _server(),
443 + modelId: 'qwen3-0.6b',
444 + messages: [
445 + _message('hi', id: 'user-1'),
446 + _message('', id: 'assistant-1', role: MessageRole.assistant),
447 + ],
448 + params: ChatParameters.defaults(),
449 + )
450 + .listen((_) {})
451 + .asFuture<void>();
452 + await _waitFor(
453 + () =>
454 + inferenceService.sessions.length == 1 &&
455 + inferenceService.sessions.single.didGenerate,
456 );
441 - try {
442 - final firstDone = ttlService
443 - .sendMessage(
444 - server: _server(),
445 - modelId: 'qwen3-0.6b',
446 - messages: [
447 - _message('hi', id: 'user-1'),
448 - _message(
449 - '',
450 - id: 'assistant-1',
451 - role: MessageRole.assistant,
452 - ),
453 - ],
454 - params: ChatParameters.defaults(),
455 - )
456 - .listen((_) {})
457 - .asFuture<void>();
458 - await _waitFor(
459 - () =>
460 - inferenceService.sessions.length == 1 &&
461 - inferenceService.sessions.single.didGenerate,
462 - );
463 - final session = inferenceService.sessions.single;
464 - session.responses.add(const gemma.TextResponse('hello'));
465 - await session.responses.close();
466 - await firstDone;
457 + final session = inferenceService.sessions.single;
458 + session.responses.add(const gemma.TextResponse('hello'));
459 + await session.responses.close();
460 + await firstDone;
461
468 - // Session is retained immediately after completion.
469 - expect(session.closeCount, 0);
462 + // Session is retained immediately after completion.
463 + expect(session.closeCount, 0);
464
471 - // After the TTL elapses, the retained session is closed.
472 - await _waitFor(() => session.closeCount == 1);
465 + // After the TTL elapses, the retained session is closed.
466 + await _waitFor(() => session.closeCount == 1);
467
474 - // A subsequent send with a different conversation id forces a
475 - // rebuild — the retained session has been closed by the TTL.
476 - final secondDone = ttlService
477 - .sendMessage(
478 - server: _server(),
479 - modelId: 'qwen3-0.6b',
480 - messages: [
481 - Message(
482 - id: 'user-1',
483 - conversationId: 'other-conversation',
484 - role: MessageRole.user,
485 - content: 'hi',
486 - createdAt: DateTime.utc(2026, 8, 9),
487 - ),
488 - Message(
489 - id: 'assistant-1',
490 - conversationId: 'other-conversation',
491 - role: MessageRole.assistant,
492 - content: '',
493 - createdAt: DateTime.utc(2026, 8, 9),
494 - ),
495 - ],
496 - params: ChatParameters.defaults(),
497 - )
498 - .listen((_) {})
499 - .asFuture<void>();
500 - await _waitFor(
501 - () =>
502 - inferenceService.sessions.length == 2 &&
503 - inferenceService.sessions.last.didGenerate,
504 - );
505 - final secondSession = inferenceService.sessions.last;
506 - secondSession.responses.add(const gemma.TextResponse('reply'));
507 - await secondSession.responses.close();
508 - await secondDone;
468 + // A subsequent send with a different conversation id forces a
469 + // rebuild — the retained session has been closed by the TTL.
470 + final secondDone = ttlService
471 + .sendMessage(
472 + server: _server(),
473 + modelId: 'qwen3-0.6b',
474 + messages: [
475 + Message(
476 + id: 'user-1',
477 + conversationId: 'other-conversation',
478 + role: MessageRole.user,
479 + content: 'hi',
480 + createdAt: DateTime.utc(2026, 8, 9),
481 + ),
482 + Message(
483 + id: 'assistant-1',
484 + conversationId: 'other-conversation',
485 + role: MessageRole.assistant,
486 + content: '',
487 + createdAt: DateTime.utc(2026, 8, 9),
488 + ),
489 + ],
490 + params: ChatParameters.defaults(),
491 + )
492 + .listen((_) {})
493 + .asFuture<void>();
494 + await _waitFor(
495 + () =>
496 + inferenceService.sessions.length == 2 &&
497 + inferenceService.sessions.last.didGenerate,
498 + );
499 + final secondSession = inferenceService.sessions.last;
500 + secondSession.responses.add(const gemma.TextResponse('reply'));
501 + await secondSession.responses.close();
502 + await secondDone;
503
510 - expect(inferenceService.createCount, 2);
511 - } finally {
512 - ttlService.dispose();
513 - }
514 - },
515 - );
504 + expect(inferenceService.createCount, 2);
505 + } finally {
506 + ttlService.dispose();
507 + }
508 + });
509
510 test(
511 'reusing a retained session re-arms its TTL from the reuse point',
@@ -528,11 +521,7 @@ void main() {
521 modelId: 'qwen3-0.6b',
522 messages: [
523 _message('hi', id: 'user-1'),
531 - _message(
532 - '',
533 - id: 'assistant-1',
534 - role: MessageRole.assistant,
535 - ),
524 + _message('', id: 'assistant-1', role: MessageRole.assistant),
525 ],
526 params: ChatParameters.defaults(),
527 )
@@ -563,11 +552,7 @@ void main() {
552 role: MessageRole.assistant,
553 ),
554 _message('again', id: 'user-2'),
566 - _message(
567 - '',
568 - id: 'assistant-2',
569 - role: MessageRole.assistant,
570 - ),
555 + _message('', id: 'assistant-2', role: MessageRole.assistant),
556 ],
557 params: ChatParameters.defaults(),
558 )
test/features/on_device/on_device_error_formatter_test.dart new
+54
@@ -0,0 +1,54 @@
1 +import 'package:flutter_test/flutter_test.dart';
2 +import 'package:localmind/features/on_device/utils/on_device_error_formatter.dart';
3 +
4 +void main() {
5 + group('formatOnDeviceError', () {
6 + test(
7 + 'returns generic unsupported message for BuiltInAiUnavailableException',
8 + () {
9 + const error =
10 + 'BuiltInAiUnavailableException(BuiltInAiAvailability.unavailableDeviceUnsupported): Built-in AI is not available: BuiltInAiAvailability.unavailableDeviceUnsupported';
11 + final formatted = formatOnDeviceError(error);
12 + expect(formatted, 'Built-in AI is not supported on this device.');
13 + },
14 + );
15 +
16 + test('returns generic unsupported message when isBuiltIn is true', () {
17 + final formatted = formatOnDeviceError(
18 + Exception('Some internal failure'),
19 + isBuiltIn: true,
20 + );
21 + expect(formatted, 'Built-in AI is not supported on this device.');
22 + });
23 +
24 + test('returns generic unsupported message for unavailableOther', () {
25 + const error = 'BuiltInAiAvailability.unavailableOther';
26 + final formatted = formatOnDeviceError(error);
27 + expect(formatted, 'Built-in AI is not supported on this device.');
28 + });
29 +
30 + test('formats missing hugging face token error cleanly', () {
31 + const error = 'missing_huggingface_token';
32 + final formatted = formatOnDeviceError(error);
33 + expect(formatted, contains('Hugging Face token required'));
34 + });
35 +
36 + test('formats disk space error cleanly', () {
37 + const error =
38 + 'FileSystemException: write failed, OS Error: No space left on device, errno = 28';
39 + final formatted = formatOnDeviceError(error);
40 + expect(formatted, contains('Not enough storage space'));
41 + });
42 +
43 + test('formats network / socket error cleanly', () {
44 + const error = 'SocketException: Failed host lookup: huggingface.co';
45 + final formatted = formatOnDeviceError(error);
46 + expect(formatted, contains('Network connection failed'));
47 + });
48 +
49 + test('strips Exception prefix from standard Dart exceptions', () {
50 + final formatted = formatOnDeviceError(Exception('Custom error message'));
51 + expect(formatted, 'Custom error message');
52 + });
53 + });
54 +}
test/features/on_device/on_device_mlx_service_test.dart
+1 -4
@@ -150,10 +150,7 @@ void main() {
150 );
151 final model = OnDeviceModel.curatedMlxModels.first;
152
153 - expect(
154 - () => service.loadModel(model),
155 - throwsA(isA<UnsupportedError>()),
156 - );
153 + expect(() => service.loadModel(model), throwsA(isA<UnsupportedError>()));
154 });
155 }
156