@tduncan / Cortana / commits / 909dfa8

feat: add app versioning #24 , refactor onboarding UI layout

abdulmominsakib committed Jun 16, 2026 at 13:05 UTC 909dfa8e097496e357f63eb255526b7b6ed62a5a
14 files changed +742 -199
lib/core/constants/app_constants.dart
+1 -1
@@ -1,7 +1,7 @@
1 class AppConstants {
2 static const String appName = 'LocalMind';
3 static const String appTagline = 'Your AI. Your Device. Your Rules.';
4 - static const String version = '1.0.0';
4 + static const String version = '1.0.0'; // Fallback; prefer PackageInfo.version at runtime
5
6 static const double defaultTemperature = 0.7;
7 static const double defaultTopP = 0.9;
lib/core/providers/app_providers.dart
+5
@@ -1,5 +1,6 @@
1 import 'package:flutter_gemma/flutter_gemma.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:package_info_plus/package_info_plus.dart';
4
5 import '../../features/settings/data/models/app_settings.dart';
6 import '../models/enums.dart';
@@ -114,3 +115,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
115 await prefs.setString('appSettings', state.toJson());
116 }
117 }
118 +
119 +final packageInfoProvider = FutureProvider<PackageInfo>((ref) async {
120 + return PackageInfo.fromPlatform();
121 +});
lib/features/chat/data/mcp_client.dart
+9 -3
@@ -7,6 +7,7 @@ import 'package:localmind/core/logger/app_logger.dart';
7 class McpClient {
8 final String serverUrl;
9 final Map<String, String>? headers;
10 + final String _version;
11 final Dio _dio;
12 bool _initialized = false;
13 McpCapabilities? _capabilities;
@@ -18,8 +19,13 @@ class McpClient {
19 final Map<String, Completer<Map<String, dynamic>>> _pendingRequests = {};
20 int _requestIdCounter = 1;
21
21 - McpClient({required this.serverUrl, this.headers, Dio? dio})
22 - : _dio = dio ?? Dio();
22 + McpClient({
23 + required this.serverUrl,
24 + this.headers,
25 + String version = '1.0.0',
26 + Dio? dio,
27 + }) : _version = version,
28 + _dio = dio ?? Dio();
29
30 bool get isInitialized => _initialized;
31 McpCapabilities? get capabilities => _capabilities;
@@ -270,7 +276,7 @@ class McpClient {
276 final response = await _sendJsonRpc('initialize', {
277 'protocolVersion': '2024-11-05',
278 'capabilities': {},
273 - 'clientInfo': {'name': 'localmind', 'version': '1.0.0'},
279 + 'clientInfo': {'name': 'localmind', 'version': _version},
280 });
281
282 _capabilities = McpCapabilities.fromJson(
lib/features/chat/data/mcp_server_manager.dart
+8 -1
@@ -4,6 +4,7 @@ const exampleMcpServerLabel = 'Example MCP';
4 const exampleMcpServerUrl = 'local://example-mcp';
5
6 class McpServerManager {
7 + final String _appVersion;
8 final Map<String, McpClient> _clients = {};
9 final Map<String, McpCapabilities> _capabilities = {};
10 final Map<String, List<McpTool>> _tools = {};
@@ -11,6 +12,8 @@ class McpServerManager {
12 final Set<String> _localExampleServers = {};
13 final Set<String> _pendingLabels = {};
14
15 + McpServerManager({String appVersion = '1.0.0'}) : _appVersion = appVersion;
16 +
17 Future<void> addServer(
18 String label,
19 String url, {
@@ -29,7 +32,11 @@ class McpServerManager {
32 _serverUrls.remove(label);
33 }
34
32 - final client = McpClient(serverUrl: url, headers: headers);
35 + final client = McpClient(
36 + serverUrl: url,
37 + headers: headers,
38 + version: _appVersion,
39 + );
40
41 final capabilities = await client.initialize();
42 final tools = await client.listTools();
lib/features/chat/providers/tooling_providers.dart
+5 -1
@@ -1,11 +1,15 @@
1 import 'package:flutter_riverpod/flutter_riverpod.dart';
2 +import '../../../core/providers/app_providers.dart';
3 import '../data/tools/tool_registry.dart';
4 import '../data/tools/builtin_tool_provider.dart';
5 import '../data/tools/mcp_tool_provider.dart';
6 import '../data/mcp_server_manager.dart';
7
8 final mcpServerManagerProvider = Provider<McpServerManager>((ref) {
8 - return McpServerManager();
9 + final packageInfo = ref.watch(packageInfoProvider);
10 + return McpServerManager(
11 + appVersion: packageInfo.value?.version ?? '1.0.0',
12 + );
13 });
14
15 final builtInToolProviderProvider = Provider<BuiltInToolProvider>((ref) {
lib/features/models/screens/model_picker_sheet.dart
+96 -30
@@ -42,6 +42,22 @@ class ModelPickerSheet extends ConsumerWidget {
42 final modelLoading = ref.watch(modelLoadingProvider);
43 final isThinking = ref.watch(modelThinkingProvider);
44
45 + ref.listen(onDeviceEngineProvider, (prev, next) {
46 + if (next.status == OnDeviceEngineStatus.loaded &&
47 + prev?.status == OnDeviceEngineStatus.loading) {
48 + ScaffoldMessenger.of(context).showSnackBar(
49 + SnackBar(
50 + content: Text(
51 + l10n.model_loaded(
52 + next.loadedModelId ?? 'Unknown',
53 + next.backend?.name ?? 'CPU',
54 + ),
55 + ),
56 + ),
57 + );
58 + }
59 + });
60 +
61 return Container(
62 height: MediaQuery.of(context).size.height * 0.7,
63 padding: const EdgeInsets.all(16),
@@ -92,7 +108,9 @@ class ModelPickerSheet extends ConsumerWidget {
108 crossAxisAlignment: CrossAxisAlignment.start,
109 children: [
110 Text(
95 - l10n.loading_model(modelLoading.modelId ?? 'model'),
111 + l10n.loading_model(
112 + modelLoading.modelId ?? 'model',
113 + ),
114 style: TextStyle(
115 fontSize: 12,
116 color: isDark
@@ -341,7 +359,8 @@ class _ModelList extends ConsumerWidget {
359 final activeServer = ref.read(activeServerProvider);
360 if (activeServer == null) return;
361
344 - final supportsLoad = model.serverType == ServerType.lmStudio ||
362 + final supportsLoad =
363 + model.serverType == ServerType.lmStudio ||
364 model.serverType == ServerType.ollama;
365
366 if (supportsLoad && !isLoaded) {
@@ -483,24 +502,29 @@ class _ModelTile extends StatelessWidget {
502 spacing: 6,
503 runSpacing: 4,
504 children: [
486 - if (model.parameterCountDisplay != null && model.parameterCountDisplay!.isNotEmpty)
505 + if (model.parameterCountDisplay != null &&
506 + model.parameterCountDisplay!.isNotEmpty)
507 _MetadataChip(
508 label: model.parameterCountDisplay!,
509 isDark: isDark,
510 ),
491 - if (model.quantization != null && model.quantization!.isNotEmpty)
511 + if (model.quantization != null &&
512 + model.quantization!.isNotEmpty)
513 _MetadataChip(
514 label: model.quantization!,
515 isDark: isDark,
516 ),
496 - if (model.formattedSize != null && model.formattedSize!.isNotEmpty)
517 + if (model.formattedSize != null &&
518 + model.formattedSize!.isNotEmpty)
519 _MetadataChip(
520 label: model.formattedSize!,
521 isDark: isDark,
522 ),
523 if (model.contextLength != null)
524 _MetadataChip(
503 - label: l10n.context_chip(model.contextLength.toString()),
525 + label: l10n.context_chip(
526 + model.contextLength.toString(),
527 + ),
528 isDark: isDark,
529 ),
530 ],
@@ -792,9 +816,13 @@ class _OnDeviceModelTile extends ConsumerWidget {
816 padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
817 margin: const EdgeInsets.only(bottom: 4),
818 decoration: BoxDecoration(
795 - color: isSelected ? accent.withValues(alpha: 0.1) : Colors.transparent,
819 + color: isSelected
820 + ? accent.withValues(alpha: 0.1)
821 + : Colors.transparent,
822 borderRadius: BorderRadius.circular(8),
797 - border: isSelected ? Border.all(color: accent.withValues(alpha: 0.3)) : null,
823 + border: isSelected
824 + ? Border.all(color: accent.withValues(alpha: 0.3))
825 + : null,
826 ),
827 child: Row(
828 children: [
@@ -809,10 +837,14 @@ class _OnDeviceModelTile extends ConsumerWidget {
837 model.name,
838 style: TextStyle(
839 fontSize: 15,
812 - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
840 + fontWeight: isSelected
841 + ? FontWeight.w600
842 + : FontWeight.w500,
843 color: isDownloaded
844 ? (isDark ? Colors.white : Colors.black)
815 - : (isDark ? const Color(0xFF555555) : const Color(0xFF999999)),
845 + : (isDark
846 + ? const Color(0xFF555555)
847 + : const Color(0xFF999999)),
848 ),
849 maxLines: 1,
850 overflow: TextOverflow.ellipsis,
@@ -839,12 +871,18 @@ class _OnDeviceModelTile extends ConsumerWidget {
871 ),
872 ],
873 ),
842 - if (!Platform.isIOS && deviceMemory != null && deviceMemory!.isOversized(model.minRamMb))
874 + if (!Platform.isIOS &&
875 + deviceMemory != null &&
876 + deviceMemory!.isOversized(model.minRamMb))
877 Padding(
878 padding: const EdgeInsets.only(top: 2),
879 child: Row(
880 children: [
847 - const Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 12),
881 + const Icon(
882 + Icons.warning_amber_rounded,
883 + color: Colors.orange,
884 + size: 12,
885 + ),
886 const SizedBox(width: 4),
887 Text(
888 l10n.may_be_large,
@@ -865,16 +903,23 @@ class _OnDeviceModelTile extends ConsumerWidget {
903 children: [
904 LinearProgressIndicator(
905 value: downloadProgress?.progress ?? 0.0,
868 - backgroundColor: isDark ? const Color(0xFF333333) : const Color(0xFFE0E0E0),
906 + backgroundColor: isDark
907 + ? const Color(0xFF333333)
908 + : const Color(0xFFE0E0E0),
909 ),
910 const SizedBox(height: 2),
911 Text(
912 isPaused
873 - ? l10n.paused_progress(((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0))
913 + ? l10n.paused_progress(
914 + ((downloadProgress?.progress ?? 0) * 100)
915 + .toStringAsFixed(0),
916 + )
917 : '${l10n.downloading_status} ${((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0)}%',
918 style: TextStyle(
919 fontSize: 10,
877 - color: isDark ? const Color(0xFF888888) : const Color(0xFF999999),
920 + color: isDark
921 + ? const Color(0xFF888888)
922 + : const Color(0xFF999999),
923 ),
924 ),
925 ],
@@ -890,7 +935,10 @@ class _OnDeviceModelTile extends ConsumerWidget {
935 isDark: isDark,
936 ),
937 _MetadataChip(label: model.license, isDark: isDark),
893 - _MetadataChip(label: model.parameterLabel, isDark: isDark),
938 + _MetadataChip(
939 + label: model.parameterLabel,
940 + isDark: isDark,
941 + ),
942 ],
943 ),
944 ],
@@ -933,7 +981,9 @@ class _OnDeviceModelTile extends ConsumerWidget {
981 icon: Icon(
982 Icons.delete_outline,
983 size: 18,
936 - color: isDark ? const Color(0xFF666666) : const Color(0xFF999999),
984 + color: isDark
985 + ? const Color(0xFF666666)
986 + : const Color(0xFF999999),
987 ),
988 tooltip: l10n.delete,
989 onPressed: () => _deleteModel(context, ref),
@@ -943,17 +993,23 @@ class _OnDeviceModelTile extends ConsumerWidget {
993 icon: Icon(
994 Icons.close,
995 size: 16,
946 - color: isDark ? const Color(0xFF666666) : const Color(0xFF999999),
996 + color: isDark
997 + ? const Color(0xFF666666)
998 + : const Color(0xFF999999),
999 ),
1000 tooltip: l10n.cancel,
949 - onPressed: () => ref.read(foregroundDownloadNotifierProvider.notifier).cancelDownload(model.id),
1001 + onPressed: () => ref
1002 + .read(foregroundDownloadNotifierProvider.notifier)
1003 + .cancelDownload(model.id),
1004 ),
1005 ] else ...[
1006 _IconButton(
1007 icon: Icon(
1008 Icons.cloud_download_outlined,
1009 size: 18,
956 - color: isDark ? const Color(0xFF555555) : const Color(0xFF999999),
1010 + color: isDark
1011 + ? const Color(0xFF555555)
1012 + : const Color(0xFF999999),
1013 ),
1014 tooltip: l10n.download,
1015 onPressed: () {
@@ -967,7 +1023,9 @@ class _OnDeviceModelTile extends ConsumerWidget {
1023 l10n.not_downloaded,
1024 style: TextStyle(
1025 fontSize: 11,
970 - color: isDark ? const Color(0xFF666666) : const Color(0xFF999999),
1026 + color: isDark
1027 + ? const Color(0xFF666666)
1028 + : const Color(0xFF999999),
1029 ),
1030 ),
1031 ],
@@ -982,16 +1040,17 @@ class _OnDeviceModelTile extends ConsumerWidget {
1040 }
1041
1042 void _loadModel(BuildContext context, WidgetRef ref) async {
1043 + final settings = ref.read(settingsProvider);
1044 + final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
1045 +
1046 if (!Platform.isIOS && deviceMemory != null) {
1047 if (!deviceMemory!.hasEnoughRam(model.minRamMb)) {
1048 final proceed = await _showRamWarning(context);
1049 + if (!context.mounted) return;
1050 if (!proceed) return;
1051 }
1052 }
1053
992 - final settings = ref.read(settingsProvider);
993 - final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
994 -
1054 final modelInfo = ModelInfo(
1055 id: model.id,
1056 name: model.name,
@@ -1004,13 +1063,12 @@ class _OnDeviceModelTile extends ConsumerWidget {
1063 );
1064
1065 await engineNotifier.loadModel(model.id, settings.preferredBackend);
1066 + if (!context.mounted) return;
1067
1068 final engineState = ref.read(onDeviceEngineProvider);
1069 if (engineState.loadedModelId == model.id) {
1070 ref.read(selectedModelProvider.notifier).setModel(modelInfo);
1011 - if (context.mounted) {
1012 - Navigator.pop(context);
1013 - }
1071 + Navigator.pop(context);
1072 }
1073 }
1074
@@ -1067,6 +1125,7 @@ class _OnDeviceModelTile extends ConsumerWidget {
1125 void _unloadModel(BuildContext context, WidgetRef ref) async {
1126 final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
1127 await engineNotifier.unloadModel();
1128 + if (!context.mounted) return;
1129
1130 final selectedModel = ref.read(selectedModelProvider);
1131 if (selectedModel?.id == model.id) {
@@ -1095,14 +1154,21 @@ class _OnDeviceModelTile extends ConsumerWidget {
1154 );
1155
1156 if (confirm == true) {
1157 + if (!context.mounted) return;
1158 +
1159 final engineState = ref.read(onDeviceEngineProvider);
1160 + final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
1161 + final selectedModelNotifier = ref.read(selectedModelProvider.notifier);
1162 + final gemmaService = ref.read(onDeviceGemmaServiceProvider);
1163 +
1164 if (engineState.loadedModelId == model.id) {
1100 - await ref.read(onDeviceEngineProvider.notifier).unloadModel();
1101 - ref.read(selectedModelProvider.notifier).clear();
1165 + await engineNotifier.unloadModel();
1166 + if (!context.mounted) return;
1167 + selectedModelNotifier.clear();
1168 }
1169
1104 - final gemmaService = ref.read(onDeviceGemmaServiceProvider);
1170 await gemmaService.deleteModel(model.id);
1171 + if (!context.mounted) return;
1172 ref.invalidate(downloadedModelsProvider);
1173 }
1174 }
lib/features/on_device/data/models/on_device_model.dart
+171 -5
@@ -12,6 +12,13 @@ class OnDeviceModel {
12 final bool isRecommended;
13 final int minRamMb;
14 final String parameterLabel;
15 + final String bestFor;
16 + final bool supportsFunctionCalling;
17 + final bool supportsThinking;
18 + final bool supportsVision;
19 + final String languagesLabel;
20 + final String? backendNote;
21 + final bool isCpuOnly;
22
23 const OnDeviceModel({
24 required this.id,
@@ -23,6 +30,13 @@ class OnDeviceModel {
30 this.isRecommended = false,
31 required this.minRamMb,
32 required this.parameterLabel,
33 + required this.bestFor,
34 + this.supportsFunctionCalling = false,
35 + this.supportsThinking = false,
36 + this.supportsVision = false,
37 + required this.languagesLabel,
38 + this.backendNote,
39 + this.isCpuOnly = false,
40 });
41
42 String get fileSizeFormatted {
@@ -45,7 +59,12 @@ class OnDeviceModel {
59 case 'deepseek-r1-distill-qwen-1.5b':
60 return ModelType.deepSeek;
61 case 'gemma4-e2b-instruct':
62 + case 'gemma4-e4b-instruct':
63 return ModelType.gemma4;
64 + case 'phi-4-mini-instruct':
65 + return ModelType.phi;
66 + case 'functiongemma-270m':
67 + return ModelType.functionGemma;
68 default:
69 return ModelType.general;
70 }
@@ -60,10 +79,14 @@ class OnDeviceModel {
79 fileSizeBytes: 614236160,
80 license: 'Apache-2.0',
81 description:
63 - 'Smallest general-purpose chat model. Fast responses, low memory usage.',
82 + 'Compact multilingual chat with function calling and thinking mode.',
83 isRecommended: true,
84 minRamMb: 2048,
85 parameterLabel: '0.6B',
86 + bestFor: 'Compact multilingual chat with function calling',
87 + supportsFunctionCalling: true,
88 + supportsThinking: true,
89 + languagesLabel: 'Multilingual',
90 ),
91 OnDeviceModel(
92 id: 'qwen2.5-1.5b-instruct',
@@ -72,9 +95,13 @@ class OnDeviceModel {
95 'https://huggingface.co/litert-community/Qwen2.5-1.5B-Instruct/resolve/main/Qwen2.5-1.5B-Instruct_multi-prefill-seq_q8_ekv4096.litertlm',
96 fileSizeBytes: 1597931520,
97 license: 'Apache-2.0',
75 - description: 'Balanced quality and size. Good for general conversation.',
98 + description:
99 + 'Strong multilingual chat and instruction following in a compact package.',
100 minRamMb: 3072,
101 parameterLabel: '1.5B',
102 + bestFor: 'Strong multilingual chat and instruction following',
103 + supportsFunctionCalling: true,
104 + languagesLabel: 'Multilingual',
105 ),
106 OnDeviceModel(
107 id: 'deepseek-r1-distill-qwen-1.5b',
@@ -84,9 +111,13 @@ class OnDeviceModel {
111 fileSizeBytes: 1833451520,
112 license: 'MIT',
113 description:
87 - 'Reasoning and chain-of-thought model. Best for logical tasks.',
114 + 'High-performance reasoning and code generation with thinking mode.',
115 minRamMb: 3584,
116 parameterLabel: '1.5B',
117 + bestFor: 'High-performance reasoning and code generation',
118 + supportsFunctionCalling: true,
119 + supportsThinking: true,
120 + languagesLabel: 'Multilingual',
121 ),
122 OnDeviceModel(
123 id: 'gemma4-e2b-instruct',
@@ -95,9 +126,144 @@ class OnDeviceModel {
126 'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm',
127 fileSizeBytes: 2583085056,
128 license: 'Apache-2.0',
98 - description: 'Google flagship model. Highest quality, requires more RAM.',
129 + description:
130 + 'Next-gen multimodal chat for text, image, and audio-capable workflows.',
131 + minRamMb: 5120,
132 + parameterLabel: 'E2B',
133 + bestFor: 'Next-gen multimodal chat: text, image, audio',
134 + supportsFunctionCalling: true,
135 + supportsThinking: true,
136 + supportsVision: true,
137 + languagesLabel: 'Multilingual',
138 + ),
139 + OnDeviceModel(
140 + id: 'gemma4-e4b-instruct',
141 + name: 'Gemma 4 E4B Instruct',
142 + huggingFaceUrl:
143 + 'https://huggingface.co/litert-community/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it.litertlm',
144 + fileSizeBytes: 4617089843,
145 + license: 'Apache-2.0',
146 + description:
147 + 'Larger Gemma 4 multimodal model for higher quality local chat.',
148 + minRamMb: 7168,
149 + parameterLabel: 'E4B',
150 + bestFor: 'Next-gen multimodal chat: text, image, audio',
151 + supportsFunctionCalling: true,
152 + supportsThinking: true,
153 + supportsVision: true,
154 + languagesLabel: 'Multilingual',
155 + ),
156 + OnDeviceModel(
157 + id: 'gemma3n-e2b-instruct',
158 + name: 'Gemma3n E2B Instruct',
159 + huggingFaceUrl:
160 + 'https://huggingface.co/google/gemma-3n-E2B-it-litert-lm/resolve/main/gemma-3n-E2B-it-int4.litertlm',
161 + fileSizeBytes: 3221225472,
162 + license: 'Gemma',
163 + description: 'On-device multimodal chat and image analysis.',
164 minRamMb: 5120,
100 - parameterLabel: '2B',
165 + parameterLabel: 'E2B',
166 + bestFor: 'On-device multimodal chat and image analysis',
167 + supportsFunctionCalling: true,
168 + supportsVision: true,
169 + languagesLabel: 'Multilingual',
170 + ),
171 + OnDeviceModel(
172 + id: 'fastvlm-0.5b',
173 + name: 'FastVLM 0.5B',
174 + huggingFaceUrl:
175 + 'https://huggingface.co/litert-community/FastVLM-0.5B/resolve/main/FastVLM-0.5B.litertlm',
176 + fileSizeBytes: 536870912,
177 + license: 'Apple AMLR',
178 + description: 'Fast vision-language inference for image understanding.',
179 + minRamMb: 2048,
180 + parameterLabel: '0.5B',
181 + bestFor: 'Fast vision-language inference',
182 + supportsVision: true,
183 + languagesLabel: 'Multilingual',
184 + ),
185 + OnDeviceModel(
186 + id: 'phi-4-mini-instruct',
187 + name: 'Phi-4 Mini Instruct',
188 + huggingFaceUrl:
189 + 'https://huggingface.co/litert-community/Phi-4-mini-instruct/resolve/main/Phi-4-mini-instruct_multi-prefill-seq_q8_ekv4096.litertlm',
190 + fileSizeBytes: 4187593114,
191 + license: 'MIT',
192 + description: 'Advanced reasoning and instruction following.',
193 + minRamMb: 6144,
194 + parameterLabel: '3.8B',
195 + bestFor: 'Advanced reasoning and instruction following',
196 + supportsFunctionCalling: true,
197 + languagesLabel: 'Multilingual',
198 + ),
199 + OnDeviceModel(
200 + id: 'gemma3-1b-it',
201 + name: 'Gemma 3 1B Instruct',
202 + huggingFaceUrl:
203 + 'https://huggingface.co/litert-community/Gemma3-1B-IT/resolve/main/gemma3-1b-it-int4.litertlm',
204 + fileSizeBytes: 536870912,
205 + license: 'Gemma',
206 + description: 'Balanced and efficient text generation.',
207 + minRamMb: 2048,
208 + parameterLabel: '1B',
209 + bestFor: 'Balanced and efficient text generation',
210 + supportsFunctionCalling: true,
211 + languagesLabel: 'Multilingual',
212 + ),
213 + OnDeviceModel(
214 + id: 'gemma3-270m-it',
215 + name: 'Gemma 3 270M Instruct',
216 + huggingFaceUrl:
217 + 'https://huggingface.co/litert-community/gemma-3-270m-it/resolve/main/gemma3-270m-it-q8.litertlm',
218 + fileSizeBytes: 322122547,
219 + license: 'Gemma',
220 + description: 'Small model suited for fine-tuning with LoRA.',
221 + minRamMb: 1024,
222 + parameterLabel: '270M',
223 + bestFor: 'Fine-tuning for specific tasks',
224 + languagesLabel: 'Multilingual',
225 + ),
226 + OnDeviceModel(
227 + id: 'functiongemma-270m',
228 + name: 'FunctionGemma 270M',
229 + huggingFaceUrl:
230 + 'https://huggingface.co/litert-community/functiongemma-270m-ft-mobile-actions/resolve/main/mobile_actions_q8_ekv1024.litertlm',
231 + fileSizeBytes: 297795584,
232 + license: 'Gemma',
233 + description: 'Specialized for function calling on-device.',
234 + minRamMb: 1024,
235 + parameterLabel: '270M',
236 + bestFor: 'Specialized on-device function calling',
237 + supportsFunctionCalling: true,
238 + languagesLabel: 'Multilingual',
239 + ),
240 + OnDeviceModel(
241 + id: 'smollm2-135m-instruct',
242 + name: 'SmolLM 135M Instruct',
243 + huggingFaceUrl:
244 + 'https://huggingface.co/litert-community/SmolLM2-135M-Instruct/resolve/main/SmolLM2_135M_Instruct.litertlm',
245 + fileSizeBytes: 141557760,
246 + license: 'Apache-2.0',
247 + description: 'Ultra-compact chat model for constrained devices.',
248 + minRamMb: 512,
249 + parameterLabel: '135M',
250 + bestFor: 'Ultra-compact resource-constrained devices',
251 + languagesLabel: 'English',
252 + ),
253 + OnDeviceModel(
254 + id: 'translategemma-4b-it',
255 + name: 'TranslateGemma 4B',
256 + huggingFaceUrl:
257 + 'https://huggingface.co/barakplasma/translategemma-4b-it-android-task-quantized/resolve/main/artifacts/int4-generic/translategemma-4b-it-int4-generic.litertlm',
258 + fileSizeBytes: 4294967296,
259 + license: 'Gemma',
260 + description: 'Single-shot translation across 55 languages.',
261 + minRamMb: 6144,
262 + parameterLabel: '4B',
263 + bestFor: 'Single-shot 55-language translation',
264 + languagesLabel: '55 languages',
265 + backendNote: 'CPU-only',
266 + isCpuOnly: true,
267 ),
268 ];
269 }
lib/features/on_device/providers/on_device_providers.dart
+14 -7
@@ -12,8 +12,8 @@ import '../data/on_device_gemma_service.dart';
12
13 final notificationPermissionServiceProvider =
14 Provider<NotificationPermissionService>((ref) {
15 - return NotificationPermissionService();
16 -});
15 + return NotificationPermissionService();
16 + });
17
18 final onDeviceGemmaServiceProvider = Provider<OnDeviceGemmaService>((ref) {
19 final service = OnDeviceGemmaService();
@@ -89,6 +89,12 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
89 await bgService.start();
90
91 try {
92 + final model = OnDeviceModel.curatedModels.firstWhere(
93 + (m) => m.id == modelId,
94 + orElse: () => throw Exception('Model not found: $modelId'),
95 + );
96 + final effectiveBackend = model.isCpuOnly ? PreferredBackend.cpu : backend;
97 +
98 // Yield control to the event loop so the UI has time to draw the loading indicator
99 // and stabilize before the native platform thread begins heavy model loading.
100 await Future.delayed(const Duration(milliseconds: 100));
@@ -101,15 +107,17 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
107 return;
108 }
109
104 - await _gemmaService.loadModel(modelId, backend);
110 + await _gemmaService.loadModel(modelId, effectiveBackend);
111
112 state = state.copyWith(
113 status: OnDeviceEngineStatus.loaded,
114 loadedModelId: modelId,
109 - backend: backend,
115 + backend: effectiveBackend,
116 );
117
112 - Log.info('Model $modelId loaded successfully with ${backend.name}');
118 + Log.info(
119 + 'Model $modelId loaded successfully with ${effectiveBackend.name}',
120 + );
121 } catch (e) {
122 Log.error('Failed to load model $modelId: $e');
123 state = state.copyWith(
@@ -149,8 +157,7 @@ class OnDeviceModelStateNotifier
157 PreferredBackend? backend,
158 OnDeviceEngineStatus? engineStatus,
159 }) {
152 - final current =
153 - state[modelId] ?? OnDeviceModelStateInfo(modelId: modelId);
160 + final current = state[modelId] ?? OnDeviceModelStateInfo(modelId: modelId);
161 state = {
162 ...state,
163 modelId: current.copyWith(
lib/features/on_device/views/model_manager_screen.dart
+137 -27
@@ -29,6 +29,22 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
29 final downloadProgress = ref.watch(foregroundDownloadNotifierProvider);
30 final deviceMemoryAsync = ref.watch(deviceMemoryProvider);
31
32 + ref.listen(onDeviceEngineProvider, (prev, next) {
33 + if (next.status == OnDeviceEngineStatus.loaded &&
34 + prev?.status == OnDeviceEngineStatus.loading) {
35 + ScaffoldMessenger.of(context).showSnackBar(
36 + SnackBar(
37 + content: Text(
38 + l10n.model_loaded(
39 + next.loadedModelId ?? 'Unknown',
40 + next.backend?.name ?? 'CPU',
41 + ),
42 + ),
43 + ),
44 + );
45 + }
46 + });
47 +
48 return Scaffold(
49 drawer: const SidebarWidget(),
50 appBar: AppBar(title: Text(l10n.on_device_models_title)),
@@ -51,7 +67,10 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
67 const SizedBox(width: 8),
68 Expanded(
69 child: Text(
54 - l10n.model_loaded(engineState.loadedModelId ?? 'Unknown', engineState.backend?.name ?? 'CPU'),
70 + l10n.model_loaded(
71 + engineState.loadedModelId ?? 'Unknown',
72 + engineState.backend?.name ?? 'CPU',
73 + ),
74 style: const TextStyle(color: Colors.green),
75 ),
76 ),
@@ -112,7 +131,7 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
131 final theme = Theme.of(context);
132 final usedMb = info.totalMemoryMb - info.availableMemoryMb;
133 final usagePercent = usedMb / info.totalMemoryMb;
115 -
134 +
135 // Memory health logic
136 Color statusColor = Colors.green;
137 String statusText = l10n.memory_healthy;
@@ -133,7 +152,9 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
152 end: Alignment.bottomRight,
153 colors: [
154 theme.colorScheme.surfaceContainerHighest,
136 - theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
155 + theme.colorScheme.surfaceContainerHighest.withValues(
156 + alpha: 0.5,
157 + ),
158 ],
159 ),
160 boxShadow: [
@@ -150,7 +171,12 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
171 child: Column(
172 children: [
173 Padding(
153 - padding: const EdgeInsetsDirectional.only(start: 20, top: 16, end: 12, bottom: 8),
174 + padding: const EdgeInsetsDirectional.only(
175 + start: 20,
176 + top: 16,
177 + end: 12,
178 + bottom: 8,
179 + ),
180 child: Row(
181 mainAxisAlignment: MainAxisAlignment.spaceBetween,
182 children: [
@@ -189,15 +215,22 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
215 Text(
216 l10n.ram_usage,
217 style: theme.textTheme.labelMedium?.copyWith(
192 - color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
218 + color: theme.colorScheme.onSurface.withValues(
219 + alpha: 0.6,
220 + ),
221 ),
222 ),
223 Container(
196 - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
224 + padding: const EdgeInsets.symmetric(
225 + horizontal: 8,
226 + vertical: 2,
227 + ),
228 decoration: BoxDecoration(
229 color: statusColor.withValues(alpha: 0.1),
230 borderRadius: BorderRadius.circular(12),
200 - border: Border.all(color: statusColor.withValues(alpha: 0.2)),
231 + border: Border.all(
232 + color: statusColor.withValues(alpha: 0.2),
233 + ),
234 ),
235 child: Text(
236 statusText,
@@ -222,7 +255,9 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
255 AnimatedContainer(
256 duration: const Duration(milliseconds: 500),
257 height: 10,
225 - width: MediaQuery.of(context).size.width * usagePercent,
258 + width:
259 + MediaQuery.of(context).size.width *
260 + usagePercent,
261 decoration: BoxDecoration(
262 gradient: LinearGradient(
263 colors: [
@@ -248,7 +283,9 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
283 mainAxisAlignment: MainAxisAlignment.spaceBetween,
284 children: [
285 Text(
251 - l10n.ram_used((usagePercent * 100).toStringAsFixed(0)),
286 + l10n.ram_used(
287 + (usagePercent * 100).toStringAsFixed(0),
288 + ),
289 style: theme.textTheme.labelSmall?.copyWith(
290 fontWeight: FontWeight.w600,
291 ),
@@ -256,7 +293,9 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
293 Text(
294 '${_formatMb(usedMb)} / ${info.totalMemoryFormatted}',
295 style: theme.textTheme.labelSmall?.copyWith(
259 - color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
296 + color: theme.colorScheme.onSurface.withValues(
297 + alpha: 0.5,
298 + ),
299 ),
300 ),
301 ],
@@ -267,7 +306,10 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
306 const SizedBox(height: 16),
307 const Divider(height: 1),
308 Padding(
270 - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20),
309 + padding: const EdgeInsets.symmetric(
310 + vertical: 12,
311 + horizontal: 20,
312 + ),
313 child: Row(
314 children: [
315 Expanded(
@@ -337,7 +379,8 @@ class _MemoryStat extends StatelessWidget {
379 @override
380 Widget build(BuildContext context) {
381 final theme = Theme.of(context);
340 - final iconColor = color ?? theme.colorScheme.onSurface.withValues(alpha: 0.4);
382 + final iconColor =
383 + color ?? theme.colorScheme.onSurface.withValues(alpha: 0.4);
384
385 return Column(
386 crossAxisAlignment: alignment,
@@ -365,11 +408,15 @@ class _MemoryStat extends StatelessWidget {
408 const SizedBox(height: 2),
409 Text(
410 value,
368 - style: (isPrimary ? theme.textTheme.titleMedium : theme.textTheme.bodyMedium)?.copyWith(
369 - fontWeight: FontWeight.bold,
370 - color: color,
371 - letterSpacing: isPrimary ? -0.5 : null,
372 - ),
411 + style:
412 + (isPrimary
413 + ? theme.textTheme.titleMedium
414 + : theme.textTheme.bodyMedium)
415 + ?.copyWith(
416 + fontWeight: FontWeight.bold,
417 + color: color,
418 + letterSpacing: isPrimary ? -0.5 : null,
419 + ),
420 ),
421 ],
422 );
@@ -462,20 +509,31 @@ class _ModelCard extends ConsumerWidget {
509 ),
510 ],
511 ),
465 - if (!Platform.isIOS && deviceMemory != null && deviceMemory!.isOversized(model.minRamMb))
512 + if (!Platform.isIOS &&
513 + deviceMemory != null &&
514 + deviceMemory!.isOversized(model.minRamMb))
515 Padding(
516 padding: const EdgeInsets.only(top: 6),
517 child: Container(
469 - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
518 + padding: const EdgeInsets.symmetric(
519 + horizontal: 8,
520 + vertical: 4,
521 + ),
522 decoration: BoxDecoration(
523 color: Colors.orange.withValues(alpha: 0.1),
524 borderRadius: BorderRadius.circular(6),
473 - border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
525 + border: Border.all(
526 + color: Colors.orange.withValues(alpha: 0.3),
527 + ),
528 ),
529 child: Row(
530 mainAxisSize: MainAxisSize.min,
531 children: [
478 - const Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 14),
532 + const Icon(
533 + Icons.warning_amber_rounded,
534 + color: Colors.orange,
535 + size: 14,
536 + ),
537 const SizedBox(width: 6),
538 Text(
539 l10n.may_be_large,
@@ -496,6 +554,8 @@ class _ModelCard extends ConsumerWidget {
554 ),
555 ),
556 const SizedBox(height: 8),
557 + _buildCapabilityChips(),
558 + const SizedBox(height: 8),
559 Row(
560 children: [
561 Text(
@@ -528,6 +588,45 @@ class _ModelCard extends ConsumerWidget {
588 );
589 }
590
591 + Widget _buildCapabilityChips() {
592 + final chips = <String>[
593 + if (model.supportsFunctionCalling) 'Tools',
594 + if (model.supportsThinking) 'Thinking',
595 + if (model.supportsVision) 'Vision',
596 + model.languagesLabel,
597 + if (model.backendNote != null) model.backendNote!,
598 + ];
599 +
600 + return Wrap(
601 + spacing: 6,
602 + runSpacing: 6,
603 + children: chips.map(_buildCapabilityChip).toList(),
604 + );
605 + }
606 +
607 + Widget _buildCapabilityChip(String label) {
608 + final isBackendNote = label == model.backendNote;
609 + return Container(
610 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
611 + decoration: BoxDecoration(
612 + color: (isBackendNote ? Colors.orange : theme.colorScheme.primary)
613 + .withValues(alpha: 0.1),
614 + borderRadius: BorderRadius.circular(6),
615 + border: Border.all(
616 + color: (isBackendNote ? Colors.orange : theme.colorScheme.primary)
617 + .withValues(alpha: 0.2),
618 + ),
619 + ),
620 + child: Text(
621 + label,
622 + style: theme.textTheme.labelSmall?.copyWith(
623 + color: isBackendNote ? Colors.orange : theme.colorScheme.primary,
624 + fontWeight: FontWeight.w600,
625 + ),
626 + ),
627 + );
628 + }
629 +
630 Widget _buildActionRow(
631 BuildContext context,
632 WidgetRef ref,
@@ -599,8 +698,12 @@ class _ModelCard extends ConsumerWidget {
698 child: LinearProgressIndicator(
699 value: downloadProgress?.progress ?? 0.0,
700 minHeight: 6,
602 - backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.1),
603 - valueColor: AlwaysStoppedAnimation<Color>(theme.colorScheme.primary),
701 + backgroundColor: theme.colorScheme.primary.withValues(
702 + alpha: 0.1,
703 + ),
704 + valueColor: AlwaysStoppedAnimation<Color>(
705 + theme.colorScheme.primary,
706 + ),
707 ),
708 ),
709 const SizedBox(height: 6),
@@ -609,7 +712,8 @@ class _ModelCard extends ConsumerWidget {
712 children: [
713 Text(
714 l10n.download_progress(
612 - ((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(1),
715 + ((downloadProgress?.progress ?? 0) * 100)
716 + .toStringAsFixed(1),
717 downloadProgress?.speedFormatted ?? '0 B/s',
718 ),
719 style: theme.textTheme.labelSmall?.copyWith(
@@ -617,7 +721,9 @@ class _ModelCard extends ConsumerWidget {
721 ),
722 ),
723 Text(
620 - l10n.eta_label(downloadProgress?.etaFormatted ?? l10n.calculating),
724 + l10n.eta_label(
725 + downloadProgress?.etaFormatted ?? l10n.calculating,
726 + ),
727 style: theme.textTheme.labelSmall,
728 ),
729 ],
@@ -626,7 +732,9 @@ class _ModelCard extends ConsumerWidget {
732 Text(
733 downloadProgress?.progressFormatted ?? '',
734 style: theme.textTheme.labelSmall?.copyWith(
629 - color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
735 + color: theme.colorScheme.onSurface.withValues(
736 + alpha: 0.5,
737 + ),
738 fontSize: 10,
739 ),
740 ),
@@ -672,7 +780,9 @@ class _ModelCard extends ConsumerWidget {
780 return Row(
781 children: [
782 Text(
675 - l10n.paused_progress(((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0)),
783 + l10n.paused_progress(
784 + ((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0),
785 + ),
786 style: theme.textTheme.bodySmall,
787 ),
788 const Spacer(),
lib/features/onboarding/screens/onboarding_model_download_screen.dart
+177 -95
@@ -46,101 +46,119 @@ class _OnboardingModelDownloadScreenState
46 body: SafeArea(
47 child: Cue.onMount(
48 motion: .smooth(),
49 - child: ListView(
50 - padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
49 + child: Column(
50 children: [
52 - Actor(
53 - acts: [
54 - .fadeIn(),
55 - .slideY(from: 0.08),
56 - ],
57 - child: Column(
58 - crossAxisAlignment: CrossAxisAlignment.stretch,
51 + Expanded(
52 + child: ListView(
53 + padding: const EdgeInsets.symmetric(
54 + horizontal: 24.0,
55 + vertical: 16.0,
56 + ),
57 children: [
60 - Text(
61 - l10n.download_model_desc,
62 - style: theme.textTheme.bodyMedium?.copyWith(
63 - color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
64 - ),
65 - ),
66 - const SizedBox(height: 24),
67 - if (!Platform.isAndroid && !Platform.isIOS)
68 - Container(
69 - padding: const EdgeInsets.all(12),
70 - margin: const EdgeInsets.only(bottom: 16),
71 - decoration: BoxDecoration(
72 - color: Colors.orange.withValues(alpha: 0.1),
73 - borderRadius: BorderRadius.circular(8),
74 - border: Border.all(color: Colors.orange),
75 - ),
76 - child: Row(
77 - children: [
78 - const Icon(Icons.info_outline, color: Colors.orange, size: 20),
79 - const SizedBox(width: 8),
80 - Expanded(
81 - child: Text(
82 - l10n.on_device_android_only,
83 - style: const TextStyle(color: Colors.orange),
58 + Actor(
59 + acts: [.fadeIn(), .slideY(from: 0.08)],
60 + child: Column(
61 + crossAxisAlignment: CrossAxisAlignment.stretch,
62 + children: [
63 + Text(
64 + l10n.download_model_desc,
65 + style: theme.textTheme.bodyMedium?.copyWith(
66 + color: theme.colorScheme.onSurface.withValues(
67 + alpha: 0.7,
68 ),
69 ),
86 - ],
87 - ),
70 + ),
71 + const SizedBox(height: 24),
72 + if (!Platform.isAndroid && !Platform.isIOS)
73 + Container(
74 + padding: const EdgeInsets.all(12),
75 + margin: const EdgeInsets.only(bottom: 16),
76 + decoration: BoxDecoration(
77 + color: Colors.orange.withValues(alpha: 0.1),
78 + borderRadius: BorderRadius.circular(8),
79 + border: Border.all(color: Colors.orange),
80 + ),
81 + child: Row(
82 + children: [
83 + const Icon(
84 + Icons.info_outline,
85 + color: Colors.orange,
86 + size: 20,
87 + ),
88 + const SizedBox(width: 8),
89 + Expanded(
90 + child: Text(
91 + l10n.on_device_android_only,
92 + style: const TextStyle(
93 + color: Colors.orange,
94 + ),
95 + ),
96 + ),
97 + ],
98 + ),
99 + ),
100 + _buildMemoryInfo(context, ref, deviceMemoryAsync),
101 + ],
102 ),
89 - _buildMemoryInfo(context, ref, deviceMemoryAsync),
90 - ],
91 - ),
92 - ),
93 - Actor(
94 - delay: 60.ms,
95 - acts: [
96 - .fadeIn(),
97 - .slideY(from: 0.08),
98 - ],
99 - child: Column(
100 - crossAxisAlignment: CrossAxisAlignment.stretch,
101 - children: models.map((model) {
102 - final isDownloaded = downloadedModelsAsync.when(
103 - data: (set) => set.contains(model.id),
104 - loading: () => false,
105 - error: (_, _) => false,
106 - );
107 - final progressInfo = downloadStates[model.id];
103 + ),
104 + Actor(
105 + delay: 60.ms,
106 + acts: [.fadeIn(), .slideY(from: 0.08)],
107 + child: Column(
108 + crossAxisAlignment: CrossAxisAlignment.stretch,
109 + children: models.map((model) {
110 + final isDownloaded = downloadedModelsAsync.when(
111 + data: (set) => set.contains(model.id),
112 + loading: () => false,
113 + error: (_, _) => false,
114 + );
115 + final progressInfo = downloadStates[model.id];
116
109 - return Padding(
110 - padding: const EdgeInsets.only(bottom: 12),
111 - child: _ModelCard(
112 - model: model,
113 - isDownloaded: isDownloaded,
114 - progressInfo: progressInfo,
115 - theme: theme,
116 - deviceMemory: deviceMemoryAsync.value,
117 + return Padding(
118 + padding: const EdgeInsets.only(bottom: 12),
119 + child: _ModelCard(
120 + model: model,
121 + isDownloaded: isDownloaded,
122 + progressInfo: progressInfo,
123 + theme: theme,
124 + deviceMemory: deviceMemoryAsync.value,
125 + ),
126 + );
127 + }).toList(),
128 ),
118 - );
119 - }).toList(),
129 + ),
130 + ],
131 ),
132 ),
133 Actor(
134 delay: 120.ms,
124 - acts: [
125 - .fadeIn(),
126 - .slideY(from: 0.08),
127 - ],
128 - child: Column(
129 - crossAxisAlignment: CrossAxisAlignment.stretch,
130 - children: [
131 - const SizedBox(height: 24),
132 - if (_isCreatingServer)
133 - const Center(child: CircularProgressIndicator())
134 - else
135 - ShadButton(
136 - width: double.infinity,
137 - onPressed: _canContinue() ? _createOnDeviceServer : null,
138 - child: Text(
139 - l10n.continue_action,
140 - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
135 + acts: [.fadeIn(), .slideY(from: 0.08)],
136 + child: Padding(
137 + padding: const EdgeInsets.symmetric(
138 + horizontal: 16,
139 + vertical: 8,
140 + ),
141 + child: Column(
142 + crossAxisAlignment: CrossAxisAlignment.stretch,
143 + children: [
144 + if (_isCreatingServer)
145 + const Center(child: CircularProgressIndicator())
146 + else
147 + ShadButton(
148 + width: double.infinity,
149 + onPressed: _canContinue()
150 + ? _createOnDeviceServer
151 + : null,
152 + child: Text(
153 + l10n.continue_action,
154 + style: const TextStyle(
155 + fontSize: 16,
156 + fontWeight: FontWeight.w600,
157 + ),
158 + ),
159 ),
142 - ),
143 - ],
160 + ],
161 + ),
162 ),
163 ),
164 ],
@@ -181,9 +199,13 @@ class _OnboardingModelDownloadScreenState
199 }
200 } catch (e) {
201 if (mounted) {
184 - ScaffoldMessenger.of(
185 - context,
186 - ).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.error_with_message(e.toString()))));
202 + ScaffoldMessenger.of(context).showSnackBar(
203 + SnackBar(
204 + content: Text(
205 + AppLocalizations.of(context)!.error_with_message(e.toString()),
206 + ),
207 + ),
208 + );
209 }
210 } finally {
211 if (mounted) {
@@ -206,9 +228,13 @@ class _OnboardingModelDownloadScreenState
228 padding: const EdgeInsets.all(12),
229 margin: const EdgeInsets.only(bottom: 24),
230 decoration: BoxDecoration(
209 - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
231 + color: theme.colorScheme.surfaceContainerHighest.withValues(
232 + alpha: 0.3,
233 + ),
234 borderRadius: BorderRadius.circular(12),
211 - border: Border.all(color: theme.colorScheme.outline.withValues(alpha: 0.1)),
235 + border: Border.all(
236 + color: theme.colorScheme.outline.withValues(alpha: 0.1),
237 + ),
238 ),
239 child: Row(
240 children: [
@@ -347,12 +373,18 @@ class _ModelCard extends ConsumerWidget {
373 ),
374 ],
375 ),
350 - if (!Platform.isIOS && deviceMemory != null && deviceMemory!.isOversized(model.minRamMb))
376 + if (!Platform.isIOS &&
377 + deviceMemory != null &&
378 + deviceMemory!.isOversized(model.minRamMb))
379 Padding(
380 padding: const EdgeInsets.only(top: 4),
381 child: Row(
382 children: [
355 - const Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 14),
383 + const Icon(
384 + Icons.warning_amber_rounded,
385 + color: Colors.orange,
386 + size: 14,
387 + ),
388 const SizedBox(width: 4),
389 Text(
390 l10n.may_be_large,
@@ -372,6 +404,8 @@ class _ModelCard extends ConsumerWidget {
404 ),
405 ),
406 const SizedBox(height: 8),
407 + _buildCapabilityChips(),
408 + const SizedBox(height: 8),
409 Row(
410 children: [
411 Text(model.fileSizeFormatted, style: theme.textTheme.labelMedium),
@@ -413,8 +447,12 @@ class _ModelCard extends ConsumerWidget {
447 child: LinearProgressIndicator(
448 value: progressInfo?.progress ?? 0.0,
449 minHeight: 6,
416 - backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.1),
417 - valueColor: AlwaysStoppedAnimation<Color>(theme.colorScheme.primary),
450 + backgroundColor: theme.colorScheme.primary.withValues(
451 + alpha: 0.1,
452 + ),
453 + valueColor: AlwaysStoppedAnimation<Color>(
454 + theme.colorScheme.primary,
455 + ),
456 ),
457 ),
458 const SizedBox(height: 6),
@@ -423,13 +461,16 @@ class _ModelCard extends ConsumerWidget {
461 children: [
462 Text(
463 l10n.download_progress(
426 - ((progressInfo?.progress ?? 0) * 100).toStringAsFixed(0),
464 + ((progressInfo?.progress ?? 0) * 100)
465 + .toStringAsFixed(0),
466 progressInfo?.speedFormatted ?? '0 B/s',
467 ),
468 style: theme.textTheme.labelSmall,
469 ),
470 Text(
432 - l10n.eta_label(progressInfo?.etaFormatted ?? l10n.calculating),
471 + l10n.eta_label(
472 + progressInfo?.etaFormatted ?? l10n.calculating,
473 + ),
474 style: theme.textTheme.labelSmall,
475 ),
476 ],
@@ -451,7 +492,9 @@ class _ModelCard extends ConsumerWidget {
492 Row(
493 children: [
494 Text(
454 - l10n.paused_progress('${((progressInfo?.progress ?? 0) * 100).toStringAsFixed(0)}%'),
495 + l10n.paused_progress(
496 + '${((progressInfo?.progress ?? 0) * 100).toStringAsFixed(0)}%',
497 + ),
498 style: theme.textTheme.bodySmall,
499 ),
500 const Spacer(),
@@ -473,6 +516,45 @@ class _ModelCard extends ConsumerWidget {
516 );
517 }
518
519 + Widget _buildCapabilityChips() {
520 + final chips = <String>[
521 + if (model.supportsFunctionCalling) 'Tools',
522 + if (model.supportsThinking) 'Thinking',
523 + if (model.supportsVision) 'Vision',
524 + model.languagesLabel,
525 + if (model.backendNote != null) model.backendNote!,
526 + ];
527 +
528 + return Wrap(
529 + spacing: 6,
530 + runSpacing: 6,
531 + children: chips.map(_buildCapabilityChip).toList(),
532 + );
533 + }
534 +
535 + Widget _buildCapabilityChip(String label) {
536 + final isBackendNote = label == model.backendNote;
537 + return Container(
538 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
539 + decoration: BoxDecoration(
540 + color: (isBackendNote ? Colors.orange : theme.colorScheme.primary)
541 + .withValues(alpha: 0.1),
542 + borderRadius: BorderRadius.circular(6),
543 + border: Border.all(
544 + color: (isBackendNote ? Colors.orange : theme.colorScheme.primary)
545 + .withValues(alpha: 0.2),
546 + ),
547 + ),
548 + child: Text(
549 + label,
550 + style: theme.textTheme.labelSmall?.copyWith(
551 + color: isBackendNote ? Colors.orange : theme.colorScheme.primary,
552 + fontWeight: FontWeight.w600,
553 + ),
554 + ),
555 + );
556 + }
557 +
558 Future<void> _startDownload(BuildContext context, WidgetRef ref) async {
559 if (!Platform.isIOS && deviceMemory != null) {
560 if (deviceMemory!.isOversized(model.minRamMb)) {
lib/features/settings/views/settings_screen.dart
+2 -1
@@ -26,6 +26,7 @@ class SettingsViews extends ConsumerWidget {
26 final settings = ref.watch(settingsProvider);
27 final currentTheme = ref.watch(themeModeProvider);
28 final systemBottomInset = bottomSystemInset(context);
29 + final packageInfo = ref.watch(packageInfoProvider);
30 final servers = (ref.watch(serversProvider).value ?? [])
31 .map((server) => (server.id, server.name))
32 .toList();
@@ -266,7 +267,7 @@ class SettingsViews extends ConsumerWidget {
267 accent: const Color(0xFFF59E0B),
268 children: [
269 _AboutPanel(
269 - title: '${l10n.app_name} v${l10n.app_version}',
270 + title: '${l10n.app_name} v${packageInfo.value?.version ?? l10n.app_version} (${packageInfo.value?.buildNumber ?? ''})',
271 subtitle: l10n.onboarding_connect_desc.replaceAll('\n', ' '),
272 providers: [
273 l10n.server_type_lm_studio,
pubspec.lock
+27 -27
@@ -777,10 +777,10 @@ packages:
777 dependency: transitive
778 description:
779 name: js
780 - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
780 + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
781 url: "https://pub.dev"
782 source: hosted
783 - version: "0.6.7"
783 + version: "0.7.2"
784 json_annotation:
785 dependency: transitive
786 description:
@@ -998,7 +998,7 @@ packages:
998 source: hosted
999 version: "2.2.0"
1000 package_info_plus:
1001 - dependency: transitive
1001 + dependency: "direct main"
1002 description:
1003 name: package_info_plus
1004 sha256: "4bf625947f6c7713ee242296a682e23e44823c09cf9d79e4f1238923c92db852"
@@ -1033,10 +1033,10 @@ packages:
1033 dependency: "direct main"
1034 description:
1035 name: path_provider
1036 - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
1036 + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
1037 url: "https://pub.dev"
1038 source: hosted
1039 - version: "2.1.5"
1039 + version: "2.1.6"
1040 path_provider_android:
1041 dependency: transitive
1042 description:
@@ -1065,10 +1065,10 @@ packages:
1065 dependency: transitive
1066 description:
1067 name: path_provider_platform_interface
1068 - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
1068 + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
1069 url: "https://pub.dev"
1070 source: hosted
1071 - version: "2.1.2"
1071 + version: "2.1.3"
1072 path_provider_windows:
1073 dependency: transitive
1074 description:
@@ -1281,74 +1281,74 @@ packages:
1281 dependency: "direct main"
1282 description:
1283 name: sherpa_onnx
1284 - sha256: b5a503cedc0605134770015fd4dba00c62f109f8a16630a52ebc04ef99ccbf93
1284 + sha256: "2177e58a3ac54f14440268af71a1118bde037f0ceb3a83f97ff6abcd9831a82e"
1285 url: "https://pub.dev"
1286 source: hosted
1287 - version: "1.13.2"
1287 + version: "1.13.3"
1288 sherpa_onnx_android_arm64:
1289 dependency: transitive
1290 description:
1291 name: sherpa_onnx_android_arm64
1292 - sha256: b6aeb7cd5b32c6d1bd209a4bff0f28044d95977620d20e1d926b0c31e0a0725f
1292 + sha256: "4f51639037b0dd951a0e1af4632038e03ac9ffa226a5b92c6d8fdc495ce8a491"
1293 url: "https://pub.dev"
1294 source: hosted
1295 - version: "1.13.2"
1295 + version: "1.13.3"
1296 sherpa_onnx_android_armeabi:
1297 dependency: transitive
1298 description:
1299 name: sherpa_onnx_android_armeabi
1300 - sha256: ab647a0a4ca4b7c31159db1bdfc79b718882a088a2ed9757b340856d626715c0
1300 + sha256: "845d4087fbc88b142ae632a9cd27a538a9064ab84de24537e62d80c5e405de11"
1301 url: "https://pub.dev"
1302 source: hosted
1303 - version: "1.13.2"
1303 + version: "1.13.3"
1304 sherpa_onnx_android_x86:
1305 dependency: transitive
1306 description:
1307 name: sherpa_onnx_android_x86
1308 - sha256: df0e8bf68ff564bf8683f285ee3e65fb296ec118dbf6ce60a906bb1b3206120e
1308 + sha256: d6f1813d6a7ec28c32bc5a4e641680c574ca012a49f253a00e20bab13e16d318
1309 url: "https://pub.dev"
1310 source: hosted
1311 - version: "1.13.2"
1311 + version: "1.13.3"
1312 sherpa_onnx_android_x86_64:
1313 dependency: transitive
1314 description:
1315 name: sherpa_onnx_android_x86_64
1316 - sha256: f9f63fafde9498bed9de45a0e1d3f2eb4d649ad257e5de30b91d139432750b84
1316 + sha256: "5815b7d14804158a82615dc5381d23b3765eb29328496d6fd0888ba59168a24c"
1317 url: "https://pub.dev"
1318 source: hosted
1319 - version: "1.13.2"
1319 + version: "1.13.3"
1320 sherpa_onnx_ios:
1321 dependency: transitive
1322 description:
1323 name: sherpa_onnx_ios
1324 - sha256: "46b30fe5db3e0741026e94f09f3515dc60071eb970e12039265e1ba98c4d812d"
1324 + sha256: "8c6cbd44157caf6389640640e0f40d20eb32d49f786bf455a457c47a4c6fd095"
1325 url: "https://pub.dev"
1326 source: hosted
1327 - version: "1.13.2"
1327 + version: "1.13.3"
1328 sherpa_onnx_linux:
1329 dependency: transitive
1330 description:
1331 name: sherpa_onnx_linux
1332 - sha256: e0b2780d809a5be6cc36b7915bbe25dd15059e357a7f0ac684feb4e6ad4e20b7
1332 + sha256: bfb7e75568a8a104c54a49601b0818ab014aa3b20d59e94c6c050f3baf22acb8
1333 url: "https://pub.dev"
1334 source: hosted
1335 - version: "1.13.2"
1335 + version: "1.13.3"
1336 sherpa_onnx_macos:
1337 dependency: transitive
1338 description:
1339 name: sherpa_onnx_macos
1340 - sha256: "8049aae4e290d01ce51267541a770eb5a61c8164d46c1127406557995b054875"
1340 + sha256: "9972de65e3014d47211e581d126439c4d9b67878527bf27a2fb0fb73466751bd"
1341 url: "https://pub.dev"
1342 source: hosted
1343 - version: "1.13.2"
1343 + version: "1.13.3"
1344 sherpa_onnx_windows:
1345 dependency: transitive
1346 description:
1347 name: sherpa_onnx_windows
1348 - sha256: "428c5fc2fea2d9083555d6281224ab7dba3a33a8fbc013de18ea843cf1236f14"
1348 + sha256: aa69acb8334509f345b17c6527dc1b5920a6e5cc4d14bcba22767ae22b0ae0ad
1349 url: "https://pub.dev"
1350 source: hosted
1351 - version: "1.13.2"
1351 + version: "1.13.3"
1352 sky_engine:
1353 dependency: transitive
1354 description: flutter
@@ -1567,10 +1567,10 @@ packages:
1567 dependency: transitive
1568 description:
1569 name: theme_extensions_builder_annotation
1570 - sha256: "75f28ac85d396d143d111a47c1395b01f3be41b7135f37bd51512921944e4206"
1570 + sha256: "9249af881cd892aeac002bb016628becac51f12439df9af2a23e3c516b0470f5"
1571 url: "https://pub.dev"
1572 source: hosted
1573 - version: "7.3.0"
1573 + version: "7.4.0"
1574 timezone:
1575 dependency: transitive
1576 description:
pubspec.yaml
+2 -1
@@ -59,7 +59,7 @@ dependencies:
59 objectbox: ^5.3.1
60 objectbox_flutter_libs: ^5.3.1
61 path: ^1.9.1
62 - flutter_gemma: ^0.16.1
62 + flutter_gemma: 0.16.5
63 wakelock_plus: ^1.2.8
64 flutter_local_notifications: ^22.0.1
65 audioplayers: ^6.4.0
@@ -67,6 +67,7 @@ dependencies:
67 just_audio_background: ^0.0.1-beta.17
68 audio_session: ^0.2.3
69 device_info_plus: ^13.1.0
70 + package_info_plus: ^10.1.0
71 sherpa_onnx: ^1.13.2
72 archive: ^4.0.2
73 loading_animation_widget: ^1.3.0
test/features/on_device/on_device_model_catalog_test.dart new
+88
@@ -0,0 +1,88 @@
1 +import 'package:flutter_gemma/flutter_gemma.dart';
2 +import 'package:flutter_test/flutter_test.dart';
3 +import 'package:localmind/features/on_device/data/models/on_device_model.dart';
4 +
5 +void main() {
6 + group('OnDeviceModel curated catalog', () {
7 + test('contains every requested model row with unique ids', () {
8 + const expectedIds = {
9 + 'gemma4-e2b-instruct',
10 + 'gemma4-e4b-instruct',
11 + 'gemma3n-e2b-instruct',
12 + 'fastvlm-0.5b',
13 + 'phi-4-mini-instruct',
14 + 'deepseek-r1-distill-qwen-1.5b',
15 + 'qwen3-0.6b',
16 + 'qwen2.5-1.5b-instruct',
17 + 'gemma3-1b-it',
18 + 'gemma3-270m-it',
19 + 'functiongemma-270m',
20 + 'smollm2-135m-instruct',
21 + 'translategemma-4b-it',
22 + };
23 +
24 + final ids = OnDeviceModel.curatedModels.map((model) => model.id).toSet();
25 +
26 + expect(ids, containsAll(expectedIds));
27 + expect(ids.length, OnDeviceModel.curatedModels.length);
28 + });
29 +
30 + test('uses concrete downloadable bundle urls', () {
31 + for (final model in OnDeviceModel.curatedModels) {
32 + expect(model.huggingFaceUrl, startsWith('https://huggingface.co/'));
33 + expect(
34 + model.huggingFaceUrl,
35 + anyOf(endsWith('.litertlm'), endsWith('.task')),
36 + reason: '${model.id} must point to a LiteRT model bundle',
37 + );
38 + expect(model.fileName, isNotEmpty);
39 + expect(model.fileName, isNot(contains('/')));
40 + }
41 + });
42 +
43 + test(
44 + 'maps models with specialized chat formats to explicit model types',
45 + () {
46 + final byId = {
47 + for (final model in OnDeviceModel.curatedModels) model.id: model,
48 + };
49 +
50 + expect(
51 + byId['gemma4-e2b-instruct']!.flutterGemmaModelType,
52 + ModelType.gemma4,
53 + );
54 + expect(
55 + byId['gemma4-e4b-instruct']!.flutterGemmaModelType,
56 + ModelType.gemma4,
57 + );
58 + expect(byId['qwen3-0.6b']!.flutterGemmaModelType, ModelType.qwen3);
59 + expect(
60 + byId['qwen2.5-1.5b-instruct']!.flutterGemmaModelType,
61 + ModelType.qwen,
62 + );
63 + expect(
64 + byId['deepseek-r1-distill-qwen-1.5b']!.flutterGemmaModelType,
65 + ModelType.deepSeek,
66 + );
67 + expect(
68 + byId['phi-4-mini-instruct']!.flutterGemmaModelType,
69 + ModelType.phi,
70 + );
71 + expect(
72 + byId['functiongemma-270m']!.flutterGemmaModelType,
73 + ModelType.functionGemma,
74 + );
75 + },
76 + );
77 +
78 + test('marks TranslateGemma as CPU-only', () {
79 + final translateGemma = OnDeviceModel.curatedModels.singleWhere(
80 + (model) => model.id == 'translategemma-4b-it',
81 + );
82 +
83 + expect(translateGemma.isCpuOnly, isTrue);
84 + expect(translateGemma.backendNote, 'CPU-only');
85 + expect(translateGemma.languagesLabel, '55 languages');
86 + });
87 + });
88 +}