11 files changed
+1659
-132
lib/features/chat/views/components/chat_bubble/assistant_bubble.dart
+72
-10
@@ -90,7 +90,9 @@ class AssistantBubble extends StatelessWidget {
90
_formatTime(message.createdAt),
91
style: TextStyle(
92
fontSize: 11,
93
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
93
+ color: isDark
94
+ ? AppColors.darkMutedText
95
+ : AppColors.lightMutedText,
96
),
97
),
98
if (message.status == MessageStatus.error) ...[
@@ -168,22 +170,82 @@ class _StreamingContentState extends State<_StreamingContent> {
170
171
@override
172
Widget build(BuildContext context) {
171
- return MarkdownBodyContent(
172
- content: _visibleContent,
173
- isDark: widget.isDark,
174
- );
173
+ return MarkdownBodyContent(content: _visibleContent, isDark: widget.isDark);
174
}
175
}
176
178
-class _StreamingIndicator extends StatelessWidget {
177
+class _StreamingIndicator extends StatefulWidget {
178
const _StreamingIndicator();
179
180
+ @override
181
+ State<_StreamingIndicator> createState() => _StreamingIndicatorState();
182
+}
183
+
184
+class _StreamingIndicatorState extends State<_StreamingIndicator>
185
+ with SingleTickerProviderStateMixin {
186
+ late final AnimationController _controller;
187
+ late final List<Animation<double>> _animations;
188
+
189
+ @override
190
+ void initState() {
191
+ super.initState();
192
+ _controller = AnimationController(
193
+ duration: const Duration(milliseconds: 1100),
194
+ vsync: this,
195
+ )..repeat();
196
+
197
+ _animations = List.generate(3, (index) {
198
+ return Tween<double>(begin: 0, end: 1).animate(
199
+ CurvedAnimation(
200
+ parent: _controller,
201
+ curve: Interval(
202
+ index * 0.2,
203
+ 0.6 + index * 0.2,
204
+ curve: Curves.easeInOut,
205
+ ),
206
+ ),
207
+ );
208
+ });
209
+ }
210
+
211
+ @override
212
+ void dispose() {
213
+ _controller.dispose();
214
+ super.dispose();
215
+ }
216
+
217
@override
218
Widget build(BuildContext context) {
183
- return const SizedBox(
184
- width: 12,
185
- height: 12,
186
- child: CircularProgressIndicator(strokeWidth: 2),
219
+ final isDark = Theme.of(context).brightness == Brightness.dark;
220
+ final dotColor = isDark
221
+ ? AppColors.darkMutedText
222
+ : AppColors.lightMutedText;
223
+
224
+ return Row(
225
+ mainAxisSize: MainAxisSize.min,
226
+ children: List.generate(3, (index) {
227
+ return AnimatedBuilder(
228
+ animation: _animations[index],
229
+ builder: (context, child) {
230
+ return Container(
231
+ margin: EdgeInsets.only(right: index == 2 ? 0 : 4),
232
+ child: Transform.translate(
233
+ offset: Offset(0, -2.5 * _animations[index].value),
234
+ child: Container(
235
+ width: 5,
236
+ height: 5,
237
+ decoration: BoxDecoration(
238
+ color: dotColor.withValues(
239
+ alpha: 0.35 + 0.65 * _animations[index].value,
240
+ ),
241
+ shape: BoxShape.circle,
242
+ ),
243
+ ),
244
+ ),
245
+ );
246
+ },
247
+ );
248
+ }),
249
);
250
}
251
}
lib/features/on_device/components/model_card.dart
+84
-22
@@ -71,11 +71,7 @@ class ModelCard extends ConsumerWidget {
71
child: Column(
72
crossAxisAlignment: CrossAxisAlignment.start,
73
children: [
74
- _ModelCardHeader(
75
- model: model,
76
- theme: theme,
77
- l10n: l10n,
78
- ),
74
+ _ModelCardHeader(model: model, theme: theme, l10n: l10n),
75
if (!Platform.isIOS &&
76
deviceMemory != null &&
77
deviceMemory!.isOversized(model.minRamMb))
@@ -161,7 +157,7 @@ class _ModelCardHeader extends StatelessWidget {
157
borderRadius: BorderRadius.circular(4),
158
),
159
child: Text(
164
- 'Imported',
160
+ model.importedSourceLabel ?? 'Imported',
161
style: theme.textTheme.labelSmall?.copyWith(
162
color: Colors.blue,
163
fontWeight: FontWeight.bold,
@@ -190,7 +186,11 @@ class _MemoryWarningBadge extends StatelessWidget {
186
child: Row(
187
mainAxisSize: MainAxisSize.min,
188
children: [
193
- const Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 14),
189
+ const Icon(
190
+ Icons.warning_amber_rounded,
191
+ color: Colors.orange,
192
+ size: 14,
193
+ ),
194
const SizedBox(width: 6),
195
Text(
196
l10n.may_be_large,
@@ -220,7 +220,9 @@ class _CapabilityChips extends StatelessWidget {
220
if (model.supportsThinking) 'Thinking',
221
if (model.supportsVision) 'Vision',
222
model.languagesLabel,
223
- if (model.backendNote != null) model.backendNote!,
223
+ if (model.backendNote != null &&
224
+ model.backendNote!.toLowerCase() != 'llama.cpp')
225
+ model.backendNote!,
226
if (model.requiresHuggingFaceToken) l10n.model_requires_huggingface_token,
227
];
228
@@ -254,30 +256,86 @@ class _CapabilityChips extends StatelessWidget {
256
}
257
258
class _ModelMetaRow extends StatelessWidget {
257
- const _ModelMetaRow({required this.model, required this.theme, required this.l10n});
259
+ const _ModelMetaRow({
260
+ required this.model,
261
+ required this.theme,
262
+ required this.l10n,
263
+ });
264
final OnDeviceModel model;
265
final ThemeData theme;
266
final AppLocalizations l10n;
267
268
@override
269
Widget build(BuildContext context) {
264
- return Row(
270
+ return Column(
271
+ crossAxisAlignment: CrossAxisAlignment.start,
272
children: [
266
- Text(model.fileSizeFormatted, style: theme.textTheme.labelMedium),
267
- const SizedBox(width: 12),
268
- Text(model.license, style: theme.textTheme.labelMedium),
269
- const SizedBox(width: 12),
270
- Text(
271
- l10n.ram_min_required('${model.minRamMb ~/ 1024 + 1}'),
272
- style: theme.textTheme.labelMedium?.copyWith(
273
- color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
274
- ),
273
+ Wrap(
274
+ spacing: 8,
275
+ runSpacing: 8,
276
+ children: [
277
+ _MetaPill(label: model.fileSizeFormatted, theme: theme),
278
+ if (!model.isImported)
279
+ _MetaPill(label: model.license, theme: theme),
280
+ _MetaPill(
281
+ label: l10n.ram_min_required('${model.minRamMb ~/ 1024 + 1}'),
282
+ theme: theme,
283
+ muted: true,
284
+ ),
285
+ ],
286
),
287
+ if (model.isImported) ...[
288
+ const SizedBox(height: 8),
289
+ Text(
290
+ model.fileName,
291
+ maxLines: 1,
292
+ overflow: TextOverflow.ellipsis,
293
+ style: theme.textTheme.labelSmall?.copyWith(
294
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.58),
295
+ ),
296
+ ),
297
+ ],
298
],
299
);
300
}
301
}
302
303
+class _MetaPill extends StatelessWidget {
304
+ const _MetaPill({
305
+ required this.label,
306
+ required this.theme,
307
+ this.muted = false,
308
+ });
309
+
310
+ final String label;
311
+ final ThemeData theme;
312
+ final bool muted;
313
+
314
+ @override
315
+ Widget build(BuildContext context) {
316
+ final color = muted
317
+ ? theme.colorScheme.onSurface.withValues(alpha: 0.62)
318
+ : theme.colorScheme.onSurface;
319
+
320
+ return Container(
321
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
322
+ decoration: BoxDecoration(
323
+ color: theme.colorScheme.surfaceContainerHighest.withValues(
324
+ alpha: 0.45,
325
+ ),
326
+ borderRadius: BorderRadius.circular(999),
327
+ ),
328
+ child: Text(
329
+ label,
330
+ style: theme.textTheme.labelSmall?.copyWith(
331
+ color: color,
332
+ fontWeight: FontWeight.w600,
333
+ ),
334
+ ),
335
+ );
336
+ }
337
+}
338
+
339
class _ModelCardActions extends ConsumerWidget {
340
const _ModelCardActions({
341
required this.model,
@@ -395,7 +453,8 @@ class _DownloadedActions extends ConsumerWidget {
453
else if (isLoaded)
454
ShadButton.outline(
455
size: ShadButtonSize.sm,
398
- onPressed: () => ref.read(onDeviceEngineProvider.notifier).unloadModel(),
456
+ onPressed: () =>
457
+ ref.read(onDeviceEngineProvider.notifier).unloadModel(),
458
child: Text(l10n.unload),
459
)
460
else
@@ -487,7 +546,9 @@ class _DownloadingActions extends ConsumerWidget {
546
child: LinearProgressIndicator(
547
value: downloadProgress?.progress ?? 0.0,
548
minHeight: 6,
490
- backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.1),
549
+ backgroundColor: theme.colorScheme.primary.withValues(
550
+ alpha: 0.1,
551
+ ),
552
valueColor: AlwaysStoppedAnimation<Color>(
553
theme.colorScheme.primary,
554
),
@@ -499,7 +560,8 @@ class _DownloadingActions extends ConsumerWidget {
560
children: [
561
Text(
562
l10n.download_progress(
502
- ((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(1),
563
+ ((downloadProgress?.progress ?? 0) * 100)
564
+ .toStringAsFixed(1),
565
downloadProgress?.speedFormatted ?? '0 B/s',
566
),
567
style: theme.textTheme.labelSmall?.copyWith(
lib/features/on_device/components/on_device_picker_section.dart
+101
-33
@@ -82,39 +82,97 @@ class OnDevicePickerSection extends ConsumerWidget {
82
: l10n.no_models_match(searchQuery),
83
style: TextStyle(
84
fontSize: 14,
85
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
85
+ color: isDark
86
+ ? AppColors.darkMutedText
87
+ : AppColors.lightMutedText,
88
),
89
),
90
);
91
}
92
91
- return ListView.builder(
92
- itemCount: filtered.length,
93
- itemBuilder: (context, index) {
94
- final model = filtered[index];
95
- final isDownloaded = downloadedIds.contains(model.id);
96
- final isLoaded = engineState.loadedModelId == model.id;
97
- final isCurrentlyLoading =
98
- engineState.status == OnDeviceEngineStatus.loading &&
99
- engineState.loadedModelId == model.id;
100
- final isSelected = selectedModelId == model.id;
101
-
102
- return _OnDeviceModelTile(
103
- model: model,
104
- isDownloaded: isDownloaded,
105
- isLoaded: isLoaded,
106
- isCurrentlyLoading: isCurrentlyLoading,
107
- isSelected: isSelected,
108
- isDark: isDark,
109
- deviceMemory: deviceMemoryAsync.value,
93
+ final importedModels =
94
+ filtered.where((model) => model.isImported).toList()..sort(
95
+ (a, b) => (b.importedAt ?? DateTime.fromMillisecondsSinceEpoch(0))
96
+ .compareTo(
97
+ a.importedAt ?? DateTime.fromMillisecondsSinceEpoch(0),
98
+ ),
99
);
111
- },
100
+ final downloadableModels = filtered
101
+ .where((model) => !model.isImported)
102
+ .toList();
103
+
104
+ Widget buildTile(OnDeviceModel model) {
105
+ final isDownloaded = downloadedIds.contains(model.id);
106
+ final isLoaded = engineState.loadedModelId == model.id;
107
+ final isCurrentlyLoading =
108
+ engineState.status == OnDeviceEngineStatus.loading &&
109
+ engineState.loadedModelId == model.id;
110
+ final isSelected = selectedModelId == model.id;
111
+
112
+ return _OnDeviceModelTile(
113
+ model: model,
114
+ isDownloaded: isDownloaded,
115
+ isLoaded: isLoaded,
116
+ isCurrentlyLoading: isCurrentlyLoading,
117
+ isSelected: isSelected,
118
+ isDark: isDark,
119
+ deviceMemory: deviceMemoryAsync.value,
120
+ );
121
+ }
122
+
123
+ return ListView(
124
+ children: [
125
+ if (importedModels.isNotEmpty) ...[
126
+ const SizedBox(height: 4),
127
+ const _PickerSectionLabel(
128
+ title: 'Imported GGUF',
129
+ subtitle: 'Already available on this device',
130
+ ),
131
+ ...importedModels.map(buildTile),
132
+ const SizedBox(height: 8),
133
+ ],
134
+ if (downloadableModels.isNotEmpty) ...[
135
+ _PickerSectionLabel(
136
+ title: l10n.available_models,
137
+ subtitle: 'Curated on-device models',
138
+ ),
139
+ ...downloadableModels.map(buildTile),
140
+ ],
141
+ ],
142
);
143
},
144
);
145
}
146
}
147
148
+class _PickerSectionLabel extends StatelessWidget {
149
+ const _PickerSectionLabel({required this.title, required this.subtitle});
150
+
151
+ final String title;
152
+ final String subtitle;
153
+
154
+ @override
155
+ Widget build(BuildContext context) {
156
+ final isDark = Theme.of(context).brightness == Brightness.dark;
157
+ final muted = isDark ? AppColors.darkMutedText : AppColors.lightMutedText;
158
+
159
+ return Padding(
160
+ padding: const EdgeInsets.fromLTRB(4, 0, 4, 8),
161
+ child: Column(
162
+ crossAxisAlignment: CrossAxisAlignment.start,
163
+ children: [
164
+ Text(
165
+ title,
166
+ style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700),
167
+ ),
168
+ const SizedBox(height: 2),
169
+ Text(subtitle, style: TextStyle(fontSize: 11, color: muted)),
170
+ ],
171
+ ),
172
+ );
173
+ }
174
+}
175
+
176
class _OnDeviceModelTile extends ConsumerWidget {
177
const _OnDeviceModelTile({
178
required this.model,
@@ -224,9 +282,9 @@ class _OnDeviceModelTile extends ConsumerWidget {
282
color: Colors.blue.withValues(alpha: 0.1),
283
borderRadius: BorderRadius.circular(4),
284
),
227
- child: const Text(
228
- 'Imported',
229
- style: TextStyle(
285
+ child: Text(
286
+ model.importedSourceLabel ?? 'Imported',
287
+ style: const TextStyle(
288
fontSize: 9,
289
fontWeight: FontWeight.bold,
290
color: Colors.blue,
@@ -302,11 +360,13 @@ class _OnDeviceModelTile extends ConsumerWidget {
360
MetadataChip(label: 'GGUF', isDark: isDark),
361
if (model.runtime == OnDeviceModelRuntime.llamaCpp)
362
MetadataChip(label: 'llama.cpp', isDark: isDark),
305
- MetadataChip(label: model.license, isDark: isDark),
306
- MetadataChip(
307
- label: model.parameterLabel,
308
- isDark: isDark,
309
- ),
363
+ if (!model.isImported)
364
+ MetadataChip(label: model.license, isDark: isDark),
365
+ if (!model.isImported)
366
+ MetadataChip(
367
+ label: model.parameterLabel,
368
+ isDark: isDark,
369
+ ),
370
],
371
),
372
],
@@ -349,7 +409,9 @@ class _OnDeviceModelTile extends ConsumerWidget {
409
icon: Icon(
410
Icons.delete_outline,
411
size: 18,
352
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
412
+ color: isDark
413
+ ? AppColors.darkMutedText
414
+ : AppColors.lightMutedText,
415
),
416
tooltip: l10n.delete,
417
onPressed: () => _deleteModel(context, ref),
@@ -359,7 +421,9 @@ class _OnDeviceModelTile extends ConsumerWidget {
421
icon: Icon(
422
Icons.close,
423
size: 16,
362
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
424
+ color: isDark
425
+ ? AppColors.darkMutedText
426
+ : AppColors.lightMutedText,
427
),
428
tooltip: l10n.cancel,
429
onPressed: () => ref
@@ -371,7 +435,9 @@ class _OnDeviceModelTile extends ConsumerWidget {
435
icon: Icon(
436
Icons.cloud_download_outlined,
437
size: 18,
374
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
438
+ color: isDark
439
+ ? AppColors.darkMutedText
440
+ : AppColors.lightMutedText,
441
),
442
tooltip: l10n.download,
443
onPressed: () async {
@@ -398,7 +464,9 @@ class _OnDeviceModelTile extends ConsumerWidget {
464
l10n.not_downloaded,
465
style: TextStyle(
466
fontSize: 11,
401
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
467
+ color: isDark
468
+ ? AppColors.darkMutedText
469
+ : AppColors.lightMutedText,
470
),
471
),
472
],
lib/features/on_device/data/imported_gguf_model_repository.dart
+234
-16
@@ -2,6 +2,7 @@ import 'dart:convert';
2
import 'dart:io';
3
import 'dart:math';
4
5
+import 'package:dio/dio.dart';
6
import 'package:path/path.dart' as p;
7
import 'package:path_provider/path_provider.dart';
8
import 'package:shared_preferences/shared_preferences.dart';
@@ -14,6 +15,8 @@ class ImportedGgufModelMetadata {
15
final String filePath;
16
final int fileSizeBytes;
17
final DateTime importedAt;
18
+ final OnDeviceImportedSource source;
19
+ final String? sourceUrl;
20
21
const ImportedGgufModelMetadata({
22
required this.id,
@@ -21,19 +24,38 @@ class ImportedGgufModelMetadata {
24
required this.filePath,
25
required this.fileSizeBytes,
26
required this.importedAt,
27
+ required this.source,
28
+ this.sourceUrl,
29
});
30
31
String get fileName => p.basename(filePath);
32
33
+ String get sourceLabel {
34
+ switch (source) {
35
+ case OnDeviceImportedSource.localFile:
36
+ return 'Local file';
37
+ case OnDeviceImportedSource.huggingFace:
38
+ return 'Hugging Face';
39
+ }
40
+ }
41
+
42
+ int get estimatedMinRamMb {
43
+ final fileSizeMb = fileSizeBytes / (1024 * 1024);
44
+ return max(2048, (fileSizeMb * 1.3).ceil());
45
+ }
46
+
47
OnDeviceModel toOnDeviceModel() {
48
+ final isHuggingFace = source == OnDeviceImportedSource.huggingFace;
49
return OnDeviceModel(
50
id: id,
51
name: name,
32
- huggingFaceUrl: '',
52
+ huggingFaceUrl: isHuggingFace ? (sourceUrl ?? '') : '',
53
fileSizeBytes: fileSizeBytes,
34
- license: 'Local file',
35
- description: 'Imported GGUF model for local llama.cpp inference.',
36
- minRamMb: 2048,
54
+ license: sourceLabel,
55
+ description: isHuggingFace
56
+ ? 'Imported GGUF model from Hugging Face for local llama.cpp inference.'
57
+ : 'Imported GGUF model from a local file for local llama.cpp inference.',
58
+ minRamMb: estimatedMinRamMb,
59
parameterLabel: 'GGUF',
60
bestFor: 'Local GGUF inference',
61
languagesLabel: 'Local',
@@ -44,6 +66,7 @@ class ImportedGgufModelMetadata {
66
localPath: filePath,
67
importedAt: importedAt,
68
isImported: true,
69
+ importedSource: source,
70
);
71
}
72
@@ -54,6 +77,8 @@ class ImportedGgufModelMetadata {
77
'filePath': filePath,
78
'fileSizeBytes': fileSizeBytes,
79
'importedAt': importedAt.toIso8601String(),
80
+ 'source': source.name,
81
+ 'sourceUrl': sourceUrl,
82
};
83
}
84
@@ -64,8 +89,20 @@ class ImportedGgufModelMetadata {
89
filePath: json['filePath'] as String,
90
fileSizeBytes: (json['fileSizeBytes'] as num).toInt(),
91
importedAt: DateTime.parse(json['importedAt'] as String),
92
+ source: _sourceFromJson(json['source']),
93
+ sourceUrl: json['sourceUrl'] as String?,
94
);
95
}
96
+
97
+ static OnDeviceImportedSource _sourceFromJson(dynamic value) {
98
+ if (value is String) {
99
+ return OnDeviceImportedSource.values.firstWhere(
100
+ (source) => source.name == value,
101
+ orElse: () => OnDeviceImportedSource.localFile,
102
+ );
103
+ }
104
+ return OnDeviceImportedSource.localFile;
105
+ }
106
}
107
108
class ImportedGgufModelRepository {
@@ -73,9 +110,10 @@ class ImportedGgufModelRepository {
110
static const _storageDirName = 'imported_gguf_models';
111
112
final SharedPreferences _prefs;
113
+ final Dio _dio;
114
final Random _random;
115
78
- ImportedGgufModelRepository(this._prefs, {Random? random})
116
+ ImportedGgufModelRepository(this._prefs, this._dio, {Random? random})
117
: _random = random ?? Random.secure();
118
119
List<ImportedGgufModelMetadata> load() {
@@ -127,19 +165,102 @@ class ImportedGgufModelRepository {
165
final id = _createId(originalName);
166
final fileName = '$id-${_sanitizeFileName(originalName)}';
167
final target = File(p.join(dir.path, fileName));
130
- await source.copy(target.path);
168
132
- final metadata = ImportedGgufModelMetadata(
133
- id: id,
134
- name: _displayNameFromFileName(originalName),
135
- filePath: target.path,
136
- fileSizeBytes: await target.length(),
137
- importedAt: DateTime.now(),
138
- );
169
+ try {
170
+ await source.copy(target.path);
171
+ await _validateGgufFile(target);
172
140
- final current = load();
141
- await saveAll([...current, metadata]);
142
- return metadata;
173
+ final metadata = ImportedGgufModelMetadata(
174
+ id: id,
175
+ name: _displayNameFromFileName(originalName),
176
+ filePath: target.path,
177
+ fileSizeBytes: await target.length(),
178
+ importedAt: DateTime.now(),
179
+ source: OnDeviceImportedSource.localFile,
180
+ );
181
+
182
+ final current = load();
183
+ await saveAll([...current, metadata]);
184
+ return metadata;
185
+ } catch (_) {
186
+ if (await target.exists()) {
187
+ await target.delete();
188
+ }
189
+ rethrow;
190
+ }
191
+ }
192
+
193
+ Future<ImportedGgufModelMetadata> importFromHuggingFaceUrl(
194
+ String sourceUrl, {
195
+ String? token,
196
+ void Function(int receivedBytes, int totalBytes)? onProgress,
197
+ CancelToken? cancelToken,
198
+ }) async {
199
+ final normalizedUrl = _normalizeHuggingFaceGgufUrl(sourceUrl);
200
+ final originalName = _fileNameFromUri(normalizedUrl);
201
+ final dir = await _modelsDirectory();
202
+ final id = _createId(originalName);
203
+ final fileName = '$id-${_sanitizeFileName(originalName)}';
204
+ final targetPath = p.join(dir.path, fileName);
205
+ final tempPath = '$targetPath.part';
206
+ final tempFile = File(tempPath);
207
+ final targetFile = File(targetPath);
208
+
209
+ if (await tempFile.exists()) {
210
+ await tempFile.delete();
211
+ }
212
+
213
+ try {
214
+ await _dio.download(
215
+ normalizedUrl.toString(),
216
+ tempPath,
217
+ cancelToken: cancelToken,
218
+ onReceiveProgress: onProgress,
219
+ options: Options(
220
+ headers: {
221
+ if (token != null && token.isNotEmpty)
222
+ HttpHeaders.authorizationHeader: 'Bearer $token',
223
+ },
224
+ followRedirects: true,
225
+ receiveTimeout: const Duration(hours: 12),
226
+ sendTimeout: const Duration(minutes: 5),
227
+ validateStatus: (status) =>
228
+ status != null && status >= 200 && status < 400,
229
+ ),
230
+ );
231
+
232
+ if (!await tempFile.exists() || await tempFile.length() <= 0) {
233
+ throw const FileSystemException(
234
+ 'The downloaded GGUF file was empty or missing.',
235
+ );
236
+ }
237
+
238
+ await _validateGgufFile(tempFile);
239
+ await tempFile.rename(targetPath);
240
+
241
+ final metadata = ImportedGgufModelMetadata(
242
+ id: id,
243
+ name: _displayNameFromFileName(originalName),
244
+ filePath: targetFile.path,
245
+ fileSizeBytes: await targetFile.length(),
246
+ importedAt: DateTime.now(),
247
+ source: OnDeviceImportedSource.huggingFace,
248
+ sourceUrl: normalizedUrl.toString(),
249
+ );
250
+
251
+ final current = load();
252
+ await saveAll([...current, metadata]);
253
+ return metadata;
254
+ } on DioException catch (e) {
255
+ if (CancelToken.isCancel(e)) {
256
+ throw const HttpException('GGUF import canceled.');
257
+ }
258
+ throw HttpException(_friendlyDioError(e));
259
+ } finally {
260
+ if (await tempFile.exists()) {
261
+ await tempFile.delete();
262
+ }
263
+ }
264
}
265
266
Future<void> delete(String id) async {
@@ -174,6 +295,103 @@ class ImportedGgufModelRepository {
295
return dir;
296
}
297
298
+ Uri _normalizeHuggingFaceGgufUrl(String input) {
299
+ final trimmed = input.trim();
300
+ if (trimmed.isEmpty) {
301
+ throw const FormatException('Enter a Hugging Face GGUF URL.');
302
+ }
303
+
304
+ final withScheme =
305
+ trimmed.startsWith('http://') || trimmed.startsWith('https://')
306
+ ? trimmed
307
+ : 'https://huggingface.co/$trimmed';
308
+
309
+ final uri = Uri.parse(withScheme);
310
+ final host = uri.host.toLowerCase();
311
+ if (!_isAllowedHuggingFaceHost(host)) {
312
+ throw const FormatException(
313
+ 'Only official Hugging Face GGUF URLs are supported.',
314
+ );
315
+ }
316
+
317
+ if (uri.scheme.toLowerCase() != 'https') {
318
+ throw const FormatException(
319
+ 'Use an HTTPS Hugging Face URL for GGUF import.',
320
+ );
321
+ }
322
+
323
+ final normalizedSegments = [...uri.pathSegments];
324
+ final blobIndex = normalizedSegments.indexOf('blob');
325
+ if (blobIndex != -1) {
326
+ normalizedSegments[blobIndex] = 'resolve';
327
+ }
328
+
329
+ final normalizedUri = uri.replace(pathSegments: normalizedSegments);
330
+ final fileName = _fileNameFromUri(normalizedUri);
331
+ if (!fileName.toLowerCase().endsWith('.gguf')) {
332
+ throw const FormatException(
333
+ 'The Hugging Face URL must point directly to a .gguf file.',
334
+ );
335
+ }
336
+ return normalizedUri;
337
+ }
338
+
339
+ String _fileNameFromUri(Uri uri) {
340
+ final segments = uri.pathSegments
341
+ .where((segment) => segment.isNotEmpty)
342
+ .toList();
343
+ if (segments.isEmpty) {
344
+ throw const FormatException('Unable to determine the GGUF file name.');
345
+ }
346
+
347
+ final fileName = Uri.decodeComponent(segments.last);
348
+ if (!fileName.toLowerCase().endsWith('.gguf')) {
349
+ throw const FormatException('Unable to determine the GGUF file name.');
350
+ }
351
+ return fileName;
352
+ }
353
+
354
+ bool _isAllowedHuggingFaceHost(String host) {
355
+ return host == 'huggingface.co' ||
356
+ host == 'www.huggingface.co' ||
357
+ host == 'hf.co';
358
+ }
359
+
360
+ Future<void> _validateGgufFile(File file) async {
361
+ final bytes = await file.openRead(0, 4).expand((chunk) => chunk).toList();
362
+ final isGguf =
363
+ bytes.length == 4 &&
364
+ bytes[0] == 0x47 &&
365
+ bytes[1] == 0x47 &&
366
+ bytes[2] == 0x55 &&
367
+ bytes[3] == 0x46;
368
+
369
+ if (!isGguf) {
370
+ throw const FormatException(
371
+ 'The selected file is not a valid GGUF model.',
372
+ );
373
+ }
374
+ }
375
+
376
+ String _friendlyDioError(DioException error) {
377
+ final statusCode = error.response?.statusCode;
378
+ if (statusCode == 401 || statusCode == 403) {
379
+ return 'Hugging Face denied access to this file. Add a valid Hugging Face token in Settings for gated or private models.';
380
+ }
381
+ if (statusCode == 404) {
382
+ return 'The GGUF file could not be found on Hugging Face.';
383
+ }
384
+ if (statusCode != null) {
385
+ return 'Failed to download GGUF from Hugging Face (HTTP $statusCode).';
386
+ }
387
+ if (error.type == DioExceptionType.connectionTimeout ||
388
+ error.type == DioExceptionType.sendTimeout ||
389
+ error.type == DioExceptionType.receiveTimeout) {
390
+ return 'The GGUF download timed out. Please try again on a stable connection.';
391
+ }
392
+ return error.message ?? 'Failed to download GGUF from Hugging Face.';
393
+ }
394
+
395
String _createId(String fileName) {
396
final micros = DateTime.now().microsecondsSinceEpoch;
397
final suffix = _random.nextInt(0xFFFFFF).toRadixString(16).padLeft(6, '0');
lib/features/on_device/data/models/on_device_model.dart
+21
@@ -6,6 +6,8 @@ enum OnDeviceModelRuntime { gemma, llamaCpp }
6
7
enum OnDeviceModelFormat { litertlm, task, binary, gguf }
8
9
+enum OnDeviceImportedSource { localFile, huggingFace }
10
+
11
class OnDeviceModel {
12
final String id;
13
final String name;
@@ -28,6 +30,7 @@ class OnDeviceModel {
30
final String? localPath;
31
final DateTime? importedAt;
32
final bool isImported;
33
+ final OnDeviceImportedSource? importedSource;
34
final bool requiresHuggingFaceToken;
35
36
const OnDeviceModel({
@@ -52,6 +55,7 @@ class OnDeviceModel {
55
this.localPath,
56
this.importedAt,
57
this.isImported = false,
58
+ this.importedSource,
59
this.requiresHuggingFaceToken = false,
60
});
61
@@ -73,6 +77,23 @@ class OnDeviceModel {
77
78
bool get isLlamaCpp => runtime == OnDeviceModelRuntime.llamaCpp;
79
80
+ bool get isImportedFromLocalFile =>
81
+ importedSource == OnDeviceImportedSource.localFile;
82
+
83
+ bool get isImportedFromHuggingFace =>
84
+ importedSource == OnDeviceImportedSource.huggingFace;
85
+
86
+ String? get importedSourceLabel {
87
+ switch (importedSource) {
88
+ case OnDeviceImportedSource.localFile:
89
+ return 'Local file';
90
+ case OnDeviceImportedSource.huggingFace:
91
+ return 'Hugging Face';
92
+ case null:
93
+ return null;
94
+ }
95
+ }
96
+
97
ModelType get flutterGemmaModelType {
98
switch (id) {
99
case 'qwen3-0.6b':
lib/features/on_device/providers/on_device_providers.dart
+40
-4
@@ -1,6 +1,7 @@
1
import 'dart:io';
2
3
-import 'package:flutter_gemma/flutter_gemma.dart';
3
+import 'package:dio/dio.dart';
4
+import 'package:flutter_gemma/flutter_gemma.dart' show PreferredBackend;
5
import 'package:flutter_riverpod/flutter_riverpod.dart';
6
7
import '../../../core/logger/app_logger.dart';
@@ -8,6 +9,7 @@ import '../../../core/models/enums.dart';
9
import '../../../core/providers/app_providers.dart';
10
import '../../../core/providers/chat_background_service_provider.dart';
11
import '../../../core/providers/review_prompt_providers.dart';
12
+import '../../../core/providers/service_providers.dart';
13
import '../../../core/providers/storage_providers.dart';
14
import '../data/imported_gguf_model_repository.dart';
15
import '../data/models/on_device_model.dart';
@@ -34,7 +36,10 @@ final onDeviceLlamaServiceProvider = Provider<OnDeviceLlamaService>((ref) {
36
37
final importedGgufModelRepositoryProvider =
38
Provider<ImportedGgufModelRepository>((ref) {
37
- return ImportedGgufModelRepository(ref.read(sharedPreferencesProvider));
39
+ return ImportedGgufModelRepository(
40
+ ref.read(sharedPreferencesProvider),
41
+ ref.read(dioProvider),
42
+ );
43
});
44
45
final importedGgufModelsProvider =
@@ -108,17 +113,46 @@ class OnDeviceEngineState {
113
}
114
115
class ImportedGgufModelsNotifier extends Notifier<List<OnDeviceModel>> {
116
+ bool _hasScheduledInitialPrune = false;
117
+
118
ImportedGgufModelRepository get _repository =>
119
ref.read(importedGgufModelRepositoryProvider);
120
121
@override
122
List<OnDeviceModel> build() {
123
+ if (!_hasScheduledInitialPrune) {
124
+ _hasScheduledInitialPrune = true;
125
+ Future.microtask(() async {
126
+ if (ref.mounted) {
127
+ await pruneMissing();
128
+ }
129
+ });
130
+ }
131
return _repository.load().map((model) => model.toOnDeviceModel()).toList();
132
}
133
119
- Future<void> importModel(String sourcePath) async {
134
+ Future<OnDeviceModel> importModel(String sourcePath) async {
135
final metadata = await _repository.importFromPath(sourcePath);
121
- state = [...state, metadata.toOnDeviceModel()];
136
+ final model = metadata.toOnDeviceModel();
137
+ state = [...state, model];
138
+ return model;
139
+ }
140
+
141
+ Future<OnDeviceModel> importModelFromHuggingFaceUrl(
142
+ String sourceUrl, {
143
+ String? huggingFaceToken,
144
+ void Function(int receivedBytes, int totalBytes)? onProgress,
145
+ CancelToken? cancelToken,
146
+ }) async {
147
+ final metadata = await _repository.importFromHuggingFaceUrl(
148
+ sourceUrl,
149
+ token: huggingFaceToken,
150
+ onProgress: onProgress,
151
+ cancelToken: cancelToken,
152
+ );
153
+ final model = metadata.toOnDeviceModel();
154
+ state = [...state, model];
155
+ return model;
156
}
157
158
Future<void> deleteModel(String modelId) async {
@@ -142,6 +176,8 @@ class ImportedGgufModelsNotifier extends Notifier<List<OnDeviceModel>> {
176
/// disposed provider container.
177
Future<void> pruneMissing() async {
178
final existing = await _repository.loadExisting();
179
+ if (!ref.mounted) return;
180
+
181
final keptIds = existing.map((m) => m.id).toSet();
182
if (keptIds.length == state.length &&
183
state.every((m) => keptIds.contains(m.id))) {
lib/features/on_device/views/model_manager_screen.dart
+1027
-26
@@ -1,21 +1,41 @@
1
+import 'package:dio/dio.dart';
2
import 'package:file_picker/file_picker.dart';
3
import 'package:flutter/foundation.dart';
4
import 'package:flutter/material.dart';
5
+import 'package:flutter/services.dart';
6
import 'package:flutter_riverpod/flutter_riverpod.dart';
5
-import 'package:shadcn_ui/shadcn_ui.dart';
7
import 'package:localmind/core/models/enums.dart';
8
+import 'package:localmind/core/providers/app_providers.dart';
9
import 'package:localmind/l10n/app_localizations.dart';
10
import 'package:localmind/features/on_device/data/models/on_device_model.dart';
9
-import 'package:localmind/features/on_device/providers/on_device_providers.dart';
11
import 'package:localmind/features/on_device/providers/foreground_download_providers.dart';
12
+import 'package:localmind/features/on_device/providers/on_device_providers.dart';
13
import 'package:localmind/features/on_device/components/memory_stats_panel.dart';
14
import 'package:localmind/features/on_device/components/model_card.dart';
15
+import 'package:url_launcher/url_launcher.dart';
16
+
17
+enum _ImportAction { localFile, huggingFace }
18
14
-class OnDeviceModelManagerScreen extends ConsumerWidget {
19
+class OnDeviceModelManagerScreen extends ConsumerStatefulWidget {
20
const OnDeviceModelManagerScreen({super.key});
21
22
@override
18
- Widget build(BuildContext context, WidgetRef ref) {
23
+ ConsumerState<OnDeviceModelManagerScreen> createState() =>
24
+ _OnDeviceModelManagerScreenState();
25
+}
26
+
27
+class _OnDeviceModelManagerScreenState
28
+ extends ConsumerState<OnDeviceModelManagerScreen> {
29
+ @override
30
+ void initState() {
31
+ super.initState();
32
+ Future.microtask(
33
+ () => ref.read(importedGgufModelsProvider.notifier).pruneMissing(),
34
+ );
35
+ }
36
+
37
+ @override
38
+ Widget build(BuildContext context) {
39
final l10n = AppLocalizations.of(context)!;
40
final theme = Theme.of(context);
41
final models = ref.watch(onDeviceModelsProvider);
@@ -23,32 +43,67 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
43
final engineState = ref.watch(onDeviceEngineProvider);
44
final downloadProgress = ref.watch(foregroundDownloadNotifierProvider);
45
46
+ final importedModels = models.where((model) => model.isImported).toList()
47
+ ..sort(
48
+ (a, b) => (b.importedAt ?? DateTime.fromMillisecondsSinceEpoch(0))
49
+ .compareTo(a.importedAt ?? DateTime.fromMillisecondsSinceEpoch(0)),
50
+ );
51
+ final curatedModels = models.where((model) => !model.isImported).toList();
52
+
53
return Column(
54
children: [
55
_ManagerHeader(
56
l10n: l10n,
57
theme: theme,
31
- onImportGguf: () => _importGguf(context, ref),
58
+ onImportLocalGguf: () => _importLocalGguf(context),
59
+ onImportFromHuggingFace: () => _importFromHuggingFace(context),
60
),
61
Expanded(
62
child: ListView(
63
padding: const EdgeInsets.all(16),
64
children: [
65
const MemoryStatsPanel(),
66
+ _ImportOverviewCard(
67
+ importedModels: importedModels,
68
+ onImportLocalGguf: () => _importLocalGguf(context),
69
+ onImportFromHuggingFace: () => _importFromHuggingFace(context),
70
+ ),
71
_EngineStatusCard(
72
engineState: engineState,
73
models: models,
74
l10n: l10n,
75
theme: theme,
76
),
44
- Text(
45
- l10n.available_models,
46
- style: theme.textTheme.titleMedium?.copyWith(
47
- fontWeight: FontWeight.bold,
77
+ const SizedBox(height: 4),
78
+ _SectionHeader(
79
+ title: 'Imported GGUF models',
80
+ subtitle: importedModels.isEmpty
81
+ ? 'Import a GGUF from your device or add one from Hugging Face. Imported models run locally with llama.cpp.'
82
+ : '${importedModels.length} imported model${importedModels.length == 1 ? '' : 's'} ready for local inference.',
83
+ ),
84
+ if (importedModels.isEmpty)
85
+ _EmptyImportedModelsCard(
86
+ onImportLocalGguf: () => _importLocalGguf(context),
87
+ onImportFromHuggingFace: () =>
88
+ _importFromHuggingFace(context),
89
+ )
90
+ else
91
+ ...importedModels.map(
92
+ (model) => ModelCard(
93
+ model: model,
94
+ downloadedAsync: downloadedAsync,
95
+ downloadProgress: downloadProgress[model.id],
96
+ engineState: engineState,
97
+ ),
98
),
99
+ const SizedBox(height: 8),
100
+ _SectionHeader(
101
+ title: l10n.available_models,
102
+ subtitle:
103
+ 'Curated on-device models you can download and manage inside LocalMind.',
104
),
50
- const SizedBox(height: 12),
51
- ...models.map(
105
+ const SizedBox(height: 4),
106
+ ...curatedModels.map(
107
(model) => ModelCard(
108
model: model,
109
downloadedAsync: downloadedAsync,
@@ -64,7 +119,7 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
119
);
120
}
121
67
- Future<void> _importGguf(BuildContext context, WidgetRef ref) async {
122
+ Future<void> _importLocalGguf(BuildContext context) async {
123
final messenger = ScaffoldMessenger.of(context);
124
try {
125
final result = await FilePicker.pickFiles(
@@ -83,9 +138,11 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
138
return;
139
}
140
86
- await ref.read(importedGgufModelsProvider.notifier).importModel(path);
141
+ final model = await ref
142
+ .read(importedGgufModelsProvider.notifier)
143
+ .importModel(path);
144
messenger.showSnackBar(
88
- const SnackBar(content: Text('GGUF model imported.')),
145
+ SnackBar(content: Text('${model.name} imported from local file.')),
146
);
147
} catch (e) {
148
messenger.showSnackBar(
@@ -93,18 +150,36 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
150
);
151
}
152
}
153
+
154
+ Future<void> _importFromHuggingFace(BuildContext context) async {
155
+ final messenger = ScaffoldMessenger.of(context);
156
+ final importedModel = await showDialog<OnDeviceModel>(
157
+ context: context,
158
+ barrierDismissible: false,
159
+ builder: (_) => const _HuggingFaceGgufImportDialog(),
160
+ );
161
+
162
+ if (!mounted || importedModel == null) return;
163
+ messenger.showSnackBar(
164
+ SnackBar(
165
+ content: Text('${importedModel.name} imported from Hugging Face.'),
166
+ ),
167
+ );
168
+ }
169
}
170
171
class _ManagerHeader extends StatelessWidget {
172
const _ManagerHeader({
173
required this.l10n,
174
required this.theme,
102
- required this.onImportGguf,
175
+ required this.onImportLocalGguf,
176
+ required this.onImportFromHuggingFace,
177
});
178
179
final AppLocalizations l10n;
180
final ThemeData theme;
107
- final VoidCallback onImportGguf;
181
+ final VoidCallback onImportLocalGguf;
182
+ final VoidCallback onImportFromHuggingFace;
183
184
@override
185
Widget build(BuildContext context) {
@@ -152,13 +227,938 @@ class _ManagerHeader extends StatelessWidget {
227
),
228
if (defaultTargetPlatform == TargetPlatform.android ||
229
defaultTargetPlatform == TargetPlatform.iOS)
155
- ShadButton.outline(
156
- size: ShadButtonSize.sm,
157
- onPressed: onImportGguf,
158
- child: const Text('Import GGUF'),
230
+ PopupMenuButton<_ImportAction>(
231
+ tooltip: 'Import GGUF',
232
+ onSelected: (value) {
233
+ switch (value) {
234
+ case _ImportAction.localFile:
235
+ onImportLocalGguf();
236
+ case _ImportAction.huggingFace:
237
+ onImportFromHuggingFace();
238
+ }
239
+ },
240
+ itemBuilder: (context) => const [
241
+ PopupMenuItem<_ImportAction>(
242
+ value: _ImportAction.localFile,
243
+ child: ListTile(
244
+ contentPadding: EdgeInsets.zero,
245
+ leading: Icon(Icons.folder_open_outlined),
246
+ title: Text('Import local GGUF'),
247
+ subtitle: Text('Copy a .gguf file from this device'),
248
+ ),
249
+ ),
250
+ PopupMenuItem<_ImportAction>(
251
+ value: _ImportAction.huggingFace,
252
+ child: ListTile(
253
+ contentPadding: EdgeInsets.zero,
254
+ leading: Icon(Icons.cloud_download_outlined),
255
+ title: Text('Import from Hugging Face'),
256
+ subtitle: Text('Paste a GGUF URL or repo path'),
257
+ ),
258
+ ),
259
+ ],
260
+ child: Container(
261
+ padding: const EdgeInsets.symmetric(
262
+ horizontal: 12,
263
+ vertical: 8,
264
+ ),
265
+ decoration: BoxDecoration(
266
+ borderRadius: BorderRadius.circular(10),
267
+ border: Border.all(
268
+ color: theme.colorScheme.outline.withValues(alpha: 0.4),
269
+ ),
270
+ ),
271
+ child: Row(
272
+ mainAxisSize: MainAxisSize.min,
273
+ children: [
274
+ Icon(
275
+ Icons.add_circle_outline,
276
+ size: 18,
277
+ color: theme.colorScheme.primary,
278
+ ),
279
+ const SizedBox(width: 8),
280
+ const Text('Import GGUF'),
281
+ ],
282
+ ),
283
+ ),
284
+ ),
285
+ ],
286
+ ),
287
+ );
288
+ }
289
+}
290
+
291
+class _ImportOverviewCard extends StatelessWidget {
292
+ const _ImportOverviewCard({
293
+ required this.importedModels,
294
+ required this.onImportLocalGguf,
295
+ required this.onImportFromHuggingFace,
296
+ });
297
+
298
+ final List<OnDeviceModel> importedModels;
299
+ final VoidCallback onImportLocalGguf;
300
+ final VoidCallback onImportFromHuggingFace;
301
+
302
+ @override
303
+ Widget build(BuildContext context) {
304
+ final theme = Theme.of(context);
305
+ final localImports = importedModels
306
+ .where((model) => model.isImportedFromLocalFile)
307
+ .length;
308
+ final huggingFaceImports = importedModels
309
+ .where((model) => model.isImportedFromHuggingFace)
310
+ .length;
311
+
312
+ return Container(
313
+ margin: const EdgeInsets.only(bottom: 16),
314
+ padding: const EdgeInsets.all(16),
315
+ decoration: BoxDecoration(
316
+ color: theme.colorScheme.primary.withValues(alpha: 0.05),
317
+ borderRadius: BorderRadius.circular(16),
318
+ border: Border.all(
319
+ color: theme.colorScheme.primary.withValues(alpha: 0.15),
320
+ ),
321
+ ),
322
+ child: Column(
323
+ crossAxisAlignment: CrossAxisAlignment.start,
324
+ children: [
325
+ Row(
326
+ crossAxisAlignment: CrossAxisAlignment.start,
327
+ children: [
328
+ Container(
329
+ padding: const EdgeInsets.all(10),
330
+ decoration: BoxDecoration(
331
+ color: theme.colorScheme.primary.withValues(alpha: 0.1),
332
+ borderRadius: BorderRadius.circular(12),
333
+ ),
334
+ child: Icon(
335
+ Icons.memory_outlined,
336
+ color: theme.colorScheme.primary,
337
+ ),
338
+ ),
339
+ const SizedBox(width: 12),
340
+ Expanded(
341
+ child: Column(
342
+ crossAxisAlignment: CrossAxisAlignment.start,
343
+ children: [
344
+ Text(
345
+ 'Bring your own GGUF models',
346
+ style: theme.textTheme.titleMedium?.copyWith(
347
+ fontWeight: FontWeight.bold,
348
+ ),
349
+ ),
350
+ const SizedBox(height: 4),
351
+ Text(
352
+ 'Import a .gguf from local storage or download one straight from Hugging Face. Imported models stay on this device and load with llama.cpp.',
353
+ style: theme.textTheme.bodySmall?.copyWith(
354
+ color: theme.colorScheme.onSurface.withValues(
355
+ alpha: 0.7,
356
+ ),
357
+ ),
358
+ ),
359
+ ],
360
+ ),
361
+ ),
362
+ ],
363
+ ),
364
+ const SizedBox(height: 12),
365
+ Wrap(
366
+ spacing: 8,
367
+ runSpacing: 8,
368
+ children: [
369
+ _SummaryPill(
370
+ icon: Icons.layers_outlined,
371
+ label: '${importedModels.length} imported',
372
+ ),
373
+ _SummaryPill(
374
+ icon: Icons.folder_open_outlined,
375
+ label: '$localImports local files',
376
+ ),
377
+ _SummaryPill(
378
+ icon: Icons.cloud_download_outlined,
379
+ label: '$huggingFaceImports Hugging Face',
380
+ ),
381
+ ],
382
+ ),
383
+ const SizedBox(height: 12),
384
+ Wrap(
385
+ spacing: 10,
386
+ runSpacing: 10,
387
+ children: [
388
+ OutlinedButton.icon(
389
+ onPressed: onImportLocalGguf,
390
+ icon: const Icon(Icons.folder_open_outlined),
391
+ label: const Text('Import local GGUF'),
392
+ ),
393
+ OutlinedButton.icon(
394
+ onPressed: onImportFromHuggingFace,
395
+ icon: const Icon(Icons.cloud_download_outlined),
396
+ label: const Text('Import from Hugging Face'),
397
+ ),
398
+ ],
399
+ ),
400
+ ],
401
+ ),
402
+ );
403
+ }
404
+}
405
+
406
+class _SummaryPill extends StatelessWidget {
407
+ const _SummaryPill({required this.icon, required this.label});
408
+
409
+ final IconData icon;
410
+ final String label;
411
+
412
+ @override
413
+ Widget build(BuildContext context) {
414
+ final theme = Theme.of(context);
415
+ return Container(
416
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
417
+ decoration: BoxDecoration(
418
+ color: theme.colorScheme.surface,
419
+ borderRadius: BorderRadius.circular(999),
420
+ border: Border.all(
421
+ color: theme.colorScheme.outline.withValues(alpha: 0.18),
422
+ ),
423
+ ),
424
+ child: Row(
425
+ mainAxisSize: MainAxisSize.min,
426
+ children: [
427
+ Icon(icon, size: 14, color: theme.colorScheme.primary),
428
+ const SizedBox(width: 6),
429
+ Text(
430
+ label,
431
+ style: theme.textTheme.labelMedium?.copyWith(
432
+ fontWeight: FontWeight.w600,
433
+ ),
434
+ ),
435
+ ],
436
+ ),
437
+ );
438
+ }
439
+}
440
+
441
+class _SectionHeader extends StatelessWidget {
442
+ const _SectionHeader({required this.title, required this.subtitle});
443
+
444
+ final String title;
445
+ final String subtitle;
446
+
447
+ @override
448
+ Widget build(BuildContext context) {
449
+ final theme = Theme.of(context);
450
+ return Padding(
451
+ padding: const EdgeInsets.only(bottom: 12),
452
+ child: Column(
453
+ crossAxisAlignment: CrossAxisAlignment.start,
454
+ children: [
455
+ Text(
456
+ title,
457
+ style: theme.textTheme.titleMedium?.copyWith(
458
+ fontWeight: FontWeight.bold,
459
+ ),
460
+ ),
461
+ const SizedBox(height: 4),
462
+ Text(
463
+ subtitle,
464
+ style: theme.textTheme.bodySmall?.copyWith(
465
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.65),
466
+ ),
467
+ ),
468
+ ],
469
+ ),
470
+ );
471
+ }
472
+}
473
+
474
+class _EmptyImportedModelsCard extends StatelessWidget {
475
+ const _EmptyImportedModelsCard({
476
+ required this.onImportLocalGguf,
477
+ required this.onImportFromHuggingFace,
478
+ });
479
+
480
+ final VoidCallback onImportLocalGguf;
481
+ final VoidCallback onImportFromHuggingFace;
482
+
483
+ @override
484
+ Widget build(BuildContext context) {
485
+ final theme = Theme.of(context);
486
+ return Container(
487
+ margin: const EdgeInsets.only(bottom: 16),
488
+ padding: const EdgeInsets.all(18),
489
+ decoration: BoxDecoration(
490
+ color: theme.colorScheme.surface,
491
+ borderRadius: BorderRadius.circular(16),
492
+ border: Border.all(
493
+ color: theme.colorScheme.outline.withValues(alpha: 0.18),
494
+ ),
495
+ ),
496
+ child: Column(
497
+ crossAxisAlignment: CrossAxisAlignment.start,
498
+ children: [
499
+ Text(
500
+ 'No imported GGUF models yet',
501
+ style: theme.textTheme.titleSmall?.copyWith(
502
+ fontWeight: FontWeight.w700,
503
+ ),
504
+ ),
505
+ const SizedBox(height: 6),
506
+ Text(
507
+ 'You can bring your own GGUF file from device storage or paste a Hugging Face URL or repo path that points to a .gguf file.',
508
+ style: theme.textTheme.bodySmall?.copyWith(
509
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
510
+ ),
511
+ ),
512
+ const SizedBox(height: 12),
513
+ Wrap(
514
+ spacing: 10,
515
+ runSpacing: 10,
516
+ children: [
517
+ OutlinedButton.icon(
518
+ onPressed: onImportLocalGguf,
519
+ icon: const Icon(Icons.folder_open_outlined),
520
+ label: const Text('Import local GGUF'),
521
+ ),
522
+ OutlinedButton.icon(
523
+ onPressed: onImportFromHuggingFace,
524
+ icon: const Icon(Icons.cloud_download_outlined),
525
+ label: const Text('Import from Hugging Face'),
526
+ ),
527
+ ],
528
+ ),
529
+ ],
530
+ ),
531
+ );
532
+ }
533
+}
534
+
535
+class _HuggingFaceGgufImportDialog extends ConsumerStatefulWidget {
536
+ const _HuggingFaceGgufImportDialog();
537
+
538
+ @override
539
+ ConsumerState<_HuggingFaceGgufImportDialog> createState() =>
540
+ _HuggingFaceGgufImportDialogState();
541
+}
542
+
543
+class _HuggingFaceGgufImportDialogState
544
+ extends ConsumerState<_HuggingFaceGgufImportDialog> {
545
+ static final Uri _huggingFaceModelsUri = Uri.parse(
546
+ 'https://huggingface.co/models?search=gguf',
547
+ );
548
+
549
+ final TextEditingController _urlController = TextEditingController();
550
+ CancelToken? _cancelToken;
551
+ bool _isImporting = false;
552
+ String? _error;
553
+ int _receivedBytes = 0;
554
+ int _totalBytes = 0;
555
+
556
+ @override
557
+ void dispose() {
558
+ _cancelToken?.cancel();
559
+ _urlController.dispose();
560
+ super.dispose();
561
+ }
562
+
563
+ @override
564
+ Widget build(BuildContext context) {
565
+ final theme = Theme.of(context);
566
+ final l10n = AppLocalizations.of(context)!;
567
+ final hasHuggingFaceToken = ref.watch(
568
+ settingsProvider.select(
569
+ (settings) => settings.huggingFaceToken?.trim().isNotEmpty == true,
570
+ ),
571
+ );
572
+ final input = _urlController.text.trim();
573
+ final preview = _ImportUrlPreview.fromInput(input);
574
+ final progress = _totalBytes > 0 ? _receivedBytes / _totalBytes : null;
575
+ final dialogWidth = MediaQuery.of(context).size.width > 560
576
+ ? 520.0
577
+ : MediaQuery.of(context).size.width * 0.82;
578
+
579
+ return AlertDialog(
580
+ titlePadding: const EdgeInsets.fromLTRB(24, 22, 24, 0),
581
+ contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
582
+ actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
583
+ title: Row(
584
+ children: [
585
+ Container(
586
+ padding: const EdgeInsets.all(10),
587
+ decoration: BoxDecoration(
588
+ color: theme.colorScheme.primary.withValues(alpha: 0.10),
589
+ borderRadius: BorderRadius.circular(12),
590
+ ),
591
+ child: Icon(
592
+ Icons.cloud_download_outlined,
593
+ color: theme.colorScheme.primary,
594
),
595
+ ),
596
+ const SizedBox(width: 12),
597
+ const Expanded(child: Text('Import GGUF from Hugging Face')),
598
],
599
),
600
+ content: SizedBox(
601
+ width: dialogWidth,
602
+ child: SingleChildScrollView(
603
+ child: Column(
604
+ mainAxisSize: MainAxisSize.min,
605
+ crossAxisAlignment: CrossAxisAlignment.start,
606
+ children: [
607
+ Text(
608
+ 'Paste a direct GGUF URL or a Hugging Face repo path like `owner/repo/blob/main/model.gguf`. Blob links are converted automatically.',
609
+ style: theme.textTheme.bodySmall?.copyWith(
610
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.72),
611
+ ),
612
+ ),
613
+ const SizedBox(height: 14),
614
+ TextField(
615
+ controller: _urlController,
616
+ enabled: !_isImporting,
617
+ maxLines: 2,
618
+ minLines: 1,
619
+ keyboardType: TextInputType.url,
620
+ textInputAction: TextInputAction.done,
621
+ onChanged: (_) {
622
+ if (_error != null) {
623
+ setState(() => _error = null);
624
+ } else {
625
+ setState(() {});
626
+ }
627
+ },
628
+ onSubmitted: (_) {
629
+ if (!_isImporting) {
630
+ _startImport();
631
+ }
632
+ },
633
+ decoration: InputDecoration(
634
+ labelText: 'GGUF URL or repo path',
635
+ hintText:
636
+ 'owner/repo/blob/main/model.gguf\nhttps://huggingface.co/owner/repo/resolve/main/model.gguf',
637
+ border: const OutlineInputBorder(),
638
+ prefixIcon: const Icon(Icons.link_rounded),
639
+ ),
640
+ ),
641
+ const SizedBox(height: 10),
642
+ Wrap(
643
+ spacing: 8,
644
+ runSpacing: 8,
645
+ children: [
646
+ OutlinedButton.icon(
647
+ onPressed: _isImporting ? null : _pasteFromClipboard,
648
+ icon: const Icon(Icons.content_paste_rounded, size: 18),
649
+ label: const Text('Paste'),
650
+ ),
651
+ OutlinedButton.icon(
652
+ onPressed: _isImporting || input.isEmpty
653
+ ? null
654
+ : _clearInput,
655
+ icon: const Icon(Icons.close_rounded, size: 18),
656
+ label: const Text('Clear'),
657
+ ),
658
+ OutlinedButton.icon(
659
+ onPressed: _isImporting
660
+ ? null
661
+ : () => _openHuggingFace(context),
662
+ icon: const Icon(Icons.open_in_new_rounded, size: 18),
663
+ label: const Text('Browse GGUFs'),
664
+ ),
665
+ ],
666
+ ),
667
+ if (preview != null) ...[
668
+ const SizedBox(height: 14),
669
+ _ImportPreviewCard(preview: preview),
670
+ ],
671
+ const SizedBox(height: 12),
672
+ Container(
673
+ width: double.infinity,
674
+ padding: const EdgeInsets.all(12),
675
+ decoration: BoxDecoration(
676
+ color: theme.colorScheme.surface,
677
+ borderRadius: BorderRadius.circular(14),
678
+ border: Border.all(
679
+ color: theme.colorScheme.outline.withValues(alpha: 0.18),
680
+ ),
681
+ ),
682
+ child: Row(
683
+ crossAxisAlignment: CrossAxisAlignment.start,
684
+ children: [
685
+ Icon(
686
+ hasHuggingFaceToken
687
+ ? Icons.verified_user_outlined
688
+ : Icons.info_outline,
689
+ size: 18,
690
+ color: hasHuggingFaceToken
691
+ ? Colors.green
692
+ : theme.colorScheme.primary,
693
+ ),
694
+ const SizedBox(width: 8),
695
+ Expanded(
696
+ child: Column(
697
+ crossAxisAlignment: CrossAxisAlignment.start,
698
+ children: [
699
+ Text(
700
+ hasHuggingFaceToken
701
+ ? 'Hugging Face token ready'
702
+ : 'Token optional but recommended',
703
+ style: theme.textTheme.labelLarge?.copyWith(
704
+ fontWeight: FontWeight.w700,
705
+ ),
706
+ ),
707
+ const SizedBox(height: 4),
708
+ Text(
709
+ hasHuggingFaceToken
710
+ ? 'Your saved token will be used automatically for gated or private repositories.'
711
+ : '${l10n.model_requires_huggingface_token} Add one in Settings if this GGUF is gated or private.',
712
+ style: theme.textTheme.bodySmall,
713
+ ),
714
+ ],
715
+ ),
716
+ ),
717
+ ],
718
+ ),
719
+ ),
720
+ if (_isImporting) ...[
721
+ const SizedBox(height: 16),
722
+ Container(
723
+ width: double.infinity,
724
+ padding: const EdgeInsets.all(14),
725
+ decoration: BoxDecoration(
726
+ color: theme.colorScheme.primary.withValues(alpha: 0.06),
727
+ borderRadius: BorderRadius.circular(16),
728
+ border: Border.all(
729
+ color: theme.colorScheme.primary.withValues(alpha: 0.14),
730
+ ),
731
+ ),
732
+ child: Column(
733
+ crossAxisAlignment: CrossAxisAlignment.start,
734
+ children: [
735
+ Row(
736
+ children: [
737
+ Expanded(
738
+ child: Text(
739
+ preview?.fileName ?? 'Downloading GGUF',
740
+ maxLines: 1,
741
+ overflow: TextOverflow.ellipsis,
742
+ style: theme.textTheme.labelLarge?.copyWith(
743
+ fontWeight: FontWeight.w700,
744
+ ),
745
+ ),
746
+ ),
747
+ const SizedBox(width: 8),
748
+ Container(
749
+ padding: const EdgeInsets.symmetric(
750
+ horizontal: 10,
751
+ vertical: 5,
752
+ ),
753
+ decoration: BoxDecoration(
754
+ color: theme.colorScheme.primary.withValues(
755
+ alpha: 0.12,
756
+ ),
757
+ borderRadius: BorderRadius.circular(999),
758
+ ),
759
+ child: Text(
760
+ progress == null
761
+ ? 'Preparing'
762
+ : '${(progress * 100).toStringAsFixed(1)}%',
763
+ style: theme.textTheme.labelMedium?.copyWith(
764
+ color: theme.colorScheme.primary,
765
+ fontWeight: FontWeight.w700,
766
+ ),
767
+ ),
768
+ ),
769
+ ],
770
+ ),
771
+ const SizedBox(height: 12),
772
+ _ImportProgressBar(progress: progress),
773
+ const SizedBox(height: 10),
774
+ Text(
775
+ progress != null
776
+ ? '${_formatBytes(_receivedBytes)} / ${_formatBytes(_totalBytes)}'
777
+ : 'Preparing download...',
778
+ style: theme.textTheme.bodySmall?.copyWith(
779
+ color: theme.colorScheme.onSurface.withValues(
780
+ alpha: 0.70,
781
+ ),
782
+ fontWeight: FontWeight.w600,
783
+ ),
784
+ ),
785
+ ],
786
+ ),
787
+ ),
788
+ ],
789
+ if (_error != null) ...[
790
+ const SizedBox(height: 12),
791
+ Container(
792
+ width: double.infinity,
793
+ padding: const EdgeInsets.all(12),
794
+ decoration: BoxDecoration(
795
+ color: Colors.red.withValues(alpha: 0.08),
796
+ borderRadius: BorderRadius.circular(14),
797
+ border: Border.all(
798
+ color: Colors.red.withValues(alpha: 0.18),
799
+ ),
800
+ ),
801
+ child: Row(
802
+ crossAxisAlignment: CrossAxisAlignment.start,
803
+ children: [
804
+ const Icon(
805
+ Icons.error_outline_rounded,
806
+ color: Colors.red,
807
+ size: 18,
808
+ ),
809
+ const SizedBox(width: 8),
810
+ Expanded(
811
+ child: Text(
812
+ _error!,
813
+ style: theme.textTheme.bodySmall?.copyWith(
814
+ color: Colors.red.shade700,
815
+ fontWeight: FontWeight.w600,
816
+ ),
817
+ ),
818
+ ),
819
+ ],
820
+ ),
821
+ ),
822
+ ],
823
+ ],
824
+ ),
825
+ ),
826
+ ),
827
+ actions: [
828
+ TextButton(
829
+ onPressed: _isImporting
830
+ ? () {
831
+ _cancelToken?.cancel();
832
+ Navigator.of(context).pop();
833
+ }
834
+ : () => Navigator.of(context).pop(),
835
+ child: Text(_isImporting ? 'Cancel import' : l10n.cancel),
836
+ ),
837
+ ElevatedButton.icon(
838
+ onPressed: _isImporting || input.isEmpty ? null : _startImport,
839
+ icon: const Icon(Icons.download_rounded, size: 18),
840
+ label: const Text('Import GGUF'),
841
+ ),
842
+ ],
843
+ );
844
+ }
845
+
846
+ Future<void> _pasteFromClipboard() async {
847
+ final data = await Clipboard.getData('text/plain');
848
+ final text = data?.text?.trim();
849
+ if (!mounted) return;
850
+ if (text == null || text.isEmpty) {
851
+ ScaffoldMessenger.of(
852
+ context,
853
+ ).showSnackBar(const SnackBar(content: Text('Clipboard is empty.')));
854
+ return;
855
+ }
856
+ setState(() {
857
+ _urlController.text = text;
858
+ _error = null;
859
+ });
860
+ }
861
+
862
+ void _clearInput() {
863
+ setState(() {
864
+ _urlController.clear();
865
+ _error = null;
866
+ });
867
+ }
868
+
869
+ Future<void> _openHuggingFace(BuildContext context) async {
870
+ final messenger = ScaffoldMessenger.of(context);
871
+ final launched = await launchUrl(
872
+ _huggingFaceModelsUri,
873
+ mode: LaunchMode.externalApplication,
874
+ );
875
+ if (!launched && context.mounted) {
876
+ messenger.showSnackBar(
877
+ const SnackBar(content: Text('Could not open Hugging Face.')),
878
+ );
879
+ }
880
+ }
881
+
882
+ Future<void> _startImport() async {
883
+ final sourceUrl = _urlController.text.trim();
884
+ if (sourceUrl.isEmpty) {
885
+ setState(() {
886
+ _error = 'Paste a Hugging Face GGUF URL or repo path.';
887
+ });
888
+ return;
889
+ }
890
+
891
+ setState(() {
892
+ _isImporting = true;
893
+ _error = null;
894
+ _receivedBytes = 0;
895
+ _totalBytes = 0;
896
+ });
897
+
898
+ _cancelToken = CancelToken();
899
+
900
+ try {
901
+ final huggingFaceToken = ref.read(
902
+ settingsProvider.select((settings) => settings.huggingFaceToken),
903
+ );
904
+ final model = await ref
905
+ .read(importedGgufModelsProvider.notifier)
906
+ .importModelFromHuggingFaceUrl(
907
+ sourceUrl,
908
+ huggingFaceToken: huggingFaceToken,
909
+ cancelToken: _cancelToken,
910
+ onProgress: (receivedBytes, totalBytes) {
911
+ if (!mounted) return;
912
+ setState(() {
913
+ _receivedBytes = receivedBytes;
914
+ _totalBytes = totalBytes > 0 ? totalBytes : 0;
915
+ });
916
+ },
917
+ );
918
+
919
+ if (!mounted) return;
920
+ Navigator.of(context).pop(model);
921
+ } catch (e) {
922
+ if (!mounted) return;
923
+ setState(() {
924
+ _isImporting = false;
925
+ _error = _friendlyError(e);
926
+ });
927
+ }
928
+ }
929
+
930
+ String _friendlyError(Object error) {
931
+ final message = error.toString();
932
+ if (message.startsWith('HttpException: ')) {
933
+ return message.substring('HttpException: '.length);
934
+ }
935
+ if (message.startsWith('FormatException: ')) {
936
+ return message.substring('FormatException: '.length);
937
+ }
938
+ if (message.startsWith('Exception: ')) {
939
+ return message.substring('Exception: '.length);
940
+ }
941
+ return message;
942
+ }
943
+
944
+ String _formatBytes(int bytes) {
945
+ if (bytes <= 0) return '0 B';
946
+ final kb = bytes / 1024;
947
+ if (kb < 1024) return '${kb.toStringAsFixed(0)} KB';
948
+ final mb = kb / 1024;
949
+ if (mb < 1024) return '${mb.toStringAsFixed(1)} MB';
950
+ final gb = mb / 1024;
951
+ return '${gb.toStringAsFixed(2)} GB';
952
+ }
953
+}
954
+
955
+class _ImportPreviewCard extends StatelessWidget {
956
+ const _ImportPreviewCard({required this.preview});
957
+
958
+ final _ImportUrlPreview preview;
959
+
960
+ @override
961
+ Widget build(BuildContext context) {
962
+ final theme = Theme.of(context);
963
+
964
+ return Container(
965
+ width: double.infinity,
966
+ padding: const EdgeInsets.all(12),
967
+ decoration: BoxDecoration(
968
+ color: theme.colorScheme.surfaceContainerHighest.withValues(
969
+ alpha: 0.35,
970
+ ),
971
+ borderRadius: BorderRadius.circular(14),
972
+ border: Border.all(
973
+ color: theme.colorScheme.outline.withValues(alpha: 0.18),
974
+ ),
975
+ ),
976
+ child: Column(
977
+ crossAxisAlignment: CrossAxisAlignment.start,
978
+ children: [
979
+ Wrap(
980
+ spacing: 8,
981
+ runSpacing: 8,
982
+ children: [
983
+ _PreviewPill(
984
+ icon: preview.isBlobLink
985
+ ? Icons.sync_alt_rounded
986
+ : Icons.link_rounded,
987
+ label: preview.isBlobLink
988
+ ? 'Blob link'
989
+ : preview.inputStyleLabel,
990
+ ),
991
+ if (preview.fileName != null)
992
+ _PreviewPill(
993
+ icon: Icons.insert_drive_file_outlined,
994
+ label: preview.fileName!,
995
+ ),
996
+ ],
997
+ ),
998
+ const SizedBox(height: 10),
999
+ if (preview.repoPath != null)
1000
+ _PreviewRow(label: 'Repository', value: preview.repoPath!),
1001
+ if (preview.normalizedDisplay != null)
1002
+ _PreviewRow(
1003
+ label: 'Detected path',
1004
+ value: preview.normalizedDisplay!,
1005
+ ),
1006
+ ],
1007
+ ),
1008
+ );
1009
+ }
1010
+}
1011
+
1012
+class _PreviewPill extends StatelessWidget {
1013
+ const _PreviewPill({required this.icon, required this.label});
1014
+
1015
+ final IconData icon;
1016
+ final String label;
1017
+
1018
+ @override
1019
+ Widget build(BuildContext context) {
1020
+ final theme = Theme.of(context);
1021
+ return Container(
1022
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
1023
+ decoration: BoxDecoration(
1024
+ color: theme.colorScheme.primary.withValues(alpha: 0.08),
1025
+ borderRadius: BorderRadius.circular(999),
1026
+ ),
1027
+ child: Row(
1028
+ mainAxisSize: MainAxisSize.min,
1029
+ children: [
1030
+ Icon(icon, size: 14, color: theme.colorScheme.primary),
1031
+ const SizedBox(width: 6),
1032
+ Text(
1033
+ label,
1034
+ style: theme.textTheme.labelMedium?.copyWith(
1035
+ color: theme.colorScheme.primary,
1036
+ fontWeight: FontWeight.w700,
1037
+ ),
1038
+ ),
1039
+ ],
1040
+ ),
1041
+ );
1042
+ }
1043
+}
1044
+
1045
+class _PreviewRow extends StatelessWidget {
1046
+ const _PreviewRow({required this.label, required this.value});
1047
+
1048
+ final String label;
1049
+ final String value;
1050
+
1051
+ @override
1052
+ Widget build(BuildContext context) {
1053
+ final theme = Theme.of(context);
1054
+ return Padding(
1055
+ padding: const EdgeInsets.only(top: 4),
1056
+ child: Column(
1057
+ crossAxisAlignment: CrossAxisAlignment.start,
1058
+ children: [
1059
+ Text(
1060
+ label,
1061
+ style: theme.textTheme.labelSmall?.copyWith(
1062
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.62),
1063
+ fontWeight: FontWeight.w700,
1064
+ ),
1065
+ ),
1066
+ const SizedBox(height: 2),
1067
+ Text(
1068
+ value,
1069
+ style: theme.textTheme.bodySmall?.copyWith(
1070
+ fontWeight: FontWeight.w600,
1071
+ ),
1072
+ ),
1073
+ ],
1074
+ ),
1075
+ );
1076
+ }
1077
+}
1078
+
1079
+class _ImportProgressBar extends StatelessWidget {
1080
+ const _ImportProgressBar({required this.progress});
1081
+
1082
+ final double? progress;
1083
+
1084
+ @override
1085
+ Widget build(BuildContext context) {
1086
+ final theme = Theme.of(context);
1087
+ final trackColor = theme.colorScheme.primary.withValues(alpha: 0.12);
1088
+ final fillColor = theme.colorScheme.primary;
1089
+ final normalizedProgress = progress?.clamp(0.0, 1.0);
1090
+
1091
+ return ClipRRect(
1092
+ borderRadius: BorderRadius.circular(999),
1093
+ child: SizedBox(
1094
+ height: 10,
1095
+ child: LinearProgressIndicator(
1096
+ value: normalizedProgress?.toDouble(),
1097
+ backgroundColor: trackColor,
1098
+ valueColor: AlwaysStoppedAnimation<Color>(fillColor),
1099
+ ),
1100
+ ),
1101
+ );
1102
+ }
1103
+}
1104
+
1105
+class _ImportUrlPreview {
1106
+ const _ImportUrlPreview({
1107
+ required this.inputStyleLabel,
1108
+ required this.isBlobLink,
1109
+ this.repoPath,
1110
+ this.fileName,
1111
+ this.normalizedDisplay,
1112
+ });
1113
+
1114
+ final String inputStyleLabel;
1115
+ final bool isBlobLink;
1116
+ final String? repoPath;
1117
+ final String? fileName;
1118
+ final String? normalizedDisplay;
1119
+
1120
+ static _ImportUrlPreview? fromInput(String input) {
1121
+ final trimmed = input.trim();
1122
+ if (trimmed.isEmpty) return null;
1123
+
1124
+ final hasScheme =
1125
+ trimmed.startsWith('https://') || trimmed.startsWith('http://');
1126
+ final displayUri = hasScheme ? Uri.tryParse(trimmed) : null;
1127
+ final pathSource = displayUri != null && displayUri.pathSegments.isNotEmpty
1128
+ ? displayUri.pathSegments
1129
+ : trimmed.split('/').where((part) => part.isNotEmpty).toList();
1130
+
1131
+ if (pathSource.isEmpty) {
1132
+ return const _ImportUrlPreview(
1133
+ inputStyleLabel: 'Manual path',
1134
+ isBlobLink: false,
1135
+ );
1136
+ }
1137
+
1138
+ final blobIndex = pathSource.indexOf('blob');
1139
+ final resolveIndex = pathSource.indexOf('resolve');
1140
+ final fileName =
1141
+ pathSource.isNotEmpty && pathSource.last.toLowerCase().endsWith('.gguf')
1142
+ ? pathSource.last
1143
+ : null;
1144
+ final repoPath = pathSource.length >= 2
1145
+ ? '${pathSource[0]}/${pathSource[1]}'
1146
+ : null;
1147
+
1148
+ String? normalizedDisplay;
1149
+ if (blobIndex != -1) {
1150
+ final normalized = [...pathSource]..[blobIndex] = 'resolve';
1151
+ normalizedDisplay = normalized.join('/');
1152
+ } else if (resolveIndex != -1 || fileName != null) {
1153
+ normalizedDisplay = pathSource.join('/');
1154
+ }
1155
+
1156
+ return _ImportUrlPreview(
1157
+ inputStyleLabel: hasScheme ? 'Direct URL' : 'Repo path',
1158
+ isBlobLink: blobIndex != -1,
1159
+ repoPath: repoPath,
1160
+ fileName: fileName,
1161
+ normalizedDisplay: normalizedDisplay,
1162
);
1163
}
1164
}
@@ -182,11 +1182,9 @@ class _EngineStatusCard extends StatelessWidget {
1182
final loadedId = engineState.loadedModelId;
1183
final loadedName = loadedId == null
1184
? 'Unknown'
185
- : models
186
- .where((m) => m.id == loadedId)
187
- .map((m) => m.name)
188
- .followedBy([loadedId])
189
- .first;
1185
+ : models.where((m) => m.id == loadedId).map((m) => m.name).followedBy(
1186
+ [loadedId],
1187
+ ).first;
1188
1189
return Container(
1190
padding: const EdgeInsets.all(12),
@@ -202,7 +1200,10 @@ class _EngineStatusCard extends StatelessWidget {
1200
const SizedBox(width: 8),
1201
Expanded(
1202
child: Text(
205
- l10n.model_loaded(loadedName, engineState.backend?.name ?? 'CPU'),
1203
+ l10n.model_loaded(
1204
+ loadedName,
1205
+ engineState.backend?.name ?? 'CPU',
1206
+ ),
1207
style: const TextStyle(color: Colors.green),
1208
),
1209
),
pubspec.yaml
+1
-1
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17
# In Windows, build-name is used as the major, minor, and patch parts
18
# of the product and file versions while build-number is used as the build suffix.
19
-version: 1.4.1+45
19
+version: 1.4.2+48
20
21
environment:
22
sdk: ">=3.12.0 <4.0.0"
test/features/on_device/imported_gguf_model_repository_test.dart
+46
-1
@@ -1,6 +1,7 @@
1
import 'dart:convert';
2
import 'dart:io';
3
4
+import 'package:dio/dio.dart';
5
import 'package:flutter_test/flutter_test.dart';
6
import 'package:localmind/features/on_device/data/imported_gguf_model_repository.dart';
7
import 'package:localmind/features/on_device/data/models/on_device_model.dart';
@@ -14,7 +15,7 @@ void main() {
15
}) async {
16
SharedPreferences.setMockInitialValues(initialValues);
17
final prefs = await SharedPreferences.getInstance();
17
- return ImportedGgufModelRepository(prefs);
18
+ return ImportedGgufModelRepository(prefs, Dio());
19
}
20
21
group('ImportedGgufModelMetadata', () {
@@ -26,6 +27,7 @@ void main() {
27
filePath: '/tmp/Tiny-Llama.Q4.gguf',
28
fileSizeBytes: 1234,
29
importedAt: importedAt,
30
+ source: OnDeviceImportedSource.localFile,
31
);
32
33
final restored = ImportedGgufModelMetadata.fromJson(
@@ -43,8 +45,28 @@ void main() {
45
expect(model.localPath, metadata.filePath);
46
expect(model.fileName, 'Tiny-Llama.Q4.gguf');
47
expect(model.isImported, isTrue);
48
+ expect(model.importedSource, OnDeviceImportedSource.localFile);
49
expect(model.isLlamaCpp, isTrue);
50
});
51
+
52
+ test('estimates imported GGUF RAM requirement from file size', () {
53
+ final metadata = ImportedGgufModelMetadata(
54
+ id: 'gguf-large',
55
+ name: 'Large GGUF',
56
+ filePath: '/tmp/Large.gguf',
57
+ fileSizeBytes: 6 * 1024 * 1024 * 1024,
58
+ importedAt: DateTime.utc(2026, 6, 21, 12),
59
+ source: OnDeviceImportedSource.huggingFace,
60
+ sourceUrl:
61
+ 'https://huggingface.co/example/repo/resolve/main/Large.gguf',
62
+ );
63
+
64
+ final model = metadata.toOnDeviceModel();
65
+
66
+ expect(model.minRamMb, greaterThan(2048));
67
+ expect(model.importedSource, OnDeviceImportedSource.huggingFace);
68
+ expect(model.huggingFaceUrl, metadata.sourceUrl);
69
+ });
70
});
71
72
group('ImportedGgufModelRepository', () {
@@ -117,6 +139,28 @@ void main() {
139
expect(existing.map((model) => model.id), ['gguf-kept']);
140
expect(repository.load().map((model) => model.id), ['gguf-kept']);
141
});
142
+
143
+ test('rejects non-Hugging Face GGUF URLs before downloading', () async {
144
+ final repository = await createRepository();
145
+
146
+ expect(
147
+ () => repository.importFromHuggingFaceUrl(
148
+ 'https://huggingface.co.evil.example/org/repo/resolve/main/model.gguf',
149
+ ),
150
+ throwsA(isA<FormatException>()),
151
+ );
152
+ });
153
+
154
+ test('requires HTTPS for Hugging Face GGUF imports', () async {
155
+ final repository = await createRepository();
156
+
157
+ expect(
158
+ () => repository.importFromHuggingFaceUrl(
159
+ 'http://huggingface.co/org/repo/resolve/main/model.gguf',
160
+ ),
161
+ throwsA(isA<FormatException>()),
162
+ );
163
+ });
164
});
165
}
166
@@ -130,5 +174,6 @@ ImportedGgufModelMetadata _metadata({
174
filePath: filePath,
175
fileSizeBytes: 42,
176
importedAt: DateTime.utc(2026, 6, 21),
177
+ source: OnDeviceImportedSource.localFile,
178
);
179
}
test/features/on_device/model_manager_gguf_widget_test.dart
+30
-19
@@ -1,5 +1,3 @@
1
-import 'dart:convert';
2
-
1
import 'package:flutter/foundation.dart';
2
import 'package:flutter/material.dart';
3
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -8,7 +6,7 @@ import 'package:localmind/core/device/device_memory_service.dart';
6
import 'package:localmind/core/providers/device_info_providers.dart';
7
import 'package:localmind/core/providers/storage_providers.dart';
8
import 'package:localmind/core/theme/app_theme.dart';
11
-import 'package:localmind/features/on_device/data/imported_gguf_model_repository.dart';
9
+import 'package:localmind/features/on_device/data/models/on_device_model.dart';
10
import 'package:localmind/features/on_device/data/on_device_gemma_service.dart';
11
import 'package:localmind/features/on_device/providers/on_device_providers.dart';
12
import 'package:localmind/features/on_device/views/model_manager_screen.dart';
@@ -17,23 +15,33 @@ import 'package:shared_preferences/shared_preferences.dart';
15
import 'package:shadcn_ui/shadcn_ui.dart';
16
17
void main() {
20
- const storageKey = 'imported_gguf_models_v1';
21
-
18
testWidgets('model manager shows Import GGUF and imported model actions', (
19
tester,
20
) async {
21
debugDefaultTargetPlatformOverride = TargetPlatform.android;
22
try {
27
- final metadata = ImportedGgufModelMetadata(
23
+ final importedModel = OnDeviceModel(
24
id: 'gguf-widget',
25
name: 'Widget GGUF',
30
- filePath: '/tmp/widget.gguf',
26
+ huggingFaceUrl: '',
27
fileSizeBytes: 4096,
28
+ license: 'Local file',
29
+ description: 'Imported GGUF model for local llama.cpp inference.',
30
+ minRamMb: 2048,
31
+ parameterLabel: 'GGUF',
32
+ bestFor: 'Local GGUF inference',
33
+ languagesLabel: 'Local',
34
+ backendNote: 'llama.cpp',
35
+ isCpuOnly: true,
36
+ runtime: OnDeviceModelRuntime.llamaCpp,
37
+ format: OnDeviceModelFormat.gguf,
38
+ localPath: '/tmp/widget.gguf',
39
importedAt: DateTime.utc(2026, 6, 21),
40
+ isImported: true,
41
+ importedSource: OnDeviceImportedSource.localFile,
42
);
34
- SharedPreferences.setMockInitialValues({
35
- storageKey: json.encode([metadata.toJson()]),
36
- });
43
+
44
+ SharedPreferences.setMockInitialValues({});
45
final prefs = await SharedPreferences.getInstance();
46
47
await tester.pumpWidget(
@@ -43,6 +51,12 @@ void main() {
51
onDeviceGemmaServiceProvider.overrideWithValue(
52
_FakeOnDeviceGemmaService(),
53
),
54
+ onDeviceModelsProvider.overrideWith(
55
+ (ref) => [...OnDeviceModel.curatedModels, importedModel],
56
+ ),
57
+ downloadedModelsProvider.overrideWith(
58
+ (ref) async => {'gguf-widget'},
59
+ ),
60
deviceMemoryProvider.overrideWith(
61
(ref) async => const DeviceMemoryInfo(
62
totalMemoryMb: 8192,
@@ -61,18 +75,15 @@ void main() {
75
),
76
),
77
);
64
- await tester.pumpAndSettle();
78
+ await tester.pump();
79
+ await tester.pump(const Duration(milliseconds: 700));
80
81
expect(find.text('Import GGUF'), findsOneWidget);
67
- await tester.scrollUntilVisible(
68
- find.text('Widget GGUF'),
69
- 500,
70
- scrollable: find.byType(Scrollable).last,
71
- );
72
- await tester.pumpAndSettle();
82
+ expect(find.text('Imported GGUF models'), findsOneWidget);
83
74
- expect(find.text('Widget GGUF'), findsOneWidget);
75
- expect(find.text('Imported'), findsWidgets);
84
+ await tester.scrollUntilVisible(find.text('Widget GGUF'), 400);
85
+ await tester.pump();
86
+ expect(find.text('Local file'), findsWidgets);
87
expect(find.text('GGUF'), findsWidgets);
88
expect(find.text('llama.cpp'), findsWidgets);
89
expect(find.text('Load'), findsWidgets);
test/features/on_device/on_device_imported_models_provider_test.dart
+3
@@ -21,6 +21,7 @@ void main() {
21
filePath: '/tmp/imported.gguf',
22
fileSizeBytes: 4096,
23
importedAt: DateTime.utc(2026, 6, 21),
24
+ source: OnDeviceImportedSource.localFile,
25
);
26
SharedPreferences.setMockInitialValues({
27
storageKey: json.encode([metadata.toJson()]),
@@ -39,6 +40,7 @@ void main() {
40
expect(imported.format, OnDeviceModelFormat.gguf);
41
expect(imported.localPath, metadata.filePath);
42
expect(imported.isImported, isTrue);
43
+ expect(imported.importedSource, OnDeviceImportedSource.localFile);
44
},
45
);
46
@@ -51,6 +53,7 @@ void main() {
53
filePath: '/tmp/downloaded.gguf',
54
fileSizeBytes: 4096,
55
importedAt: DateTime.utc(2026, 6, 21),
56
+ source: OnDeviceImportedSource.localFile,
57
);
58
SharedPreferences.setMockInitialValues({
59
storageKey: json.encode([metadata.toJson()]),