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