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