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