@tduncan / Cortana / commits / 0991103

feat: add flutter_gemma_builtin_ai, refactor OS widget snapshot logic, and improve invocation handling

abdulmominsakib committed Aug 25, 2026 at 14:23 UTC 09911036bcaf54843d35a811061cedccd4aa680c
7 files changed +290 -26
lib/features/os_widget/data/models/os_widget_models.dart
+3 -2
@@ -1,4 +1,4 @@
1 -enum OsWidgetAction { chat, newChat, voice, unknown }
1 +enum OsWidgetAction { chat, newChat, voice }
2
3 class OsWidgetInvocation {
4 final OsWidgetAction action;
@@ -16,7 +16,8 @@ class OsWidgetInvocation {
16 final action = switch (actionParam) {
17 'new_chat' || 'newChat' => OsWidgetAction.newChat,
18 'voice' => OsWidgetAction.voice,
19 - 'chat' => OsWidgetAction.chat,
19 + // Default to chat for "chat" and any unrecognized action so the
20 + // user always lands on a usable surface.
21 _ => OsWidgetAction.chat,
22 };
23
lib/features/os_widget/providers/os_widget_providers.dart
+33 -9
@@ -31,13 +31,22 @@ final widgetPendingPromptProvider =
31 WidgetPendingPromptNotifier.new,
32 );
33
34 -final osWidgetSnapshotSyncProvider = Provider<void>((ref) {
34 +/// Pure selector for the model name + configured flag that should be shown on
35 +/// the home-screen widget. Pure function of Riverpod state — no side effects.
36 +///
37 +/// Side effects (calling out to the home_widget plugin) live in
38 +/// `OsWidgetInvocationHost` via `ref.listen`, so this stays declarative.
39 +class OsWidgetSnapshot {
40 + final String? modelName;
41 + final bool isConfigured;
42 +
43 + const OsWidgetSnapshot({this.modelName, required this.isConfigured});
44 +}
45 +
46 +final osWidgetSnapshotProvider = Provider<OsWidgetSnapshot>((ref) {
47 final settings = ref.watch(settingsProvider);
48 final defaultModelId = settings.defaultModelId;
49 final defaultModelServerId = settings.defaultModelServerId;
38 - final service = ref.watch(osWidgetServiceProvider);
39 -
40 - if (!service.isSupportedPlatform) return;
50
51 final isConfigured =
52 defaultModelId != null &&
@@ -46,8 +55,7 @@ final osWidgetSnapshotSyncProvider = Provider<void>((ref) {
55 defaultModelServerId.isNotEmpty;
56
57 if (!isConfigured) {
49 - service.updateWidgetSnapshot(modelName: null, isConfigured: false);
50 - return;
58 + return const OsWidgetSnapshot(modelName: null, isConfigured: false);
59 }
60
61 final availableAsync = ref.watch(
@@ -55,7 +63,23 @@ final osWidgetSnapshotSyncProvider = Provider<void>((ref) {
63 );
64 final models = availableAsync.value ?? const <ModelInfo>[];
65 final model = models.where((m) => m.id == defaultModelId).firstOrNull;
58 - final modelName = model?.displayName ?? defaultModelId;
59 -
60 - service.updateWidgetSnapshot(modelName: modelName, isConfigured: true);
66 + return OsWidgetSnapshot(
67 + modelName: model?.displayName ?? defaultModelId,
68 + isConfigured: true,
69 + );
70 });
71 +
72 +/// Push the snapshot to the native widget. Convenience for `ref.listen`
73 +/// callbacks that have a WidgetRef handy; reads the service for the caller.
74 +Future<void> pushOsWidgetSnapshot(
75 + WidgetRef ref, {
76 + required String? modelName,
77 + required bool isConfigured,
78 +}) async {
79 + final service = ref.read(osWidgetServiceProvider);
80 + if (!service.isSupportedPlatform) return;
81 + await service.updateWidgetSnapshot(
82 + modelName: modelName,
83 + isConfigured: isConfigured,
84 + );
85 +}
lib/features/os_widget/views/os_widget_invocation_host.dart
+60 -12
@@ -40,6 +40,7 @@ class _OsWidgetInvocationHostState
40 String _loadingStatusMessage = 'Loading model into memory...';
41 bool _isShowingVoiceMode = false;
42 bool _isCanceled = false;
43 + bool _isDisposed = false;
44
45 @override
46 void initState() {
@@ -50,8 +51,6 @@ class _OsWidgetInvocationHostState
51 _subscription = service.invocations.listen(_handleInvocation);
52 WidgetsBinding.instance.addPostFrameCallback((_) {
53 unawaited(service.initialize());
53 - // Initialize widget snapshot sync
54 - ref.read(osWidgetSnapshotSyncProvider);
54 });
55 }
56
@@ -60,7 +59,7 @@ class _OsWidgetInvocationHostState
59
60 switch (invocation.action) {
61 case OsWidgetAction.voice:
63 - await _handleVoiceInvocation();
62 + await _handleVoiceInvocation(prompt: invocation.prompt);
63 break;
64 case OsWidgetAction.newChat:
65 await _handleChatInvocation(
@@ -69,7 +68,6 @@ class _OsWidgetInvocationHostState
68 );
69 break;
70 case OsWidgetAction.chat:
72 - case OsWidgetAction.unknown:
71 await _handleChatInvocation(
72 prompt: invocation.prompt,
73 startNewChat: false,
@@ -78,10 +76,18 @@ class _OsWidgetInvocationHostState
76 }
77 }
78
81 - Future<void> _handleVoiceInvocation() async {
79 + Future<void> _handleVoiceInvocation({String? prompt}) async {
80 if (!mounted || _isShowingVoiceMode) return;
81 if (ref.read(voiceModeProvider).isActive) return;
82
83 + // If a text prompt was attached to the voice invocation, surface it as
84 + // a pending chat prompt rather than dropping it on the floor — the user
85 + // will still land on voice mode but the text won't be lost if they
86 + // dismiss voice and continue in chat.
87 + if (prompt != null && prompt.isNotEmpty) {
88 + ref.read(widgetPendingPromptProvider.notifier).setPrompt(prompt);
89 + }
90 +
91 _isShowingVoiceMode = true;
92 try {
93 await VoiceModeOverlay.show(context);
@@ -94,7 +100,7 @@ class _OsWidgetInvocationHostState
100 String? prompt,
101 required bool startNewChat,
102 }) async {
97 - if (!mounted) return;
103 + if (!mounted || _isDisposed) return;
104
105 final settings = ref.read(settingsProvider);
106 final defaultModelId = settings.defaultModelId;
@@ -121,6 +127,7 @@ class _OsWidgetInvocationHostState
127 }
128
129 final servers = await ref.read(serversProvider.future);
130 + if (!mounted || _isDisposed) return;
131 final defaultServer = servers
132 .where((s) => s.id == defaultModelServerId)
133 .firstOrNull;
@@ -172,6 +179,12 @@ class _OsWidgetInvocationHostState
179 _loadingStatusMessage = 'Loading model into memory...';
180 });
181
182 + // Tracks whether the post-load state mutations should be applied.
183 + // Set to false if the user cancels or the host is torn down before
184 + // the model finishes loading — otherwise the user's chosen cancellation
185 + // would still be overridden by these mutations.
186 + var applyPostLoadState = true;
187 +
188 try {
189 // 1. Set active server
190 ref.read(activeServerIdProvider.notifier).setActiveServer(defaultServer);
@@ -190,6 +203,10 @@ class _OsWidgetInvocationHostState
203 defaultModelId,
204 settings.preferredBackend,
205 );
206 + if (_isCanceled || !mounted || _isDisposed) {
207 + applyPostLoadState = false;
208 + return;
209 + }
210 }
211 } else if (defaultServer.type == ServerType.lmStudio) {
212 final apiService = ref.read(serverApiServiceProvider);
@@ -197,7 +214,11 @@ class _OsWidgetInvocationHostState
214 _loadingStatusMessage = 'Checking LM Studio instance...';
215 });
216 final running = await apiService.fetchRunningModels(defaultServer);
200 - if (!running.contains(defaultModelId) && !_isCanceled) {
217 + if (_isCanceled || !mounted || _isDisposed) {
218 + applyPostLoadState = false;
219 + return;
220 + }
221 + if (!running.contains(defaultModelId)) {
222 setState(() {
223 _loadingStatusMessage = 'Loading model in LM Studio...';
224 });
@@ -212,15 +233,21 @@ class _OsWidgetInvocationHostState
233 defaultModelId,
234 contextLength: ref.read(chatParamsProvider).contextLength,
235 );
236 + if (_isCanceled || !mounted || _isDisposed) {
237 + applyPostLoadState = false;
238 + return;
239 + }
240 }
241 }
242
218 - if (_isCanceled || !mounted) return;
219 -
243 // 3. Set selected model
244 final available = await ref.read(
245 availableModelsProvider(defaultServer.id).future,
246 );
247 + if (_isCanceled || !mounted || _isDisposed) {
248 + applyPostLoadState = false;
249 + return;
250 + }
251 final targetModel =
252 available.where((m) => m.id == defaultModelId).firstOrNull ??
253 ModelInfo(
@@ -244,6 +271,7 @@ class _OsWidgetInvocationHostState
271 }
272 } catch (e) {
273 Log.error('Error loading default model from widget: $e');
274 + applyPostLoadState = false;
275 if (mounted) {
276 ShadToaster.of(context).show(
277 ShadToast.destructive(
@@ -253,7 +281,10 @@ class _OsWidgetInvocationHostState
281 );
282 }
283 } finally {
256 - if (mounted) {
284 + // Only close the overlay if no cancellation/disposal happened during
285 + // the load. When cancelled, _cancelLoading already set _isLoadingModel
286 + // to false; when the host was disposed, the overlay widget is gone.
287 + if (applyPostLoadState && mounted && !_isDisposed) {
288 setState(() {
289 _isLoadingModel = false;
290 });
@@ -263,6 +294,7 @@ class _OsWidgetInvocationHostState
294
295 void _cancelLoading() {
296 _isCanceled = true;
297 + if (!mounted || _isDisposed) return;
298 setState(() {
299 _isLoadingModel = false;
300 });
@@ -270,14 +302,30 @@ class _OsWidgetInvocationHostState
302
303 @override
304 void dispose() {
305 + _isDisposed = true;
306 unawaited(_subscription?.cancel());
307 super.dispose();
308 }
309
310 @override
311 Widget build(BuildContext context) {
279 - // Keep widget snapshot synced with active settings
280 - ref.watch(osWidgetSnapshotSyncProvider);
312 + // Push the snapshot to the native widget whenever settings / model list
313 + // change. We use listen (not watch) so the host doesn't rebuild — the
314 + // rebuild side-effect lived in a Provider<void> previously, which made
315 + // the data flow harder to follow.
316 + ref.listen<OsWidgetSnapshot>(osWidgetSnapshotProvider, (previous, next) {
317 + if (previous?.modelName == next.modelName &&
318 + previous?.isConfigured == next.isConfigured) {
319 + return;
320 + }
321 + unawaited(
322 + pushOsWidgetSnapshot(
323 + ref,
324 + modelName: next.modelName,
325 + isConfigured: next.isConfigured,
326 + ),
327 + );
328 + });
329
330 return Stack(
331 children: [
pubspec.lock
+10 -2
@@ -586,10 +586,18 @@ packages:
586 dependency: "direct main"
587 description:
588 name: flutter_gemma
589 - sha256: "00093a86d841581f588b96b46994d33eb64213d47e9aa19489b72e4dde92692d"
589 + sha256: "33d854b96167c324f38058914814de270f7f8c7e73a9733a4c8da71b8182e685"
590 url: "https://pub.dev"
591 source: hosted
592 - version: "1.5.2"
592 + version: "1.6.5"
593 + flutter_gemma_builtin_ai:
594 + dependency: "direct main"
595 + description:
596 + name: flutter_gemma_builtin_ai
597 + sha256: "5bcbda8119b192b6915796545e2a5e093e750e791ea1c79d593777d83572f82e"
598 + url: "https://pub.dev"
599 + source: hosted
600 + version: "0.2.0"
601 flutter_gemma_litertlm:
602 dependency: "direct main"
603 description:
pubspec.yaml
+2 -1
@@ -64,8 +64,9 @@ dependencies:
64 objectbox: ^5.3.1
65 objectbox_flutter_libs: ^5.3.1
66 path: ^1.9.1
67 - flutter_gemma: ^1.5.2
67 + flutter_gemma: ^1.6.5
68 flutter_gemma_litertlm: ^1.3.1
69 + flutter_gemma_builtin_ai: ^0.2.0
70 llamadart: ^0.8.4
71 llamadart_llama_cpp_flutter: ^0.0.4
72 wakelock_plus: ^1.2.8
test/features/os_widget/os_widget_invocation_host_test.dart
+46
@@ -60,6 +60,52 @@ void main() {
60 });
61
62 group('OsWidgetInvocationHost', () {
63 + testWidgets(
64 + 'voice invocation with a prompt surfaces the prompt as pending',
65 + (tester) async {
66 + SharedPreferences.setMockInitialValues({});
67 + final prefs = await SharedPreferences.getInstance();
68 + final service = OsWidgetService(isSupportedOverride: true);
69 +
70 + late ProviderContainer container;
71 +
72 + await tester.pumpWidget(
73 + ProviderScope(
74 + overrides: [
75 + sharedPreferencesProvider.overrideWithValue(prefs),
76 + osWidgetServiceProvider.overrideWithValue(service),
77 + settingsProvider.overrideWith(_TestSettingsNotifier.new),
78 + serversProvider.overrideWith(_StubServersNotifier.new),
79 + ],
80 + child: Consumer(
81 + builder: (context, ref, _) {
82 + container = ProviderScope.containerOf(context);
83 + return const ShadApp(
84 + home: Scaffold(
85 + body: OsWidgetInvocationHost(child: Text('Root Body')),
86 + ),
87 + );
88 + },
89 + ),
90 + ),
91 + );
92 + await tester.pumpAndSettle();
93 +
94 + service.dispatchInvocation(
95 + const OsWidgetInvocation(
96 + action: OsWidgetAction.voice,
97 + prompt: 'Voice-mode preset prompt',
98 + ),
99 + );
100 +
101 + await tester.pump();
102 + await tester.pump(const Duration(milliseconds: 50));
103 +
104 + final pending = container.read(widgetPendingPromptProvider);
105 + expect(pending, 'Voice-mode preset prompt');
106 + },
107 + );
108 +
109 testWidgets(
110 'renders child and handles widget prompt when default model is unset',
111 (tester) async {
test/features/os_widget/os_widget_snapshot_provider_test.dart new
+136
@@ -0,0 +1,136 @@
1 +import 'package:flutter_riverpod/flutter_riverpod.dart';
2 +import 'package:flutter_test/flutter_test.dart';
3 +import 'package:localmind/core/models/enums.dart';
4 +import 'package:localmind/core/providers/app_providers.dart';
5 +import 'package:localmind/core/providers/storage_providers.dart';
6 +import 'package:shared_preferences/shared_preferences.dart';
7 +import 'package:localmind/features/models/data/models/model_info.dart';
8 +import 'package:localmind/features/os_widget/providers/os_widget_providers.dart';
9 +import 'package:localmind/features/servers/data/models/server.dart';
10 +import 'package:localmind/features/servers/providers/server_providers.dart';
11 +import 'package:localmind/features/settings/data/models/app_settings.dart';
12 +
13 +class _TestSettingsNotifier extends SettingsNotifier {
14 + _TestSettingsNotifier({this.customSettings});
15 + final AppSettings? customSettings;
16 +
17 + @override
18 + AppSettings build() => customSettings ?? AppSettings();
19 +}
20 +
21 +class _StubServersNotifier extends ServersNotifier {
22 + _StubServersNotifier([this.servers = const []]);
23 + final List<Server> servers;
24 +
25 + @override
26 + Future<List<Server>> build() async => servers;
27 +}
28 +
29 +void main() {
30 + group('osWidgetSnapshotProvider', () {
31 + test('returns unconfigured snapshot when no default model is set', () async {
32 + SharedPreferences.setMockInitialValues({});
33 + final prefs = await SharedPreferences.getInstance();
34 +
35 + final container = ProviderContainer(
36 + overrides: [
37 + sharedPreferencesProvider.overrideWithValue(prefs),
38 + settingsProvider.overrideWith(_TestSettingsNotifier.new),
39 + serversProvider.overrideWith(_StubServersNotifier.new),
40 + ],
41 + );
42 + addTearDown(container.dispose);
43 +
44 + // Force settings to build
45 + container.read(settingsProvider);
46 +
47 + final snapshot = container.read(osWidgetSnapshotProvider);
48 + expect(snapshot.isConfigured, isFalse);
49 + expect(snapshot.modelName, isNull);
50 + });
51 +
52 + test('returns configured snapshot with model display name', () async {
53 + SharedPreferences.setMockInitialValues({});
54 + final prefs = await SharedPreferences.getInstance();
55 +
56 + final server = Server(
57 + id: 'srv-1',
58 + name: 'Test Server',
59 + host: 'http://localhost',
60 + port: 11434,
61 + type: ServerType.ollama,
62 + createdAt: DateTime.now(),
63 + lastConnectedAt: DateTime.now(),
64 + );
65 + final model = ModelInfo(
66 + id: 'llama3:8b',
67 + name: 'Llama 3 8B',
68 + serverId: server.id,
69 + serverType: ServerType.ollama,
70 + );
71 +
72 + final settings = AppSettings(
73 + defaultModelId: model.id,
74 + defaultModelServerId: server.id,
75 + );
76 +
77 + final container = ProviderContainer(
78 + overrides: [
79 + sharedPreferencesProvider.overrideWithValue(prefs),
80 + settingsProvider.overrideWith(
81 + () => _TestSettingsNotifier(customSettings: settings),
82 + ),
83 + serversProvider.overrideWith(() => _StubServersNotifier([server])),
84 + availableModelsProvider(server.id).overrideWith((ref) async => [model]),
85 + ],
86 + );
87 + addTearDown(container.dispose);
88 +
89 + // Touch the future to force it to resolve before we read the snapshot.
90 + await container.read(availableModelsProvider(server.id).future);
91 +
92 + final snapshot = container.read(osWidgetSnapshotProvider);
93 + expect(snapshot.isConfigured, isTrue);
94 + expect(snapshot.modelName, 'Llama 3 8B');
95 + });
96 +
97 + test('falls back to model id when model is missing from available list',
98 + () async {
99 + SharedPreferences.setMockInitialValues({});
100 + final prefs = await SharedPreferences.getInstance();
101 +
102 + final server = Server(
103 + id: 'srv-1',
104 + name: 'Test Server',
105 + host: 'http://localhost',
106 + port: 11434,
107 + type: ServerType.ollama,
108 + createdAt: DateTime.now(),
109 + lastConnectedAt: DateTime.now(),
110 + );
111 +
112 + final settings = AppSettings(
113 + defaultModelId: 'missing-model',
114 + defaultModelServerId: server.id,
115 + );
116 +
117 + final container = ProviderContainer(
118 + overrides: [
119 + sharedPreferencesProvider.overrideWithValue(prefs),
120 + settingsProvider.overrideWith(
121 + () => _TestSettingsNotifier(customSettings: settings),
122 + ),
123 + serversProvider.overrideWith(() => _StubServersNotifier([server])),
124 + availableModelsProvider(server.id).overrideWith((ref) async => []),
125 + ],
126 + );
127 + addTearDown(container.dispose);
128 +
129 + await container.read(availableModelsProvider(server.id).future);
130 +
131 + final snapshot = container.read(osWidgetSnapshotProvider);
132 + expect(snapshot.isConfigured, isTrue);
133 + expect(snapshot.modelName, 'missing-model');
134 + });
135 + });
136 +}
\ No newline at end of file