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
+}