15 files changed
+845
-289
README.md
+13
-1
@@ -17,17 +17,28 @@ LocalMind is a premium Flutter mobile application designed to provide a beautifu
17
</p>
18
19
20
+## 🆕 What's New (v1.1.0)
21
+
22
+- 📲 **On-Device AI Support (Android)**: Private, fast, and offline. You can now run powerful AI models directly on your device. No internet required for conversations!
23
+- 📂 **All-New Model Manager**: Download and manage curated AI models like **Qwen 3 (0.6B)**, **DeepSeek R1**, and **Gemma 4** directly within the app.
24
+- 🎨 **Sidebar Revamped**: A more intuitive and feature-rich navigation experience.
25
+- ⚡ **Refactored Asset Management**: Improved efficiency and smaller app footprint for better performance.
26
+- 🐞 **General Bug Fixes**: Stability improvements and minor UI refinements.
27
+
28
## 🚀 Vision
29
30
Built for those who value privacy and performance, LocalMind connects directly to your local inference servers like **LM Studio** or **Ollama**, as well as cloud providers like **OpenRouter**. No middleware, no tracking, and no hidden subscriptions. Just you and your models.
31
32
## ✨ Key Features
33
34
+- 📲 **On-Device AI Support (Android)**: Private, fast, and offline. Run powerful AI models directly on your device with no internet required.
35
+- 📂 **Model Manager**: Download and manage curated models like Qwen 3 (0.6B), DeepSeek R1, and Gemma 4 directly within the app.
36
- 🖥️ **Multi-Server Connection**: Seamlessly connect to LM Studio, Ollama, or OpenRouter with real-time health monitoring.
37
- 💬 **Premium Chat Experience**: A polished, ChatGPT/Claude-inspired interface with:
38
- **Streaming Responses**: Real-time SSE support for instant feedback.
39
- **Markdown Rendering**: Beautiful rendering of text and code with syntax highlighting.
40
- **Message Actions**: Copy, retry, or delete individual messages with a long-press menu.
41
+- 🎨 **Revamped Sidebar**: A cleaner, more intuitive, and feature-rich navigation experience.
42
- 🧠 **Persona Management**: Switch between specialized AI personas (Code Assistant, Math Tutor, etc.) or create your own with custom system prompts.
43
- 🔄 **Live Model Swapping**: Change the active model mid-conversation without losing context.
44
- 📦 **Conversation History**: Full-text search through your past chats, pinning important conversations, and local-only storage.
@@ -40,9 +51,10 @@ Built for those who value privacy and performance, LocalMind connects directly t
51
LocalMind is built with modern, scalable technologies:
52
53
- **Framework**: [Flutter](https://flutter.dev/) (Latest Stable)
54
+- **On-Device AI**: [LiteRT (TensorFlow Lite)](https://ai.google.dev/edge/litert) for high-performance localized inference.
55
- **UI Components**: Shadcn-inspired design system (`shadcn_ui`)
56
- **State Management**: [Riverpod](https://riverpod.dev/) for robust, testable state.
45
-- **Local Storage**: [Hive CE](https://pub.dev/packages/hive_ce) for high-performance on-device persistence.
57
+- **Local Storage**: [ObjectBox](https://objectbox.io/) (and Hive) for high-performance on-device persistence.
58
- **Networking**: [Dio](https://pub.dev/packages/dio) for optimized REST API handling.
59
- **Rendering**: `flutter_markdown` + `flutter_highlight` for rich content.
60
lib/features/on_device/data/download_notification_service.dart
new
+125
@@ -0,0 +1,125 @@
1
+import 'package:flutter_local_notifications/flutter_local_notifications.dart';
2
+import 'models/model_download_progress.dart';
3
+
4
+class DownloadNotificationService {
5
+ static const String channelId = 'model_downloads';
6
+ static const String channelName = 'Model Downloads';
7
+ static const String channelDescription = 'Notifications for AI model downloads';
8
+
9
+ final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
10
+
11
+ Future<void> init() async {
12
+ const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher');
13
+ const darwinInit = DarwinInitializationSettings();
14
+ const initSettings = InitializationSettings(android: androidInit, iOS: darwinInit);
15
+
16
+ await _notifications.initialize(
17
+ initSettings,
18
+ onDidReceiveNotificationResponse: (details) {
19
+ // Handle notification click if needed
20
+ },
21
+ );
22
+
23
+ // Create the channel for Android
24
+ const androidChannel = AndroidNotificationChannel(
25
+ channelId,
26
+ channelName,
27
+ description: channelDescription,
28
+ importance: Importance.low, // Use low to avoid annoying sound for progress updates
29
+ showBadge: false,
30
+ enableVibration: false,
31
+ playSound: false,
32
+ );
33
+
34
+ await _notifications
35
+ .resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
36
+ ?.createNotificationChannel(androidChannel);
37
+ }
38
+
39
+ Future<void> showProgressNotification({
40
+ required int id,
41
+ required String modelName,
42
+ required ModelDownloadProgress progress,
43
+ }) async {
44
+ final androidDetails = AndroidNotificationDetails(
45
+ channelId,
46
+ channelName,
47
+ channelDescription: channelDescription,
48
+ importance: Importance.low,
49
+ priority: Priority.low,
50
+ onlyAlertOnce: true,
51
+ showProgress: true,
52
+ maxProgress: 100,
53
+ progress: (progress.fraction * 100).toInt(),
54
+ ongoing: true, // Keep notification visible during download
55
+ category: AndroidNotificationCategory.progress,
56
+ );
57
+
58
+ final darwinDetails = DarwinNotificationDetails(
59
+ presentAlert: false,
60
+ presentBadge: false,
61
+ presentSound: false,
62
+ subtitle: modelName,
63
+ );
64
+
65
+ final details = NotificationDetails(android: androidDetails, iOS: darwinDetails);
66
+
67
+ await _notifications.show(
68
+ id,
69
+ 'Downloading $modelName',
70
+ '${(progress.fraction * 100).toStringAsFixed(1)}% • ${progress.speedFormatted} • ETA: ${progress.etaFormatted}',
71
+ details,
72
+ );
73
+ }
74
+
75
+ Future<void> showCompleteNotification({
76
+ required int id,
77
+ required String modelName,
78
+ }) async {
79
+ const androidDetails = AndroidNotificationDetails(
80
+ channelId,
81
+ channelName,
82
+ channelDescription: channelDescription,
83
+ importance: Importance.high,
84
+ priority: Priority.high,
85
+ ongoing: false,
86
+ );
87
+
88
+ const details = NotificationDetails(android: androidDetails);
89
+
90
+ await _notifications.show(
91
+ id,
92
+ 'Download Complete',
93
+ '$modelName is ready to use.',
94
+ details,
95
+ );
96
+ }
97
+
98
+ Future<void> showFailedNotification({
99
+ required int id,
100
+ required String modelName,
101
+ required String error,
102
+ }) async {
103
+ const androidDetails = AndroidNotificationDetails(
104
+ channelId,
105
+ channelName,
106
+ channelDescription: channelDescription,
107
+ importance: Importance.high,
108
+ priority: Priority.high,
109
+ ongoing: false,
110
+ );
111
+
112
+ const details = NotificationDetails(android: androidDetails);
113
+
114
+ await _notifications.show(
115
+ id,
116
+ 'Download Failed',
117
+ 'Failed to download $modelName: $error',
118
+ details,
119
+ );
120
+ }
121
+
122
+ Future<void> cancelNotification(int id) async {
123
+ await _notifications.cancel(id);
124
+ }
125
+}
lib/features/on_device/data/foreground_download_service.dart
+26
-16
@@ -19,7 +19,6 @@ class ForegroundDownloadService {
19
ForegroundDownloadService(this._downloadService);
20
21
/// Returns a broadcast stream of status updates for [modelId].
22
- /// Creates the stream controller if one doesn't exist yet.
22
Stream<DownloadStatusUpdate> getStatusStream(String modelId) {
23
return _progressControllers
24
.putIfAbsent(
@@ -30,9 +29,6 @@ class ForegroundDownloadService {
29
}
30
31
/// Start downloading [model]. Progress is emitted via [getStatusStream].
33
- ///
34
- /// Call [getStatusStream] before this method to subscribe to progress updates.
35
- /// Returns the absolute path to the downloaded file on success.
32
Future<String> downloadModel(OnDeviceModel model, {String? token}) async {
33
final modelsDir = await OnDeviceEngineService.getModelDirectory();
34
final filePath = '$modelsDir/${model.fileName}';
@@ -40,7 +36,6 @@ class ForegroundDownloadService {
36
37
if (await file.exists()) {
38
Log.info('Model ${model.id} already downloaded at $filePath');
43
- // Signal already downloaded via the stream
39
final controller = _progressControllers[model.id];
40
if (controller != null && !controller.isClosed) {
41
controller.add(
@@ -56,13 +51,11 @@ class ForegroundDownloadService {
51
return filePath;
52
}
53
59
- // Ensure the stream controller exists (created by getStatusStream)
54
final controller = _progressControllers.putIfAbsent(
55
model.id,
56
() => StreamController<DownloadStatusUpdate>.broadcast(),
57
);
58
65
- // Emit initial pending status
59
if (!controller.isClosed) {
60
controller.add(
61
DownloadStatusUpdate(
@@ -74,7 +67,6 @@ class ForegroundDownloadService {
67
);
68
}
69
77
- // Listen to the underlying download stream
70
final completer = Completer<String>();
71
72
final subscription = _downloadService
@@ -82,15 +74,18 @@ class ForegroundDownloadService {
74
.listen(
75
(progress) {
76
if (controller.isClosed) return;
85
- final percent = progress.total > 0
86
- ? (progress.received / progress.total * 100).round()
87
- : 0;
77
+ final percent = (progress.fraction * 100).round();
78
controller.add(
79
DownloadStatusUpdate(
80
modelId: model.id,
81
taskId: model.id,
82
status: DownloadStatus.running,
83
progress: percent,
84
+ receivedBytes: progress.receivedBytes,
85
+ totalBytes: progress.totalBytes,
86
+ bytesPerSecond: progress.bytesPerSecond,
87
+ etaSeconds: progress.estimatedSecondsRemaining,
88
+ isResumed: progress.isResumed,
89
),
90
);
91
},
@@ -135,6 +130,7 @@ class ForegroundDownloadService {
130
/// Cancel an in-flight download for [modelId].
131
Future<void> cancelDownload(String modelId) async {
132
_downloadService.cancelDownload(modelId);
133
+ _emitStatus(modelId, DownloadStatus.canceled);
134
await _cleanup(modelId);
135
}
136
@@ -144,17 +140,31 @@ class ForegroundDownloadService {
140
await controller?.close();
141
}
142
147
- /// Pause is not supported — downloads are continuous streams.
143
void pauseDownload(String modelId) {
149
- Log.warning('Pause not supported for foreground downloads');
144
+ Log.info('Pausing download for $modelId');
145
+ _downloadService.cancelDownload(modelId);
146
+ _emitStatus(modelId, DownloadStatus.paused);
147
+ _cleanup(modelId);
148
+ }
149
+
150
+ void _emitStatus(String modelId, DownloadStatus status) {
151
+ final controller = _progressControllers[modelId];
152
+ if (controller != null && !controller.isClosed) {
153
+ controller.add(
154
+ DownloadStatusUpdate(
155
+ modelId: modelId,
156
+ taskId: modelId,
157
+ status: status,
158
+ progress: 0, // Notifier should preserve existing progress
159
+ ),
160
+ );
161
+ }
162
}
163
152
- /// Resume is not supported — start a new download instead.
164
void resumeDownload(String modelId) {
154
- Log.warning('Resume not supported for foreground downloads');
165
+ Log.warning('Resume UI should trigger downloadModel again for $modelId');
166
}
167
157
- /// Cancel and let the UI restart the download.
168
Future<void> retryDownload(String modelId) async {
169
await cancelDownload(modelId);
170
}
lib/features/on_device/data/model_downloader.dart
new
+218
@@ -0,0 +1,218 @@
1
+import 'dart:async';
2
+import 'dart:io';
3
+
4
+import 'package:dio/dio.dart';
5
+import 'package:wakelock_plus/wakelock_plus.dart';
6
+
7
+import '../../../core/logger/app_logger.dart';
8
+import 'download_notification_service.dart';
9
+import 'models/model_download_progress.dart';
10
+import 'models/on_device_model.dart';
11
+import 'on_device_engine_service.dart';
12
+
13
+class ModelDownloader {
14
+ final DownloadNotificationService _notificationService;
15
+ final Dio _dio = Dio();
16
+ final Map<String, CancelToken> _activeDownloads = {};
17
+
18
+ ModelDownloader(this._notificationService);
19
+
20
+ Stream<ModelDownloadProgress> downloadModel(
21
+ OnDeviceModel model, {
22
+ String? token,
23
+ }) async* {
24
+ final modelsDir = await OnDeviceEngineService.getModelDirectory();
25
+ final finalPath = '$modelsDir/${model.fileName}';
26
+ final partialPath = '$finalPath.part';
27
+
28
+ // 1. Check if already complete
29
+ if (await File(finalPath).exists()) {
30
+ Log.info('Model ${model.id} already exists at $finalPath');
31
+ return;
32
+ }
33
+
34
+ final partialFile = File(partialPath);
35
+ final cancelToken = CancelToken();
36
+ _activeDownloads[model.id] = cancelToken;
37
+
38
+ // 2. Prevent sleep
39
+ await WakelockPlus.enable();
40
+
41
+ int receivedBytes = 0;
42
+ bool isResumed = false;
43
+
44
+ // 3. Check for partial file to resume
45
+ if (await partialFile.exists()) {
46
+ receivedBytes = await partialFile.length();
47
+ isResumed = receivedBytes > 0;
48
+ Log.info('Resuming download for ${model.id} from $receivedBytes bytes');
49
+ }
50
+
51
+ final startTime = DateTime.now();
52
+ DateTime lastProgressUpdate = DateTime.now();
53
+ int bytesSinceLastUpdate = 0;
54
+
55
+ try {
56
+ // 4. Resolve redirects manually to handle Auth header safety
57
+ final response = await _resolveWithRedirects(
58
+ url: model.huggingFaceUrl,
59
+ token: token,
60
+ startByte: receivedBytes,
61
+ cancelToken: cancelToken,
62
+ );
63
+
64
+ final totalBytes = (response.headers.value(Headers.contentLengthHeader) != null)
65
+ ? int.parse(response.headers.value(Headers.contentLengthHeader)!) + receivedBytes
66
+ : model.fileSizeBytes;
67
+
68
+ final IOSink sink = partialFile.openWrite(mode: FileMode.append);
69
+
70
+ final stream = response.data?.stream;
71
+ if (stream == null) {
72
+ throw DioException(
73
+ requestOptions: response.requestOptions,
74
+ error: 'Response data stream is null',
75
+ );
76
+ }
77
+
78
+ await for (final List<int> chunk in stream) {
79
+ sink.add(chunk);
80
+ receivedBytes += chunk.length;
81
+ bytesSinceLastUpdate += chunk.length;
82
+
83
+ final now = DateTime.now();
84
+ final elapsedSinceLastUpdate = now.difference(lastProgressUpdate);
85
+
86
+ // Update progress every 500ms
87
+ if (elapsedSinceLastUpdate.inMilliseconds >= 500) {
88
+ final totalElapsed = now.difference(startTime);
89
+ final bytesDownloadedInThisSession = receivedBytes - (isResumed ? (await partialFile.length() - bytesSinceLastUpdate) : 0);
90
+
91
+ // Use a simple bytes per second based on the current session's progress
92
+ final bytesPerSecond = (bytesDownloadedInThisSession / totalElapsed.inMilliseconds * 1000).toInt();
93
+
94
+ final remainingBytes = totalBytes - receivedBytes;
95
+ final etaSeconds = bytesPerSecond > 0 ? (remainingBytes / bytesPerSecond).toInt() : null;
96
+
97
+ final progress = ModelDownloadProgress(
98
+ receivedBytes: receivedBytes,
99
+ totalBytes: totalBytes,
100
+ fraction: receivedBytes / totalBytes,
101
+ bytesPerSecond: bytesPerSecond,
102
+ estimatedSecondsRemaining: etaSeconds,
103
+ isResumed: isResumed,
104
+ );
105
+
106
+ yield progress;
107
+
108
+ // 5. Update Notification
109
+ await _notificationService.showProgressNotification(
110
+ id: model.id.hashCode,
111
+ modelName: model.name,
112
+ progress: progress,
113
+ );
114
+
115
+ lastProgressUpdate = now;
116
+ bytesSinceLastUpdate = 0;
117
+ }
118
+ }
119
+
120
+ await sink.flush();
121
+ await sink.close();
122
+
123
+ // 6. Promote .part to final
124
+ await partialFile.rename(finalPath);
125
+ Log.info('Model ${model.id} downloaded successfully');
126
+
127
+ // 7. Final notification
128
+ await _notificationService.showCompleteNotification(
129
+ id: model.id.hashCode,
130
+ modelName: model.name,
131
+ );
132
+ } catch (e) {
133
+ if (e is DioException && CancelToken.isCancel(e)) {
134
+ Log.info('Download cancelled for ${model.id}');
135
+ } else {
136
+ Log.error('Failed to download model ${model.id}: $e');
137
+ await _notificationService.showFailedNotification(
138
+ id: model.id.hashCode,
139
+ modelName: model.name,
140
+ error: e.toString(),
141
+ );
142
+ rethrow;
143
+ }
144
+ } finally {
145
+ _activeDownloads.remove(model.id);
146
+
147
+ // 8. Restore sleep behavior if no other downloads active
148
+ if (_activeDownloads.isEmpty) {
149
+ await WakelockPlus.disable();
150
+ }
151
+ }
152
+ }
153
+
154
+ void cancelDownload(String modelId) {
155
+ _activeDownloads[modelId]?.cancel();
156
+ }
157
+
158
+ /// Manually follows redirects to ensure Authorization header is only sent to HF.
159
+ Future<Response<ResponseBody>> _resolveWithRedirects({
160
+ required String url,
161
+ required String? token,
162
+ required int startByte,
163
+ required CancelToken cancelToken,
164
+ }) async {
165
+ String currentUrl = url;
166
+ final Uri originalUri = Uri.parse(url);
167
+
168
+ for (int hop = 0; hop < 5; hop++) {
169
+ final options = Options(
170
+ responseType: ResponseType.stream,
171
+ followRedirects: false,
172
+ validateStatus: (status) => status != null && status < 500,
173
+ headers: {
174
+ if (startByte > 0) 'Range': 'bytes=$startByte-',
175
+ },
176
+ );
177
+
178
+ // Only add Auth header if we are on the original host
179
+ final currentUri = Uri.parse(currentUrl);
180
+ if (token != null && currentUri.host == originalUri.host) {
181
+ options.headers!['Authorization'] = 'Bearer ${token.trim()}';
182
+ }
183
+
184
+ final response = await _dio.get<ResponseBody>(
185
+ currentUrl,
186
+ options: options,
187
+ cancelToken: cancelToken,
188
+ );
189
+
190
+ if (response.statusCode == 200 || response.statusCode == 206) {
191
+ return response;
192
+ }
193
+
194
+ if (response.statusCode! >= 300 && response.statusCode! < 400) {
195
+ final location = response.headers.value('location');
196
+ if (location == null) {
197
+ throw DioException(
198
+ requestOptions: response.requestOptions,
199
+ error: 'Redirect without location header',
200
+ );
201
+ }
202
+ currentUrl = Uri.parse(currentUrl).resolve(location).toString();
203
+ continue;
204
+ }
205
+
206
+ throw DioException(
207
+ requestOptions: response.requestOptions,
208
+ response: response,
209
+ error: 'Server returned ${response.statusCode}',
210
+ );
211
+ }
212
+
213
+ throw DioException(
214
+ requestOptions: RequestOptions(path: url),
215
+ error: 'Too many redirects',
216
+ );
217
+ }
218
+}
lib/features/on_device/data/models/download_progress_info.dart
+78
@@ -6,6 +6,11 @@ class DownloadProgressInfo {
6
final double progress;
7
final String? taskId;
8
final String? error;
9
+ final int receivedBytes;
10
+ final int totalBytes;
11
+ final int bytesPerSecond;
12
+ final int? etaSeconds;
13
+ final bool isResumed;
14
15
const DownloadProgressInfo({
16
required this.modelId,
@@ -13,6 +18,11 @@ class DownloadProgressInfo {
18
this.progress = 0.0,
19
this.taskId,
20
this.error,
21
+ this.receivedBytes = 0,
22
+ this.totalBytes = 0,
23
+ this.bytesPerSecond = 0,
24
+ this.etaSeconds,
25
+ this.isResumed = false,
26
});
27
28
DownloadProgressInfo copyWith({
@@ -21,6 +31,11 @@ class DownloadProgressInfo {
31
double? progress,
32
String? taskId,
33
String? error,
34
+ int? receivedBytes,
35
+ int? totalBytes,
36
+ int? bytesPerSecond,
37
+ int? etaSeconds,
38
+ bool? isResumed,
39
}) {
40
return DownloadProgressInfo(
41
modelId: modelId ?? this.modelId,
@@ -28,6 +43,69 @@ class DownloadProgressInfo {
43
progress: progress ?? this.progress,
44
taskId: taskId ?? this.taskId,
45
error: error ?? this.error,
46
+ receivedBytes: receivedBytes ?? this.receivedBytes,
47
+ totalBytes: totalBytes ?? this.totalBytes,
48
+ bytesPerSecond: bytesPerSecond ?? this.bytesPerSecond,
49
+ etaSeconds: etaSeconds ?? this.etaSeconds,
50
+ isResumed: isResumed ?? this.isResumed,
51
);
52
}
53
+
54
+ Map<String, dynamic> toJson() {
55
+ return {
56
+ 'modelId': modelId,
57
+ 'status': status.index,
58
+ 'progress': progress,
59
+ 'taskId': taskId,
60
+ 'error': error,
61
+ 'receivedBytes': receivedBytes,
62
+ 'totalBytes': totalBytes,
63
+ 'bytesPerSecond': bytesPerSecond,
64
+ 'etaSeconds': etaSeconds,
65
+ 'isResumed': isResumed,
66
+ };
67
+ }
68
+
69
+ factory DownloadProgressInfo.fromJson(Map<String, dynamic> json) {
70
+ return DownloadProgressInfo(
71
+ modelId: json['modelId'] as String,
72
+ status: DownloadStatus.values[json['status'] as int],
73
+ progress: (json['progress'] as num).toDouble(),
74
+ taskId: json['taskId'] as String?,
75
+ error: json['error'] as String?,
76
+ receivedBytes: json['receivedBytes'] as int,
77
+ totalBytes: json['totalBytes'] as int,
78
+ bytesPerSecond: json['bytesPerSecond'] as int,
79
+ etaSeconds: json['etaSeconds'] as int?,
80
+ isResumed: json['isResumed'] as bool? ?? false,
81
+ );
82
+ }
83
+
84
+ String get speedFormatted {
85
+ if (bytesPerSecond < 1024) return '$bytesPerSecond B/s';
86
+ if (bytesPerSecond < 1024 * 1024) {
87
+ return '${(bytesPerSecond / 1024).toStringAsFixed(1)} KB/s';
88
+ }
89
+ return '${(bytesPerSecond / (1024 * 1024)).toStringAsFixed(1)} MB/s';
90
+ }
91
+
92
+ String get etaFormatted {
93
+ if (etaSeconds == null) return 'Unknown';
94
+ if (etaSeconds! < 60) return '$etaSeconds sec';
95
+ final minutes = etaSeconds! ~/ 60;
96
+ final seconds = etaSeconds! % 60;
97
+ if (minutes < 60) return '$minutes min $seconds sec';
98
+ final hours = minutes ~/ 60;
99
+ final remainingMinutes = minutes % 60;
100
+ return '$hours hr $remainingMinutes min';
101
+ }
102
+
103
+ String get progressFormatted {
104
+ final mb = receivedBytes / (1024 * 1024);
105
+ final totalMb = totalBytes / (1024 * 1024);
106
+ if (totalMb >= 1024) {
107
+ return '${(mb / 1024).toStringAsFixed(2)} GB / ${(totalMb / 1024).toStringAsFixed(2)} GB';
108
+ }
109
+ return '${mb.toStringAsFixed(0)} MB / ${totalMb.toStringAsFixed(0)} MB';
110
+ }
111
}
lib/features/on_device/data/models/download_status.dart
+10
@@ -13,11 +13,21 @@ class DownloadStatusUpdate {
13
final String taskId;
14
final DownloadStatus status;
15
final int progress; // 0-100
16
+ final int receivedBytes;
17
+ final int totalBytes;
18
+ final int bytesPerSecond;
19
+ final int? etaSeconds;
20
+ final bool isResumed;
21
22
DownloadStatusUpdate({
23
required this.modelId,
24
required this.taskId,
25
required this.status,
26
required this.progress,
27
+ this.receivedBytes = 0,
28
+ this.totalBytes = 0,
29
+ this.bytesPerSecond = 0,
30
+ this.etaSeconds,
31
+ this.isResumed = false,
32
});
33
}
lib/features/on_device/data/models/model_download_progress.dart
new
+45
@@ -0,0 +1,45 @@
1
+class ModelDownloadProgress {
2
+ final int receivedBytes;
3
+ final int totalBytes;
4
+ final double fraction;
5
+ final int bytesPerSecond;
6
+ final int? estimatedSecondsRemaining;
7
+ final bool isResumed;
8
+
9
+ const ModelDownloadProgress({
10
+ required this.receivedBytes,
11
+ required this.totalBytes,
12
+ required this.fraction,
13
+ required this.bytesPerSecond,
14
+ this.estimatedSecondsRemaining,
15
+ required this.isResumed,
16
+ });
17
+
18
+ String get speedFormatted {
19
+ if (bytesPerSecond < 1024) return '$bytesPerSecond B/s';
20
+ if (bytesPerSecond < 1024 * 1024) {
21
+ return '${(bytesPerSecond / 1024).toStringAsFixed(1)} KB/s';
22
+ }
23
+ return '${(bytesPerSecond / (1024 * 1024)).toStringAsFixed(1)} MB/s';
24
+ }
25
+
26
+ String get etaFormatted {
27
+ if (estimatedSecondsRemaining == null) return 'Unknown';
28
+ if (estimatedSecondsRemaining! < 60) return '$estimatedSecondsRemaining sec';
29
+ final minutes = estimatedSecondsRemaining! ~/ 60;
30
+ final seconds = estimatedSecondsRemaining! % 60;
31
+ if (minutes < 60) return '$minutes min $seconds sec';
32
+ final hours = minutes ~/ 60;
33
+ final remainingMinutes = minutes % 60;
34
+ return '$hours hr $remainingMinutes min';
35
+ }
36
+
37
+ String get progressFormatted {
38
+ final mb = receivedBytes / (1024 * 1024);
39
+ final totalMb = totalBytes / (1024 * 1024);
40
+ if (totalMb >= 1024) {
41
+ return '${(mb / 1024).toStringAsFixed(2)} GB / ${(totalMb / 1024).toStringAsFixed(2)} GB';
42
+ }
43
+ return '${mb.toStringAsFixed(0)} MB / ${totalMb.toStringAsFixed(0)} MB';
44
+ }
45
+}
lib/features/on_device/data/on_device_model_download_service.dart
+11
-160
@@ -2,181 +2,32 @@ import 'dart:async';
2
import 'dart:io';
3
4
import '../../../core/logger/app_logger.dart';
5
+import 'model_downloader.dart';
6
+import 'models/model_download_progress.dart';
7
import 'models/on_device_model.dart';
8
import 'on_device_engine_service.dart';
9
8
-/// Snapshot of an in-flight download.
9
-class DownloadProgress {
10
- const DownloadProgress({required this.received, required this.total});
11
-
12
- final int received;
13
- final int total;
14
-
15
- double get fraction => total <= 0 ? 0 : received / total;
16
-}
17
-
18
-/// Downloads `.litertlm` model files from HuggingFace using `dart:io HttpClient`.
10
+/// Downloads `.litertlm` model files from HuggingFace using [ModelDownloader].
11
///
20
-/// Follows redirects manually so the `Authorization` header is only sent to
21
-/// the original HuggingFace host (not the CDN). Downloads stream to a `.part`
22
-/// file and are renamed atomically on success.
12
+/// Handles file management tasks like deleting and listing downloaded models.
13
class OnDeviceModelDownloadService {
24
- final Map<String, HttpClient> _activeClients = {};
14
+ final ModelDownloader _downloader;
15
+
16
+ OnDeviceModelDownloadService(this._downloader);
17
18
/// Downloads [model] from HuggingFace, emitting progress updates.
19
///
20
/// [token] is an optional HuggingFace access token for gated models.
29
- Stream<DownloadProgress> downloadModel(
21
+ Stream<ModelDownloadProgress> downloadModel(
22
OnDeviceModel model, {
23
String? token,
32
- }) async* {
33
- final modelsDir = await OnDeviceEngineService.getModelDirectory();
34
- final finalPath = '$modelsDir/${model.fileName}';
35
- final partialPath = '$finalPath.part';
36
-
37
- // Already downloaded
38
- if (await File(finalPath).exists()) {
39
- Log.info('Model ${model.id} already downloaded at $finalPath');
40
- return;
41
- }
42
-
43
- // Clean up any stale partial file from a previous attempt
44
- final partialFile = File(partialPath);
45
- if (await partialFile.exists()) {
46
- await partialFile.delete();
47
- }
48
-
49
- final client = HttpClient();
50
- _activeClients[model.id] = client;
51
- IOSink? sink;
52
-
53
- try {
54
- final response = await _resolveWithRedirects(
55
- client: client,
56
- url: Uri.parse(model.huggingFaceUrl),
57
- token: token,
58
- );
59
-
60
- final total = response.contentLength > 0
61
- ? response.contentLength
62
- : model.fileSizeBytes;
63
- var received = 0;
64
-
65
- sink = partialFile.openWrite();
66
-
67
- await for (final chunk in response) {
68
- sink.add(chunk);
69
- received += chunk.length;
70
- yield DownloadProgress(received: received, total: total);
71
- }
72
-
73
- await sink.flush();
74
- await sink.close();
75
- sink = null;
76
-
77
- // Atomically promote .part file to final name
78
- await partialFile.rename(finalPath);
79
-
80
- Log.info('Model ${model.id} downloaded successfully to $finalPath');
81
- } catch (e) {
82
- // Clean up partial file on failure
83
- try {
84
- if (await partialFile.exists()) {
85
- await partialFile.delete();
86
- }
87
- } catch (_) {}
88
- Log.error('Failed to download model ${model.id}: $e');
89
- rethrow;
90
- } finally {
91
- _activeClients.remove(model.id);
92
- try {
93
- await sink?.close();
94
- } catch (_) {}
95
- client.close(force: true);
96
- }
24
+ }) {
25
+ return _downloader.downloadModel(model, token: token);
26
}
27
28
/// Cancel an in-flight download for [modelId].
29
void cancelDownload(String modelId) {
101
- final client = _activeClients[modelId];
102
- if (client != null) {
103
- client.close(force: true);
104
- _activeClients.remove(modelId);
105
- }
106
- }
107
-
108
- /// Manually follow redirects, passing the HF `Authorization` header
109
- /// only on the original host. The CDN URLs returned by HuggingFace are
110
- /// pre-signed and may reject extra auth headers.
111
- Future<HttpClientResponse> _resolveWithRedirects({
112
- required HttpClient client,
113
- required Uri url,
114
- required String? token,
115
- }) async {
116
- var current = url;
117
- final originHost = url.host;
118
-
119
- for (var hop = 0; hop < 6; hop++) {
120
- final request = await client.getUrl(current);
121
- request.followRedirects = false;
122
-
123
- // Only send bearer token to the original HuggingFace host
124
- final cleanedToken = token?.trim();
125
- if (cleanedToken != null &&
126
- cleanedToken.isNotEmpty &&
127
- current.host == originHost) {
128
- request.headers.set('Authorization', 'Bearer $cleanedToken');
129
- }
130
-
131
- final response = await request.close();
132
-
133
- if (response.statusCode == 200) {
134
- return response;
135
- }
136
-
137
- if (response.isRedirect) {
138
- final location = response.headers.value('location');
139
- await response.drain<void>();
140
- if (location == null) {
141
- throw HttpException(
142
- 'Redirect with no Location header (status ${response.statusCode})',
143
- );
144
- }
145
- current = current.resolve(location);
146
- continue;
147
- }
148
-
149
- // Read a small preview of the error body
150
- final body = await _readBodyPreview(response);
151
- if (response.statusCode == 401 || response.statusCode == 403) {
152
- throw HttpException(
153
- 'Access denied (HTTP ${response.statusCode}). '
154
- '${body.isNotEmpty ? "Server said: $body" : ""}',
155
- );
156
- }
157
- throw HttpException(
158
- 'Download failed: HTTP ${response.statusCode}'
159
- '${body.isNotEmpty ? " — $body" : ""}',
160
- );
161
- }
162
-
163
- throw const HttpException('Too many redirects');
164
- }
165
-
166
- /// Read at most ~512 bytes of the response body for error messages.
167
- Future<String> _readBodyPreview(HttpClientResponse response) async {
168
- try {
169
- final buffer = StringBuffer();
170
- var read = 0;
171
- await for (final chunk in response) {
172
- buffer.write(String.fromCharCodes(chunk));
173
- read += chunk.length;
174
- if (read >= 512) break;
175
- }
176
- return buffer.toString().trim().replaceAll(RegExp(r'\s+'), ' ');
177
- } catch (_) {
178
- return '';
179
- }
30
+ _downloader.cancelDownload(modelId);
31
}
32
33
/// Delete a downloaded model from disk.
lib/features/on_device/providers/foreground_download_providers.dart
+87
-22
@@ -1,7 +1,9 @@
1
import 'dart:async';
2
+import 'dart:convert';
3
4
import 'package:flutter_riverpod/flutter_riverpod.dart';
5
6
+import '../../../core/providers/storage_providers.dart';
7
import '../data/models/download_progress_info.dart';
8
import '../data/models/download_status.dart';
9
import 'on_device_providers.dart';
@@ -18,6 +20,7 @@ class ForegroundDownloadNotifier
20
extends Notifier<Map<String, DownloadProgressInfo>> {
21
final Map<String, StreamSubscription<DownloadStatusUpdate>> _subscriptions =
22
{};
23
+ static const _storageKey = 'model_downloads_state';
24
25
@override
26
Map<String, DownloadProgressInfo> build() {
@@ -27,9 +30,42 @@ class ForegroundDownloadNotifier
30
}
31
_subscriptions.clear();
32
});
33
+
34
+ return _loadState();
35
+ }
36
+
37
+ Map<String, DownloadProgressInfo> _loadState() {
38
+ try {
39
+ final prefs = ref.read(sharedPreferencesProvider);
40
+ final jsonStr = prefs.getString(_storageKey);
41
+ if (jsonStr != null) {
42
+ final Map<String, dynamic> data = json.decode(jsonStr);
43
+ return data.map((k, v) {
44
+ final info = DownloadProgressInfo.fromJson(v);
45
+ // If status was active, mark as paused on app restart
46
+ if (info.status == DownloadStatus.running ||
47
+ info.status == DownloadStatus.pending) {
48
+ return MapEntry(k, info.copyWith(status: DownloadStatus.paused));
49
+ }
50
+ return MapEntry(k, info);
51
+ });
52
+ }
53
+ } catch (e) {
54
+ // Ignore errors loading state
55
+ }
56
return {};
57
}
58
59
+ void _saveState() {
60
+ try {
61
+ final prefs = ref.read(sharedPreferencesProvider);
62
+ final data = state.map((k, v) => MapEntry(k, v.toJson()));
63
+ prefs.setString(_storageKey, json.encode(data));
64
+ } catch (e) {
65
+ // Ignore errors saving state
66
+ }
67
+ }
68
+
69
Future<void> startDownload(String modelId) async {
70
final models = ref.read(onDeviceModelsProvider);
71
final model = models.firstWhere(
@@ -37,14 +73,19 @@ class ForegroundDownloadNotifier
73
orElse: () => throw Exception('Model not found: $modelId'),
74
);
75
76
+ // If we have existing progress, preserve it but set status to running
77
+ final existingInfo = state[modelId];
78
state = {
79
...state,
42
- modelId: DownloadProgressInfo(
43
- modelId: modelId,
44
- status: DownloadStatus.running,
45
- progress: 0.0,
46
- ),
80
+ modelId:
81
+ existingInfo?.copyWith(status: DownloadStatus.running) ??
82
+ DownloadProgressInfo(
83
+ modelId: modelId,
84
+ status: DownloadStatus.running,
85
+ progress: 0.0,
86
+ ),
87
};
88
+ _saveState();
89
90
final downloadService = ref.read(foregroundDownloadServiceProvider);
91
@@ -53,69 +94,93 @@ class ForegroundDownloadNotifier
94
_subscriptions[modelId] = downloadService.getStatusStream(modelId).listen((
95
update,
96
) {
97
+ final currentInfo = state[update.modelId];
98
+
99
+ // Merge progress: if update has 0 progress but we have current progress (e.g. from pause),
100
+ // we might want to keep the current one unless it's a real restart.
101
+ // Actually, service sends 0 for terminal states like failed/canceled.
102
+
103
final info = DownloadProgressInfo(
104
modelId: update.modelId,
105
status: update.status,
59
- progress: update.progress / 100.0,
106
+ progress: update.status == DownloadStatus.paused || update.status == DownloadStatus.canceled
107
+ ? (currentInfo?.progress ?? 0.0)
108
+ : update.progress / 100.0,
109
taskId: update.taskId,
110
+ receivedBytes: update.status == DownloadStatus.paused || update.status == DownloadStatus.canceled
111
+ ? (currentInfo?.receivedBytes ?? 0)
112
+ : update.receivedBytes,
113
+ totalBytes: update.status == DownloadStatus.paused || update.status == DownloadStatus.canceled
114
+ ? (currentInfo?.totalBytes ?? 0)
115
+ : update.totalBytes,
116
+ bytesPerSecond: update.bytesPerSecond,
117
+ etaSeconds: update.etaSeconds,
118
+ isResumed: update.isResumed,
119
);
120
+
121
state = {...state, update.modelId: info};
122
+ _saveState();
123
124
if (update.status == DownloadStatus.complete) {
125
ref.invalidate(downloadedModelsProvider);
66
- state = {...state}..remove(update.modelId);
126
_removeCompletedDownload(update.modelId);
127
} else if (update.status == DownloadStatus.failed ||
69
- update.status == DownloadStatus.canceled) {
128
+ update.status == DownloadStatus.canceled ||
129
+ update.status == DownloadStatus.paused) {
130
state = {
131
...state,
132
update.modelId: info.copyWith(
133
status: update.status,
134
error: update.status == DownloadStatus.failed
135
? 'Download failed'
76
- : 'Download canceled',
136
+ : null,
137
),
138
};
79
- _removeCompletedDownload(update.modelId);
139
+ _saveState();
140
}
141
});
142
143
try {
144
await downloadService.downloadModel(model);
145
} catch (e) {
86
- // Error already handled via stream
146
+ // Error handled via stream
147
}
148
}
149
150
void _removeCompletedDownload(String modelId) {
91
- final current = Map<String, DownloadProgressInfo>.from(state);
92
- current.remove(modelId);
93
- state = current;
94
- _subscriptions[modelId]?.cancel();
95
- _subscriptions.remove(modelId);
151
+ // Small delay to let UI show 100%
152
+ Future.delayed(const Duration(seconds: 1), () {
153
+ if (state.containsKey(modelId)) {
154
+ final current = Map<String, DownloadProgressInfo>.from(state);
155
+ current.remove(modelId);
156
+ state = current;
157
+ _saveState();
158
+ _subscriptions[modelId]?.cancel();
159
+ _subscriptions.remove(modelId);
160
+ }
161
+ });
162
}
163
164
Future<void> cancelDownload(String modelId) async {
165
final downloadService = ref.read(foregroundDownloadServiceProvider);
166
await downloadService.cancelDownload(modelId);
167
state = {...state}..remove(modelId);
168
+ _saveState();
169
_subscriptions[modelId]?.cancel();
170
_subscriptions.remove(modelId);
171
}
172
106
- // Pause and resume are not supported
173
Future<void> pauseDownload(String modelId) async {
108
- // No-op
174
+ final downloadService = ref.read(foregroundDownloadServiceProvider);
175
+ downloadService.pauseDownload(modelId);
176
}
177
178
Future<void> resumeDownload(String modelId) async {
112
- // No-op
179
+ await startDownload(modelId);
180
}
181
182
Future<void> retryDownload(String modelId) async {
116
- final downloadService = ref.read(foregroundDownloadServiceProvider);
117
- await downloadService.retryDownload(modelId);
118
- // Restart download
183
+ await cancelDownload(modelId);
184
await startDownload(modelId);
185
}
186
lib/features/on_device/providers/on_device_providers.dart
+17
-2
@@ -9,6 +9,8 @@ import '../data/models/on_device_model.dart';
9
import '../data/on_device_engine_service.dart';
10
import '../data/on_device_model_download_service.dart';
11
import '../data/foreground_download_service.dart';
12
+import '../data/model_downloader.dart';
13
+import '../data/download_notification_service.dart';
14
15
final onDeviceEngineProvider =
16
NotifierProvider<OnDeviceEngineNotifier, OnDeviceEngineState>(() {
@@ -19,16 +21,29 @@ final onDeviceModelsProvider = Provider<List<OnDeviceModel>>((ref) {
21
return OnDeviceModel.curatedModels;
22
});
23
24
+final downloadNotificationServiceProvider =
25
+ Provider<DownloadNotificationService>((ref) {
26
+ return DownloadNotificationService();
27
+});
28
+
29
+final modelDownloaderProvider = Provider<ModelDownloader>((ref) {
30
+ final notificationService = ref.read(downloadNotificationServiceProvider);
31
+ return ModelDownloader(notificationService);
32
+});
33
+
34
final onDeviceDownloadServiceProvider = Provider<OnDeviceModelDownloadService>((
35
ref,
36
) {
25
- return OnDeviceModelDownloadService();
37
+ final downloader = ref.read(modelDownloaderProvider);
38
+ return OnDeviceModelDownloadService(downloader);
39
});
40
41
final foregroundDownloadServiceProvider = Provider<ForegroundDownloadService>((
42
ref,
43
) {
31
- return ForegroundDownloadService(ref.read(onDeviceDownloadServiceProvider));
44
+ return ForegroundDownloadService(
45
+ ref.read(onDeviceDownloadServiceProvider),
46
+ );
47
});
48
49
final downloadedModelsProvider = FutureProvider<Set<String>>((ref) async {
lib/features/on_device/views/model_manager_screen.dart
+67
-23
@@ -149,7 +149,9 @@ class _ModelCard extends ConsumerWidget {
149
(downloadProgress!.status == DownloadStatus.running ||
150
downloadProgress!.status == DownloadStatus.pending);
151
152
- final isPaused = downloadProgress?.status == DownloadStatus.paused;
152
+ final isPaused =
153
+ downloadProgress?.status == DownloadStatus.paused ||
154
+ downloadProgress?.status == DownloadStatus.canceled;
155
156
return Padding(
157
padding: const EdgeInsets.only(bottom: 12),
@@ -294,30 +296,55 @@ class _ModelCard extends ConsumerWidget {
296
}
297
298
if (isDownloading) {
297
- return Row(
299
+ return Column(
300
+ crossAxisAlignment: CrossAxisAlignment.start,
301
children: [
299
- Expanded(
300
- child: Column(
301
- crossAxisAlignment: CrossAxisAlignment.start,
302
- children: [
303
- LinearProgressIndicator(
304
- value: downloadProgress?.progress ?? 0.0,
305
- ),
306
- const SizedBox(height: 4),
307
- Text(
308
- 'Downloading - ${((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0)}%',
309
- style: theme.textTheme.labelSmall,
302
+ Row(
303
+ children: [
304
+ Expanded(
305
+ child: Column(
306
+ crossAxisAlignment: CrossAxisAlignment.start,
307
+ children: [
308
+ LinearProgressIndicator(
309
+ value: downloadProgress?.progress ?? 0.0,
310
+ backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.1),
311
+ ),
312
+ const SizedBox(height: 6),
313
+ Row(
314
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
315
+ children: [
316
+ Text(
317
+ '${((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(1)}% • ${downloadProgress?.speedFormatted ?? '0 B/s'}',
318
+ style: theme.textTheme.labelSmall?.copyWith(
319
+ fontWeight: FontWeight.w600,
320
+ ),
321
+ ),
322
+ Text(
323
+ 'ETA: ${downloadProgress?.etaFormatted ?? 'Calculating...'}',
324
+ style: theme.textTheme.labelSmall,
325
+ ),
326
+ ],
327
+ ),
328
+ const SizedBox(height: 2),
329
+ Text(
330
+ downloadProgress?.progressFormatted ?? '',
331
+ style: theme.textTheme.labelSmall?.copyWith(
332
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
333
+ fontSize: 10,
334
+ ),
335
+ ),
336
+ ],
337
),
311
- ],
312
- ),
313
- ),
314
- const SizedBox(width: 8),
315
- ShadButton.outline(
316
- size: ShadButtonSize.sm,
317
- onPressed: () => ref
318
- .read(foregroundDownloadNotifierProvider.notifier)
319
- .cancelDownload(model.id),
320
- child: const Text('Cancel'),
338
+ ),
339
+ const SizedBox(width: 12),
340
+ ShadButton.outline(
341
+ size: ShadButtonSize.sm,
342
+ onPressed: () => ref
343
+ .read(foregroundDownloadNotifierProvider.notifier)
344
+ .pauseDownload(model.id),
345
+ child: const Text('Pause'),
346
+ ),
347
+ ],
348
),
349
],
350
);
@@ -344,6 +371,23 @@ class _ModelCard extends ConsumerWidget {
371
);
372
}
373
374
+ if (isPaused) {
375
+ return Row(
376
+ children: [
377
+ Text(
378
+ 'Paused - ${((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0)}%',
379
+ style: theme.textTheme.bodySmall,
380
+ ),
381
+ const Spacer(),
382
+ ShadButton.outline(
383
+ size: ShadButtonSize.sm,
384
+ onPressed: () => _startDownload(ref),
385
+ child: const Text('Resume'),
386
+ ),
387
+ ],
388
+ );
389
+ }
390
+
391
return Row(
392
children: [
393
const Spacer(),
lib/features/onboarding/screens/onboarding_model_download_screen.dart
+67
-58
@@ -9,8 +9,10 @@ import 'package:shadcn_ui/shadcn_ui.dart';
9
import '../../../core/models/enums.dart';
10
import '../../../core/routes/app_routes.dart';
11
import '../../on_device/data/models/on_device_model.dart';
12
-import '../../on_device/data/on_device_model_download_service.dart';
12
+import '../../on_device/data/models/download_status.dart';
13
+import '../../on_device/data/models/download_progress_info.dart';
14
import '../../on_device/providers/on_device_providers.dart';
15
+import '../../on_device/providers/foreground_download_providers.dart';
16
import '../../servers/data/models/server.dart';
17
import '../../servers/providers/server_providers.dart';
18
@@ -24,16 +26,14 @@ class OnboardingModelDownloadScreen extends ConsumerStatefulWidget {
26
27
class _OnboardingModelDownloadScreenState
28
extends ConsumerState<OnboardingModelDownloadScreen> {
27
- String? _downloadingModelId;
28
- double _downloadProgress = 0.0;
29
bool _isCreatingServer = false;
30
- StreamSubscription<DownloadProgress>? _downloadSubscription;
30
31
@override
32
Widget build(BuildContext context) {
33
final theme = Theme.of(context);
34
final models = ref.watch(onDeviceModelsProvider);
35
final downloadedModelsAsync = ref.watch(downloadedModelsProvider);
36
+ final downloadStates = ref.watch(foregroundDownloadNotifierProvider);
37
final isAndroid = Platform.isAndroid;
38
39
return Scaffold(
@@ -77,17 +77,14 @@ class _OnboardingModelDownloadScreenState
77
loading: () => false,
78
error: (_, _) => false,
79
);
80
- final isDownloading = _downloadingModelId == model.id;
80
+ final progressInfo = downloadStates[model.id];
81
+
82
return Padding(
83
padding: const EdgeInsets.only(bottom: 12),
84
child: _ModelCard(
85
model: model,
86
isDownloaded: isDownloaded,
86
- isDownloading: isDownloading,
87
- downloadProgress: _downloadProgress,
88
- onDownload: isDownloaded || isDownloading
89
- ? null
90
- : () => _downloadModel(model),
87
+ progressInfo: progressInfo,
88
theme: theme,
89
),
90
);
@@ -115,39 +112,6 @@ class _OnboardingModelDownloadScreenState
112
return downloadedSet.hasValue && downloadedSet.value!.isNotEmpty;
113
}
114
118
- Future<void> _downloadModel(OnDeviceModel model) async {
119
- await _downloadSubscription?.cancel();
120
- setState(() {
121
- _downloadingModelId = model.id;
122
- _downloadProgress = 0.0;
123
- });
124
-
125
- final downloadService = ref.read(onDeviceDownloadServiceProvider);
126
- try {
127
- await for (final progress in downloadService.downloadModel(model)) {
128
- if (!mounted) return;
129
- setState(() {
130
- _downloadProgress = progress.fraction;
131
- });
132
- }
133
- ref.invalidate(downloadedModelsProvider);
134
- if (mounted) {
135
- setState(() {
136
- _downloadingModelId = null;
137
- });
138
- }
139
- } catch (e) {
140
- if (mounted) {
141
- setState(() {
142
- _downloadingModelId = null;
143
- });
144
- ScaffoldMessenger.of(context).showSnackBar(
145
- SnackBar(content: Text('Download failed: ${e.toString()}')),
146
- );
147
- }
148
- }
149
- }
150
-
115
Future<void> _createOnDeviceServer() async {
116
setState(() => _isCreatingServer = true);
117
@@ -186,25 +150,28 @@ class _OnboardingModelDownloadScreenState
150
}
151
}
152
189
-class _ModelCard extends StatelessWidget {
153
+class _ModelCard extends ConsumerWidget {
154
final OnDeviceModel model;
155
final bool isDownloaded;
192
- final bool isDownloading;
193
- final double downloadProgress;
194
- final VoidCallback? onDownload;
156
+ final DownloadProgressInfo? progressInfo;
157
final ThemeData theme;
158
159
const _ModelCard({
160
required this.model,
161
required this.isDownloaded,
200
- required this.isDownloading,
201
- required this.downloadProgress,
202
- this.onDownload,
162
+ this.progressInfo,
163
required this.theme,
164
});
165
166
@override
207
- Widget build(BuildContext context) {
167
+ Widget build(BuildContext context, WidgetRef ref) {
168
+ final isDownloading =
169
+ progressInfo?.status == DownloadStatus.running ||
170
+ progressInfo?.status == DownloadStatus.pending;
171
+ final isPaused =
172
+ progressInfo?.status == DownloadStatus.paused ||
173
+ progressInfo?.status == DownloadStatus.canceled;
174
+
175
return Container(
176
padding: const EdgeInsets.all(16),
177
decoration: BoxDecoration(
@@ -288,21 +255,63 @@ class _ModelCard extends StatelessWidget {
255
],
256
)
257
else if (isDownloading)
291
- Column(
292
- crossAxisAlignment: CrossAxisAlignment.stretch,
258
+ Row(
259
+ children: [
260
+ Expanded(
261
+ child: Column(
262
+ crossAxisAlignment: CrossAxisAlignment.stretch,
263
+ children: [
264
+ LinearProgressIndicator(value: progressInfo?.progress ?? 0),
265
+ const SizedBox(height: 4),
266
+ Row(
267
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
268
+ children: [
269
+ Text(
270
+ '${((progressInfo?.progress ?? 0) * 100).toStringAsFixed(0)}% • ${progressInfo?.speedFormatted ?? "0 B/s"}',
271
+ style: theme.textTheme.labelSmall,
272
+ ),
273
+ Text(
274
+ 'ETA: ${progressInfo?.etaFormatted ?? "..."}',
275
+ style: theme.textTheme.labelSmall,
276
+ ),
277
+ ],
278
+ ),
279
+ ],
280
+ ),
281
+ ),
282
+ const SizedBox(width: 12),
283
+ ShadButton.outline(
284
+ size: ShadButtonSize.sm,
285
+ onPressed: () => ref
286
+ .read(foregroundDownloadNotifierProvider.notifier)
287
+ .pauseDownload(model.id),
288
+ child: const Text('Pause'),
289
+ ),
290
+ ],
291
+ )
292
+ else if (isPaused)
293
+ Row(
294
children: [
294
- LinearProgressIndicator(value: downloadProgress),
295
- const SizedBox(height: 4),
295
Text(
297
- '${(downloadProgress * 100).toStringAsFixed(0)}%',
298
- style: theme.textTheme.labelSmall,
296
+ 'Paused - ${((progressInfo?.progress ?? 0) * 100).toStringAsFixed(0)}%',
297
+ style: theme.textTheme.bodySmall,
298
+ ),
299
+ const Spacer(),
300
+ ShadButton.outline(
301
+ size: ShadButtonSize.sm,
302
+ onPressed: () => ref
303
+ .read(foregroundDownloadNotifierProvider.notifier)
304
+ .startDownload(model.id),
305
+ child: const Text('Resume'),
306
),
307
],
308
)
309
else
310
ShadButton.outline(
311
size: ShadButtonSize.sm,
305
- onPressed: onDownload,
312
+ onPressed: () => ref
313
+ .read(foregroundDownloadNotifierProvider.notifier)
314
+ .startDownload(model.id),
315
child: const Text('Download'),
316
),
317
],
lib/main.dart
+13
-5
@@ -5,6 +5,7 @@ import 'package:shared_preferences/shared_preferences.dart';
5
import 'app.dart';
6
import 'core/providers/storage_providers.dart';
7
import 'core/storage/objectbox_store.dart';
8
+import 'features/on_device/providers/on_device_providers.dart';
9
10
Future<void> main() async {
11
WidgetsFlutterBinding.ensureInitialized();
@@ -12,12 +13,19 @@ Future<void> main() async {
13
final prefs = await SharedPreferences.getInstance();
14
final database = await ObjectBoxStore.create();
15
16
+ final container = ProviderContainer(
17
+ overrides: [
18
+ sharedPreferencesProvider.overrideWithValue(prefs),
19
+ databaseProvider.overrideWithValue(database),
20
+ ],
21
+ );
22
+
23
+ // Initialize notification service
24
+ await container.read(downloadNotificationServiceProvider).init();
25
+
26
runApp(
16
- ProviderScope(
17
- overrides: [
18
- sharedPreferencesProvider.overrideWithValue(prefs),
19
- databaseProvider.overrideWithValue(database),
20
- ],
27
+ UncontrolledProviderScope(
28
+ container: container,
29
child: const App(),
30
),
31
);
pubspec.lock
+65
-1
@@ -334,6 +334,30 @@ packages:
334
url: "https://pub.dev"
335
source: hosted
336
version: "0.3.0"
337
+ flutter_local_notifications:
338
+ dependency: "direct main"
339
+ description:
340
+ name: flutter_local_notifications
341
+ sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610
342
+ url: "https://pub.dev"
343
+ source: hosted
344
+ version: "18.0.1"
345
+ flutter_local_notifications_linux:
346
+ dependency: transitive
347
+ description:
348
+ name: flutter_local_notifications_linux
349
+ sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01"
350
+ url: "https://pub.dev"
351
+ source: hosted
352
+ version: "5.0.0"
353
+ flutter_local_notifications_platform_interface:
354
+ dependency: transitive
355
+ description:
356
+ name: flutter_local_notifications_platform_interface
357
+ sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52"
358
+ url: "https://pub.dev"
359
+ source: hosted
360
+ version: "8.0.0"
361
flutter_localizations:
362
dependency: transitive
363
description: flutter
@@ -733,6 +757,22 @@ packages:
757
url: "https://pub.dev"
758
source: hosted
759
version: "2.2.0"
760
+ package_info_plus:
761
+ dependency: transitive
762
+ description:
763
+ name: package_info_plus
764
+ sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
765
+ url: "https://pub.dev"
766
+ source: hosted
767
+ version: "9.0.1"
768
+ package_info_plus_platform_interface:
769
+ dependency: transitive
770
+ description:
771
+ name: package_info_plus_platform_interface
772
+ sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
773
+ url: "https://pub.dev"
774
+ source: hosted
775
+ version: "3.2.1"
776
path:
777
dependency: "direct main"
778
description:
@@ -1098,6 +1138,14 @@ packages:
1138
url: "https://pub.dev"
1139
source: hosted
1140
version: "7.3.0"
1141
+ timezone:
1142
+ dependency: transitive
1143
+ description:
1144
+ name: timezone
1145
+ sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
1146
+ url: "https://pub.dev"
1147
+ source: hosted
1148
+ version: "0.10.1"
1149
two_dimensional_scrollables:
1150
dependency: transitive
1151
description:
@@ -1226,6 +1274,22 @@ packages:
1274
url: "https://pub.dev"
1275
source: hosted
1276
version: "15.0.2"
1277
+ wakelock_plus:
1278
+ dependency: "direct main"
1279
+ description:
1280
+ name: wakelock_plus
1281
+ sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1
1282
+ url: "https://pub.dev"
1283
+ source: hosted
1284
+ version: "1.5.2"
1285
+ wakelock_plus_platform_interface:
1286
+ dependency: transitive
1287
+ description:
1288
+ name: wakelock_plus_platform_interface
1289
+ sha256: "14b2e5b9e35c2631e656913c47adecdd71633ae92896a27a64c8f1fcfabc21cc"
1290
+ url: "https://pub.dev"
1291
+ source: hosted
1292
+ version: "1.5.0"
1293
watcher:
1294
dependency: transitive
1295
description:
@@ -1300,4 +1364,4 @@ packages:
1364
version: "3.1.3"
1365
sdks:
1366
dart: ">=3.11.0 <4.0.0"
1303
- flutter: ">=3.38.4"
1367
+ flutter: ">=3.41.0"
pubspec.yaml
+3
-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.0.0+10
19
+version: 1.1.0+12
20
21
environment:
22
sdk: ^3.11.0
@@ -55,6 +55,8 @@ dependencies:
55
objectbox_flutter_libs: ^5.3.1
56
path: ^1.9.1
57
flutter_litert_lm: ^0.3.0
58
+ wakelock_plus: ^1.2.8
59
+ flutter_local_notifications: ^18.0.1
60
61
dev_dependencies:
62
flutter_test: