@tduncan / Cortana / commits / 55057bd

refactor: replace custom on-device engine with flutter_gemma for enhanced model management and inference capabilities

abdulmominsakib committed May 24, 2026 at 20:29 UTC 55057bd1f46fffe5c9e07db2064ef60a57553ba2
35 files changed +1188 -1220
README.md
+1 -1
@@ -60,7 +60,7 @@ LocalMind is built with modern, scalable technologies:
60 LocalMind is rapidly evolving. Here is what's coming next:
61
62 - [x] **AI Voice (Text-to-Speech)**: Listen to your AI's responses with high-quality TTS.
63 -- [ ] **Context Smart Replies**: Integrated suggested follow-ups to keep the conversation flowing.
63 +- [x] **Context Smart Replies**: Integrated suggested follow-ups to keep the conversation flowing.
64 - [x] **Multimodal Support**: Attach images and documents for vision-capable models.
65 - [x] **Tablet/Desktop Optimization**: A split-view layout for large-screen productivity.
66 - [x] **Export Options**: Export your chats to Markdown or PDF to share or archive.
android/app/src/main/AndroidManifest.xml
+9
@@ -11,6 +11,15 @@
11 android:name="${applicationName}"
12 android:icon="@mipmap/ic_launcher"
13 android:usesCleartextTraffic="true">
14 + <uses-native-library
15 + android:name="libOpenCL.so"
16 + android:required="false"/>
17 + <uses-native-library
18 + android:name="libOpenCL-car.so"
19 + android:required="false"/>
20 + <uses-native-library
21 + android:name="libOpenCL-pixel.so"
22 + android:required="false"/>
23 <activity
24 android:name=".MainActivity"
25 android:exported="true"
docs/superpowers/specs/2026-05-24-flutter-gemma-migration-design.md new
+340
@@ -0,0 +1,340 @@
1 +# Migration: flutter_litert_lm to flutter_gemma
2 +
3 +**Date:** 2026-05-24
4 +**Status:** Approved
5 +**Scope:** Replace on-device inference engine and model management with flutter_gemma for cross-platform support and better performance/stability.
6 +
7 +## Goals
8 +
9 +1. Replace `flutter_litert_lm` with `flutter_gemma` ^0.16.1
10 +2. Enable on-device inference on Android and iOS (desktop platforms deferred)
11 +3. Use flutter_gemma's built-in model download/management system
12 +4. Maintain existing UX patterns (model manager, chat streaming, backend selection)
13 +
14 +## Non-Goals
15 +
16 +- Adding multimodal (vision/audio) support in this migration
17 +- Adding function calling or thinking mode support
18 +- Adding embedding/RAG capabilities
19 +- Changing the remote server chat services (LM Studio, Ollama, etc.)
20 +
21 +---
22 +
23 +## Dependency Changes
24 +
25 +### pubspec.yaml
26 +
27 +**Remove:**
28 +```yaml
29 +flutter_litert_lm: ^0.3.0
30 +```
31 +
32 +**Add:**
33 +```yaml
34 +flutter_gemma: ^0.16.1
35 +```
36 +
37 +---
38 +
39 +## Files to Delete
40 +
41 +These files are entirely replaced by flutter_gemma's built-in APIs:
42 +
43 +| File | Lines | Reason |
44 +|------|-------|--------|
45 +| `lib/features/on_device/data/on_device_engine_service.dart` | 132 | Replaced by `FlutterGemma.getActiveModel()` |
46 +| `lib/features/on_device/data/model_downloader.dart` | 218 | Replaced by `FlutterGemma.installModel().fromNetwork()` |
47 +| `lib/features/on_device/data/on_device_model_download_service.dart` | 101 | Replaced by FlutterGemma model management |
48 +| `lib/features/on_device/data/foreground_download_service.dart` | 171 | Replaced by FlutterGemma progress callbacks |
49 +| `lib/features/on_device/data/download_notification_service.dart` | ~100 | FlutterGemma handles Android foreground service |
50 +| `lib/features/on_device/data/models/model_download_progress.dart` | ~50 | Replaced by int progress callback |
51 +
52 +**Total removed: ~770 lines**
53 +
54 +---
55 +
56 +## Files to Create
57 +
58 +### `lib/features/on_device/data/on_device_gemma_service.dart`
59 +
60 +New engine service wrapping flutter_gemma. Responsibilities:
61 +- Initialize FlutterGmma at app startup
62 +- Install models from network with progress tracking
63 +- Load/unload models with backend preference
64 +- Create chat instances for inference
65 +- Check installed models
66 +- Delete models
67 +
68 +**Key API surface:**
69 +
70 +```dart
71 +class OnDeviceGemmaService {
72 + // Initialization
73 + static Future<void> initialize({String? huggingFaceToken});
74 +
75 + // Model management
76 + Future<void> installModel(OnDeviceModel model, {void Function(int)? onProgress, String? token});
77 + Future<bool> isModelInstalled(String modelId);
78 + Future<List<String>> getInstalledModelIds();
79 + Future<void> deleteModel(String modelId);
80 +
81 + // Engine lifecycle
82 + Future<void> loadModel(String modelId, PreferredBackend backend, {int maxTokens = 2048});
83 + Future<void> unloadModel();
84 + bool get isLoaded;
85 +
86 + // Chat
87 + InferenceChat? createChat({String? systemInstruction});
88 + InferenceModel? get activeModel;
89 +}
90 +```
91 +
92 +**Implementation notes:**
93 +- `installModel` calls `FlutterGemma.installModel(modelType:, fileType: ModelFileType.litertlm).fromNetwork(url).withProgress(cb).install()`
94 +- `loadModel` calls `FlutterGemma.getActiveModel(maxTokens:, preferredBackend:)`
95 +- `createChat` calls `model.createChat(systemInstruction:)`
96 +- `deleteModel` calls `FlutterGemma.uninstallModel(fileName)`
97 +- `isModelInstalled` calls `FlutterGemma.isModelInstalled(fileName)`
98 +- Model IDs map to filenames: `'$modelId.litertlm'`
99 +
100 +---
101 +
102 +## Files to Rewrite
103 +
104 +### `lib/features/on_device/data/on_device_chat_service.dart`
105 +
106 +**Current:** Wraps `LiteLmConversation`, streams `LiteLmMessage`
107 +**New:** Creates `InferenceChat` from `OnDeviceGemmaService`, streams `ModelResponse`
108 +
109 +**Key changes:**
110 +- Remove all `flutter_litert_lm` imports
111 +- Use `OnDeviceGemmaService` instead of `OnDeviceEngineService`
112 +- For each `sendMessage()` call:
113 + 1. Get or create `InferenceChat` from service
114 + 2. Add conversation history as `Message.text()` chunks via `chat.addQueryChunk()`
115 + 3. Call `chat.generateChatResponseAsync()` for streaming
116 + 4. Map `TextResponse` → `ChatResponse(type: message, content: token)`
117 + 5. Map `FunctionCallResponse` → `ChatResponse(type: toolCall, ...)`
118 + 6. Map `ThinkingResponse` → `ChatResponse(type: reasoning, ...)`
119 +- `cancelStream()` calls `chat.stopGeneration()` then `chat.close()`
120 +- Store active chat reference for cancellation
121 +
122 +**Message conversion:**
123 +```dart
124 +List<Message> _convertMessages(List<Message> messages) {
125 + return messages
126 + .where((m) => m.role == MessageRole.user || m.role == MessageRole.assistant || m.role == MessageRole.system)
127 + .map((m) => Message.text(text: m.content, isUser: m.role == MessageRole.user))
128 + .toList();
129 +}
130 +```
131 +
132 +**Streaming mapping:**
133 +```dart
134 +Stream<ChatResponse> _mapGemmaStream(Stream<ModelResponse> stream) async* {
135 + await for (final response in stream) {
136 + if (response is TextResponse) {
137 + yield ChatResponse(type: ChatResponseType.message, content: response.token);
138 + } else if (response is FunctionCallResponse) {
139 + yield ChatResponse(type: ChatResponseType.toolCall,
140 + toolCall: ToolCallData(tool: response.name, arguments: response.args));
141 + } else if (response is ThinkingResponse) {
142 + yield ChatResponse(type: ChatResponseType.reasoning,
143 + reasoningContent: response.content);
144 + }
145 + }
146 + yield ChatResponse(type: ChatResponseType.done);
147 +}
148 +```
149 +
150 +### `lib/features/on_device/providers/on_device_providers.dart`
151 +
152 +**Key changes:**
153 +- Replace `OnDeviceEngineService` references with `OnDeviceGemmaService`
154 +- Replace `LiteLmBackendType` with `PreferredBackend` from flutter_gemma
155 +- `OnDeviceEngineNotifier`:
156 + - `loadModel()` calls `gemmaService.loadModel(modelId, backend)`
157 + - Uses `ChatBackgroundService` for model loading (heavy mmap operation)
158 + - `unloadModel()` calls `gemmaService.unloadModel()`
159 +- `OnDeviceModelStateNotifier`: no major changes
160 +- Remove `modelDownloaderProvider`, `onDeviceDownloadServiceProvider`, `foregroundDownloadServiceProvider`
161 +- Add `onDeviceGemmaServiceProvider` providing `OnDeviceGemmaService`
162 +- Update `isOnDevicePlatformSupportedProvider` to support iOS
163 +
164 +**Platform support provider:**
165 +```dart
166 +final isOnDevicePlatformSupportedProvider = Provider<bool>((ref) {
167 + return Platform.isAndroid || Platform.isIOS;
168 +});
169 +```
170 +
171 +### `lib/features/on_device/providers/foreground_download_providers.dart`
172 +
173 +**Rewrite to use flutter_gemma's download system:**
174 +- `ForegroundDownloadNotifier.startDownload()` calls `OnDeviceGemmaService.installModel()` with progress callback
175 +- Progress callback updates state directly (int 0-100)
176 +- No more stream subscriptions or broadcast controllers
177 +- Simplified pause/resume (flutter_gemma handles retry internally)
178 +- Keep SharedPreferences persistence for download state across restarts
179 +
180 +### `lib/features/on_device/views/model_manager_screen.dart`
181 +
182 +**Key changes:**
183 +- Remove `if (!isAndroid)` warning banner — all platforms supported
184 +- Update download progress to use simplified flutter_gemma progress (int 0-100)
185 +- Remove custom download UI complexity (pause/resume buttons — flutter_gemma handles retry)
186 +- Keep memory health display
187 +- Keep model cards with load/unload/delete actions
188 +
189 +### `lib/features/on_device/data/models/on_device_model.dart`
190 +
191 +**Add ModelType mapping:**
192 +```dart
193 +ModelType get flutterGemmaModelType {
194 + switch (id) {
195 + case 'qwen3-0.6b': return ModelType.qwen3;
196 + case 'qwen2.5-1.5b-instruct': return ModelType.qwen;
197 + case 'deepseek-r1-distill-qwen-1.5b': return ModelType.deepSeek;
198 + case 'gemma4-e2b-instruct': return ModelType.gemma4;
199 + default: return ModelType.general;
200 + }
201 +}
202 +```
203 +
204 +**Update `fileName` getter:**
205 +```dart
206 +String get fileName => '$id.litertlm'; // unchanged — same format
207 +```
208 +
209 +### `lib/core/models/enums.dart`
210 +
211 +**Changes:**
212 +- Remove `LiteLmBackendType` enum (replaced by `PreferredBackend` from flutter_gemma)
213 +- Keep `OnDeviceEngineStatus` and `OnDeviceModelState` enums
214 +- Add import for `package:flutter_gemma/flutter_gemma.dart`
215 +
216 +---
217 +
218 +## Files to Update (Minor)
219 +
220 +### `lib/features/chat/data/chat_service.dart`
221 +
222 +Update `ChatService.forServer()` factory:
223 +```dart
224 +case ServerType.onDevice:
225 + if (onDeviceGemma == null) {
226 + throw StateError('OnDeviceGemmaService is required for onDevice server type');
227 + }
228 + return OnDeviceChatService(onDeviceGemma);
229 +```
230 +
231 +Change parameter from `OnDeviceEngineService? onDeviceEngine` to `OnDeviceGemmaService? onDeviceGemma`.
232 +
233 +### `lib/features/chat/providers/chat_providers.dart`
234 +
235 +Update `chatServiceProvider`:
236 +```dart
237 +final chatServiceProvider = Provider<ChatService?>((ref) {
238 + final server = ref.watch(activeServerProvider);
239 + if (server == null) return null;
240 + if (server.type == ServerType.onDevice) {
241 + final gemmaService = ref.read(onDeviceGemmaServiceProvider);
242 + return ChatService.forServer(server.type, ref.read(dioProvider), onDeviceGemma: gemmaService);
243 + }
244 + return ChatService.forServer(server.type, ref.read(dioProvider));
245 +});
246 +```
247 +
248 +### `lib/core/services/chat_background_service.dart`
249 +
250 +**Simplify:** Still needed for model loading (heavy mmap), but download foreground service is handled by flutter_gemma. Keep start/stop for model load operations.
251 +
252 +### `android/app/src/main/AndroidManifest.xml`
253 +
254 +Add GPU support if not already present:
255 +```xml
256 +<uses-native-library android:name="libOpenCL.so" android:required="false"/>
257 +<uses-native-library android:name="libOpenCL-car.so" android:required="false"/>
258 +<uses-native-library android:name="libOpenCL-pixel.so" android:required="false"/>
259 +```
260 +
261 +### `ios/Podfile`
262 +
263 +```ruby
264 +platform :ios, '16.0'
265 +use_frameworks! :linkage => :static
266 +```
267 +
268 +### `ios/Runner/Info.plist`
269 +
270 +Add:
271 +```xml
272 +<key>UIFileSharingEnabled</key>
273 +<true/>
274 +<key>NSLocalNetworkUsageDescription</key>
275 +<string>This app requires local network access for model inference services.</string>
276 +```
277 +
278 +### `ios/Runner/Runner.entitlements`
279 +
280 +Add memory entitlements:
281 +```xml
282 +<key>com.apple.developer.kernel.extended-virtual-addressing</key>
283 +<true/>
284 +<key>com.apple.developer.kernel.increased-memory-limit</key>
285 +<true/>
286 +```
287 +
288 +---
289 +
290 +## Model Storage Consideration
291 +
292 +flutter_gemma manages its own model storage. Existing models downloaded by the custom `ModelDownloader` to `{appSupportDir}/on_device_models/{modelId}.litertlm` will NOT be automatically recognized by flutter_gemma.
293 +
294 +**Migration approach:**
295 +1. On first launch after migration, check for existing `.litertlm` files in the old location
296 +2. For each found model, use `FlutterGemma.installModel().fromFile(oldPath).install()` to register it
297 +3. This avoids re-downloading multi-GB models
298 +4. Old directory can be cleaned up after successful registration
299 +
300 +---
301 +
302 +## Initialization
303 +
304 +Add to `main.dart`:
305 +```dart
306 +void main() async {
307 + WidgetsFlutterBinding.ensureInitialized();
308 +
309 + // Initialize flutter_gemma
310 + await OnDeviceGemmaService.initialize(
311 + huggingFaceToken: const String.fromEnvironment('HUGGINGFACE_TOKEN'),
312 + );
313 +
314 + runApp(MyApp());
315 +}
316 +```
317 +
318 +---
319 +
320 +## Testing Strategy
321 +
322 +1. **Unit tests:** Mock `FlutterGemmaPlugin` for `OnDeviceGemmaService` tests
323 +2. **Widget tests:** Model manager screen with mocked providers
324 +3. **Integration tests:** Full download → load → inference flow on each platform
325 +4. **Platform matrix:** Android (arm64), iOS (arm64)
326 +
327 +---
328 +
329 +## Platform Setup Checklist
330 +
331 +### Android
332 +- [x] Already supported
333 +- [ ] Add OpenCL manifest entries for GPU support
334 +
335 +### iOS (NEW)
336 +- [ ] Set minimum iOS 16.0 in Podfile
337 +- [ ] Enable file sharing in Info.plist
338 +- [ ] Add memory entitlements in Runner.entitlements
339 +- [ ] Set static linkage in Podfile
340 +- [ ] Add network usage description
ios/Podfile
+2 -2
@@ -1,5 +1,5 @@
1 # Uncomment this line to define a global platform for your project
2 -platform :ios, '15.0'
2 +platform :ios, '16.0'
3
4 # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
5 ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -28,7 +28,7 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe
28 flutter_ios_podfile_setup
29
30 target 'Runner' do
31 - use_frameworks!
31 + use_frameworks! :linkage => :static
32
33 flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
34 target 'RunnerTests' do
ios/Podfile.lock
+27 -6
@@ -2,6 +2,8 @@ PODS:
2 - audioplayers_darwin (0.0.1):
3 - Flutter
4 - FlutterMacOS
5 + - background_downloader (0.0.1):
6 + - Flutter
7 - clipboard (3.0.9):
8 - Flutter
9 - device_info_plus (0.0.1):
@@ -41,8 +43,10 @@ PODS:
43 - DKImagePickerController/PhotoGallery
44 - Flutter
45 - Flutter (1.0.0)
44 - - flutter_litert_lm (0.3.0):
46 + - flutter_gemma (0.16.1):
47 - Flutter
48 + - MediaPipeTasksGenAI (= 0.10.33)
49 + - MediaPipeTasksGenAIC (= 0.10.33)
50 - flutter_local_notifications (0.0.1):
51 - Flutter
52 - flutter_secure_storage (6.0.0):
@@ -51,6 +55,11 @@ PODS:
55 - Flutter
56 - haptic_feedback (0.5.1):
57 - Flutter
58 + - large_file_handler (0.0.1):
59 + - Flutter
60 + - MediaPipeTasksGenAI (0.10.33):
61 + - MediaPipeTasksGenAIC (= 0.10.33)
62 + - MediaPipeTasksGenAIC (0.10.33)
63 - ObjectBox (5.3.0-beta.4)
64 - objectbox_flutter_libs (0.0.1):
65 - Flutter
@@ -73,15 +82,17 @@ PODS:
82
83 DEPENDENCIES:
84 - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
85 + - background_downloader (from `.symlinks/plugins/background_downloader/ios`)
86 - clipboard (from `.symlinks/plugins/clipboard/ios`)
87 - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
88 - file_picker (from `.symlinks/plugins/file_picker/ios`)
89 - Flutter (from `Flutter`)
80 - - flutter_litert_lm (from `.symlinks/plugins/flutter_litert_lm/ios`)
90 + - flutter_gemma (from `.symlinks/plugins/flutter_gemma/ios`)
91 - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
92 - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
93 - flutter_tts (from `.symlinks/plugins/flutter_tts/ios`)
94 - haptic_feedback (from `.symlinks/plugins/haptic_feedback/ios`)
95 + - large_file_handler (from `.symlinks/plugins/large_file_handler/ios`)
96 - objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
97 - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
98 - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
@@ -93,6 +104,8 @@ SPEC REPOS:
104 trunk:
105 - DKImagePickerController
106 - DKPhotoGallery
107 + - MediaPipeTasksGenAI
108 + - MediaPipeTasksGenAIC
109 - ObjectBox
110 - SDWebImage
111 - SwiftyGif
@@ -100,6 +113,8 @@ SPEC REPOS:
113 EXTERNAL SOURCES:
114 audioplayers_darwin:
115 :path: ".symlinks/plugins/audioplayers_darwin/darwin"
116 + background_downloader:
117 + :path: ".symlinks/plugins/background_downloader/ios"
118 clipboard:
119 :path: ".symlinks/plugins/clipboard/ios"
120 device_info_plus:
@@ -108,8 +123,8 @@ EXTERNAL SOURCES:
123 :path: ".symlinks/plugins/file_picker/ios"
124 Flutter:
125 :path: Flutter
111 - flutter_litert_lm:
112 - :path: ".symlinks/plugins/flutter_litert_lm/ios"
126 + flutter_gemma:
127 + :path: ".symlinks/plugins/flutter_gemma/ios"
128 flutter_local_notifications:
129 :path: ".symlinks/plugins/flutter_local_notifications/ios"
130 flutter_secure_storage:
@@ -118,6 +133,8 @@ EXTERNAL SOURCES:
133 :path: ".symlinks/plugins/flutter_tts/ios"
134 haptic_feedback:
135 :path: ".symlinks/plugins/haptic_feedback/ios"
136 + large_file_handler:
137 + :path: ".symlinks/plugins/large_file_handler/ios"
138 objectbox_flutter_libs:
139 :path: ".symlinks/plugins/objectbox_flutter_libs/ios"
140 package_info_plus:
@@ -133,17 +150,21 @@ EXTERNAL SOURCES:
150
151 SPEC CHECKSUMS:
152 audioplayers_darwin: f15e209a3e856d1a7edcf98dc029f484fead2242
153 + background_downloader: a05c77d32a0d70615b9c04577aa203535fc924ff
154 clipboard: 1dfb1b37c0b722d20b0ca76a89bb2e7194e85811
155 device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
156 DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
157 DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
158 file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
159 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
142 - flutter_litert_lm: 07a899d1d3f22806951c3be384bd13dae3951a41
160 + flutter_gemma: 9c3d57a8dff68fe107140bb4ed908ff53ff6dab0
161 flutter_local_notifications: df98d66e515e1ca797af436137b4459b160ad8c9
162 flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
163 flutter_tts: 91ad1884033cd95ef6e9338754adad54f01f090f
164 haptic_feedback: a366a27ec760f0e865651605b95de8a9bb04ef01
165 + large_file_handler: e3686e246052b0f03955789d3a04604574f80afe
166 + MediaPipeTasksGenAI: 8eed8e6d03fe26d30c4205cb5295ec7b394432ad
167 + MediaPipeTasksGenAIC: cb098ecea44e248efe8fe7e2f62b4cb0df781802
168 ObjectBox: eccb95ea2054c39d81dfa2d4ccc5f1e31187228a
169 objectbox_flutter_libs: 893a4322e8ec87438912bae6dfa745929adc6bf5
170 package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
@@ -154,6 +175,6 @@ SPEC CHECKSUMS:
175 url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa
176 wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e
177
157 -PODFILE CHECKSUM: 53a6aebc29ccee84c41f92f409fc20cd4ca011f1
178 +PODFILE CHECKSUM: c0915517eae1a513480956f02c7781054ca94770
179
180 COCOAPODS: 1.16.2
ios/Runner.xcodeproj/project.pbxproj
+18
@@ -202,6 +202,7 @@
202 9705A1C41CF9048500538489 /* Embed Frameworks */,
203 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
204 6F568F0CE167E332F9CAF0D6 /* [CP] Embed Pods Frameworks */,
205 + 91D3378898CA91FDC6BCE7FE /* [CP] Copy Pods Resources */,
206 );
207 buildRules = (
208 );
@@ -306,6 +307,23 @@
307 shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
308 showEnvVarsInLog = 0;
309 };
310 + 91D3378898CA91FDC6BCE7FE /* [CP] Copy Pods Resources */ = {
311 + isa = PBXShellScriptBuildPhase;
312 + buildActionMask = 2147483647;
313 + files = (
314 + );
315 + inputFileListPaths = (
316 + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
317 + );
318 + name = "[CP] Copy Pods Resources";
319 + outputFileListPaths = (
320 + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
321 + );
322 + runOnlyForDeploymentPostprocessing = 0;
323 + shellPath = /bin/sh;
324 + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
325 + showEnvVarsInLog = 0;
326 + };
327 9740EEB61CF901F6004384FC /* Run Script */ = {
328 isa = PBXShellScriptBuildPhase;
329 alwaysOutOfDate = 1;
ios/Runner/AppDelegate.swift
+9 -6
@@ -9,18 +9,22 @@ import UIKit
9 _ application: UIApplication,
10 didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
11 ) -> Bool {
12 - let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
13 - let channel = FlutterMethodChannel(name: "localmind/chat_background", binaryMessenger: controller.binaryMessenger)
12 + GeneratedPluginRegistrant.register(with: self)
13
14 + let result = super.application(application, didFinishLaunchingWithOptions: launchOptions)
15 +
16 + guard let controller = window?.rootViewController as? FlutterViewController else {
17 + return result
18 + }
19 +
20 + let channel = FlutterMethodChannel(name: "localmind/chat_background", binaryMessenger: controller.binaryMessenger)
21 channel.setMethodCallHandler({
22 (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
23 switch call.method {
24 case "startForeground":
19 - // On iOS, we start a background task assertion
25 self.startBackgroundTask()
26 result(nil)
27 case "stopForeground":
23 - // On iOS, we end the background task assertion
28 self.stopBackgroundTask()
29 result(nil)
30 default:
@@ -28,8 +32,7 @@ import UIKit
32 }
33 })
34
31 - GeneratedPluginRegistrant.register(with: self)
32 - return super.application(application, didFinishLaunchingWithOptions: launchOptions)
35 + return result
36 }
37
38 private func startBackgroundTask() {
ios/Runner/Info.plist
+4
@@ -79,5 +79,9 @@
79 <string>processing</string>
80 <string>audio</string>
81 </array>
82 + <key>UIFileSharingEnabled</key>
83 + <true/>
84 + <key>NSLocalNetworkUsageDescription</key>
85 + <string>This app requires local network access for model inference services.</string>
86 </dict>
87 </plist>
lib/bootstrap/bootstrap_host.dart
+5 -2
@@ -8,7 +8,7 @@ import '../app.dart';
8 import '../core/providers/highlighter_provider.dart';
9 import '../core/providers/storage_providers.dart';
10 import '../core/storage/objectbox_store.dart';
11 -import '../features/on_device/providers/on_device_providers.dart';
11 +import '../features/on_device/data/on_device_gemma_service.dart';
12 import '../core/models/enums.dart';
13 import '../features/servers/data/models/server.dart';
14 import '../features/servers/providers/server_providers.dart';
@@ -65,7 +65,10 @@ class _BootstrapHostState extends State<BootstrapHost> {
65 );
66
67 _updateStage(BootstrapStage.initializingServices, 'Initializing services...');
68 - await container.read(downloadNotificationServiceProvider).init();
68 + await OnDeviceGemmaService.initialize();
69 +
70 + // Migrate models from old custom download location
71 + await OnDeviceGemmaService.migrateOldModels();
72
73 _updateStage(BootstrapStage.configuringServer, 'Configuring server...');
74 final servers = await container.read(serversProvider.future);
lib/core/models/enums.dart
-2
@@ -212,8 +212,6 @@ enum ModelStatus { unloaded, loading, loaded, preloaded, thinking }
212
213 enum OnDeviceEngineStatus { notLoaded, loading, loaded, error }
214
215 -enum LiteLmBackendType { cpu, gpu, npu }
216 -
215 enum OnDeviceModelState {
216 notDownloaded,
217 downloading,
lib/core/providers/app_providers.dart
+2 -1
@@ -1,3 +1,4 @@
1 +import 'package:flutter_gemma/flutter_gemma.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3
4 import '../../features/settings/data/models/app_settings.dart';
@@ -85,7 +86,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
86 _update(state.copyWith(codeThemeDark: value));
87 void setCodeThemeLight(SyntaxThemeName value) =>
88 _update(state.copyWith(codeThemeLight: value));
88 - void setPreferredBackend(LiteLmBackendType value) =>
89 + void setPreferredBackend(PreferredBackend value) =>
90 _update(state.copyWith(preferredBackend: value));
91 void setTtsEngine(EngineId value) =>
92 _update(state.copyWith(ttsEngine: value, ttsVoiceId: null));
lib/core/utils/bpe_decoder.dart new
+92
@@ -0,0 +1,92 @@
1 +import 'dart:convert';
2 +
3 +class SimpleStringSink implements Sink<String> {
4 + final void Function(String) onAdd;
5 + SimpleStringSink(this.onAdd);
6 +
7 + @override
8 + void add(String data) => onAdd(data);
9 +
10 + @override
11 + void close() {}
12 +}
13 +
14 +/// A decoder that converts BPE-encoded unicode strings (used by Qwen/GPT-2 tokenizers)
15 +/// back into standard UTF-8 strings. Supports streaming/chunked decoding.
16 +class BpeDecoder {
17 + late final ByteConversionSink _utf8Sink;
18 + String _decoded = '';
19 +
20 + BpeDecoder() {
21 + _utf8Sink = utf8.decoder.startChunkedConversion(
22 + SimpleStringSink((str) {
23 + _decoded += str;
24 + }),
25 + );
26 + }
27 +
28 + /// Decodes a chunk of a BPE-encoded unicode string and returns the newly decoded text segment.
29 + String decodeChunk(String bpeChunk) {
30 + final bytes = <int>[];
31 + for (var i = 0; i < bpeChunk.length; i++) {
32 + final code = bpeChunk.codeUnitAt(i);
33 + final byte = _bpeToByte[code] ?? code;
34 + if (byte >= 0 && byte <= 255) {
35 + bytes.add(byte);
36 + }
37 + }
38 +
39 + final previousLength = _decoded.length;
40 + _utf8Sink.add(bytes);
41 + return _decoded.substring(previousLength);
42 + }
43 +
44 + /// Flushes any remaining bytes in the decoder buffer and returns the remaining decoded text.
45 + String flush() {
46 + final previousLength = _decoded.length;
47 + _utf8Sink.close();
48 + return _decoded.substring(previousLength);
49 + }
50 +}
51 +
52 +const Map<int, int> _bpeToByte = {
53 + 33: 33, 34: 34, 35: 35, 36: 36, 37: 37, 38: 38, 39: 39, 40: 40,
54 + 41: 41, 42: 42, 43: 43, 44: 44, 45: 45, 46: 46, 47: 47, 48: 48,
55 + 49: 49, 50: 50, 51: 51, 52: 52, 53: 53, 54: 54, 55: 55, 56: 56,
56 + 57: 57, 58: 58, 59: 59, 60: 60, 61: 61, 62: 62, 63: 63, 64: 64,
57 + 65: 65, 66: 66, 67: 67, 68: 68, 69: 69, 70: 70, 71: 71, 72: 72,
58 + 73: 73, 74: 74, 75: 75, 76: 76, 77: 77, 78: 78, 79: 79, 80: 80,
59 + 81: 81, 82: 82, 83: 83, 84: 84, 85: 85, 86: 86, 87: 87, 88: 88,
60 + 90: 90, 91: 91, 92: 92, 93: 93, 94: 94, 95: 95, 96: 96, 97: 97,
61 + 98: 98, 99: 99, 100: 100, 101: 101, 102: 102, 103: 103, 104: 104,
62 + 105: 105, 106: 106, 107: 107, 108: 108, 109: 109, 110: 110,
63 + 111: 111, 112: 112, 113: 113, 114: 114, 115: 115, 116: 116,
64 + 117: 117, 118: 118, 119: 119, 120: 120, 121: 121, 122: 122,
65 + 123: 123, 124: 124, 125: 125, 126: 126, 161: 161, 162: 162,
66 + 163: 163, 164: 164, 165: 165, 166: 166, 167: 167, 168: 168,
67 + 169: 169, 170: 170, 171: 171, 172: 172, 174: 174, 175: 175,
68 + 176: 176, 177: 177, 178: 178, 179: 179, 180: 180, 181: 181,
69 + 182: 182, 183: 183, 184: 184, 185: 185, 186: 186, 187: 187,
70 + 188: 188, 189: 189, 190: 190, 191: 191, 192: 192, 193: 193,
71 + 194: 194, 195: 195, 196: 196, 197: 197, 198: 198, 199: 199,
72 + 200: 200, 201: 201, 202: 202, 203: 203, 204: 204, 205: 205,
73 + 206: 206, 207: 207, 208: 208, 209: 209, 210: 210, 211: 211,
74 + 212: 212, 213: 213, 214: 214, 215: 215, 216: 216, 217: 217,
75 + 218: 218, 219: 219, 220: 220, 221: 221, 222: 222, 223: 223,
76 + 224: 224, 225: 225, 226: 226, 227: 227, 228: 228, 229: 229,
77 + 230: 230, 231: 231, 232: 232, 233: 233, 234: 234, 235: 235,
78 + 236: 236, 237: 237, 238: 238, 239: 239, 240: 240, 241: 241,
79 + 242: 242, 243: 243, 244: 244, 245: 245, 246: 246, 247: 247,
80 + 248: 248, 249: 249, 250: 250, 251: 251, 252: 252, 253: 253,
81 + 254: 255, 255: 255, 256: 0, 257: 1, 258: 2, 259: 3, 260: 4,
82 + 261: 5, 262: 6, 263: 7, 264: 8, 265: 9, 266: 10, 267: 11, 268: 12,
83 + 269: 13, 270: 14, 271: 15, 272: 16, 273: 17, 274: 18, 275: 19,
84 + 276: 20, 277: 21, 278: 22, 279: 23, 280: 24, 281: 25, 282: 26,
85 + 283: 27, 284: 28, 285: 29, 286: 30, 287: 31, 288: 32, 289: 127,
86 + 290: 128, 291: 129, 292: 130, 293: 131, 294: 132, 295: 133,
87 + 296: 134, 297: 135, 298: 136, 299: 137, 300: 138, 301: 139,
88 + 302: 140, 303: 141, 304: 142, 305: 143, 306: 144, 307: 145,
89 + 308: 146, 309: 147, 310: 148, 311: 149, 312: 150, 313: 151,
90 + 314: 152, 315: 153, 316: 154, 317: 155, 318: 156, 319: 157,
91 + 320: 158, 321: 159, 322: 160, 323: 173,
92 +};
lib/features/chat/data/chat_service.dart
+5 -5
@@ -8,7 +8,7 @@ import 'models/chat_parameters.dart';
8 import 'models/mcp_integration.dart';
9 import '../../../core/models/enums.dart';
10 import '../../../core/logger/app_logger.dart';
11 -import '../../on_device/data/on_device_engine_service.dart';
11 +import '../../on_device/data/on_device_gemma_service.dart';
12 import '../../on_device/data/on_device_chat_service.dart';
13
14 abstract class ChatService {
@@ -26,7 +26,7 @@ abstract class ChatService {
26 static ChatService forServer(
27 ServerType type,
28 Dio dio, {
29 - OnDeviceEngineService? onDeviceEngine,
29 + OnDeviceGemmaService? onDeviceGemma,
30 }) {
31 switch (type) {
32 case ServerType.lmStudio:
@@ -38,12 +38,12 @@ abstract class ChatService {
38 case ServerType.openRouter:
39 return OpenRouterChatService(dio);
40 case ServerType.onDevice:
41 - if (onDeviceEngine == null) {
41 + if (onDeviceGemma == null) {
42 throw StateError(
43 - 'OnDeviceEngineService is required for onDevice server type',
43 + 'OnDeviceGemmaService is required for onDevice server type',
44 );
45 }
46 - return OnDeviceChatService(onDeviceEngine);
46 + return OnDeviceChatService(onDeviceGemma);
47 }
48 }
49 }
lib/features/chat/providers/chat_providers.dart
+2 -2
@@ -237,11 +237,11 @@ final chatServiceProvider = Provider<ChatService?>((ref) {
237 return null;
238 }
239 if (server.type == ServerType.onDevice) {
240 - final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
240 + final gemmaService = ref.read(onDeviceGemmaServiceProvider);
241 return ChatService.forServer(
242 server.type,
243 ref.read(dioProvider),
244 - onDeviceEngine: engineNotifier.engineService,
244 + onDeviceGemma: gemmaService,
245 );
246 }
247 return ChatService.forServer(server.type, ref.read(dioProvider));
lib/features/chat/views/components/notification_permission_banner.dart
+2 -2
@@ -29,7 +29,7 @@ class _NotificationPermissionBannerState
29 final settings = ref.read(settingsProvider);
30 if (settings.hasAskedForNotifications) return;
31
32 - final service = ref.read(downloadNotificationServiceProvider);
32 + final service = ref.read(notificationPermissionServiceProvider);
33 final granted = await service.isPermissionGranted();
34
35 if (!granted) {
@@ -42,7 +42,7 @@ class _NotificationPermissionBannerState
42 }
43
44 Future<void> _requestPermission() async {
45 - final service = ref.read(downloadNotificationServiceProvider);
45 + final service = ref.read(notificationPermissionServiceProvider);
46 await service.requestPermission();
47 await _dismiss(true);
48 }
lib/features/models/screens/model_picker_sheet.dart
+2 -2
@@ -1093,8 +1093,8 @@ class _OnDeviceModelTile extends ConsumerWidget {
1093 ref.read(selectedModelProvider.notifier).clear();
1094 }
1095
1096 - final downloadService = ref.read(onDeviceDownloadServiceProvider);
1097 - await downloadService.deleteModel(model.id);
1096 + final gemmaService = ref.read(onDeviceGemmaServiceProvider);
1097 + await gemmaService.deleteModel(model.id);
1098 ref.invalidate(downloadedModelsProvider);
1099 }
1100 }
lib/features/on_device/data/download_notification_service.dart deleted
-167
@@ -1,167 +0,0 @@
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 -
126 - Future<bool> isPermissionGranted() async {
127 - final androidImplementation = _notifications
128 - .resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
129 - if (androidImplementation != null) {
130 - return await androidImplementation.areNotificationsEnabled() ?? false;
131 - }
132 -
133 - final iosImplementation = _notifications
134 - .resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
135 - if (iosImplementation != null) {
136 - // In flutter_local_notifications, there isn't a direct "check" method for iOS permissions
137 - // that returns a bool without requesting. However, we can use the resolvePlatformSpecificImplementation
138 - // or assume it's false if we haven't asked.
139 - // For now, we return false to trigger the banner which will then call requestPermission.
140 - return false;
141 - }
142 -
143 - return false;
144 - }
145 -
146 - Future<bool> requestPermission() async {
147 - final androidImplementation = _notifications
148 - .resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
149 - if (androidImplementation != null) {
150 - final granted = await androidImplementation.requestNotificationsPermission();
151 - return granted ?? false;
152 - }
153 -
154 - final iosImplementation = _notifications
155 - .resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
156 - if (iosImplementation != null) {
157 - final granted = await iosImplementation.requestPermissions(
158 - alert: true,
159 - badge: true,
160 - sound: true,
161 - );
162 - return granted ?? false;
163 - }
164 -
165 - return false;
166 - }
167 -}
lib/features/on_device/data/foreground_download_service.dart deleted
-171
@@ -1,171 +0,0 @@
1 -import 'dart:async';
2 -import 'dart:io';
3 -
4 -import '../../../core/logger/app_logger.dart';
5 -import 'models/download_status.dart';
6 -import 'models/on_device_model.dart';
7 -import 'on_device_engine_service.dart';
8 -import 'on_device_model_download_service.dart';
9 -
10 -/// Wraps [OnDeviceModelDownloadService] with broadcast streams for UI consumption.
11 -///
12 -/// Progress updates are emitted as [DownloadStatusUpdate] via [getStatusStream].
13 -class ForegroundDownloadService {
14 - final OnDeviceModelDownloadService _downloadService;
15 - final Map<String, StreamSubscription> _subscriptions = {};
16 - final Map<String, StreamController<DownloadStatusUpdate>>
17 - _progressControllers = {};
18 -
19 - ForegroundDownloadService(this._downloadService);
20 -
21 - /// Returns a broadcast stream of status updates for [modelId].
22 - Stream<DownloadStatusUpdate> getStatusStream(String modelId) {
23 - return _progressControllers
24 - .putIfAbsent(
25 - modelId,
26 - () => StreamController<DownloadStatusUpdate>.broadcast(),
27 - )
28 - .stream;
29 - }
30 -
31 - /// Start downloading [model]. Progress is emitted via [getStatusStream].
32 - Future<String> downloadModel(OnDeviceModel model, {String? token}) async {
33 - final modelsDir = await OnDeviceEngineService.getModelDirectory();
34 - final filePath = '$modelsDir/${model.fileName}';
35 - final file = File(filePath);
36 -
37 - if (await file.exists()) {
38 - Log.info('Model ${model.id} already downloaded at $filePath');
39 - final controller = _progressControllers[model.id];
40 - if (controller != null && !controller.isClosed) {
41 - controller.add(
42 - DownloadStatusUpdate(
43 - modelId: model.id,
44 - taskId: model.id,
45 - status: DownloadStatus.complete,
46 - progress: 100,
47 - ),
48 - );
49 - await _cleanup(model.id);
50 - }
51 - return filePath;
52 - }
53 -
54 - final controller = _progressControllers.putIfAbsent(
55 - model.id,
56 - () => StreamController<DownloadStatusUpdate>.broadcast(),
57 - );
58 -
59 - if (!controller.isClosed) {
60 - controller.add(
61 - DownloadStatusUpdate(
62 - modelId: model.id,
63 - taskId: model.id,
64 - status: DownloadStatus.pending,
65 - progress: 0,
66 - ),
67 - );
68 - }
69 -
70 - final completer = Completer<String>();
71 -
72 - final subscription = _downloadService
73 - .downloadModel(model, token: token)
74 - .listen(
75 - (progress) {
76 - if (controller.isClosed) return;
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 - },
92 - onError: (Object e) {
93 - if (!controller.isClosed) {
94 - controller.add(
95 - DownloadStatusUpdate(
96 - modelId: model.id,
97 - taskId: model.id,
98 - status: DownloadStatus.failed,
99 - progress: 0,
100 - ),
101 - );
102 - }
103 - _cleanup(model.id);
104 - completer.completeError(e);
105 - },
106 - onDone: () {
107 - if (!completer.isCompleted) {
108 - if (!controller.isClosed) {
109 - controller.add(
110 - DownloadStatusUpdate(
111 - modelId: model.id,
112 - taskId: model.id,
113 - status: DownloadStatus.complete,
114 - progress: 100,
115 - ),
116 - );
117 - }
118 - _cleanup(model.id);
119 - completer.complete(filePath);
120 - }
121 - },
122 - cancelOnError: true,
123 - );
124 -
125 - _subscriptions[model.id] = subscription;
126 -
127 - return completer.future;
128 - }
129 -
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 -
137 - Future<void> _cleanup(String modelId) async {
138 - await _subscriptions.remove(modelId)?.cancel();
139 - final controller = _progressControllers.remove(modelId);
140 - await controller?.close();
141 - }
142 -
143 - void pauseDownload(String modelId) {
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 -
164 - void resumeDownload(String modelId) {
165 - Log.warning('Resume UI should trigger downloadModel again for $modelId');
166 - }
167 -
168 - Future<void> retryDownload(String modelId) async {
169 - await cancelDownload(modelId);
170 - }
171 -}
lib/features/on_device/data/model_downloader.dart deleted
-218
@@ -1,218 +0,0 @@
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 - int initialReceivedBytes = 0; // captured before download, used for session speed calc
44 -
45 - // 3. Check for partial file to resume
46 - if (await partialFile.exists()) {
47 - receivedBytes = await partialFile.length();
48 - initialReceivedBytes = receivedBytes;
49 - isResumed = receivedBytes > 0;
50 - Log.info('Resuming download for ${model.id} from $receivedBytes bytes');
51 - }
52 -
53 - final startTime = DateTime.now();
54 - DateTime lastProgressUpdate = DateTime.now();
55 -
56 - try {
57 - // 4. Resolve redirects manually to handle Auth header safety
58 - final response = await _resolveWithRedirects(
59 - url: model.huggingFaceUrl,
60 - token: token,
61 - startByte: receivedBytes,
62 - cancelToken: cancelToken,
63 - );
64 -
65 - final totalBytes = (response.headers.value(Headers.contentLengthHeader) != null)
66 - ? int.parse(response.headers.value(Headers.contentLengthHeader)!) + receivedBytes
67 - : model.fileSizeBytes;
68 -
69 - final IOSink sink = partialFile.openWrite(mode: FileMode.append);
70 -
71 - final stream = response.data?.stream;
72 - if (stream == null) {
73 - throw DioException(
74 - requestOptions: response.requestOptions,
75 - error: 'Response data stream is null',
76 - );
77 - }
78 -
79 - await for (final List<int> chunk in stream) {
80 - sink.add(chunk);
81 - receivedBytes += 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 - // Use a simple bytes per second based on the current session's progress
90 - // Use in-memory initialReceivedBytes instead of calling partialFile.length() (stat syscall)
91 - final bytesDownloadedInThisSession =
92 - receivedBytes - (isResumed ? initialReceivedBytes : 0);
93 - final bytesPerSecond = (bytesDownloadedInThisSession / totalElapsed.inMilliseconds * 1000).toInt();
94 -
95 - final remainingBytes = totalBytes - receivedBytes;
96 - final etaSeconds = bytesPerSecond > 0 ? (remainingBytes / bytesPerSecond).toInt() : null;
97 -
98 - final progress = ModelDownloadProgress(
99 - receivedBytes: receivedBytes,
100 - totalBytes: totalBytes,
101 - fraction: receivedBytes / totalBytes,
102 - bytesPerSecond: bytesPerSecond,
103 - estimatedSecondsRemaining: etaSeconds,
104 - isResumed: isResumed,
105 - );
106 -
107 - yield progress;
108 -
109 - // 5. Update Notification
110 - await _notificationService.showProgressNotification(
111 - id: model.id.hashCode,
112 - modelName: model.name,
113 - progress: progress,
114 - );
115 -
116 - lastProgressUpdate = now;
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/model_download_progress.dart deleted
-45
@@ -1,45 +0,0 @@
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/models/on_device_model.dart
+21 -4
@@ -1,3 +1,5 @@
1 +import 'package:flutter_gemma/flutter_gemma.dart';
2 +
3 import '../../../../core/models/enums.dart';
4
5 class OnDeviceModel {
@@ -32,7 +34,22 @@ class OnDeviceModel {
34 return '${mb.toStringAsFixed(0)} MB';
35 }
36
35 - String get fileName => '$id.litertlm';
37 + String get fileName => huggingFaceUrl.split('/').last;
38 +
39 + ModelType get flutterGemmaModelType {
40 + switch (id) {
41 + case 'qwen3-0.6b':
42 + return ModelType.qwen3;
43 + case 'qwen2.5-1.5b-instruct':
44 + return ModelType.qwen;
45 + case 'deepseek-r1-distill-qwen-1.5b':
46 + return ModelType.deepSeek;
47 + case 'gemma4-e2b-instruct':
48 + return ModelType.gemma4;
49 + default:
50 + return ModelType.general;
51 + }
52 + }
53
54 static const List<OnDeviceModel> curatedModels = [
55 OnDeviceModel(
@@ -118,7 +135,7 @@ class OnDeviceModelStateInfo {
135 final OnDeviceModelState state;
136 final double downloadProgress;
137 final String? error;
121 - final LiteLmBackendType backend;
138 + final PreferredBackend backend;
139 final OnDeviceEngineStatus engineStatus;
140
141 const OnDeviceModelStateInfo({
@@ -126,7 +143,7 @@ class OnDeviceModelStateInfo {
143 this.state = OnDeviceModelState.notDownloaded,
144 this.downloadProgress = 0.0,
145 this.error,
129 - this.backend = LiteLmBackendType.cpu,
146 + this.backend = PreferredBackend.cpu,
147 this.engineStatus = OnDeviceEngineStatus.notLoaded,
148 });
149
@@ -135,7 +152,7 @@ class OnDeviceModelStateInfo {
152 OnDeviceModelState? state,
153 double? downloadProgress,
154 String? error,
138 - LiteLmBackendType? backend,
155 + PreferredBackend? backend,
156 OnDeviceEngineStatus? engineStatus,
157 }) {
158 return OnDeviceModelStateInfo(
lib/features/on_device/data/notification_permission_service.dart new
+52
@@ -0,0 +1,52 @@
1 +import 'package:flutter_local_notifications/flutter_local_notifications.dart';
2 +
3 +class NotificationPermissionService {
4 + final FlutterLocalNotificationsPlugin _notifications =
5 + FlutterLocalNotificationsPlugin();
6 +
7 + Future<void> init() async {
8 + const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher');
9 + const darwinInit = DarwinInitializationSettings();
10 + const initSettings = InitializationSettings(
11 + android: androidInit,
12 + iOS: darwinInit,
13 + );
14 +
15 + await _notifications.initialize(initSettings);
16 + }
17 +
18 + Future<bool> isPermissionGranted() async {
19 + final androidImplementation = _notifications
20 + .resolvePlatformSpecificImplementation<
21 + AndroidFlutterLocalNotificationsPlugin>();
22 + if (androidImplementation != null) {
23 + return await androidImplementation.areNotificationsEnabled() ?? false;
24 + }
25 + return false;
26 + }
27 +
28 + Future<bool> requestPermission() async {
29 + final androidImplementation = _notifications
30 + .resolvePlatformSpecificImplementation<
31 + AndroidFlutterLocalNotificationsPlugin>();
32 + if (androidImplementation != null) {
33 + final granted =
34 + await androidImplementation.requestNotificationsPermission();
35 + return granted ?? false;
36 + }
37 +
38 + final iosImplementation = _notifications
39 + .resolvePlatformSpecificImplementation<
40 + IOSFlutterLocalNotificationsPlugin>();
41 + if (iosImplementation != null) {
42 + final granted = await iosImplementation.requestPermissions(
43 + alert: true,
44 + badge: true,
45 + sound: true,
46 + );
47 + return granted ?? false;
48 + }
49 +
50 + return false;
51 + }
52 +}
lib/features/on_device/data/on_device_chat_service.dart
+122 -72
@@ -1,24 +1,25 @@
1 import 'dart:async';
2
3 -import 'package:flutter_litert_lm/flutter_litert_lm.dart';
3 +import 'package:flutter_gemma/flutter_gemma.dart' as gemma;
4
5 import '../../../core/logger/app_logger.dart';
6 import '../../../core/models/enums.dart';
7 +import '../../../core/utils/bpe_decoder.dart';
8 import '../../chat/data/chat_service.dart';
9 import '../../chat/data/models/chat_parameters.dart';
10 import '../../chat/data/models/mcp_integration.dart';
11 import '../../chat/data/models/message.dart' hide ToolCallData;
12 import '../../servers/data/models/server.dart';
12 -import 'on_device_engine_service.dart';
13 +import 'on_device_gemma_service.dart';
14
15 class OnDeviceChatService implements ChatService {
15 - final OnDeviceEngineService _engineService;
16 - StreamSubscription<LiteLmMessage>? _currentSubscription;
16 + final OnDeviceGemmaService _gemmaService;
17 + StreamSubscription<gemma.ModelResponse>? _currentSubscription;
18 StreamController<ChatResponse>? _streamController;
18 - LiteLmConversation? _activeConversation;
19 + gemma.InferenceChat? _activeChat;
20 bool _isCancelled = false;
21
21 - OnDeviceChatService(this._engineService);
22 + OnDeviceChatService(this._gemmaService);
23
24 @override
25 Stream<ChatResponse> sendMessage({
@@ -29,26 +30,26 @@ class OnDeviceChatService implements ChatService {
30 List<McpIntegration>? integrations,
31 String? previousResponseId,
32 }) {
32 - // Cancel any previous inference before starting a new one
33 cancelStream();
34 _isCancelled = false;
35 _streamController = StreamController<ChatResponse>();
36
37 - _startInference(messages, params);
37 + _startInference(modelId, messages, params);
38
39 return _streamController!.stream;
40 }
41
42 Future<void> _startInference(
43 + String modelId,
44 List<Message> messages,
45 ChatParameters params,
46 ) async {
47 try {
47 - if (_engineService.engine == null || _isCancelled) {
48 + if (!_gemmaService.isLoaded || _isCancelled) {
49 _streamController?.add(
50 const ChatResponse(
51 type: ChatResponseType.error,
51 - content: 'Engine not loaded',
52 + content: 'Model not loaded',
53 ),
54 );
55 _streamController?.add(const ChatResponse(type: ChatResponseType.done));
@@ -60,92 +61,138 @@ class OnDeviceChatService implements ChatService {
61 ? params.systemPrompt
62 : null;
63
63 - final samplerConfig = LiteLmSamplerConfig(
64 - temperature: params.temperature,
65 - topK: 40,
66 - topP: params.topP,
67 - );
68 -
69 - final conversation = await _engineService.createConversation(
64 + final chat = await _gemmaService.createChat(
65 systemInstruction: systemInstruction,
71 - samplerConfig: samplerConfig,
66 );
67
74 - // Store a reference so cancelStream can dispose it
75 - _activeConversation = conversation;
68 + if (chat == null) {
69 + _streamController?.add(
70 + const ChatResponse(
71 + type: ChatResponseType.error,
72 + content: 'Failed to create chat session',
73 + ),
74 + );
75 + _streamController?.add(const ChatResponse(type: ChatResponseType.done));
76 + await _streamController?.close();
77 + return;
78 + }
79
77 - final liteMessages = _convertMessages(messages);
80 + _activeChat = chat;
81 +
82 + // Convert and add all messages except the last user message to context
83 + final gemmaMessages = _convertMessages(messages);
84
85 if (_isCancelled) {
80 - _disposeConversation(conversation);
86 + _disposeChat(chat);
87 _streamController?.add(const ChatResponse(type: ChatResponseType.done));
88 await _streamController?.close();
89 return;
90 }
91
86 - final lastUserMessage = liteMessages
87 - .where((m) => m.role == 'user')
88 - .lastOrNull;
89 - if (lastUserMessage == null) {
90 - _streamController?.add(
91 - const ChatResponse(
92 - type: ChatResponseType.error,
93 - content: 'No user message found',
94 - ),
95 - );
96 - _disposeConversation(conversation);
92 + // Add all messages except the last one as context
93 + if (gemmaMessages.length > 1) {
94 + for (final msg in gemmaMessages.sublist(0, gemmaMessages.length - 1)) {
95 + await chat.addQueryChunk(msg);
96 + }
97 + }
98 +
99 + // Add the last user message
100 + final lastMessage = gemmaMessages.last;
101 + await chat.addQueryChunk(lastMessage);
102 +
103 + if (_isCancelled) {
104 + _disposeChat(chat);
105 _streamController?.add(const ChatResponse(type: ChatResponseType.done));
106 await _streamController?.close();
107 return;
108 }
109
102 - final stream = conversation.sendMessageStream(lastUserMessage.text ?? '');
103 -
104 - // Use .listen() instead of `await for` so the subscription can be
105 - // cancelled externally without waiting for the next native token.
110 + // Start streaming response
111 + final responseStream = chat.generateChatResponseAsync();
112 final completer = Completer<void>();
113
108 - _currentSubscription = stream.listen(
109 - (delta) {
114 + final isBpeModel = modelId.toLowerCase().contains('qwen') ||
115 + modelId.toLowerCase().contains('deepseek');
116 + final textDecoder = isBpeModel ? BpeDecoder() : null;
117 + final reasoningDecoder = isBpeModel ? BpeDecoder() : null;
118 +
119 + _currentSubscription = responseStream.listen(
120 + (response) {
121 if (_isCancelled) return;
122
112 - if (delta.text.isNotEmpty) {
123 + if (response is gemma.TextResponse) {
124 + if (response.token.isNotEmpty) {
125 + final content = textDecoder != null
126 + ? textDecoder.decodeChunk(response.token)
127 + : response.token;
128 + if (content.isNotEmpty) {
129 + _streamController?.add(
130 + ChatResponse(
131 + type: ChatResponseType.message,
132 + content: content,
133 + ),
134 + );
135 + }
136 + }
137 + } else if (response is gemma.FunctionCallResponse) {
138 _streamController?.add(
139 ChatResponse(
115 - type: ChatResponseType.message,
116 - content: delta.text,
140 + type: ChatResponseType.toolCall,
141 + toolCall: ToolCallData(
142 + tool: response.name,
143 + arguments: response.args,
144 + ),
145 ),
146 );
147 + } else if (response is gemma.ThinkingResponse) {
148 + if (response.content.isNotEmpty) {
149 + final reasoningContent = reasoningDecoder != null
150 + ? reasoningDecoder.decodeChunk(response.content)
151 + : response.content;
152 + if (reasoningContent.isNotEmpty) {
153 + _streamController?.add(
154 + ChatResponse(
155 + type: ChatResponseType.reasoning,
156 + reasoningContent: reasoningContent,
157 + ),
158 + );
159 + }
160 + }
161 }
120 -
121 - if (delta.toolCalls.isNotEmpty) {
122 - for (final tc in delta.toolCalls) {
162 + },
163 + onDone: () {
164 + if (!_isCancelled) {
165 + final finalToken = textDecoder?.flush() ?? '';
166 + final finalReasoning = reasoningDecoder?.flush() ?? '';
167 + if (finalToken.isNotEmpty) {
168 _streamController?.add(
169 ChatResponse(
125 - type: ChatResponseType.toolCall,
126 - toolCall:
127 - ToolCallData(tool: tc.name, arguments: tc.arguments),
170 + type: ChatResponseType.message,
171 + content: finalToken,
172 + ),
173 + );
174 + }
175 + if (finalReasoning.isNotEmpty) {
176 + _streamController?.add(
177 + ChatResponse(
178 + type: ChatResponseType.reasoning,
179 + reasoningContent: finalReasoning,
180 ),
181 );
182 }
131 - }
132 - },
133 - onDone: () {
134 - if (!_isCancelled) {
183 _streamController?.add(
184 const ChatResponse(type: ChatResponseType.done),
185 );
138 - _disposeConversation(conversation);
186 + _disposeChat(chat);
187 _streamController?.close();
188 }
141 - _activeConversation = null;
189 + _activeChat = null;
190 _currentSubscription = null;
191 if (!completer.isCompleted) completer.complete();
192 },
193 onError: (error) {
194 Log.error('OnDevice stream error: $error');
147 - if (!_isCancelled &&
148 - !(_streamController?.isClosed ?? true)) {
195 + if (!_isCancelled && !(_streamController?.isClosed ?? true)) {
196 _streamController?.add(
197 ChatResponse(
198 type: ChatResponseType.error,
@@ -157,8 +204,8 @@ class OnDeviceChatService implements ChatService {
204 );
205 _streamController?.close();
206 }
160 - _disposeConversation(conversation);
161 - _activeConversation = null;
207 + _disposeChat(chat);
208 + _activeChat = null;
209 _currentSubscription = null;
210 if (!completer.isCompleted) completer.complete();
211 },
@@ -181,14 +228,13 @@ class OnDeviceChatService implements ChatService {
228 }
229 }
230
184 - /// Dispose a conversation without blocking — fire and forget.
185 - void _disposeConversation(LiteLmConversation conversation) {
186 - conversation.dispose().catchError((e) {
187 - Log.error('Error disposing conversation: $e');
231 + void _disposeChat(gemma.InferenceChat chat) {
232 + chat.close().catchError((e) {
233 + Log.error('Error disposing chat: $e');
234 });
235 }
236
191 - List<({String role, String? text})> _convertMessages(List<Message> messages) {
237 + List<gemma.Message> _convertMessages(List<Message> messages) {
238 return messages
239 .where(
240 (m) =>
@@ -196,7 +242,12 @@ class OnDeviceChatService implements ChatService {
242 m.role == MessageRole.assistant ||
243 m.role == MessageRole.system,
244 )
199 - .map((m) => (role: m.role.name, text: m.content))
245 + .map(
246 + (m) => gemma.Message.text(
247 + text: m.content,
248 + isUser: m.role == MessageRole.user,
249 + ),
250 + )
251 .toList();
252 }
253
@@ -204,17 +255,16 @@ class OnDeviceChatService implements ChatService {
255 void cancelStream() {
256 _isCancelled = true;
257
207 - // Cancel the native stream subscription first — this stops the
208 - // EventChannel from delivering more tokens and unblocks the loop.
258 _currentSubscription?.cancel();
259 _currentSubscription = null;
260
212 - // Dispose the active conversation (fire-and-forget to avoid blocking
213 - // the UI thread if the native side is mid-inference).
214 - final conversation = _activeConversation;
215 - _activeConversation = null;
216 - if (conversation != null) {
217 - _disposeConversation(conversation);
261 + final chat = _activeChat;
262 + _activeChat = null;
263 + if (chat != null) {
264 + chat.stopGeneration().then((_) => _disposeChat(chat)).catchError((e) {
265 + Log.error('Error stopping generation: $e');
266 + _disposeChat(chat);
267 + });
268 }
269
270 if (!(_streamController?.isClosed ?? true)) {
lib/features/on_device/data/on_device_engine_service.dart deleted
-132
@@ -1,132 +0,0 @@
1 -import 'dart:async';
2 -import 'dart:io';
3 -
4 -import 'package:flutter_litert_lm/flutter_litert_lm.dart';
5 -import 'package:path_provider/path_provider.dart';
6 -
7 -import '../../../core/logger/app_logger.dart';
8 -import '../../../core/models/enums.dart';
9 -
10 -class OnDeviceEngineService {
11 - LiteLmEngine? _engine;
12 - String? _currentModelPath;
13 - LiteLmBackendType? _currentBackend;
14 - bool _isDisposed = false;
15 -
16 - LiteLmEngine? get engine => _engine;
17 - String? get currentModelPath => _currentModelPath;
18 - bool get isLoaded => _engine != null && !_isDisposed;
19 - bool get isDisposed => _isDisposed;
20 -
21 - Future<LiteLmEngine> createEngine(
22 - String modelPath,
23 - LiteLmBackendType backendType,
24 - ) async {
25 - if (_isDisposed) {
26 - throw StateError('OnDeviceEngineService has been disposed');
27 - }
28 -
29 - if (_engine != null &&
30 - _currentModelPath == modelPath &&
31 - _currentBackend == backendType) {
32 - return _engine!;
33 - }
34 -
35 - if (_engine != null) {
36 - await disposeEngine();
37 - }
38 -
39 - final backend = _mapBackendType(backendType);
40 -
41 - Log.info(
42 - 'Creating LiteLmEngine with modelPath=$modelPath, backend=$backendType',
43 - );
44 -
45 - final config = LiteLmEngineConfig(modelPath: modelPath, backend: backend);
46 -
47 - _engine = await LiteLmEngine.create(config);
48 - _currentModelPath = modelPath;
49 - _currentBackend = backendType;
50 -
51 - Log.info('LiteLmEngine created successfully');
52 - return _engine!;
53 - }
54 -
55 - Future<LiteLmConversation> createConversation({
56 - String? systemInstruction,
57 - LiteLmSamplerConfig? samplerConfig,
58 - List<LiteLmTool>? tools,
59 - }) async {
60 - if (_engine == null) {
61 - throw StateError('Engine not loaded. Call createEngine first.');
62 - }
63 -
64 - final config = LiteLmConversationConfig(
65 - systemInstruction: systemInstruction,
66 - samplerConfig: samplerConfig,
67 - tools: tools,
68 - );
69 -
70 - return await _engine!.createConversation(config);
71 - }
72 -
73 - Future<void> disposeEngine() async {
74 - if (_engine != null) {
75 - try {
76 - await _engine!.dispose();
77 - } catch (e) {
78 - Log.error('Error disposing engine: $e');
79 - }
80 - _engine = null;
81 - _currentModelPath = null;
82 - _currentBackend = null;
83 - }
84 - }
85 -
86 - void dispose() {
87 - _isDisposed = true;
88 - disposeEngine();
89 - }
90 -
91 - LiteLmBackend _mapBackendType(LiteLmBackendType type) {
92 - switch (type) {
93 - case LiteLmBackendType.cpu:
94 - return LiteLmBackend.cpu;
95 - case LiteLmBackendType.gpu:
96 - return LiteLmBackend.gpu;
97 - case LiteLmBackendType.npu:
98 - return LiteLmBackend.npu;
99 - }
100 - }
101 -
102 - static Future<String> getModelDirectory() async {
103 - final dir = await getApplicationSupportDirectory();
104 - final modelsDir = Directory('${dir.path}/on_device_models');
105 - if (!await modelsDir.exists()) {
106 - await modelsDir.create(recursive: true);
107 - }
108 - return modelsDir.path;
109 - }
110 -
111 - static Future<String?> getModelPath(String modelId) async {
112 - final dir = await getModelDirectory();
113 - final file = File('$dir/$modelId.litertlm');
114 - if (await file.exists()) {
115 - return file.path;
116 - }
117 - return null;
118 - }
119 -
120 - static Future<bool> isModelDownloaded(String modelId) async {
121 - final path = await getModelPath(modelId);
122 - return path != null;
123 - }
124 -
125 - static Future<void> deleteModel(String modelId) async {
126 - final dir = await getModelDirectory();
127 - final file = File('$dir/$modelId.litertlm');
128 - if (await file.exists()) {
129 - await file.delete();
130 - }
131 - }
132 -}
lib/features/on_device/data/on_device_gemma_service.dart new
+226
@@ -0,0 +1,226 @@
1 +import 'dart:io';
2 +
3 +import 'package:flutter_gemma/flutter_gemma.dart';
4 +import 'package:flutter_gemma/core/domain/model_source.dart';
5 +import 'package:path_provider/path_provider.dart';
6 +
7 +import '../../../core/logger/app_logger.dart';
8 +import 'models/on_device_model.dart';
9 +
10 +class OnDeviceGemmaService {
11 + InferenceModel? _model;
12 + String? _currentModelId;
13 + PreferredBackend? _currentBackend;
14 + bool _isDisposed = false;
15 +
16 + InferenceModel? get activeModel => _model;
17 + String? get currentModelId => _currentModelId;
18 + bool get isLoaded => _model != null && !_isDisposed;
19 + bool get isDisposed => _isDisposed;
20 +
21 + static Future<void> initialize({String? huggingFaceToken}) async {
22 + await FlutterGemma.initialize(
23 + huggingFaceToken: huggingFaceToken,
24 + maxDownloadRetries: 10,
25 + );
26 + }
27 +
28 + Future<void> installModel(
29 + OnDeviceModel model, {
30 + void Function(int)? onProgress,
31 + String? token,
32 + }) async {
33 + Log.info('Installing model ${model.id} from ${model.huggingFaceUrl}');
34 +
35 + await FlutterGemma.installModel(
36 + modelType: model.flutterGemmaModelType,
37 + fileType: ModelFileType.litertlm,
38 + )
39 + .fromNetwork(
40 + model.huggingFaceUrl,
41 + token: token,
42 + foreground: true,
43 + )
44 + .withProgress((progress) {
45 + onProgress?.call(progress);
46 + }).install();
47 +
48 + Log.info('Model ${model.id} installed successfully');
49 + }
50 +
51 + Future<bool> isModelInstalled(String modelId) async {
52 + final model = OnDeviceModel.curatedModels.firstWhere(
53 + (m) => m.id == modelId,
54 + orElse: () => throw Exception('Model not found: $modelId'),
55 + );
56 + return FlutterGemma.isModelInstalled(model.fileName);
57 + }
58 +
59 + Future<List<String>> getInstalledModelIds() async {
60 + final installed = await FlutterGemma.listInstalledModels();
61 + final installedSet = installed.toSet();
62 + final installedIds = <String>[];
63 +
64 + for (final model in OnDeviceModel.curatedModels) {
65 + if (installedSet.contains(model.fileName)) {
66 + installedIds.add(model.id);
67 + }
68 + }
69 + return installedIds;
70 + }
71 +
72 + Future<void> deleteModel(String modelId) async {
73 + final model = OnDeviceModel.curatedModels.firstWhere(
74 + (m) => m.id == modelId,
75 + orElse: () => throw Exception('Model not found: $modelId'),
76 + );
77 + await FlutterGemma.uninstallModel(model.fileName);
78 +
79 + // Also clean up old custom download location if it exists
80 + try {
81 + final dir = await getApplicationSupportDirectory();
82 + final oldFile = File('${dir.path}/on_device_models/$modelId.litertlm');
83 + if (await oldFile.exists()) {
84 + await oldFile.delete();
85 + Log.info('Cleaned up old model file: ${oldFile.path}');
86 + }
87 + } catch (e) {
88 + Log.error('Error cleaning up old model file: $e');
89 + }
90 +
91 + Log.info('Model $modelId deleted');
92 + }
93 +
94 + Future<void> loadModel(
95 + String modelId,
96 + PreferredBackend backend, {
97 + int maxTokens = 2048,
98 + }) async {
99 + if (_isDisposed) {
100 + throw StateError('OnDeviceGemmaService has been disposed');
101 + }
102 +
103 + if (_model != null &&
104 + _currentModelId == modelId &&
105 + _currentBackend == backend) {
106 + return;
107 + }
108 +
109 + if (_model != null) {
110 + await unloadModel();
111 + }
112 +
113 + Log.info(
114 + 'Loading model $modelId with backend=$backend, maxTokens=$maxTokens',
115 + );
116 +
117 + final model = OnDeviceModel.curatedModels.firstWhere(
118 + (m) => m.id == modelId,
119 + orElse: () => throw Exception('Model not found: $modelId'),
120 + );
121 +
122 + final spec = InferenceModelSpec(
123 + name: model.fileName,
124 + modelSource: ModelSource.network(model.huggingFaceUrl),
125 + modelType: model.flutterGemmaModelType,
126 + fileType: ModelFileType.litertlm,
127 + );
128 + FlutterGemmaPlugin.instance.modelManager.setActiveModel(spec);
129 +
130 + _model = await FlutterGemma.getActiveModel(
131 + maxTokens: maxTokens,
132 + preferredBackend: backend,
133 + );
134 + _currentModelId = modelId;
135 + _currentBackend = backend;
136 +
137 + Log.info('Model $modelId loaded successfully');
138 + }
139 +
140 + Future<InferenceChat?> createChat({String? systemInstruction}) async {
141 + if (_model == null) {
142 + throw StateError('Model not loaded. Call loadModel first.');
143 + }
144 +
145 + return _model!.createChat(
146 + systemInstruction: systemInstruction,
147 + );
148 + }
149 +
150 + Future<void> unloadModel() async {
151 + if (_model != null) {
152 + try {
153 + await _model!.close();
154 + } catch (e) {
155 + Log.error('Error closing model: $e');
156 + }
157 + _model = null;
158 + _currentModelId = null;
159 + _currentBackend = null;
160 + }
161 + }
162 +
163 + void dispose() {
164 + _isDisposed = true;
165 + unloadModel();
166 + }
167 +
168 + /// Migrate models from old custom download location to flutter_gemma storage.
169 + /// Returns the number of models migrated.
170 + static Future<int> migrateOldModels() async {
171 + try {
172 + final dir = await getApplicationSupportDirectory();
173 + final oldModelsDir = Directory('${dir.path}/on_device_models');
174 + if (!await oldModelsDir.exists()) return 0;
175 +
176 + int migrated = 0;
177 + final entities = await oldModelsDir.list().toList();
178 + for (final entity in entities) {
179 + if (entity is File && entity.path.endsWith('.litertlm')) {
180 + final fileName = entity.path.split('/').last;
181 + final modelId = fileName.replaceAll('.litertlm', '');
182 +
183 + // Check if already registered in flutter_gemma
184 + final alreadyInstalled =
185 + await FlutterGemma.isModelInstalled(fileName);
186 + if (alreadyInstalled) continue;
187 +
188 + // Register the existing file with flutter_gemma
189 + try {
190 + await FlutterGemma.installModel(
191 + modelType: _inferModelType(modelId),
192 + fileType: ModelFileType.litertlm,
193 + ).fromFile(entity.path).install();
194 + migrated++;
195 + Log.info('Migrated model $modelId from ${entity.path}');
196 + } catch (e) {
197 + Log.error('Failed to migrate model $modelId: $e');
198 + }
199 + }
200 + }
201 +
202 + if (migrated > 0) {
203 + Log.info('Migrated $migrated models from old storage');
204 + }
205 + return migrated;
206 + } catch (e) {
207 + Log.error('Error during model migration: $e');
208 + return 0;
209 + }
210 + }
211 +
212 + static ModelType _inferModelType(String modelId) {
213 + switch (modelId) {
214 + case 'qwen3-0.6b':
215 + return ModelType.qwen3;
216 + case 'qwen2.5-1.5b-instruct':
217 + return ModelType.qwen;
218 + case 'deepseek-r1-distill-qwen-1.5b':
219 + return ModelType.deepSeek;
220 + case 'gemma4-e2b-instruct':
221 + return ModelType.gemma4;
222 + default:
223 + return ModelType.general;
224 + }
225 + }
226 +}
lib/features/on_device/data/on_device_model_download_service.dart deleted
-101
@@ -1,101 +0,0 @@
1 -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 -
10 -/// Downloads `.litertlm` model files from HuggingFace using [ModelDownloader].
11 -///
12 -/// Handles file management tasks like deleting and listing downloaded models.
13 -class OnDeviceModelDownloadService {
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.
21 - Stream<ModelDownloadProgress> downloadModel(
22 - OnDeviceModel model, {
23 - String? token,
24 - }) {
25 - return _downloader.downloadModel(model, token: token);
26 - }
27 -
28 - /// Cancel an in-flight download for [modelId].
29 - void cancelDownload(String modelId) {
30 - _downloader.cancelDownload(modelId);
31 - }
32 -
33 - /// Delete a downloaded model from disk.
34 - Future<void> deleteModel(String modelId) async {
35 - final modelsDir = await OnDeviceEngineService.getModelDirectory();
36 - final file = File('$modelsDir/$modelId.litertlm');
37 - if (await file.exists()) {
38 - await file.delete();
39 - Log.info('Model $modelId deleted');
40 - }
41 - }
42 -
43 - /// List all downloaded models.
44 - Future<List<DownloadedModel>> getDownloadedModels() async {
45 - final modelsDir = await OnDeviceEngineService.getModelDirectory();
46 - final dir = Directory(modelsDir);
47 - if (!await dir.exists()) {
48 - return [];
49 - }
50 -
51 - final List<DownloadedModel> result = [];
52 - final entities = await dir.list().toList();
53 - for (final entity in entities) {
54 - if (entity is File && entity.path.endsWith('.litertlm')) {
55 - final fileName = entity.path.split('/').last;
56 - final modelId = fileName.replaceAll('.litertlm', '');
57 - final stat = await entity.stat();
58 - result.add(
59 - DownloadedModel(
60 - modelId: modelId,
61 - filePath: entity.path,
62 - downloadedAt: stat.modified,
63 - ),
64 - );
65 - }
66 - }
67 - return result;
68 - }
69 -
70 - /// Check if a model has been downloaded.
71 - Future<bool> isModelDownloaded(String modelId) async {
72 - final modelsDir = await OnDeviceEngineService.getModelDirectory();
73 - final file = File('$modelsDir/$modelId.litertlm');
74 - return file.exists();
75 - }
76 -
77 - /// Get total size of all downloaded models.
78 - Future<int> getModelsTotalSizeBytes() async {
79 - final models = await getDownloadedModels();
80 - int total = 0;
81 - for (final model in models) {
82 - final file = File(model.filePath);
83 - if (await file.exists()) {
84 - total += await file.length();
85 - }
86 - }
87 - return total;
88 - }
89 -}
90 -
91 -class DownloadedModel {
92 - final String modelId;
93 - final String filePath;
94 - final DateTime downloadedAt;
95 -
96 - const DownloadedModel({
97 - required this.modelId,
98 - required this.filePath,
99 - required this.downloadedAt,
100 - });
101 -}
lib/features/on_device/providers/foreground_download_providers.dart
+71 -83
@@ -3,6 +3,7 @@ import 'dart:convert';
3
4 import 'package:flutter_riverpod/flutter_riverpod.dart';
5
6 +import '../../../core/logger/app_logger.dart';
7 import '../../../core/providers/storage_providers.dart';
8 import '../data/models/download_progress_info.dart';
9 import '../data/models/download_status.dart';
@@ -18,19 +19,12 @@ final foregroundDownloadNotifierProvider =
19
20 class ForegroundDownloadNotifier
21 extends Notifier<Map<String, DownloadProgressInfo>> {
21 - final Map<String, StreamSubscription<DownloadStatusUpdate>> _subscriptions =
22 - {};
22 + final Map<String, bool> _activeDownloads = {};
23 + final Map<String, DateTime> _downloadStartTimes = {};
24 static const _storageKey = 'model_downloads_state';
25
26 @override
27 Map<String, DownloadProgressInfo> build() {
27 - ref.onDispose(() {
28 - for (final sub in _subscriptions.values) {
29 - sub.cancel();
30 - }
31 - _subscriptions.clear();
32 - });
33 -
28 return _loadState();
29 }
30
@@ -42,7 +36,6 @@ class ForegroundDownloadNotifier
36 final Map<String, dynamic> data = json.decode(jsonStr);
37 return data.map((k, v) {
38 final info = DownloadProgressInfo.fromJson(v);
45 - // If status was active, mark as paused on app restart
39 if (info.status == DownloadStatus.running ||
40 info.status == DownloadStatus.pending) {
41 return MapEntry(k, info.copyWith(status: DownloadStatus.paused));
@@ -73,110 +66,105 @@ class ForegroundDownloadNotifier
66 orElse: () => throw Exception('Model not found: $modelId'),
67 );
68
76 - // If we have existing progress, preserve it but set status to running
77 - final existingInfo = state[modelId];
69 + _activeDownloads[modelId] = true;
70 + _downloadStartTimes[modelId] = DateTime.now();
71 +
72 state = {
73 ...state,
80 - modelId:
81 - existingInfo?.copyWith(status: DownloadStatus.running) ??
82 - DownloadProgressInfo(
83 - modelId: modelId,
84 - status: DownloadStatus.running,
85 - progress: 0.0,
86 - ),
74 + modelId: DownloadProgressInfo(
75 + modelId: modelId,
76 + status: DownloadStatus.running,
77 + progress: 0.0,
78 + totalBytes: model.fileSizeBytes,
79 + ),
80 };
81 _saveState();
82
90 - final downloadService = ref.read(foregroundDownloadServiceProvider);
91 -
92 - // Listen to status updates for this model
93 - _subscriptions[modelId]?.cancel();
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,
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,
83 + final gemmaService = ref.read(onDeviceGemmaServiceProvider);
84 +
85 + try {
86 + await gemmaService.installModel(
87 + model,
88 + onProgress: (progress) {
89 + if (_activeDownloads[modelId] != true) return;
90 +
91 + final startTime = _downloadStartTimes[modelId] ?? DateTime.now();
92 + final elapsed = DateTime.now().difference(startTime).inSeconds;
93 + final total = model.fileSizeBytes;
94 + final received = ((progress / 100.0) * total).round();
95 +
96 + final bytesPerSecond = elapsed > 0 ? (received / elapsed).round() : 0;
97 + final etaSeconds = bytesPerSecond > 0 ? ((total - received) / bytesPerSecond).round() : null;
98 +
99 + state = {
100 + ...state,
101 + modelId: DownloadProgressInfo(
102 + modelId: modelId,
103 + status: DownloadStatus.running,
104 + progress: progress / 100.0,
105 + receivedBytes: received,
106 + totalBytes: total,
107 + bytesPerSecond: bytesPerSecond,
108 + etaSeconds: etaSeconds,
109 + ),
110 + };
111 + _saveState();
112 + },
113 );
120 -
121 - state = {...state, update.modelId: info};
122 - _saveState();
114
124 - if (update.status == DownloadStatus.complete) {
125 - ref.invalidate(downloadedModelsProvider);
126 - _removeCompletedDownload(update.modelId);
127 - } else if (update.status == DownloadStatus.failed ||
128 - update.status == DownloadStatus.canceled ||
129 - update.status == DownloadStatus.paused) {
115 + // Download completed
116 + if (_activeDownloads[modelId] == true) {
117 state = {
118 ...state,
132 - update.modelId: info.copyWith(
133 - status: update.status,
134 - error: update.status == DownloadStatus.failed
135 - ? 'Download failed'
136 - : null,
119 + modelId: DownloadProgressInfo(
120 + modelId: modelId,
121 + status: DownloadStatus.complete,
122 + progress: 1.0,
123 + receivedBytes: model.fileSizeBytes,
124 + totalBytes: model.fileSizeBytes,
125 ),
126 };
127 _saveState();
140 - }
141 - });
128
143 - try {
144 - await downloadService.downloadModel(model);
129 + ref.invalidate(downloadedModelsProvider);
130 + _removeCompletedDownload(modelId);
131 + }
132 } catch (e) {
146 - // Error handled via stream
133 + Log.error('Download failed for $modelId: $e');
134 + if (_activeDownloads[modelId] == true) {
135 + state = {
136 + ...state,
137 + modelId: DownloadProgressInfo(
138 + modelId: modelId,
139 + status: DownloadStatus.failed,
140 + progress: state[modelId]?.progress ?? 0.0,
141 + error: 'Download failed: ${e.toString()}',
142 + ),
143 + };
144 + _saveState();
145 + }
146 + } finally {
147 + _activeDownloads.remove(modelId);
148 + _downloadStartTimes.remove(modelId);
149 }
150 }
151
152 void _removeCompletedDownload(String modelId) {
151 - // Small delay to let UI show 100%
153 Future.delayed(const Duration(seconds: 1), () {
154 if (state.containsKey(modelId)) {
155 final current = Map<String, DownloadProgressInfo>.from(state);
156 current.remove(modelId);
157 state = current;
158 _saveState();
158 - _subscriptions[modelId]?.cancel();
159 - _subscriptions.remove(modelId);
159 }
160 });
161 }
162
163 Future<void> cancelDownload(String modelId) async {
165 - final downloadService = ref.read(foregroundDownloadServiceProvider);
166 - await downloadService.cancelDownload(modelId);
164 + _activeDownloads.remove(modelId);
165 + _downloadStartTimes.remove(modelId);
166 state = {...state}..remove(modelId);
167 _saveState();
169 - _subscriptions[modelId]?.cancel();
170 - _subscriptions.remove(modelId);
171 - }
172 -
173 - Future<void> pauseDownload(String modelId) async {
174 - final downloadService = ref.read(foregroundDownloadServiceProvider);
175 - downloadService.pauseDownload(modelId);
176 - }
177 -
178 - Future<void> resumeDownload(String modelId) async {
179 - await startDownload(modelId);
168 }
169
170 Future<void> retryDownload(String modelId) async {
lib/features/on_device/providers/on_device_providers.dart
+32 -60
@@ -1,17 +1,25 @@
1 -import 'dart:async';
1 import 'dart:io';
2
3 +import 'package:flutter_gemma/flutter_gemma.dart';
4 import 'package:flutter_riverpod/flutter_riverpod.dart';
5
6 import '../../../core/logger/app_logger.dart';
7 import '../../../core/models/enums.dart';
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';
8 import '../../../core/providers/chat_background_service_provider.dart';
9 +import '../data/models/on_device_model.dart';
10 +import '../data/notification_permission_service.dart';
11 +import '../data/on_device_gemma_service.dart';
12 +
13 +final notificationPermissionServiceProvider =
14 + Provider<NotificationPermissionService>((ref) {
15 + return NotificationPermissionService();
16 +});
17 +
18 +final onDeviceGemmaServiceProvider = Provider<OnDeviceGemmaService>((ref) {
19 + final service = OnDeviceGemmaService();
20 + ref.onDispose(() => service.dispose());
21 + return service;
22 +});
23
24 final onDeviceEngineProvider =
25 NotifierProvider<OnDeviceEngineNotifier, OnDeviceEngineState>(() {
@@ -22,35 +30,10 @@ final onDeviceModelsProvider = Provider<List<OnDeviceModel>>((ref) {
30 return OnDeviceModel.curatedModels;
31 });
32
25 -final downloadNotificationServiceProvider =
26 - Provider<DownloadNotificationService>((ref) {
27 - return DownloadNotificationService();
28 -});
29 -
30 -final modelDownloaderProvider = Provider<ModelDownloader>((ref) {
31 - final notificationService = ref.read(downloadNotificationServiceProvider);
32 - return ModelDownloader(notificationService);
33 -});
34 -
35 -final onDeviceDownloadServiceProvider = Provider<OnDeviceModelDownloadService>((
36 - ref,
37 -) {
38 - final downloader = ref.read(modelDownloaderProvider);
39 - return OnDeviceModelDownloadService(downloader);
40 -});
41 -
42 -final foregroundDownloadServiceProvider = Provider<ForegroundDownloadService>((
43 - ref,
44 -) {
45 - return ForegroundDownloadService(
46 - ref.read(onDeviceDownloadServiceProvider),
47 - );
48 -});
49 -
33 final downloadedModelsProvider = FutureProvider<Set<String>>((ref) async {
51 - final downloadService = ref.read(onDeviceDownloadServiceProvider);
52 - final models = await downloadService.getDownloadedModels();
53 - return models.map((m) => m.modelId).toSet();
34 + final gemmaService = ref.read(onDeviceGemmaServiceProvider);
35 + final installed = await gemmaService.getInstalledModelIds();
36 + return installed.toSet();
37 });
38
39 final onDeviceModelStateProvider =
@@ -64,14 +47,12 @@ final onDeviceModelStateProvider =
47 class OnDeviceEngineState {
48 final OnDeviceEngineStatus status;
49 final String? loadedModelId;
67 - final String? loadedModelPath;
68 - final LiteLmBackendType? backend;
50 + final PreferredBackend? backend;
51 final String? error;
52
53 const OnDeviceEngineState({
54 this.status = OnDeviceEngineStatus.notLoaded,
55 this.loadedModelId,
74 - this.loadedModelPath,
56 this.backend,
57 this.error,
58 });
@@ -79,14 +60,12 @@ class OnDeviceEngineState {
60 OnDeviceEngineState copyWith({
61 OnDeviceEngineStatus? status,
62 String? loadedModelId,
82 - String? loadedModelPath,
83 - LiteLmBackendType? backend,
63 + PreferredBackend? backend,
64 String? error,
65 }) {
66 return OnDeviceEngineState(
67 status: status ?? this.status,
68 loadedModelId: loadedModelId ?? this.loadedModelId,
89 - loadedModelPath: loadedModelPath ?? this.loadedModelPath,
69 backend: backend ?? this.backend,
70 error: error ?? this.error,
71 );
@@ -94,29 +73,24 @@ class OnDeviceEngineState {
73 }
74
75 class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
97 - OnDeviceEngineService? _engineService;
98 -
76 @override
77 OnDeviceEngineState build() {
78 return const OnDeviceEngineState();
79 }
80
104 - OnDeviceEngineService get engineService {
105 - _engineService ??= OnDeviceEngineService();
106 - return _engineService!;
107 - }
81 + OnDeviceGemmaService get _gemmaService =>
82 + ref.read(onDeviceGemmaServiceProvider);
83
109 - Future<void> loadModel(String modelId, LiteLmBackendType backend) async {
84 + Future<void> loadModel(String modelId, PreferredBackend backend) async {
85 state = state.copyWith(status: OnDeviceEngineStatus.loading, error: null);
86
112 - // Protect the heavy native model-load (mmap of 1–7 GB) from Android
113 - // CPU throttling by keeping a foreground service active for its duration.
87 + // Protect the heavy native model-load from Android CPU throttling
88 final bgService = ref.read(chatBackgroundServiceProvider);
89 await bgService.start();
90
91 try {
118 - final modelPath = await OnDeviceEngineService.getModelPath(modelId);
119 - if (modelPath == null) {
92 + final isInstalled = await _gemmaService.isModelInstalled(modelId);
93 + if (!isInstalled) {
94 state = state.copyWith(
95 status: OnDeviceEngineStatus.error,
96 error: 'Model not found. Please download it first.',
@@ -124,12 +98,11 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
98 return;
99 }
100
127 - await engineService.createEngine(modelPath, backend);
101 + await _gemmaService.loadModel(modelId, backend);
102
103 state = state.copyWith(
104 status: OnDeviceEngineStatus.loaded,
105 loadedModelId: modelId,
132 - loadedModelPath: modelPath,
106 backend: backend,
107 );
108
@@ -141,20 +114,19 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
114 error: 'Failed to load model: ${e.toString()}',
115 );
116 } finally {
144 - // Always stop the foreground service — even if load fails.
117 await bgService.stop();
118 }
119 }
120
121 Future<void> unloadModel() async {
122 try {
151 - await engineService.disposeEngine();
123 + await _gemmaService.unloadModel();
124 } catch (_) {}
125 state = const OnDeviceEngineState();
126 }
127
156 - Future<void> dispose() async {
157 - engineService.dispose();
128 + Future<void> disposeService() async {
129 + _gemmaService.dispose();
130 state = const OnDeviceEngineState();
131 }
132 }
@@ -171,7 +143,7 @@ class OnDeviceModelStateNotifier
143 OnDeviceModelState? modelState,
144 double? downloadProgress,
145 String? error,
174 - LiteLmBackendType? backend,
146 + PreferredBackend? backend,
147 OnDeviceEngineStatus? engineStatus,
148 }) {
149 final current =
@@ -220,5 +192,5 @@ class OnDeviceModelStateNotifier
192 }
193
194 final isOnDevicePlatformSupportedProvider = Provider<bool>((ref) {
223 - return Platform.isAndroid;
195 + return Platform.isAndroid || Platform.isIOS;
196 });
lib/features/on_device/views/backend_selector.dart
+45 -76
@@ -1,11 +1,9 @@
1 -import 'dart:io';
2 -
1 import 'package:flutter/material.dart';
2 +import 'package:flutter_gemma/flutter_gemma.dart';
3 import 'package:flutter_riverpod/flutter_riverpod.dart';
4 import 'package:localmind/l10n/app_localizations.dart';
5 import 'package:shadcn_ui/shadcn_ui.dart';
6
8 -import '../../../core/models/enums.dart';
7 import '../../../core/providers/app_providers.dart';
8
9 class BackendSelector extends ConsumerWidget {
@@ -16,7 +14,6 @@ class BackendSelector extends ConsumerWidget {
14 final l10n = AppLocalizations.of(context)!;
15 final settings = ref.watch(settingsProvider);
16 final currentBackend = settings.preferredBackend;
19 - final isAndroid = Platform.isAndroid;
17
18 return Column(
19 crossAxisAlignment: CrossAxisAlignment.start,
@@ -28,77 +25,49 @@ class BackendSelector extends ConsumerWidget {
25 ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
26 ),
27 const SizedBox(height: 8),
31 - if (!isAndroid)
32 - Container(
33 - padding: const EdgeInsets.all(12),
34 - margin: const EdgeInsets.only(bottom: 12),
35 - decoration: BoxDecoration(
36 - color: Colors.orange.withValues(alpha: 0.1),
37 - borderRadius: BorderRadius.circular(8),
38 - border: Border.all(color: Colors.orange),
39 - ),
40 - child: Row(
41 - children: [
42 - const Icon(Icons.info_outline, color: Colors.orange, size: 20),
43 - const SizedBox(width: 8),
44 - Expanded(
45 - child: Text(
46 - l10n.backend_ios_notice,
47 - style: const TextStyle(color: Colors.orange),
48 - ),
49 - ),
50 - ],
51 - ),
52 - ),
53 - ...LiteLmBackendType.values.map((backend) {
54 - final isAvailable = isAndroid || backend == LiteLmBackendType.cpu;
28 + ...PreferredBackend.values.map((backend) {
29 final isSelected = currentBackend == backend;
30 return Padding(
31 padding: const EdgeInsets.only(bottom: 8),
58 - child: Opacity(
59 - opacity: isAvailable ? 1.0 : 0.4,
60 - child: ShadButton.outline(
61 - width: double.infinity,
62 - onPressed: isAvailable
63 - ? () => ref
64 - .read(settingsProvider.notifier)
65 - .setPreferredBackend(backend)
66 - : null,
67 - child: Row(
68 - children: [
69 - Icon(
70 - isSelected
71 - ? Icons.radio_button_checked
72 - : Icons.radio_button_off,
73 - size: 20,
74 - color: isSelected
75 - ? Theme.of(context).colorScheme.primary
76 - : Theme.of(
77 - context,
78 - ).colorScheme.onSurface.withValues(alpha: 0.5),
79 - ),
80 - const SizedBox(width: 12),
81 - Expanded(
82 - child: Column(
83 - crossAxisAlignment: CrossAxisAlignment.start,
84 - children: [
85 - Text(
86 - backend.name.toUpperCase(),
87 - style: const TextStyle(fontWeight: FontWeight.w600),
88 - ),
89 - Text(
90 - _backendDescription(backend, context),
91 - style: Theme.of(context).textTheme.bodySmall
92 - ?.copyWith(
93 - color: Theme.of(context).colorScheme.onSurface
94 - .withValues(alpha: 0.6),
95 - ),
96 - ),
97 - ],
98 - ),
32 + child: ShadButton.outline(
33 + width: double.infinity,
34 + onPressed: () => ref
35 + .read(settingsProvider.notifier)
36 + .setPreferredBackend(backend),
37 + child: Row(
38 + children: [
39 + Icon(
40 + isSelected
41 + ? Icons.radio_button_checked
42 + : Icons.radio_button_off,
43 + size: 20,
44 + color: isSelected
45 + ? Theme.of(context).colorScheme.primary
46 + : Theme.of(
47 + context,
48 + ).colorScheme.onSurface.withValues(alpha: 0.5),
49 + ),
50 + const SizedBox(width: 12),
51 + Expanded(
52 + child: Column(
53 + crossAxisAlignment: CrossAxisAlignment.start,
54 + children: [
55 + Text(
56 + backend.name.toUpperCase(),
57 + style: const TextStyle(fontWeight: FontWeight.w600),
58 + ),
59 + Text(
60 + _backendDescription(backend, context),
61 + style: Theme.of(context).textTheme.bodySmall
62 + ?.copyWith(
63 + color: Theme.of(context).colorScheme.onSurface
64 + .withValues(alpha: 0.6),
65 + ),
66 + ),
67 + ],
68 ),
100 - ],
101 - ),
69 + ),
70 + ],
71 ),
72 ),
73 );
@@ -107,15 +76,15 @@ class BackendSelector extends ConsumerWidget {
76 );
77 }
78
110 - String _backendDescription(LiteLmBackendType backend, BuildContext context) {
79 + String _backendDescription(PreferredBackend backend, BuildContext context) {
80 final l10n = AppLocalizations.of(context)!;
81 switch (backend) {
113 - case LiteLmBackendType.cpu:
82 + case PreferredBackend.cpu:
83 return l10n.backend_cpu_desc;
115 - case LiteLmBackendType.gpu:
84 + case PreferredBackend.gpu:
85 return l10n.backend_gpu_desc;
117 - case LiteLmBackendType.npu:
118 - return l10n.backend_npu_desc;
86 + case PreferredBackend.npu:
87 + return 'Neural Processing Unit';
88 }
89 }
90 }
lib/features/on_device/views/model_manager_screen.dart
+12 -32
@@ -1,5 +1,3 @@
1 -import 'dart:io';
2 -
1 import 'package:flutter/material.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3 import 'package:localmind/l10n/app_localizations.dart';
@@ -27,7 +25,6 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
25 final downloadedAsync = ref.watch(downloadedModelsProvider);
26 final engineState = ref.watch(onDeviceEngineProvider);
27 final downloadProgress = ref.watch(foregroundDownloadNotifierProvider);
30 - final isAndroid = Platform.isAndroid;
28 final deviceMemoryAsync = ref.watch(deviceMemoryProvider);
29
30 return Scaffold(
@@ -36,28 +33,6 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
33 body: ListView(
34 padding: const EdgeInsets.all(16),
35 children: [
39 - if (!isAndroid)
40 - Container(
41 - padding: const EdgeInsets.all(12),
42 - margin: const EdgeInsets.only(bottom: 16),
43 - decoration: BoxDecoration(
44 - color: Colors.orange.withValues(alpha: 0.1),
45 - borderRadius: BorderRadius.circular(8),
46 - border: Border.all(color: Colors.orange),
47 - ),
48 - child: Row(
49 - children: [
50 - const Icon(Icons.info_outline, color: Colors.orange, size: 20),
51 - const SizedBox(width: 8),
52 - Expanded(
53 - child: Text(
54 - l10n.on_device_android_only,
55 - style: const TextStyle(color: Colors.orange),
56 - ),
57 - ),
58 - ],
59 - ),
60 - ),
36 _buildMemoryInfo(context, ref, deviceMemoryAsync),
37 if (engineState.status == OnDeviceEngineStatus.loaded)
38 Container(
@@ -617,9 +592,14 @@ class _ModelCard extends ConsumerWidget {
592 child: Column(
593 crossAxisAlignment: CrossAxisAlignment.start,
594 children: [
620 - LinearProgressIndicator(
621 - value: downloadProgress?.progress ?? 0.0,
622 - backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.1),
595 + ClipRRect(
596 + borderRadius: BorderRadius.circular(4),
597 + child: LinearProgressIndicator(
598 + value: downloadProgress?.progress ?? 0.0,
599 + minHeight: 6,
600 + backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.1),
601 + valueColor: AlwaysStoppedAnimation<Color>(theme.colorScheme.primary),
602 + ),
603 ),
604 const SizedBox(height: 6),
605 Row(
@@ -656,8 +636,8 @@ class _ModelCard extends ConsumerWidget {
636 size: ShadButtonSize.sm,
637 onPressed: () => ref
638 .read(foregroundDownloadNotifierProvider.notifier)
659 - .pauseDownload(model.id),
660 - child: Text(l10n.pause),
639 + .cancelDownload(model.id),
640 + child: Text(l10n.cancel),
641 ),
642 ],
643 ),
@@ -814,8 +794,8 @@ class _ModelCard extends ConsumerWidget {
794 await ref.read(onDeviceEngineProvider.notifier).unloadModel();
795 }
796
817 - final downloadService = ref.read(onDeviceDownloadServiceProvider);
818 - await downloadService.deleteModel(model.id);
797 + final gemmaService = ref.read(onDeviceGemmaServiceProvider);
798 + await gemmaService.deleteModel(model.id);
799 ref.invalidate(downloadedModelsProvider);
800 }
801 }
lib/features/onboarding/screens/onboarding_model_download_screen.dart
+13 -5
@@ -372,14 +372,22 @@ class _ModelCard extends ConsumerWidget {
372 child: Column(
373 crossAxisAlignment: CrossAxisAlignment.stretch,
374 children: [
375 - LinearProgressIndicator(value: progressInfo?.progress ?? 0),
376 - const SizedBox(height: 4),
375 + ClipRRect(
376 + borderRadius: BorderRadius.circular(4),
377 + child: LinearProgressIndicator(
378 + value: progressInfo?.progress ?? 0.0,
379 + minHeight: 6,
380 + backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.1),
381 + valueColor: AlwaysStoppedAnimation<Color>(theme.colorScheme.primary),
382 + ),
383 + ),
384 + const SizedBox(height: 6),
385 Row(
386 mainAxisAlignment: MainAxisAlignment.spaceBetween,
387 children: [
388 Text(
389 l10n.download_progress(
382 - '${((progressInfo?.progress ?? 0) * 100).toStringAsFixed(0)}%',
390 + ((progressInfo?.progress ?? 0) * 100).toStringAsFixed(0),
391 progressInfo?.speedFormatted ?? '0 B/s',
392 ),
393 style: theme.textTheme.labelSmall,
@@ -398,8 +406,8 @@ class _ModelCard extends ConsumerWidget {
406 size: ShadButtonSize.sm,
407 onPressed: () => ref
408 .read(foregroundDownloadNotifierProvider.notifier)
401 - .pauseDownload(model.id),
402 - child: Text(l10n.pause),
409 + .cancelDownload(model.id),
410 + child: Text(l10n.cancel),
411 ),
412 ],
413 )
lib/features/onboarding/screens/onboarding_notification_permission_screen.dart
+1 -1
@@ -40,7 +40,7 @@ class _OnboardingNotificationPermissionScreenState
40 Future<void> _requestPermission() async {
41 setState(() => _isProcessing = true);
42 try {
43 - final notificationService = ref.read(downloadNotificationServiceProvider);
43 + final notificationService = ref.read(notificationPermissionServiceProvider);
44 await notificationService.requestPermission();
45 await _completeOnboarding();
46 } finally {
lib/features/settings/data/models/app_settings.dart
+15 -4
@@ -1,6 +1,7 @@
1 import 'dart:convert';
2
3 import 'package:flutter/material.dart';
4 +import 'package:flutter_gemma/flutter_gemma.dart';
5
6 import '../../../../core/models/enums.dart';
7 import '../../../../features/tts/data/kitten_tts_model.dart';
@@ -30,7 +31,7 @@ class AppSettings {
31 final bool newChatMcpEnabled;
32 final SyntaxThemeName codeThemeDark;
33 final SyntaxThemeName codeThemeLight;
33 - final LiteLmBackendType preferredBackend;
34 + final PreferredBackend preferredBackend;
35 final EngineId ttsEngine;
36 final String? ttsVoiceId;
37 final double ttsSpeed;
@@ -60,7 +61,7 @@ class AppSettings {
61 this.newChatMcpEnabled = true,
62 this.codeThemeDark = SyntaxThemeName.dark,
63 this.codeThemeLight = SyntaxThemeName.light,
63 - this.preferredBackend = LiteLmBackendType.cpu,
64 + this.preferredBackend = PreferredBackend.cpu,
65 this.ttsEngine = EngineId.system,
66 this.ttsVoiceId,
67 this.ttsSpeed = 1.0,
@@ -91,7 +92,7 @@ class AppSettings {
92 bool? newChatMcpEnabled,
93 SyntaxThemeName? codeThemeDark,
94 SyntaxThemeName? codeThemeLight,
94 - LiteLmBackendType? preferredBackend,
95 + PreferredBackend? preferredBackend,
96 EngineId? ttsEngine,
97 Object? ttsVoiceId = _unset,
98 double? ttsSpeed,
@@ -195,7 +196,7 @@ class AppSettings {
196 newChatMcpEnabled: map['newChatMcpEnabled'] ?? true,
197 codeThemeDark: SyntaxThemeName.values[map['codeThemeDark'] ?? 0],
198 codeThemeLight: SyntaxThemeName.values[map['codeThemeLight'] ?? 1],
198 - preferredBackend: LiteLmBackendType.values[map['preferredBackend'] ?? 0],
199 + preferredBackend: _parsePreferredBackend(map['preferredBackend']),
200 ttsEngine: _parseEngine(map['ttsEngine']),
201 ttsVoiceId: map['ttsVoiceId'],
202 ttsSpeed: map['ttsSpeed']?.toDouble() ?? 1.0,
@@ -206,6 +207,16 @@ class AppSettings {
207 );
208 }
209
210 + static PreferredBackend _parsePreferredBackend(dynamic value) {
211 + if (value is int && value < PreferredBackend.values.length) {
212 + return PreferredBackend.values[value];
213 + }
214 + // Handle migration from old LiteLmBackendType (had cpu=0, gpu=1, npu=2)
215 + // Map npu (2) to gpu since flutter_gemma only has cpu/gpu
216 + if (value is int && value == 2) return PreferredBackend.gpu;
217 + return PreferredBackend.cpu;
218 + }
219 +
220 static EngineId _parseEngine(dynamic value) {
221 if (value is String) {
222 return engineIdFromString(value);
pubspec.lock
+56 -16
@@ -105,6 +105,14 @@ packages:
105 url: "https://pub.dev"
106 source: hosted
107 version: "4.3.0"
108 + background_downloader:
109 + dependency: transitive
110 + description:
111 + name: background_downloader
112 + sha256: "4cb23d9ad4f5060944f38164e7b90d4bf99b57b2472a3bd4676e59b2db4afd06"
113 + url: "https://pub.dev"
114 + source: hosted
115 + version: "9.5.4"
116 boolean_selector:
117 dependency: transitive
118 description:
@@ -273,6 +281,14 @@ packages:
281 url: "https://pub.dev"
282 source: hosted
283 version: "1.0.9"
284 + dart_sentencepiece_tokenizer:
285 + dependency: transitive
286 + description:
287 + name: dart_sentencepiece_tokenizer
288 + sha256: "85825632845cf6427ea0cd13dfba96b4341cf63525165155e5b9b97011239289"
289 + url: "https://pub.dev"
290 + source: hosted
291 + version: "1.3.2"
292 dart_style:
293 dependency: transitive
294 description:
@@ -398,6 +414,14 @@ packages:
414 url: "https://pub.dev"
415 source: hosted
416 version: "4.5.2"
417 + flutter_gemma:
418 + dependency: "direct main"
419 + description:
420 + name: flutter_gemma
421 + sha256: a56c1745a6218fac9c3456d7449a8dbdd684a870cb73f407fcf47f38f1e3633e
422 + url: "https://pub.dev"
423 + source: hosted
424 + version: "0.16.1"
425 flutter_lints:
426 dependency: "direct dev"
427 description:
@@ -406,14 +430,6 @@ packages:
430 url: "https://pub.dev"
431 source: hosted
432 version: "6.0.0"
409 - flutter_litert_lm:
410 - dependency: "direct main"
411 - description:
412 - name: flutter_litert_lm
413 - sha256: "222fa9e669c6864c901049930b5639c7276a18b508a38f1fa8697427bde2abb0"
414 - url: "https://pub.dev"
415 - source: hosted
416 - version: "0.3.0"
433 flutter_local_notifications:
434 dependency: "direct main"
435 description:
@@ -693,6 +709,14 @@ packages:
709 url: "https://pub.dev"
710 source: hosted
711 version: "4.12.0"
712 + large_file_handler:
713 + dependency: transitive
714 + description:
715 + name: large_file_handler
716 + sha256: c2ecaf552e8039119bbe3a094d15aecbafe833899f30b2dcfbb513eddf76b36f
717 + url: "https://pub.dev"
718 + source: hosted
719 + version: "0.3.1"
720 leak_tracker:
721 dependency: transitive
722 description:
@@ -733,6 +757,14 @@ packages:
757 url: "https://pub.dev"
758 source: hosted
759 version: "1.3.0"
760 + local_hnsw:
761 + dependency: transitive
762 + description:
763 + name: local_hnsw
764 + sha256: "7fb0988e3f850121774d9dfe94068f843d87908d6b1e9ed5039710d8d9ae3f7a"
765 + url: "https://pub.dev"
766 + source: hosted
767 + version: "1.0.0"
768 logger:
769 dependency: "direct main"
770 description:
@@ -769,10 +801,10 @@ packages:
801 dependency: transitive
802 description:
803 name: matcher
772 - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
804 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
805 url: "https://pub.dev"
806 source: hosted
775 - version: "0.12.19"
807 + version: "0.12.18"
808 material_color_utilities:
809 dependency: transitive
810 description:
@@ -1242,6 +1274,14 @@ packages:
1274 url: "https://pub.dev"
1275 source: hosted
1276 version: "1.10.2"
1277 + sqlite3:
1278 + dependency: transitive
1279 + description:
1280 + name: sqlite3
1281 + sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
1282 + url: "https://pub.dev"
1283 + source: hosted
1284 + version: "3.3.1"
1285 stack_trace:
1286 dependency: transitive
1287 description:
@@ -1311,26 +1351,26 @@ packages:
1351 dependency: transitive
1352 description:
1353 name: test
1314 - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
1354 + sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a"
1355 url: "https://pub.dev"
1356 source: hosted
1317 - version: "1.30.0"
1357 + version: "1.29.0"
1358 test_api:
1359 dependency: transitive
1360 description:
1361 name: test_api
1322 - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
1362 + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
1363 url: "https://pub.dev"
1364 source: hosted
1325 - version: "0.7.10"
1365 + version: "0.7.9"
1366 test_core:
1367 dependency: transitive
1368 description:
1369 name: test_core
1330 - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
1370 + sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943"
1371 url: "https://pub.dev"
1372 source: hosted
1333 - version: "0.6.16"
1373 + version: "0.6.15"
1374 theme_extensions_builder_annotation:
1375 dependency: transitive
1376 description:
pubspec.yaml
+2 -2
@@ -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.2.9+28
19 +version: 1.3.0+30
20
21 environment:
22 sdk: ^3.11.0
@@ -59,7 +59,7 @@ dependencies:
59 objectbox: ^5.3.1
60 objectbox_flutter_libs: ^5.3.1
61 path: ^1.9.1
62 - flutter_litert_lm: ^0.3.0
62 + flutter_gemma: ^0.16.1
63 wakelock_plus: ^1.2.8
64 flutter_local_notifications: ^18.0.1
65 audioplayers: ^6.4.0