@tduncan / Cortana / commits / 89f599d

feat: implement persistent download state, background notifications, and pause/resume functionality for model downloads

abdulmominsakib committed Apr 20, 2026 at 13:34 UTC 89f599de3762e1487f6668cb8e5e9e8e80de6bc3
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: