main
dart 906 lines 27.7 KB
Raw
1 import '../views/components/imported_model_projector_setting.dart';
2 import '../views/components/imported_model_reasoning_setting.dart';
3 import 'package:flutter_gemma_builtin_ai/flutter_gemma_builtin_ai.dart';
4 import 'package:hugeicons/hugeicons.dart';
5 import 'dart:io';
6
7 import 'package:flutter/material.dart';
8 import 'package:flutter_riverpod/flutter_riverpod.dart';
9 import 'package:go_router/go_router.dart';
10 import 'package:shadcn_ui/shadcn_ui.dart';
11 import 'package:localmind/core/device/device_memory_service.dart';
12 import 'package:localmind/core/models/enums.dart';
13 import 'package:localmind/core/providers/app_providers.dart';
14 import 'package:localmind/core/routes/app_routes.dart';
15 import 'package:localmind/features/on_device/data/models/on_device_model.dart';
16 import 'package:localmind/features/on_device/data/models/download_progress_info.dart';
17 import 'package:localmind/features/on_device/data/models/download_status.dart';
18 import 'package:localmind/features/on_device/providers/on_device_providers.dart';
19 import 'package:localmind/features/on_device/providers/foreground_download_providers.dart';
20 import 'package:localmind/l10n/app_localizations.dart';
21 import 'package:localmind/features/models/components/ram_warning_dialog.dart';
22
23 class ModelCard extends ConsumerWidget {
24 const ModelCard({
25 super.key,
26 required this.model,
27 required this.downloadedAsync,
28 required this.downloadProgress,
29 required this.engineState,
30 this.deviceMemory,
31 });
32
33 final OnDeviceModel model;
34 final AsyncValue<Set<String>> downloadedAsync;
35 final DownloadProgressInfo? downloadProgress;
36 final OnDeviceEngineState engineState;
37 final DeviceMemoryInfo? deviceMemory;
38
39 @override
40 Widget build(BuildContext context, WidgetRef ref) {
41 final l10n = AppLocalizations.of(context)!;
42 final theme = Theme.of(context);
43 final isDownloaded = downloadedAsync.when(
44 data: (set) => set.contains(model.id),
45 loading: () => false,
46 error: (_, _) => false,
47 );
48
49 final isLoading =
50 engineState.status == OnDeviceEngineStatus.loading &&
51 engineState.loadedModelId == model.id;
52
53 final isDownloading =
54 downloadProgress != null &&
55 (downloadProgress!.status == DownloadStatus.running ||
56 downloadProgress!.status == DownloadStatus.pending);
57
58 final isPaused =
59 downloadProgress?.status == DownloadStatus.paused ||
60 downloadProgress?.status == DownloadStatus.canceled;
61
62 return Padding(
63 padding: const EdgeInsets.only(bottom: 12),
64 child: Container(
65 padding: const EdgeInsets.all(16),
66 decoration: BoxDecoration(
67 color: theme.colorScheme.surface,
68 borderRadius: BorderRadius.circular(12),
69 border: Border.all(
70 color: isDownloaded
71 ? Colors.green.withValues(alpha: 0.5)
72 : theme.colorScheme.outline.withValues(alpha: 0.3),
73 width: isDownloaded ? 1.5 : 1,
74 ),
75 ),
76 child: Column(
77 crossAxisAlignment: CrossAxisAlignment.start,
78 children: [
79 _ModelCardHeader(model: model, theme: theme, l10n: l10n),
80 if (!Platform.isIOS &&
81 deviceMemory != null &&
82 deviceMemory!.isOversized(model.minRamMb))
83 Padding(
84 padding: const EdgeInsets.only(top: 6),
85 child: _MemoryWarningBadge(l10n: l10n, theme: theme),
86 ),
87 const SizedBox(height: 4),
88 Text(
89 model.description,
90 style: theme.textTheme.bodySmall?.copyWith(
91 color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
92 ),
93 ),
94 const SizedBox(height: 8),
95 _CapabilityChips(model: model, theme: theme),
96 if (model.isImported) ImportedModelReasoningSetting(model: model),
97 if (model.isImported && model.format == OnDeviceModelFormat.gguf)
98 ImportedModelProjectorSetting(model: model),
99 const SizedBox(height: 8),
100 _ModelMetaRow(model: model, theme: theme, l10n: l10n),
101 const SizedBox(height: 8),
102 _ModelCardActions(
103 model: model,
104 isDownloaded: isDownloaded,
105 isLoading: isLoading,
106 isDownloading: isDownloading,
107 isPaused: isPaused,
108 downloadProgress: downloadProgress,
109 theme: theme,
110 l10n: l10n,
111 engineState: engineState,
112 deviceMemory: deviceMemory,
113 ),
114 ],
115 ),
116 ),
117 );
118 }
119 }
120
121 class _ModelCardHeader extends StatelessWidget {
122 const _ModelCardHeader({
123 required this.model,
124 required this.theme,
125 required this.l10n,
126 });
127
128 final OnDeviceModel model;
129 final ThemeData theme;
130 final AppLocalizations l10n;
131
132 @override
133 Widget build(BuildContext context) {
134 return Row(
135 children: [
136 Expanded(
137 child: Text(
138 model.name,
139 style: theme.textTheme.titleSmall?.copyWith(
140 fontWeight: FontWeight.bold,
141 ),
142 ),
143 ),
144 if (model.isRecommended)
145 Container(
146 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
147 decoration: BoxDecoration(
148 color: theme.colorScheme.primary.withValues(alpha: 0.1),
149 borderRadius: BorderRadius.circular(4),
150 ),
151 child: Text(
152 l10n.recommended,
153 style: theme.textTheme.labelSmall?.copyWith(
154 color: theme.colorScheme.primary,
155 fontWeight: FontWeight.bold,
156 ),
157 ),
158 ),
159 if (model.isImported)
160 Container(
161 margin: const EdgeInsets.only(left: 6),
162 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
163 decoration: BoxDecoration(
164 color: Colors.blue.withValues(alpha: 0.1),
165 borderRadius: BorderRadius.circular(4),
166 ),
167 child: Text(
168 model.importedSourceLabel ?? 'Imported',
169 style: theme.textTheme.labelSmall?.copyWith(
170 color: Colors.blue,
171 fontWeight: FontWeight.bold,
172 ),
173 ),
174 ),
175 if (model.isMlx)
176 Container(
177 margin: const EdgeInsets.only(left: 6),
178 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
179 decoration: BoxDecoration(
180 color: Colors.purple.withValues(alpha: 0.1),
181 borderRadius: BorderRadius.circular(4),
182 ),
183 child: Text(
184 ' Apple MLX',
185 style: theme.textTheme.labelSmall?.copyWith(
186 color: Colors.purple,
187 fontWeight: FontWeight.bold,
188 ),
189 ),
190 ),
191 if (model.isBuiltIn)
192 Container(
193 margin: const EdgeInsets.only(left: 6),
194 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
195 decoration: BoxDecoration(
196 color: Colors.teal.withValues(alpha: 0.1),
197 borderRadius: BorderRadius.circular(4),
198 ),
199 child: Text(
200 Platform.isIOS || Platform.isMacOS
201 ? ' Apple Intelligence'
202 : '⚡ Built-in AI',
203 style: theme.textTheme.labelSmall?.copyWith(
204 color: Colors.teal,
205 fontWeight: FontWeight.bold,
206 ),
207 ),
208 ),
209 ],
210 );
211 }
212 }
213
214 class _MemoryWarningBadge extends StatelessWidget {
215 const _MemoryWarningBadge({required this.l10n, required this.theme});
216 final AppLocalizations l10n;
217 final ThemeData theme;
218
219 @override
220 Widget build(BuildContext context) {
221 return Container(
222 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
223 decoration: BoxDecoration(
224 color: Colors.orange.withValues(alpha: 0.1),
225 borderRadius: BorderRadius.circular(6),
226 border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
227 ),
228 child: Row(
229 mainAxisSize: MainAxisSize.min,
230 children: [
231 const HugeIcon(
232 icon: HugeIcons.strokeRoundedAlertCircle,
233 color: Colors.orange,
234 size: 14,
235 ),
236 const SizedBox(width: 6),
237 Text(
238 l10n.may_be_large,
239 style: theme.textTheme.labelSmall?.copyWith(
240 color: Colors.orange,
241 fontWeight: FontWeight.bold,
242 ),
243 ),
244 ],
245 ),
246 );
247 }
248 }
249
250 class _CapabilityChips extends StatelessWidget {
251 const _CapabilityChips({required this.model, required this.theme});
252 final OnDeviceModel model;
253 final ThemeData theme;
254
255 @override
256 Widget build(BuildContext context) {
257 final l10n = AppLocalizations.of(context)!;
258 final chips = <String>[
259 if (model.format == OnDeviceModelFormat.gguf) 'GGUF',
260 if (model.runtime == OnDeviceModelRuntime.llamaCpp) 'llama.cpp',
261 if (model.runtime == OnDeviceModelRuntime.mlx) ' Metal GPU',
262 if (model.isBuiltIn) '⚡ Built-in',
263 if (model.supportsFunctionCalling) 'Tools',
264 if (model.supportsThinking) 'Thinking',
265 if (model.supportsVision) 'Vision',
266 model.languagesLabel,
267 if (model.backendNote != null &&
268 model.backendNote!.toLowerCase() != 'llama.cpp' &&
269 model.backendNote!.toLowerCase() != 'apple mlx')
270 model.backendNote!,
271 if (model.requiresHuggingFaceToken) l10n.model_requires_huggingface_token,
272 ];
273
274 return Wrap(
275 spacing: 6,
276 runSpacing: 6,
277 children: chips.map((label) {
278 final isBackendNote = label == model.backendNote;
279 return Container(
280 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
281 decoration: BoxDecoration(
282 color: (isBackendNote ? Colors.orange : theme.colorScheme.primary)
283 .withValues(alpha: 0.1),
284 borderRadius: BorderRadius.circular(6),
285 border: Border.all(
286 color: (isBackendNote ? Colors.orange : theme.colorScheme.primary)
287 .withValues(alpha: 0.2),
288 ),
289 ),
290 child: Text(
291 label,
292 style: theme.textTheme.labelSmall?.copyWith(
293 color: isBackendNote ? Colors.orange : theme.colorScheme.primary,
294 fontWeight: FontWeight.w600,
295 ),
296 ),
297 );
298 }).toList(),
299 );
300 }
301 }
302
303 class _ModelMetaRow extends StatelessWidget {
304 const _ModelMetaRow({
305 required this.model,
306 required this.theme,
307 required this.l10n,
308 });
309 final OnDeviceModel model;
310 final ThemeData theme;
311 final AppLocalizations l10n;
312
313 @override
314 Widget build(BuildContext context) {
315 return Column(
316 crossAxisAlignment: CrossAxisAlignment.start,
317 children: [
318 Wrap(
319 spacing: 8,
320 runSpacing: 8,
321 children: [
322 _MetaPill(label: model.fileSizeFormatted, theme: theme),
323 if (!model.isImported)
324 _MetaPill(label: model.license, theme: theme),
325 _MetaPill(
326 label: l10n.ram_min_required('${model.minRamMb ~/ 1024 + 1}'),
327 theme: theme,
328 muted: true,
329 ),
330 ],
331 ),
332 if (model.isImported) ...[
333 const SizedBox(height: 8),
334 Text(
335 model.fileName,
336 maxLines: 1,
337 overflow: TextOverflow.ellipsis,
338 style: theme.textTheme.labelSmall?.copyWith(
339 color: theme.colorScheme.onSurface.withValues(alpha: 0.58),
340 ),
341 ),
342 ],
343 ],
344 );
345 }
346 }
347
348 class _MetaPill extends StatelessWidget {
349 const _MetaPill({
350 required this.label,
351 required this.theme,
352 this.muted = false,
353 });
354
355 final String label;
356 final ThemeData theme;
357 final bool muted;
358
359 @override
360 Widget build(BuildContext context) {
361 final color = muted
362 ? theme.colorScheme.onSurface.withValues(alpha: 0.62)
363 : theme.colorScheme.onSurface;
364
365 return Container(
366 padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
367 decoration: BoxDecoration(
368 color: theme.colorScheme.surfaceContainerHighest.withValues(
369 alpha: 0.45,
370 ),
371 borderRadius: BorderRadius.circular(999),
372 ),
373 child: Text(
374 label,
375 style: theme.textTheme.labelSmall?.copyWith(
376 color: color,
377 fontWeight: FontWeight.w600,
378 ),
379 ),
380 );
381 }
382 }
383
384 class _ModelCardActions extends ConsumerWidget {
385 const _ModelCardActions({
386 required this.model,
387 required this.isDownloaded,
388 required this.isLoading,
389 required this.isDownloading,
390 required this.isPaused,
391 this.downloadProgress,
392 required this.theme,
393 required this.l10n,
394 required this.engineState,
395 this.deviceMemory,
396 });
397
398 final OnDeviceModel model;
399 final bool isDownloaded;
400 final bool isLoading;
401 final bool isDownloading;
402 final bool isPaused;
403 final DownloadProgressInfo? downloadProgress;
404 final ThemeData theme;
405 final AppLocalizations l10n;
406 final OnDeviceEngineState engineState;
407 final DeviceMemoryInfo? deviceMemory;
408
409 @override
410 Widget build(BuildContext context, WidgetRef ref) {
411 if (isDownloaded) {
412 return _DownloadedActions(
413 model: model,
414 isLoading: isLoading,
415 engineState: engineState,
416 l10n: l10n,
417 theme: theme,
418 );
419 }
420
421 if (isDownloading) {
422 return _DownloadingActions(
423 model: model,
424 downloadProgress: downloadProgress,
425 theme: theme,
426 l10n: l10n,
427 );
428 }
429
430 final builtInAvailability = model.isBuiltIn
431 ? ref.watch(builtInAiAvailabilityProvider).value
432 : null;
433 final isBuiltInUnsupported =
434 model.isBuiltIn &&
435 (builtInAvailability ==
436 BuiltInAiAvailability.unavailableDeviceUnsupported ||
437 builtInAvailability == BuiltInAiAvailability.unavailableOther);
438
439 if (downloadProgress?.status == DownloadStatus.failed) {
440 return _FailedDownloadActions(
441 model: model,
442 downloadProgress: downloadProgress,
443 theme: theme,
444 l10n: l10n,
445 isUnsupported: isBuiltInUnsupported,
446 );
447 }
448
449 if (isPaused) {
450 return _PausedDownloadActions(
451 model: model,
452 downloadProgress: downloadProgress,
453 theme: theme,
454 l10n: l10n,
455 );
456 }
457
458 if (isBuiltInUnsupported) {
459 return _BuiltInUnsupportedActions(model: model, theme: theme, l10n: l10n);
460 }
461
462 return _NotDownloadedActions(
463 model: model,
464 deviceMemory: deviceMemory,
465 theme: theme,
466 l10n: l10n,
467 );
468 }
469 }
470
471 class _DownloadedActions extends ConsumerWidget {
472 const _DownloadedActions({
473 required this.model,
474 required this.isLoading,
475 required this.engineState,
476 required this.l10n,
477 required this.theme,
478 });
479
480 final OnDeviceModel model;
481 final bool isLoading;
482 final OnDeviceEngineState engineState;
483 final AppLocalizations l10n;
484 final ThemeData theme;
485
486 @override
487 Widget build(BuildContext context, WidgetRef ref) {
488 final isLoaded = engineState.loadedModelId == model.id;
489
490 return Column(
491 mainAxisSize: MainAxisSize.min,
492 crossAxisAlignment: CrossAxisAlignment.stretch,
493 children: [
494 Row(
495 children: [
496 HugeIcon(
497 icon: isLoaded
498 ? HugeIcons.strokeRoundedCpu
499 : HugeIcons.strokeRoundedCheckmarkCircle01,
500 color: isLoaded ? Colors.green : Colors.grey,
501 size: 18,
502 ),
503 const SizedBox(width: 4),
504 Text(
505 isLoaded ? l10n.loaded_status : l10n.downloaded,
506 style: theme.textTheme.bodySmall?.copyWith(
507 color: isLoaded ? Colors.green : Colors.grey,
508 fontWeight: FontWeight.w600,
509 ),
510 ),
511 const Spacer(),
512 if (isLoaded)
513 ShadButton.outline(
514 size: ShadButtonSize.sm,
515 onPressed: () =>
516 ref.read(onDeviceEngineProvider.notifier).unloadModel(),
517 child: Text(l10n.unload),
518 )
519 else if (!isLoading)
520 ShadButton.outline(
521 size: ShadButtonSize.sm,
522 onPressed: () => _loadModel(context, ref),
523 child: Text(l10n.load),
524 ),
525 if (!model.isBuiltIn) ...[
526 const SizedBox(width: 8),
527 ShadButton.outline(
528 size: ShadButtonSize.sm,
529 onPressed: () => _deleteModel(context, ref),
530 child: Text(l10n.delete),
531 ),
532 ],
533 ],
534 ),
535 if (isLoading) ...[
536 const SizedBox(height: 8),
537 ClipRRect(
538 borderRadius: BorderRadius.circular(2),
539 child: LinearProgressIndicator(
540 minHeight: 3,
541 backgroundColor: theme.colorScheme.primary.withValues(
542 alpha: 0.15,
543 ),
544 valueColor: AlwaysStoppedAnimation<Color>(
545 theme.colorScheme.primary,
546 ),
547 ),
548 ),
549 ],
550 ],
551 );
552 }
553
554 Future<void> _loadModel(BuildContext context, WidgetRef ref) async {
555 final settings = ref.read(settingsProvider);
556 final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
557 await engineNotifier.loadModel(model.id, settings.preferredBackend);
558 }
559
560 Future<void> _deleteModel(BuildContext context, WidgetRef ref) async {
561 final confirm = await ModelConfirmDialog.show(
562 context: context,
563 title: l10n.delete_model_title,
564 message: l10n.delete_model_body(model.name),
565 confirmLabel: l10n.delete,
566 cancelLabel: l10n.cancel,
567 isDestructive: true,
568 icon: HugeIcons.strokeRoundedDelete02,
569 );
570
571 if (confirm == true) {
572 // Guard every ref-touching statement post-await — the parent widget
573 // tree can rebuild and unmount this card during the long-running
574 // delete operations (especially model-file removal on disk).
575 if (!context.mounted) return;
576 final engineState = ref.read(onDeviceEngineProvider);
577 if (engineState.loadedModelId == model.id) {
578 await ref.read(onDeviceEngineProvider.notifier).unloadModel();
579 }
580 if (!context.mounted) return;
581
582 if (model.isImported) {
583 await ref
584 .read(importedGgufModelsProvider.notifier)
585 .deleteModel(model.id);
586 } else if (model.runtime == OnDeviceModelRuntime.mlx) {
587 if (!context.mounted) return;
588 final mlxService = ref.read(onDeviceMlxServiceProvider);
589 await mlxService.deleteModel(model.id);
590 } else {
591 if (!context.mounted) return;
592 final gemmaService = ref.read(onDeviceGemmaServiceProvider);
593 await gemmaService.deleteModel(model.id);
594 }
595 if (!context.mounted) return;
596 ref.invalidate(downloadedModelsProvider);
597 }
598 }
599 }
600
601 class _DownloadingActions extends ConsumerWidget {
602 const _DownloadingActions({
603 required this.model,
604 this.downloadProgress,
605 required this.theme,
606 required this.l10n,
607 });
608
609 final OnDeviceModel model;
610 final DownloadProgressInfo? downloadProgress;
611 final ThemeData theme;
612 final AppLocalizations l10n;
613
614 @override
615 Widget build(BuildContext context, WidgetRef ref) {
616 return Column(
617 crossAxisAlignment: CrossAxisAlignment.start,
618 children: [
619 Row(
620 children: [
621 Expanded(
622 child: Column(
623 crossAxisAlignment: CrossAxisAlignment.start,
624 children: [
625 ClipRRect(
626 borderRadius: BorderRadius.circular(4),
627 child: LinearProgressIndicator(
628 value: downloadProgress?.progress ?? 0.0,
629 minHeight: 6,
630 backgroundColor: theme.colorScheme.primary.withValues(
631 alpha: 0.1,
632 ),
633 valueColor: AlwaysStoppedAnimation<Color>(
634 theme.colorScheme.primary,
635 ),
636 ),
637 ),
638 const SizedBox(height: 6),
639 Row(
640 mainAxisAlignment: MainAxisAlignment.spaceBetween,
641 children: [
642 Text(
643 l10n.download_progress(
644 ((downloadProgress?.progress ?? 0) * 100)
645 .toStringAsFixed(1),
646 downloadProgress?.speedFormatted ?? '0 B/s',
647 ),
648 style: theme.textTheme.labelSmall?.copyWith(
649 fontWeight: FontWeight.w600,
650 ),
651 ),
652 Text(
653 l10n.eta_label(
654 downloadProgress?.etaFormatted ?? l10n.calculating,
655 ),
656 style: theme.textTheme.labelSmall,
657 ),
658 ],
659 ),
660 const SizedBox(height: 2),
661 Text(
662 downloadProgress?.progressFormatted ?? '',
663 style: theme.textTheme.labelSmall?.copyWith(
664 color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
665 fontSize: 10,
666 ),
667 ),
668 ],
669 ),
670 ),
671 const SizedBox(width: 12),
672 ShadButton.outline(
673 size: ShadButtonSize.sm,
674 onPressed: () => ref
675 .read(foregroundDownloadNotifierProvider.notifier)
676 .cancelDownload(model.id),
677 child: Text(l10n.cancel),
678 ),
679 ],
680 ),
681 ],
682 );
683 }
684 }
685
686 class _FailedDownloadActions extends ConsumerWidget {
687 const _FailedDownloadActions({
688 required this.model,
689 this.downloadProgress,
690 required this.theme,
691 required this.l10n,
692 this.isUnsupported = false,
693 });
694
695 final OnDeviceModel model;
696 final DownloadProgressInfo? downloadProgress;
697 final ThemeData theme;
698 final AppLocalizations l10n;
699 final bool isUnsupported;
700
701 @override
702 Widget build(BuildContext context, WidgetRef ref) {
703 final rawError = downloadProgress?.error;
704 final isDeviceUnsupported =
705 isUnsupported ||
706 (model.isBuiltIn &&
707 (rawError != null &&
708 (rawError.contains('unavailableDeviceUnsupported') ||
709 rawError.contains('BuiltInAiUnavailableException') ||
710 rawError.contains('unavailableOther'))));
711
712 final displayError = isDeviceUnsupported
713 ? l10n.builtin_ai_not_supported
714 : (rawError ?? l10n.download_failed);
715
716 return Row(
717 children: [
718 HugeIcon(
719 icon: isDeviceUnsupported
720 ? HugeIcons.strokeRoundedUnavailable
721 : HugeIcons.strokeRoundedAlertCircle,
722 color: Colors.red[400]!,
723 size: 18,
724 ),
725 const SizedBox(width: 6),
726 Expanded(
727 child: Text(
728 displayError,
729 style: theme.textTheme.bodySmall?.copyWith(color: Colors.red[400]),
730 ),
731 ),
732 if (!isDeviceUnsupported) ...[
733 const SizedBox(width: 8),
734 ShadButton.outline(
735 size: ShadButtonSize.sm,
736 onPressed: () => ref
737 .read(foregroundDownloadNotifierProvider.notifier)
738 .retryDownload(model.id),
739 child: Text(l10n.retry),
740 ),
741 ],
742 ],
743 );
744 }
745 }
746
747 class _BuiltInUnsupportedActions extends StatelessWidget {
748 const _BuiltInUnsupportedActions({
749 required this.model,
750 required this.theme,
751 required this.l10n,
752 });
753
754 final OnDeviceModel model;
755 final ThemeData theme;
756 final AppLocalizations l10n;
757
758 @override
759 Widget build(BuildContext context) {
760 return Row(
761 children: [
762 HugeIcon(
763 icon: HugeIcons.strokeRoundedUnavailable,
764 color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
765 size: 18,
766 ),
767 const SizedBox(width: 6),
768 Expanded(
769 child: Text(
770 l10n.builtin_ai_not_supported,
771 style: theme.textTheme.bodySmall?.copyWith(
772 color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
773 ),
774 ),
775 ),
776 ],
777 );
778 }
779 }
780
781 class _PausedDownloadActions extends ConsumerWidget {
782 const _PausedDownloadActions({
783 required this.model,
784 this.downloadProgress,
785 required this.theme,
786 required this.l10n,
787 });
788
789 final OnDeviceModel model;
790 final DownloadProgressInfo? downloadProgress;
791 final ThemeData theme;
792 final AppLocalizations l10n;
793
794 @override
795 Widget build(BuildContext context, WidgetRef ref) {
796 return Row(
797 children: [
798 Text(
799 l10n.paused_progress(
800 ((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0),
801 ),
802 style: theme.textTheme.bodySmall,
803 ),
804 const Spacer(),
805 ShadButton.outline(
806 size: ShadButtonSize.sm,
807 onPressed: () => _startDownload(context, ref),
808 child: Text(l10n.resume),
809 ),
810 ],
811 );
812 }
813
814 Future<void> _startDownload(BuildContext context, WidgetRef ref) async {
815 final result = await ref
816 .read(foregroundDownloadNotifierProvider.notifier)
817 .startDownload(model.id);
818
819 if (result == 'missing_huggingface_token' && context.mounted) {
820 ScaffoldMessenger.maybeOf(context)?.showSnackBar(
821 SnackBar(
822 content: Text(l10n.model_missing_huggingface_token),
823 duration: const Duration(seconds: 6),
824 action: SnackBarAction(
825 label: l10n.settings_title,
826 onPressed: () => context.push(AppRoutes.settings),
827 ),
828 ),
829 );
830 }
831 }
832 }
833
834 class _NotDownloadedActions extends ConsumerWidget {
835 const _NotDownloadedActions({
836 required this.model,
837 this.deviceMemory,
838 required this.theme,
839 required this.l10n,
840 });
841
842 final OnDeviceModel model;
843 final DeviceMemoryInfo? deviceMemory;
844 final ThemeData theme;
845 final AppLocalizations l10n;
846
847 @override
848 Widget build(BuildContext context, WidgetRef ref) {
849 final label = model.isBuiltIn ? l10n.builtin_ai_enable : l10n.download;
850 return Row(
851 children: [
852 const Spacer(),
853 ShadButton.outline(
854 size: ShadButtonSize.sm,
855 onPressed: () => _startDownload(context, ref),
856 child: Text(label),
857 ),
858 ],
859 );
860 }
861
862 Future<void> _startDownload(BuildContext context, WidgetRef ref) async {
863 final l10n = AppLocalizations.of(context)!;
864 if (!Platform.isIOS && deviceMemory != null) {
865 if (deviceMemory!.isOversized(model.minRamMb)) {
866 final proceed = await _showRamWarning(
867 context,
868 l10n.ram_warning_body_download(
869 '${model.minRamMb ~/ 1024 + 1}',
870 deviceMemory!.totalMemoryFormatted,
871 ),
872 );
873 if (!proceed) return;
874 }
875 }
876
877 final result = await ref
878 .read(foregroundDownloadNotifierProvider.notifier)
879 .startDownload(model.id);
880
881 if (result == 'missing_huggingface_token' && context.mounted) {
882 ScaffoldMessenger.maybeOf(context)?.showSnackBar(
883 SnackBar(
884 content: Text(l10n.model_missing_huggingface_token),
885 duration: const Duration(seconds: 6),
886 action: SnackBarAction(
887 label: l10n.settings_title,
888 onPressed: () => context.push(AppRoutes.settings),
889 ),
890 ),
891 );
892 }
893 }
894
895 Future<bool> _showRamWarning(BuildContext context, String message) async {
896 final availableRam = deviceMemory?.availableMemoryFormatted;
897 final requiredRam = '${model.minRamMb ~/ 1024 + 1} GB';
898
899 return RamWarningDialog.show(
900 context: context,
901 availableRam: availableRam,
902 requiredRam: requiredRam,
903 message: message,
904 );
905 }
906 }