18 files changed
+1044
-297
lib/features/chat/data/smart_reply_service.dart
+6
-8
@@ -9,6 +9,11 @@ import 'models/chat_parameters.dart';
9
import 'models/message.dart';
10
11
class SmartReplyService {
12
+ static final RegExp _greetingPattern = RegExp(
13
+ r'\b(?:hello|hi|hey|greetings|good morning|good afternoon|good evening|welcome)\b',
14
+ caseSensitive: false,
15
+ );
16
+
17
final Map<String, Future<List<String>>> _inFlightSuggestions = {};
18
19
Future<List<String>> suggestRepliesWithLLM({
@@ -304,14 +309,7 @@ class SmartReplyService {
309
final lowerContent = cleanContent.toLowerCase();
310
311
// 1. Greetings & Welcomes
307
- if (lowerContent.contains('hello') ||
308
- lowerContent.contains('hi') ||
309
- lowerContent.contains('hey') ||
310
- lowerContent.contains('greetings') ||
311
- lowerContent.contains('good morning') ||
312
- lowerContent.contains('good afternoon') ||
313
- lowerContent.contains('good evening') ||
314
- lowerContent.contains('welcome')) {
312
+ if (_greetingPattern.hasMatch(cleanContent)) {
313
return [
314
'Hello! How are you?',
315
'I have a question',
lib/features/chat/providers/chat_notifier.dart
+21
-13
@@ -735,20 +735,28 @@ class ChatNotifier extends Notifier<ChatState> {
735
}
736
737
Future<void> sendMessage(String content, {List<File>? attachments}) async {
738
- final server = ref.read(activeServerProvider);
739
- final selectedModel = ref.read(selectedModelProvider);
740
- final chatService = ref.read(chatServiceProvider);
738
+ final target = ref.read(activeChatTargetProvider);
739
+ final server = target.server;
740
+ final selectedModel = target.selectedModel;
741
+ final effectiveModelId = target.effectiveModelId;
742
743
if (server == null) {
744
state = state.copyWith(errorMessage: 'No server connected');
745
return;
746
}
747
748
+ if (effectiveModelId == null) {
749
+ state = state.copyWith(errorMessage: modelSelectionRequiredMessage);
750
+ return;
751
+ }
752
+
753
if (content.trim().isEmpty &&
754
(attachments == null || attachments.isEmpty)) {
755
return;
756
}
757
758
+ final chatService = ref.read(chatServiceProvider);
759
+
760
await _abortStreamImmediately();
761
762
// Stop the STT listener so the mic doesn't stay red while the model
@@ -827,7 +835,7 @@ class ChatNotifier extends Notifier<ChatState> {
835
content: '',
836
createdAt: DateTime.now(),
837
status: MessageStatus.streaming,
830
- modelId: selectedModel?.id,
838
+ modelId: effectiveModelId,
839
variantGroupId: assistantGroupId,
840
variantIndex: 0,
841
threadOrder: assistantThreadOrder,
@@ -922,7 +930,7 @@ class ChatNotifier extends Notifier<ChatState> {
930
_streamSubscription = chatService
931
.sendMessage(
932
server: server,
925
- modelId: selectedModel?.id ?? 'default',
933
+ modelId: effectiveModelId,
934
messages: messagesForApi,
935
params: chatParams,
936
integrations: integrations,
@@ -1618,7 +1626,8 @@ class ChatNotifier extends Notifier<ChatState> {
1626
}
1627
1628
String? _resolveModelIdForTitle(Conversation? conversation) {
1621
- return conversation?.modelId ?? ref.read(selectedModelProvider)?.id;
1629
+ return conversation?.modelId ??
1630
+ ref.read(activeChatTargetProvider).effectiveModelId;
1631
}
1632
1633
ChatService? _resolveChatServiceForTitle(Server server) {
@@ -1658,9 +1667,7 @@ class ChatNotifier extends Notifier<ChatState> {
1667
}) {
1668
final modelId =
1669
selectedModel?.id ??
1661
- (server.type == ServerType.onDevice
1662
- ? ref.read(onDeviceEngineProvider).loadedModelId
1663
- : null);
1670
+ ref.read(activeChatTargetProvider).effectiveModelId;
1671
1672
unawaited(
1673
ref
@@ -2360,17 +2367,18 @@ class ChatNotifier extends Notifier<ChatState> {
2367
if (state.isStreaming) return;
2368
if (state.messages.isEmpty) return;
2369
2363
- final server = ref.read(activeServerProvider);
2364
- final selectedModel = ref.read(selectedModelProvider);
2370
+ final target = ref.read(activeChatTargetProvider);
2371
+ final server = target.server;
2372
+ final modelId = target.effectiveModelId;
2373
final chatService = ref.read(chatServiceProvider);
2366
- if (server == null || chatService == null || selectedModel == null) return;
2374
+ if (server == null || chatService == null || modelId == null) return;
2375
2376
final generated = await ref
2377
.read(smartReplyServiceProvider)
2378
.generateUserMessage(
2379
chatService: chatService,
2380
server: server,
2373
- modelId: selectedModel.id,
2381
+ modelId: modelId,
2382
messages: state.messages,
2383
params: ref.read(chatParamsProvider),
2384
);
lib/features/chat/providers/model_selection_providers.dart
+77
-2
@@ -9,14 +9,89 @@ import 'package:localmind/features/chat/providers/chat_reasoning_providers.dart'
9
import 'package:localmind/features/chat/providers/model_loading_providers.dart';
10
import 'package:localmind/features/models/data/models/model_info.dart';
11
import 'package:localmind/features/models/utils/model_instance_utils.dart';
12
+import 'package:localmind/features/on_device/data/models/on_device_model.dart';
13
import 'package:localmind/features/servers/data/models/server.dart';
14
import 'package:localmind/features/servers/providers/server_providers.dart';
15
import 'package:localmind/features/on_device/providers/on_device_providers.dart';
16
17
+const modelSelectionRequiredMessage = 'You need to select a model first';
18
+
19
+class ActiveChatTarget {
20
+ final Server? server;
21
+ final ModelInfo? selectedModel;
22
+ final String? effectiveModelId;
23
+ final String modelLabel;
24
+
25
+ const ActiveChatTarget({
26
+ required this.server,
27
+ required this.selectedModel,
28
+ required this.effectiveModelId,
29
+ required this.modelLabel,
30
+ });
31
+
32
+ bool get isReady => server != null && effectiveModelId != null;
33
+}
34
+
35
+/// The server/model pair that a message will actually use.
36
+///
37
+/// A loaded on-device model remains usable even before the model picker has
38
+/// recreated its [ModelInfo]. A native restored spec is deliberately excluded:
39
+/// it is metadata for lazy loading, not proof that a model is in memory.
40
+/// Remote servers retain their existing `default` model routing when no
41
+/// explicit model is selected.
42
+final activeChatTargetProvider = Provider<ActiveChatTarget>((ref) {
43
+ final server = ref.watch(activeServerProvider);
44
+ if (server == null) {
45
+ return const ActiveChatTarget(
46
+ server: null,
47
+ selectedModel: null,
48
+ effectiveModelId: null,
49
+ modelLabel: 'No model',
50
+ );
51
+ }
52
+
53
+ final selected = ref.watch(selectedModelProvider);
54
+ final selectedForServer = selected?.serverId == server.id ? selected : null;
55
+ if (selectedForServer != null) {
56
+ return ActiveChatTarget(
57
+ server: server,
58
+ selectedModel: selectedForServer,
59
+ effectiveModelId: selectedForServer.id,
60
+ modelLabel: selectedForServer.displayName,
61
+ );
62
+ }
63
+
64
+ if (server.type != ServerType.onDevice) {
65
+ return ActiveChatTarget(
66
+ server: server,
67
+ selectedModel: null,
68
+ effectiveModelId: 'default',
69
+ modelLabel: 'Default model',
70
+ );
71
+ }
72
+
73
+ final engine = ref.watch(onDeviceEngineProvider);
74
+ final modelId = engine.status == OnDeviceEngineStatus.loaded
75
+ ? engine.loadedModelId
76
+ : null;
77
+ final model = modelId == null
78
+ ? null
79
+ : OnDeviceModel.curatedModels
80
+ .where((candidate) => candidate.id == modelId)
81
+ .firstOrNull;
82
+
83
+ return ActiveChatTarget(
84
+ server: server,
85
+ selectedModel: null,
86
+ effectiveModelId: modelId,
87
+ modelLabel: model?.name ?? modelId ?? 'No model',
88
+ );
89
+});
90
+
91
final selectedModelProvider =
92
NotifierProvider<SelectedModelNotifier, ModelInfo?>(() {
18
- return SelectedModelNotifier();
19
-});
93
+ return SelectedModelNotifier();
94
+ });
95
96
class SelectedModelNotifier extends Notifier<ModelInfo?> {
97
@override
lib/features/chat/providers/smart_reply_providers.dart
+5
-5
@@ -5,7 +5,6 @@ import 'package:localmind/core/models/enums.dart';
5
import 'package:localmind/core/providers/app_providers.dart';
6
import 'package:localmind/features/conversations/providers/conversation_providers.dart'
7
as conv;
8
-import 'package:localmind/features/servers/providers/server_providers.dart';
8
import 'package:localmind/features/voice_mode/providers/voice_mode_provider.dart';
9
import 'chat_notifier.dart';
10
import 'chat_params_providers.dart';
@@ -40,17 +39,18 @@ final smartRepliesProvider = FutureProvider<List<String>>((ref) async {
39
List<String> suggestions = [];
40
41
if (settings.smartReplyEnabled) {
43
- final server = ref.watch(activeServerProvider);
44
- final selectedModel = ref.watch(selectedModelProvider);
42
+ final target = ref.watch(activeChatTargetProvider);
43
+ final server = target.server;
44
+ final modelId = target.effectiveModelId;
45
final chatParams = ref.watch(chatParamsProvider);
46
final chatService = ref.watch(chatServiceProvider);
47
48
- if (server != null && chatService != null && selectedModel != null) {
48
+ if (server != null && chatService != null && modelId != null) {
49
final service = ref.read(smartReplyServiceProvider);
50
suggestions = await service.suggestRepliesWithLLM(
51
chatService: chatService,
52
server: server,
53
- modelId: selectedModel.id,
53
+ modelId: modelId,
54
messages: messages,
55
params: chatParams,
56
personaSystemPrompt: settings.smartRepliesUsePersona
lib/features/chat/views/components/chat_bottom_bar.dart
+6
-5
@@ -5,7 +5,8 @@ import 'package:localmind/core/providers/app_providers.dart';
5
import 'package:localmind/features/chat/views/components/chat_input_bar.dart';
6
import 'package:localmind/features/chat/views/components/top_bar/smart_reply_chips.dart';
7
import 'package:localmind/features/chat/providers/chat_providers.dart';
8
-import 'package:localmind/features/conversations/providers/conversation_providers.dart' as conv;
8
+import 'package:localmind/features/conversations/providers/conversation_providers.dart'
9
+ as conv;
10
import 'package:localmind/features/models/views/model_picker_sheet.dart';
11
import 'package:localmind/l10n/app_localizations.dart';
12
@@ -109,11 +110,11 @@ class SmartReplyChipsWrapper extends ConsumerWidget {
110
Widget build(BuildContext context, WidgetRef ref) {
111
return SmartReplyChips(
112
onSend: (message) {
112
- if (ref.read(selectedModelProvider) == null) {
113
+ if (!ref.read(activeChatTargetProvider).isReady) {
114
final l10n = AppLocalizations.of(context)!;
114
- ScaffoldMessenger.of(context).showSnackBar(
115
- SnackBar(content: Text(l10n.model_required_toast)),
116
- );
115
+ ScaffoldMessenger.of(
116
+ context,
117
+ ).showSnackBar(SnackBar(content: Text(l10n.model_required_toast)));
118
showModalBottomSheet(
119
context: context,
120
isScrollControlled: true,
lib/features/chat/views/components/chat_input_bar.dart
+7
-18
@@ -25,7 +25,6 @@ import '../../../models/components/thinking_mode_chip.dart';
25
import 'attach_sheet.dart';
26
import 'image_preview_dialog.dart';
27
import 'model_chip.dart';
28
-import '../../../on_device/providers/on_device_providers.dart';
28
import '../../../voice_mode/views/voice_mode_overlay.dart';
29
30
class ChatInputBar extends ConsumerStatefulWidget {
@@ -363,16 +362,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
362
363
Widget _buildVoiceModeButton(ThemeData theme) {
364
final isDark = theme.brightness == Brightness.dark;
366
- final onDeviceLoaded = ref.watch(
367
- onDeviceEngineProvider.select(
368
- (s) => s.status == OnDeviceEngineStatus.loaded,
369
- ),
370
- );
371
- final connectionStatus = ref.watch(connectionStatusProvider);
372
- final isConnected = connectionStatus == ConnectionStatus.connected;
373
- final hasRemoteModel =
374
- isConnected && ref.watch(selectedModelProvider) != null;
375
- final modelReady = onDeviceLoaded || hasRemoteModel;
365
+ final modelReady = ref.watch(activeChatTargetProvider).isReady;
366
final l10n = AppLocalizations.of(context)!;
367
368
return Container(
@@ -443,7 +433,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
433
434
if (text.isEmpty && _attachedFiles.isEmpty) return;
435
446
- if (!_ensureModelSelected()) return;
436
+ if (!_ensureChatTarget()) return;
437
438
ref.read(appHapticsProvider).medium();
439
@@ -471,7 +461,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
461
final text = _controller.text.trim();
462
if (text.isEmpty && _attachedFiles.isEmpty) return;
463
474
- if (!_ensureModelSelected()) return;
464
+ if (!_ensureChatTarget()) return;
465
466
ref.read(appHapticsProvider).medium();
467
ref
@@ -489,11 +479,10 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
479
});
480
}
481
492
- /// Returns true if a model is currently selected. When no model is selected,
493
- /// shows a SnackBar prompting the user to pick one and opens the model
494
- /// picker sheet, then returns false so the caller can bail out.
495
- bool _ensureModelSelected() {
496
- if (ref.read(selectedModelProvider) != null) return true;
482
+ /// Returns true when the active server has a usable model target. When it
483
+ /// does not, prompts the user to choose an on-device model.
484
+ bool _ensureChatTarget() {
485
+ if (ref.read(activeChatTargetProvider).isReady) return true;
486
final l10n = AppLocalizations.of(context)!;
487
ScaffoldMessenger.of(
488
context,
lib/features/chat/views/components/message_list/message_area.dart
+3
-2
@@ -7,7 +7,8 @@ import 'package:localmind/core/services/share_service.dart';
7
import 'package:localmind/features/chat/data/models/message.dart';
8
import 'package:localmind/features/chat/providers/chat_providers.dart';
9
import 'package:localmind/features/conversations/data/models/conversation.dart';
10
-import 'package:localmind/features/conversations/providers/conversation_providers.dart' as conv;
10
+import 'package:localmind/features/conversations/providers/conversation_providers.dart'
11
+ as conv;
12
import 'package:localmind/features/models/views/model_picker_sheet.dart';
13
import 'package:localmind/features/personas/providers/personas_providers.dart';
14
import 'package:localmind/features/chat/views/components/model_info_sheet.dart';
@@ -70,7 +71,7 @@ class MessageArea extends ConsumerWidget {
71
return EmptyState(
72
isCloudProvider: isCloudProvider,
73
onQuickPrompt: (prompt) {
73
- if (ref.read(selectedModelProvider) == null) {
74
+ if (!ref.read(activeChatTargetProvider).isReady) {
75
final toastL10n = AppLocalizations.of(context)!;
76
ScaffoldMessenger.of(context).showSnackBar(
77
SnackBar(content: Text(toastL10n.model_required_toast)),
lib/features/chat/views/components/top_bar/screen_app_bar.dart
+179
-169
@@ -51,48 +51,51 @@ class ScreenAppBar extends ConsumerWidget {
51
top: true,
52
bottom: false,
53
child: Container(
54
- height: 56,
55
- padding: const EdgeInsets.symmetric(horizontal: 4),
56
- child: Row(
57
- children: [
58
- IconButton(
59
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedCancel01),
60
- onPressed: () =>
61
- ref.read(messageSelectionModeProvider.notifier).disable(),
62
- ),
63
- Expanded(
64
- child: Text(
65
- l10n.selected_count(selectedMessageIds.length),
66
- style: const TextStyle(
67
- fontSize: 16,
68
- fontWeight: FontWeight.w600,
54
+ height: 56,
55
+ padding: const EdgeInsets.symmetric(horizontal: 4),
56
+ child: Row(
57
+ children: [
58
+ IconButton(
59
+ icon: const HugeIcon(icon: HugeIcons.strokeRoundedCancel01),
60
+ onPressed: () =>
61
+ ref.read(messageSelectionModeProvider.notifier).disable(),
62
+ ),
63
+ Expanded(
64
+ child: Text(
65
+ l10n.selected_count(selectedMessageIds.length),
66
+ style: const TextStyle(
67
+ fontSize: 16,
68
+ fontWeight: FontWeight.w600,
69
+ ),
70
),
71
),
71
- ),
72
- IconButton(
73
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedShare01),
74
- tooltip: l10n.export_conversation,
75
- onPressed: selectedMessageIds.isEmpty
76
- ? null
77
- : () => _shareSelectedMessages(
78
- context,
79
- ref,
80
- selectedMessageIds,
81
- ),
82
- ),
83
- IconButton(
84
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedDelete01, color: Colors.red),
85
- tooltip: l10n.delete,
86
- onPressed: selectedMessageIds.isEmpty
87
- ? null
88
- : () => _deleteSelectedMessages(
89
- context,
90
- ref,
91
- selectedMessageIds,
92
- ),
93
- ),
94
- ],
95
- ),
72
+ IconButton(
73
+ icon: const HugeIcon(icon: HugeIcons.strokeRoundedShare01),
74
+ tooltip: l10n.export_conversation,
75
+ onPressed: selectedMessageIds.isEmpty
76
+ ? null
77
+ : () => _shareSelectedMessages(
78
+ context,
79
+ ref,
80
+ selectedMessageIds,
81
+ ),
82
+ ),
83
+ IconButton(
84
+ icon: const HugeIcon(
85
+ icon: HugeIcons.strokeRoundedDelete01,
86
+ color: Colors.red,
87
+ ),
88
+ tooltip: l10n.delete,
89
+ onPressed: selectedMessageIds.isEmpty
90
+ ? null
91
+ : () => _deleteSelectedMessages(
92
+ context,
93
+ ref,
94
+ selectedMessageIds,
95
+ ),
96
+ ),
97
+ ],
98
+ ),
99
),
100
);
101
}
@@ -101,136 +104,145 @@ class ScreenAppBar extends ConsumerWidget {
104
top: true,
105
bottom: false,
106
child: Container(
104
- height: 56,
105
- padding: const EdgeInsets.symmetric(horizontal: 4),
106
- child: Row(
107
- children: [
108
- ShadResponsiveBuilder(
109
- builder: (context, breakpoint) {
110
- final isDesktop =
111
- breakpoint >= ShadTheme.of(context).breakpoints.md;
112
- if (isDesktop) return const SizedBox.shrink();
113
- return IconButton(
114
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedMenu01),
115
- onPressed: () => Scaffold.of(context).openDrawer(),
116
- );
117
- },
118
- ),
119
- const SizedBox(width: 8),
120
- Expanded(
121
- child: _TitleBlock(
122
- title: _appBarTitle(l10n),
123
- isDark: isDark,
107
+ height: 56,
108
+ padding: const EdgeInsets.symmetric(horizontal: 4),
109
+ child: Row(
110
+ children: [
111
+ ShadResponsiveBuilder(
112
+ builder: (context, breakpoint) {
113
+ final isDesktop =
114
+ breakpoint >= ShadTheme.of(context).breakpoints.md;
115
+ if (isDesktop) return const SizedBox.shrink();
116
+ return IconButton(
117
+ icon: const HugeIcon(icon: HugeIcons.strokeRoundedMenu01),
118
+ onPressed: () => Scaffold.of(context).openDrawer(),
119
+ );
120
+ },
121
),
125
- ),
126
- Stack(
127
- children: [
128
- IconButton(
129
- icon: HugeIcon(
130
- icon: HugeIcons.strokeRoundedFilterHorizontal,
131
- size: 24,
132
- color: isDark ? Colors.white70 : Colors.black87,
133
- ),
134
- onPressed: () =>
135
- showChatSettingsSheet(context, initialTab: 'parameters'),
136
- tooltip: l10n.chat_parameters_tooltip,
137
- ),
138
- PositionedDirectional(
139
- top: 4,
140
- end: 0,
141
- child: Container(
142
- padding: const EdgeInsets.all(2),
143
- decoration: BoxDecoration(
144
- color: isMcpEnabled ? Colors.green : Colors.grey,
145
- shape: BoxShape.circle,
146
- ),
147
- child: const HugeIcon(
148
- icon: HugeIcons.strokeRoundedTools,
149
- size: 10,
150
- color: Colors.white,
122
+ const SizedBox(width: 8),
123
+ Expanded(
124
+ child: _TitleBlock(title: _appBarTitle(l10n), isDark: isDark),
125
+ ),
126
+ Stack(
127
+ children: [
128
+ IconButton(
129
+ icon: HugeIcon(
130
+ icon: HugeIcons.strokeRoundedFilterHorizontal,
131
+ size: 24,
132
+ color: isDark ? Colors.white70 : Colors.black87,
133
),
134
+ onPressed: () =>
135
+ showChatSettingsSheet(context, initialTab: 'parameters'),
136
+ tooltip: l10n.chat_parameters_tooltip,
137
),
153
- ),
154
- ],
155
- ),
156
- ChatModeIconButton(
157
- hasMessages: hasMessages,
158
- isTemporary: isTemporary,
159
- isDark: isDark,
160
- onPressed: onChatModeAction,
161
- ),
162
- PopupMenuButton<String>(
163
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedMoreVertical),
164
- onSelected: onMenuAction,
165
- itemBuilder: (context) => [
166
- PopupMenuItem(
167
- value: 'new_chat',
168
- child: ListTile(
169
- leading: const HugeIcon(icon: HugeIcons.strokeRoundedAdd01),
170
- title: Text(l10n.nav_new_chat),
171
- contentPadding: EdgeInsets.zero,
172
- ),
173
- ),
174
- if (activeConversation != null && !isTemporary) ...[
175
- PopupMenuItem(
176
- value: 'rename',
177
- child: ListTile(
178
- leading: const HugeIcon(icon: HugeIcons.strokeRoundedPencilEdit02),
179
- title: Text(l10n.rename_conversation),
180
- contentPadding: EdgeInsets.zero,
138
+ PositionedDirectional(
139
+ top: 4,
140
+ end: 0,
141
+ child: Container(
142
+ padding: const EdgeInsets.all(2),
143
+ decoration: BoxDecoration(
144
+ color: isMcpEnabled ? Colors.green : Colors.grey,
145
+ shape: BoxShape.circle,
146
+ ),
147
+ child: const HugeIcon(
148
+ icon: HugeIcons.strokeRoundedTools,
149
+ size: 10,
150
+ color: Colors.white,
151
+ ),
152
),
153
),
154
+ ],
155
+ ),
156
+ ChatModeIconButton(
157
+ hasMessages: hasMessages,
158
+ isTemporary: isTemporary,
159
+ isDark: isDark,
160
+ onPressed: onChatModeAction,
161
+ ),
162
+ PopupMenuButton<String>(
163
+ icon: const HugeIcon(icon: HugeIcons.strokeRoundedMoreVertical),
164
+ onSelected: onMenuAction,
165
+ itemBuilder: (context) => [
166
PopupMenuItem(
184
- value: 'move_to_folder',
167
+ value: 'new_chat',
168
child: ListTile(
186
- leading: const HugeIcon(icon: HugeIcons.strokeRoundedFolder01),
187
- title: Text(l10n.move_to_folder),
169
+ leading: const HugeIcon(icon: HugeIcons.strokeRoundedAdd01),
170
+ title: Text(l10n.nav_new_chat),
171
contentPadding: EdgeInsets.zero,
172
),
173
),
191
- ],
192
- if (hasMessages) ...[
174
+ if (activeConversation != null && !isTemporary) ...[
175
+ PopupMenuItem(
176
+ value: 'rename',
177
+ child: ListTile(
178
+ leading: const HugeIcon(
179
+ icon: HugeIcons.strokeRoundedPencilEdit02,
180
+ ),
181
+ title: Text(l10n.rename_conversation),
182
+ contentPadding: EdgeInsets.zero,
183
+ ),
184
+ ),
185
+ PopupMenuItem(
186
+ value: 'move_to_folder',
187
+ child: ListTile(
188
+ leading: const HugeIcon(
189
+ icon: HugeIcons.strokeRoundedFolder01,
190
+ ),
191
+ title: Text(l10n.move_to_folder),
192
+ contentPadding: EdgeInsets.zero,
193
+ ),
194
+ ),
195
+ ],
196
+ if (hasMessages) ...[
197
+ PopupMenuItem(
198
+ value: 'export_chat',
199
+ child: ListTile(
200
+ leading: const HugeIcon(
201
+ icon: HugeIcons.strokeRoundedUpload01,
202
+ ),
203
+ title: Text(l10n.export_conversation),
204
+ contentPadding: EdgeInsets.zero,
205
+ ),
206
+ ),
207
+ PopupMenuItem(
208
+ value: 'share_chat',
209
+ child: ListTile(
210
+ leading: const HugeIcon(
211
+ icon: HugeIcons.strokeRoundedShare01,
212
+ ),
213
+ title: Text(l10n.share_conversation),
214
+ contentPadding: EdgeInsets.zero,
215
+ ),
216
+ ),
217
+ ],
218
PopupMenuItem(
194
- value: 'export_chat',
219
+ value: 'persona',
220
child: ListTile(
196
- leading: const HugeIcon(icon: HugeIcons.strokeRoundedUpload01),
197
- title: Text(l10n.export_conversation),
221
+ leading: HugeIcon(
222
+ icon: hasPersonas
223
+ ? HugeIcons.strokeRoundedExchange01
224
+ : HugeIcons.strokeRoundedRobot01,
225
+ ),
226
+ title: Text(
227
+ hasPersonas ? l10n.change_persona : l10n.set_persona,
228
+ ),
229
contentPadding: EdgeInsets.zero,
230
),
231
),
232
PopupMenuItem(
202
- value: 'share_chat',
233
+ value: 'clear',
234
child: ListTile(
204
- leading: const HugeIcon(icon: HugeIcons.strokeRoundedShare01),
205
- title: Text(l10n.share_conversation),
235
+ leading: const HugeIcon(
236
+ icon: HugeIcons.strokeRoundedDelete01,
237
+ ),
238
+ title: Text(l10n.clear_conversation),
239
contentPadding: EdgeInsets.zero,
240
),
241
),
242
],
210
- PopupMenuItem(
211
- value: 'persona',
212
- child: ListTile(
213
- leading: HugeIcon(icon:
214
- hasPersonas ? HugeIcons.strokeRoundedExchange01 : HugeIcons.strokeRoundedRobot01,
215
- ),
216
- title: Text(
217
- hasPersonas ? l10n.change_persona : l10n.set_persona,
218
- ),
219
- contentPadding: EdgeInsets.zero,
220
- ),
221
- ),
222
- PopupMenuItem(
223
- value: 'clear',
224
- child: ListTile(
225
- leading: const HugeIcon(icon: HugeIcons.strokeRoundedDelete01),
226
- title: Text(l10n.clear_conversation),
227
- contentPadding: EdgeInsets.zero,
228
- ),
229
- ),
230
- ],
231
- ),
232
- ],
233
- ),
243
+ ),
244
+ ],
245
+ ),
246
),
247
);
248
}
@@ -337,17 +349,25 @@ class ChatModeIconButton extends StatelessWidget {
349
color: activeColor.withValues(alpha: 0.25),
350
shape: BoxShape.circle,
351
),
340
- child: HugeIcon(icon: HugeIcons.strokeRoundedIncognito, size: 20, color: activeColor),
352
+ child: HugeIcon(
353
+ icon: HugeIcons.strokeRoundedIncognito,
354
+ size: 20,
355
+ color: activeColor,
356
+ ),
357
)
342
- : HugeIcon(icon: HugeIcons.strokeRoundedIncognito, size: 22, color: inactiveColor),
358
+ : HugeIcon(
359
+ icon: HugeIcons.strokeRoundedIncognito,
360
+ size: 22,
361
+ color: inactiveColor,
362
+ ),
363
);
364
}
365
366
return IconButton(
367
onPressed: onPressed,
368
tooltip: l10n.nav_new_chat,
349
- icon: HugeIcon(icon:
350
- HugeIcons.strokeRoundedMessageAdd01,
369
+ icon: HugeIcon(
370
+ icon: HugeIcons.strokeRoundedMessageAdd01,
371
size: 22,
372
color: isDark ? Colors.white70 : Colors.black87,
373
),
@@ -355,15 +375,11 @@ class ChatModeIconButton extends StatelessWidget {
375
}
376
}
377
358
-/// Title block shown in the chat app bar — conversation title on top,
359
-/// active server name directly below it with a small status dot
360
-/// (green when connected, grey otherwise). The row is intentionally
361
-/// non-interactive; server switching still lives in the sidebar.
378
+/// Title block shown in the chat app bar — conversation title on top and the
379
+/// active server name below. Model selection is already visible and editable
380
+/// from the model picker near the composer.
381
class _TitleBlock extends ConsumerWidget {
363
- const _TitleBlock({
364
- required this.title,
365
- required this.isDark,
366
- });
382
+ const _TitleBlock({required this.title, required this.isDark});
383
384
final String title;
385
final bool isDark;
@@ -386,10 +402,7 @@ class _TitleBlock extends ConsumerWidget {
402
children: [
403
AutoSizeText(
404
title,
389
- style: const TextStyle(
390
- fontSize: 17,
391
- fontWeight: FontWeight.bold,
392
- ),
405
+ style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
406
maxLines: 1,
407
overflow: TextOverflow.ellipsis,
408
),
@@ -417,10 +430,7 @@ class _TitleBlock extends ConsumerWidget {
430
],
431
)
432
else
420
- Text(
421
- '',
422
- style: TextStyle(fontSize: 11, color: mutedColor),
423
- ),
433
+ Text('', style: TextStyle(fontSize: 11, color: mutedColor)),
434
],
435
),
436
);
lib/features/models/components/model_list.dart
+38
-22
@@ -23,12 +23,14 @@ class ModelList extends ConsumerStatefulWidget {
23
required this.selectedModelId,
24
required this.isDark,
25
this.scrollController,
26
+ this.closeOnSelect = true,
27
});
28
29
final String serverId;
30
final String? selectedModelId;
31
final bool isDark;
32
final ScrollController? scrollController;
33
+ final bool closeOnSelect;
34
35
@override
36
ConsumerState<ModelList> createState() => _ModelListState();
@@ -98,10 +100,7 @@ class _ModelListState extends ConsumerState<ModelList> {
100
return sorted;
101
}
102
101
- Future<void> _showModelOptions(
102
- BuildContext context,
103
- ModelInfo model,
104
- ) async {
103
+ Future<void> _showModelOptions(BuildContext context, ModelInfo model) async {
104
final l10n = AppLocalizations.of(context)!;
105
final metadata = ref.read(modelMetadataProvider)[model.id];
106
final noteController = TextEditingController(text: metadata?.note ?? '');
@@ -113,7 +112,7 @@ class _ModelListState extends ConsumerState<ModelList> {
112
final settings = ref.read(settingsProvider);
113
final isDefaultModel =
114
settings.defaultModelServerId == widget.serverId &&
116
- settings.defaultModelId == model.id;
115
+ settings.defaultModelId == model.id;
116
117
return SafeArea(
118
child: Padding(
@@ -163,8 +162,8 @@ class _ModelListState extends ConsumerState<ModelList> {
162
),
163
ListTile(
164
contentPadding: EdgeInsets.zero,
166
- leading: HugeIcon(icon:
167
- metadata?.isFavorite == true
165
+ leading: HugeIcon(
166
+ icon: metadata?.isFavorite == true
167
? HugeIcons.strokeRoundedStar
168
: HugeIcons.strokeRoundedStar,
169
color: Colors.amber[700],
@@ -231,7 +230,11 @@ class _ModelListState extends ConsumerState<ModelList> {
230
child: Column(
231
mainAxisAlignment: MainAxisAlignment.center,
232
children: [
234
- HugeIcon(icon: HugeIcons.strokeRoundedCloud, size: 64, color: Colors.red[300]),
233
+ HugeIcon(
234
+ icon: HugeIcons.strokeRoundedCloud,
235
+ size: 64,
236
+ color: Colors.red[300],
237
+ ),
238
const SizedBox(height: 16),
239
Text(
240
l10n.server_offline,
@@ -246,7 +249,9 @@ class _ModelListState extends ConsumerState<ModelList> {
249
l10n.could_not_establish_connection,
250
style: TextStyle(
251
fontSize: 13,
249
- color: widget.isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
252
+ color: widget.isDark
253
+ ? AppColors.darkMutedText
254
+ : AppColors.lightMutedText,
255
),
256
textAlign: TextAlign.center,
257
),
@@ -277,7 +282,11 @@ class _ModelListState extends ConsumerState<ModelList> {
282
child: Column(
283
mainAxisAlignment: MainAxisAlignment.center,
284
children: [
280
- HugeIcon(icon: HugeIcons.strokeRoundedInformationCircle, size: 48, color: Colors.red[400]),
285
+ HugeIcon(
286
+ icon: HugeIcons.strokeRoundedInformationCircle,
287
+ size: 48,
288
+ color: Colors.red[400],
289
+ ),
290
const SizedBox(height: 16),
291
Text(
292
l10n.failed_load_models,
@@ -292,7 +301,9 @@ class _ModelListState extends ConsumerState<ModelList> {
301
err.toString(),
302
style: TextStyle(
303
fontSize: 12,
295
- color: widget.isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
304
+ color: widget.isDark
305
+ ? AppColors.darkMutedText
306
+ : AppColors.lightMutedText,
307
),
308
textAlign: TextAlign.center,
309
),
@@ -309,8 +320,9 @@ class _ModelListState extends ConsumerState<ModelList> {
320
data: (models) {
321
final serversAsync = ref.watch(serversProvider);
322
final servers = serversAsync.value ?? [];
312
- final activeServer =
313
- servers.where((s) => s.id == widget.serverId).firstOrNull;
323
+ final activeServer = servers
324
+ .where((s) => s.id == widget.serverId)
325
+ .firstOrNull;
326
final loadedModelsAsync = activeServer != null
327
? ref.watch(loadedModelsProvider(activeServer))
328
: const AsyncValue<Set<String>>.data(<String>{});
@@ -344,7 +356,9 @@ class _ModelListState extends ConsumerState<ModelList> {
356
: l10n.no_models_match(searchQuery),
357
style: TextStyle(
358
fontSize: 14,
347
- color: widget.isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
359
+ color: widget.isDark
360
+ ? AppColors.darkMutedText
361
+ : AppColors.lightMutedText,
362
),
363
),
364
);
@@ -364,7 +378,8 @@ class _ModelListState extends ConsumerState<ModelList> {
378
isSelected: isSelected,
379
isLoaded: isLoaded,
380
isDark: widget.isDark,
367
- isLoading: modelLoading.isLoading && modelLoading.modelId == model.id,
381
+ isLoading:
382
+ modelLoading.isLoading && modelLoading.modelId == model.id,
383
isFavorite: modelMeta?.isFavorite ?? false,
384
isDefault: ref.watch(
385
settingsProvider.select(
@@ -387,8 +402,9 @@ class _ModelListState extends ConsumerState<ModelList> {
402
try {
403
final apiService = ref.read(serverApiServiceProvider);
404
final settings = ref.read(settingsProvider);
390
- final contextLength =
391
- ref.read(chatParamsProvider).contextLength;
405
+ final contextLength = ref
406
+ .read(chatParamsProvider)
407
+ .contextLength;
408
409
if (settings.unloadModelsBeforeLoad) {
410
final instances = await ref.read(
@@ -423,7 +439,7 @@ class _ModelListState extends ConsumerState<ModelList> {
439
}
440
441
ref.read(selectedModelProvider.notifier).setModel(model);
426
- if (context.mounted) {
442
+ if (widget.closeOnSelect && context.mounted) {
443
Navigator.pop(context);
444
}
445
},
@@ -452,9 +468,9 @@ class _ModelListState extends ConsumerState<ModelList> {
468
final message = activeServer.isOllamaFamily
469
? l10n.model_unloaded_ollama(model.name)
470
: l10n.model_unloaded_success(model.name);
455
- ScaffoldMessenger.of(context).showSnackBar(
456
- SnackBar(content: Text(message)),
457
- );
471
+ ScaffoldMessenger.of(
472
+ context,
473
+ ).showSnackBar(SnackBar(content: Text(message)));
474
}
475
} catch (e) {
476
if (context.mounted) {
@@ -473,4 +489,4 @@ class _ModelListState extends ConsumerState<ModelList> {
489
},
490
);
491
}
476
-}
\ No newline at end of file
492
+}
lib/features/on_device/components/on_device_picker_section.dart
+30
-16
@@ -28,11 +28,13 @@ class OnDevicePickerSection extends ConsumerWidget {
28
required this.selectedModelId,
29
required this.isDark,
30
this.scrollController,
31
+ this.closeOnSelect = true,
32
});
33
34
final String? selectedModelId;
35
final bool isDark;
36
final ScrollController? scrollController;
37
+ final bool closeOnSelect;
38
39
@override
40
Widget build(BuildContext context, WidgetRef ref) {
@@ -49,7 +51,11 @@ class OnDevicePickerSection extends ConsumerWidget {
51
child: Column(
52
mainAxisAlignment: MainAxisAlignment.center,
53
children: [
52
- HugeIcon(icon: HugeIcons.strokeRoundedInformationCircle, size: 48, color: Colors.red[400]),
54
+ HugeIcon(
55
+ icon: HugeIcons.strokeRoundedInformationCircle,
56
+ size: 48,
57
+ color: Colors.red[400],
58
+ ),
59
const SizedBox(height: 16),
60
Text(
61
l10n.failed_load_models,
@@ -139,6 +145,7 @@ class OnDevicePickerSection extends ConsumerWidget {
145
isSelected: isSelected,
146
isDark: isDark,
147
deviceMemory: deviceMemoryAsync.value,
148
+ closeOnSelect: closeOnSelect,
149
);
150
}
151
@@ -204,6 +211,7 @@ class _OnDeviceModelTile extends ConsumerWidget {
211
required this.isCurrentlyLoading,
212
required this.isSelected,
213
required this.isDark,
214
+ required this.closeOnSelect,
215
this.deviceMemory,
216
});
217
@@ -213,6 +221,7 @@ class _OnDeviceModelTile extends ConsumerWidget {
221
final bool isCurrentlyLoading;
222
final bool isSelected;
223
final bool isDark;
224
+ final bool closeOnSelect;
225
final DeviceMemoryInfo? deviceMemory;
226
227
@override
@@ -327,8 +336,8 @@ class _OnDeviceModelTile extends ConsumerWidget {
336
padding: const EdgeInsets.only(top: 2),
337
child: Row(
338
children: [
330
- const HugeIcon(icon:
331
- HugeIcons.strokeRoundedAlertCircle,
339
+ const HugeIcon(
340
+ icon: HugeIcons.strokeRoundedAlertCircle,
341
color: Colors.orange,
342
size: 12,
343
),
@@ -360,7 +369,8 @@ class _OnDeviceModelTile extends ConsumerWidget {
369
Text(
370
isPaused
371
? l10n.paused_progress(
363
- ((downloadProgress?.progress ?? 0) * 100)
372
+ ((downloadProgress?.progress ?? 0) *
373
+ 100)
374
.toStringAsFixed(0),
375
)
376
: '${l10n.downloading_status} ${((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0)}%',
@@ -425,8 +435,8 @@ class _OnDeviceModelTile extends ConsumerWidget {
435
),
436
const SizedBox(width: 8),
437
_IconButton(
428
- icon: HugeIcon(icon:
429
- HugeIcons.strokeRoundedPower,
438
+ icon: HugeIcon(
439
+ icon: HugeIcons.strokeRoundedPower,
440
size: 18,
441
color: Colors.red[400],
442
),
@@ -435,14 +445,18 @@ class _OnDeviceModelTile extends ConsumerWidget {
445
),
446
] else if (isDownloaded && !isCurrentlyLoading) ...[
447
_IconButton(
438
- icon: HugeIcon(icon: HugeIcons.strokeRoundedPlay, size: 20, color: accent),
448
+ icon: HugeIcon(
449
+ icon: HugeIcons.strokeRoundedPlay,
450
+ size: 20,
451
+ color: accent,
452
+ ),
453
tooltip: l10n.load,
454
onPressed: () => _loadModel(context, ref),
455
),
456
const SizedBox(width: 4),
457
_IconButton(
444
- icon: HugeIcon(icon:
445
- HugeIcons.strokeRoundedDelete01,
458
+ icon: HugeIcon(
459
+ icon: HugeIcons.strokeRoundedDelete01,
460
size: 18,
461
color: isDark
462
? AppColors.darkMutedText
@@ -453,8 +467,8 @@ class _OnDeviceModelTile extends ConsumerWidget {
467
),
468
] else if (isDownloading) ...[
469
_IconButton(
456
- icon: HugeIcon(icon:
457
- HugeIcons.strokeRoundedCancel01,
470
+ icon: HugeIcon(
471
+ icon: HugeIcons.strokeRoundedCancel01,
472
size: 16,
473
color: isDark
474
? AppColors.darkMutedText
@@ -467,8 +481,8 @@ class _OnDeviceModelTile extends ConsumerWidget {
481
),
482
] else if (!isCurrentlyLoading) ...[
483
_IconButton(
470
- icon: HugeIcon(icon:
471
- HugeIcons.strokeRoundedCloudDownload,
484
+ icon: HugeIcon(
485
+ icon: HugeIcons.strokeRoundedCloudDownload,
486
size: 18,
487
color: isDark
488
? AppColors.darkMutedText
@@ -583,7 +597,7 @@ class _OnDeviceModelTile extends ConsumerWidget {
597
final engineState = ref.read(onDeviceEngineProvider);
598
if (engineState.loadedModelId == model.id) {
599
ref.read(selectedModelProvider.notifier).setModel(modelInfo);
586
- Navigator.pop(context);
600
+ if (closeOnSelect) Navigator.pop(context);
601
}
602
}
603
@@ -623,7 +637,7 @@ class _OnDeviceModelTile extends ConsumerWidget {
637
);
638
639
ref.read(selectedModelProvider.notifier).setModel(modelInfo);
626
- Navigator.pop(context);
640
+ if (closeOnSelect) Navigator.pop(context);
641
}
642
643
void _unloadModel(BuildContext context, WidgetRef ref) async {
@@ -699,4 +713,4 @@ class _IconButton extends StatelessWidget {
713
),
714
);
715
}
702
-}
\ No newline at end of file
716
+}
lib/features/on_device/providers/on_device_providers.dart
+3
-2
@@ -306,8 +306,9 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
306
final activeServer = ref.read(activeServerProvider);
307
if (activeServer?.type != ServerType.onDevice) {
308
final servers = await ref.read(serversProvider.future);
309
- final onDeviceServer =
310
- servers.where((s) => s.type == ServerType.onDevice).firstOrNull;
309
+ final onDeviceServer = servers
310
+ .where((s) => s.type == ServerType.onDevice)
311
+ .firstOrNull;
312
if (onDeviceServer != null) {
313
ref
314
.read(activeServerIdProvider.notifier)
lib/features/voice_mode/providers/voice_mode_provider.dart
+41
-11
@@ -106,18 +106,28 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
106
(previous, next) => _onTtsStateChanged(previous, next),
107
);
108
109
+ // A server or genuinely loaded model can become available while the voice
110
+ // overlay is open. Resume automatically instead of leaving the user on a
111
+ // stale selection error.
112
+ ref.listen<ActiveChatTarget>(activeChatTargetProvider, (previous, next) {
113
+ final waitingForTarget =
114
+ state.phase == VoiceModePhase.error &&
115
+ (state.error == modelSelectionRequiredMessage ||
116
+ state.error == 'No server connected');
117
+ if (_active && waitingForTarget && next.isReady) {
118
+ unawaited(startListening());
119
+ }
120
+ });
121
+
122
// Listen to STT errors and show them directly on the voice screen.
110
- ref.listen<String?>(
111
- sttProvider.select((s) => s.error),
112
- (previous, next) {
113
- if (!_active || next == null) return;
114
- state = state.copyWith(
115
- phase: VoiceModePhase.error,
116
- error: _mapSttError(next),
117
- );
118
- ref.read(voiceFeedbackProvider).playDisconnected();
119
- },
120
- );
123
+ ref.listen<String?>(sttProvider.select((s) => s.error), (previous, next) {
124
+ if (!_active || next == null) return;
125
+ state = state.copyWith(
126
+ phase: VoiceModePhase.error,
127
+ error: _mapSttError(next),
128
+ );
129
+ ref.read(voiceFeedbackProvider).playDisconnected();
130
+ });
131
132
return const VoiceModeState();
133
}
@@ -131,6 +141,7 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
141
_active = true;
142
_generatingFired = false;
143
state = const VoiceModeState(isActive: true, phase: VoiceModePhase.idle);
144
+ if (!_ensureChatTarget()) return;
145
ref.read(voiceFeedbackProvider).playConnected();
146
// Auto-start listening.
147
startListening();
@@ -139,6 +150,7 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
150
/// Start listening for user speech.
151
Future<void> startListening() async {
152
if (!_active) return;
153
+ if (!_ensureChatTarget()) return;
154
155
_isSendingTranscript = false;
156
state = state.copyWith(
@@ -198,6 +210,8 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
210
return;
211
}
212
213
+ if (!_ensureChatTarget()) return;
214
+
215
// Reset the mic level so the waveform visualizer doesn't keep
216
// dancing on a stale value once we leave the listening phase.
217
state = state.copyWith(
@@ -359,6 +373,22 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
373
state = state.copyWith(phase: VoiceModePhase.idle);
374
}
375
}
376
+
377
+ bool _ensureChatTarget() {
378
+ final target = ref.read(activeChatTargetProvider);
379
+ if (target.isReady) return true;
380
+
381
+ _isSendingTranscript = false;
382
+ state = state.copyWith(
383
+ isActive: true,
384
+ phase: VoiceModePhase.error,
385
+ error: target.server == null
386
+ ? 'No server connected'
387
+ : modelSelectionRequiredMessage,
388
+ micLevel: 0,
389
+ );
390
+ return false;
391
+ }
392
}
393
394
String _mapSttError(String error) {
lib/features/voice_mode/views/voice_mode_overlay.dart
+248
-24
@@ -9,7 +9,12 @@ import 'package:hugeicons/hugeicons.dart';
9
import '../../../../core/models/enums.dart';
10
import '../../../../core/providers/app_providers.dart';
11
import '../../../../core/services/app_haptics.dart';
12
+import '../../../../l10n/app_localizations.dart';
13
import '../../chat/providers/chat_notifier.dart';
14
+import '../../chat/providers/model_selection_providers.dart';
15
+import '../../models/components/model_list.dart';
16
+import '../../on_device/components/on_device_picker_section.dart';
17
+import '../../servers/providers/server_providers.dart';
18
import '../../tts/providers/tts_providers.dart';
19
import '../../tts/views/tts_model_manager_screen.dart';
20
import '../providers/voice_mode_provider.dart';
@@ -182,6 +187,12 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
187
225.0,
188
255.0,
189
);
190
+ final target = ref.watch(activeChatTargetProvider);
191
+ final showInlineModelPicker = target.server != null && !target.isReady;
192
+ final modelPickerHeight = (MediaQuery.sizeOf(context).height / 3).clamp(
193
+ 220.0,
194
+ 380.0,
195
+ );
196
197
return PopScope(
198
canPop: false,
@@ -257,32 +268,81 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
268
],
269
),
270
),
260
-
261
- const Spacer(flex: 2),
262
-
263
- GestureDetector(
264
- onTap: _handleCenterAction,
265
- child: VoiceVisualizer(
266
- phase: state.phase,
267
- micLevel: state.micLevel,
268
- responseProgress: responseProgress,
269
- size: orbSize,
271
+ if (showInlineModelPicker) ...[
272
+ Expanded(
273
+ child: LayoutBuilder(
274
+ builder: (context, constraints) {
275
+ final compactOrbSize =
276
+ (constraints.maxHeight * 0.52).clamp(
277
+ 110.0,
278
+ orbSize,
279
+ );
280
+ return Column(
281
+ mainAxisAlignment: MainAxisAlignment.center,
282
+ children: [
283
+ GestureDetector(
284
+ onTap: _handleCenterAction,
285
+ child: VoiceVisualizer(
286
+ phase: state.phase,
287
+ micLevel: state.micLevel,
288
+ responseProgress: responseProgress,
289
+ size: compactOrbSize,
290
+ ),
291
+ ),
292
+ const SizedBox(height: 12),
293
+ Flexible(
294
+ child: Padding(
295
+ padding: const EdgeInsets.symmetric(
296
+ horizontal: 32,
297
+ ),
298
+ child: _StreamingTranscript(
299
+ phase: state.phase,
300
+ transcript: state.transcript,
301
+ response: state.response,
302
+ error: state.error,
303
+ ),
304
+ ),
305
+ ),
306
+ ],
307
+ );
308
+ },
309
+ ),
310
),
271
- ),
272
-
273
- const SizedBox(height: 28),
274
-
275
- Padding(
276
- padding: const EdgeInsets.symmetric(horizontal: 32),
277
- child: _StreamingTranscript(
278
- phase: state.phase,
279
- transcript: state.transcript,
280
- response: state.response,
281
- error: state.error,
311
+ Padding(
312
+ padding: const EdgeInsets.symmetric(horizontal: 20),
313
+ child: SizedBox(
314
+ height: modelPickerHeight,
315
+ child: const VoiceModelPickerPanel(),
316
+ ),
317
),
283
- ),
284
-
285
- const Spacer(flex: 3),
318
+ const SizedBox(height: 10),
319
+ ] else ...[
320
+ const Spacer(flex: 2),
321
+ GestureDetector(
322
+ onTap: _handleCenterAction,
323
+ child: VoiceVisualizer(
324
+ phase: state.phase,
325
+ micLevel: state.micLevel,
326
+ responseProgress: responseProgress,
327
+ size: orbSize,
328
+ ),
329
+ ),
330
+ const SizedBox(height: 28),
331
+ Padding(
332
+ padding: const EdgeInsets.symmetric(horizontal: 32),
333
+ child: _StreamingTranscript(
334
+ phase: state.phase,
335
+ transcript: state.transcript,
336
+ response: state.response,
337
+ error: state.error,
338
+ ),
339
+ ),
340
+ const Spacer(flex: 3),
341
+ const Padding(
342
+ padding: EdgeInsets.fromLTRB(20, 0, 20, 12),
343
+ child: Center(child: VoiceTargetChip()),
344
+ ),
345
+ ],
346
347
VoiceModeControls(
348
phase: state.phase,
@@ -313,6 +373,170 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
373
}
374
}
375
376
+class VoiceModelPickerPanel extends ConsumerWidget {
377
+ const VoiceModelPickerPanel({super.key});
378
+
379
+ @override
380
+ Widget build(BuildContext context, WidgetRef ref) {
381
+ final l10n = AppLocalizations.of(context)!;
382
+ final server = ref.watch(activeServerProvider);
383
+ if (server == null) return const SizedBox.shrink();
384
+
385
+ final selectedModel = ref.watch(selectedModelProvider);
386
+ final isDark = Theme.of(context).brightness == Brightness.dark;
387
+ final foreground = isDark ? Colors.white : const Color(0xFF25243A);
388
+ final surface = isDark
389
+ ? const Color(0xFF151720).withValues(alpha: 0.94)
390
+ : Colors.white.withValues(alpha: 0.88);
391
+
392
+ return Semantics(
393
+ container: true,
394
+ label: '${l10n.select_model_title}, ${server.name}',
395
+ child: Container(
396
+ key: const ValueKey('voice-model-picker-panel'),
397
+ padding: const EdgeInsets.fromLTRB(14, 12, 14, 8),
398
+ decoration: BoxDecoration(
399
+ color: surface,
400
+ borderRadius: BorderRadius.circular(22),
401
+ border: Border.all(color: foreground.withValues(alpha: 0.10)),
402
+ boxShadow: [
403
+ BoxShadow(
404
+ color: Colors.black.withValues(alpha: isDark ? 0.22 : 0.07),
405
+ blurRadius: 22,
406
+ offset: const Offset(0, 8),
407
+ ),
408
+ ],
409
+ ),
410
+ child: Column(
411
+ crossAxisAlignment: CrossAxisAlignment.start,
412
+ children: [
413
+ Row(
414
+ children: [
415
+ Icon(
416
+ Icons.memory_rounded,
417
+ size: 16,
418
+ color: foreground.withValues(alpha: 0.62),
419
+ ),
420
+ const SizedBox(width: 7),
421
+ Expanded(
422
+ child: Text(
423
+ l10n.select_model_title,
424
+ maxLines: 1,
425
+ overflow: TextOverflow.ellipsis,
426
+ style: TextStyle(
427
+ fontSize: 14,
428
+ fontWeight: FontWeight.w600,
429
+ color: foreground.withValues(alpha: 0.86),
430
+ ),
431
+ ),
432
+ ),
433
+ Text(
434
+ server.name,
435
+ maxLines: 1,
436
+ overflow: TextOverflow.ellipsis,
437
+ style: TextStyle(
438
+ fontSize: 11,
439
+ color: foreground.withValues(alpha: 0.48),
440
+ ),
441
+ ),
442
+ ],
443
+ ),
444
+ const SizedBox(height: 8),
445
+ Expanded(
446
+ child: server.type == ServerType.onDevice
447
+ ? OnDevicePickerSection(
448
+ selectedModelId: selectedModel?.id,
449
+ isDark: isDark,
450
+ closeOnSelect: false,
451
+ )
452
+ : ModelList(
453
+ serverId: server.id,
454
+ selectedModelId: selectedModel?.id,
455
+ isDark: isDark,
456
+ closeOnSelect: false,
457
+ ),
458
+ ),
459
+ ],
460
+ ),
461
+ ),
462
+ );
463
+ }
464
+}
465
+
466
+class VoiceTargetChip extends ConsumerWidget {
467
+ const VoiceTargetChip({super.key});
468
+
469
+ @override
470
+ Widget build(BuildContext context, WidgetRef ref) {
471
+ final target = ref.watch(activeChatTargetProvider);
472
+ final server = target.server;
473
+ if (server == null) return const SizedBox.shrink();
474
+
475
+ final theme = Theme.of(context);
476
+ final isDark = theme.brightness == Brightness.dark;
477
+ final foreground = isDark ? Colors.white : const Color(0xFF25243A);
478
+
479
+ return Semantics(
480
+ label: 'Active server ${server.name}, model ${target.modelLabel}',
481
+ child: Container(
482
+ constraints: const BoxConstraints(maxWidth: 320),
483
+ padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
484
+ decoration: BoxDecoration(
485
+ color: foreground.withValues(alpha: isDark ? 0.055 : 0.035),
486
+ borderRadius: BorderRadius.circular(999),
487
+ border: Border.all(
488
+ color: foreground.withValues(alpha: isDark ? 0.10 : 0.075),
489
+ ),
490
+ ),
491
+ child: Row(
492
+ mainAxisSize: MainAxisSize.min,
493
+ children: [
494
+ Icon(
495
+ Icons.dns_outlined,
496
+ size: 12,
497
+ color: foreground.withValues(alpha: 0.55),
498
+ ),
499
+ const SizedBox(width: 5),
500
+ Flexible(
501
+ child: Text(
502
+ server.name,
503
+ maxLines: 1,
504
+ overflow: TextOverflow.ellipsis,
505
+ style: TextStyle(
506
+ fontSize: 11,
507
+ fontWeight: FontWeight.w500,
508
+ color: foreground.withValues(alpha: 0.62),
509
+ ),
510
+ ),
511
+ ),
512
+ Padding(
513
+ padding: const EdgeInsets.symmetric(horizontal: 6),
514
+ child: Text(
515
+ '•',
516
+ style: TextStyle(
517
+ fontSize: 10,
518
+ color: foreground.withValues(alpha: 0.32),
519
+ ),
520
+ ),
521
+ ),
522
+ Flexible(
523
+ child: Text(
524
+ target.modelLabel,
525
+ maxLines: 1,
526
+ overflow: TextOverflow.ellipsis,
527
+ style: TextStyle(
528
+ fontSize: 11,
529
+ color: foreground.withValues(alpha: 0.52),
530
+ ),
531
+ ),
532
+ ),
533
+ ],
534
+ ),
535
+ ),
536
+ );
537
+ }
538
+}
539
+
540
/// Subscribes to the chat streaming message and feeds its content into
541
/// [VoiceTranscript] during the processing phase.
542
class _StreamingTranscript extends ConsumerWidget {
test/features/chat/providers/active_chat_target_provider_test.dart
new
+88
@@ -0,0 +1,88 @@
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/features/chat/providers/model_selection_providers.dart';
5
+import 'package:localmind/features/on_device/providers/on_device_providers.dart';
6
+import 'package:localmind/features/servers/data/models/server.dart';
7
+import 'package:localmind/features/servers/providers/server_providers.dart';
8
+
9
+void main() {
10
+ test('uses a loaded on-device model without an explicit selection', () {
11
+ final container = ProviderContainer(
12
+ overrides: [
13
+ activeServerProvider.overrideWith(_OnDeviceServerNotifier.new),
14
+ onDeviceEngineProvider.overrideWith(_LoadedEngineNotifier.new),
15
+ ],
16
+ );
17
+ addTearDown(container.dispose);
18
+
19
+ final target = container.read(activeChatTargetProvider);
20
+
21
+ expect(target.isReady, isTrue);
22
+ expect(target.effectiveModelId, 'smollm2-135m-instruct');
23
+ expect(target.modelLabel, 'SmolLM 135M Instruct');
24
+ });
25
+
26
+ test('does not treat a native restored spec as a loaded model', () {
27
+ final container = ProviderContainer(
28
+ overrides: [
29
+ activeServerProvider.overrideWith(_OnDeviceServerNotifier.new),
30
+ onDeviceEngineProvider.overrideWith(_EmptyEngineNotifier.new),
31
+ ],
32
+ );
33
+ addTearDown(container.dispose);
34
+
35
+ final target = container.read(activeChatTargetProvider);
36
+
37
+ expect(target.isReady, isFalse);
38
+ expect(target.effectiveModelId, isNull);
39
+ expect(target.modelLabel, 'No model');
40
+ });
41
+
42
+ test('keeps default routing for a selected remote server', () {
43
+ final container = ProviderContainer(
44
+ overrides: [activeServerProvider.overrideWith(_RemoteServerNotifier.new)],
45
+ );
46
+ addTearDown(container.dispose);
47
+
48
+ final target = container.read(activeChatTargetProvider);
49
+
50
+ expect(target.isReady, isTrue);
51
+ expect(target.effectiveModelId, 'default');
52
+ expect(target.modelLabel, 'Default model');
53
+ });
54
+}
55
+
56
+class _OnDeviceServerNotifier extends ActiveServerNotifier {
57
+ @override
58
+ Server? build() => _server(ServerType.onDevice, 'On-Device');
59
+}
60
+
61
+class _RemoteServerNotifier extends ActiveServerNotifier {
62
+ @override
63
+ Server? build() => _server(ServerType.ollama, 'Ollama');
64
+}
65
+
66
+class _LoadedEngineNotifier extends OnDeviceEngineNotifier {
67
+ @override
68
+ OnDeviceEngineState build() => const OnDeviceEngineState(
69
+ status: OnDeviceEngineStatus.loaded,
70
+ loadedModelId: 'smollm2-135m-instruct',
71
+ );
72
+}
73
+
74
+class _EmptyEngineNotifier extends OnDeviceEngineNotifier {
75
+ @override
76
+ OnDeviceEngineState build() => const OnDeviceEngineState();
77
+}
78
+
79
+Server _server(ServerType type, String name) => Server(
80
+ id: type == ServerType.onDevice ? 'on-device' : 'remote',
81
+ name: name,
82
+ type: type,
83
+ host: 'localhost',
84
+ port: 11434,
85
+ createdAt: DateTime.utc(2026, 8, 13),
86
+ lastConnectedAt: DateTime.utc(2026, 8, 13),
87
+ status: ConnectionStatus.connected,
88
+);
test/features/chat/providers/chat_notifier_model_guard_test.dart
new
+53
@@ -0,0 +1,53 @@
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/features/chat/providers/chat_notifier.dart';
5
+import 'package:localmind/features/chat/providers/model_selection_providers.dart';
6
+import 'package:localmind/features/on_device/providers/on_device_providers.dart';
7
+import 'package:localmind/features/servers/data/models/server.dart';
8
+import 'package:localmind/features/servers/providers/server_providers.dart';
9
+
10
+void main() {
11
+ test('sendMessage refuses to send when no model is selected', () async {
12
+ final container = ProviderContainer(
13
+ overrides: [
14
+ activeServerProvider.overrideWith(_ConnectedServerNotifier.new),
15
+ onDeviceEngineProvider.overrideWith(_EmptyEngineNotifier.new),
16
+ chatProvider.overrideWith(_GuardTestChatNotifier.new),
17
+ ],
18
+ );
19
+ addTearDown(container.dispose);
20
+
21
+ await container.read(chatProvider.notifier).sendMessage('hello');
22
+
23
+ final state = container.read(chatProvider);
24
+ expect(state.errorMessage, modelSelectionRequiredMessage);
25
+ expect(state.messages, isEmpty);
26
+ expect(state.allMessages, isEmpty);
27
+ expect(state.isStreaming, isFalse);
28
+ });
29
+}
30
+
31
+class _EmptyEngineNotifier extends OnDeviceEngineNotifier {
32
+ @override
33
+ OnDeviceEngineState build() => const OnDeviceEngineState();
34
+}
35
+
36
+class _GuardTestChatNotifier extends ChatNotifier {
37
+ @override
38
+ ChatState build() => const ChatState();
39
+}
40
+
41
+class _ConnectedServerNotifier extends ActiveServerNotifier {
42
+ @override
43
+ Server? build() => Server(
44
+ id: 'on-device',
45
+ name: 'On-Device',
46
+ type: ServerType.onDevice,
47
+ host: '',
48
+ port: 0,
49
+ createdAt: DateTime.utc(2026, 8, 13),
50
+ lastConnectedAt: DateTime.utc(2026, 8, 13),
51
+ status: ConnectionStatus.connected,
52
+ );
53
+}
test/features/chat/smart_reply_service_single_flight_test.dart
+14
@@ -11,6 +11,20 @@ import 'package:localmind/features/chat/data/tools/tool_definition.dart';
11
import 'package:localmind/features/servers/data/models/server.dart';
12
13
void main() {
14
+ test('does not treat hi inside Chicago as a greeting', () {
15
+ final replies = SmartReplyService().getFallbackReplies(
16
+ 'The conference in Chicago is next month.',
17
+ );
18
+
19
+ expect(replies, isNot(contains('Hello! How are you?')));
20
+ });
21
+
22
+ test('still recognizes hi as a standalone greeting', () {
23
+ final replies = SmartReplyService().getFallbackReplies('Hi there!');
24
+
25
+ expect(replies, contains('Hello! How are you?'));
26
+ });
27
+
28
test(
29
'shares an in-flight suggestion request for the same assistant message',
30
() async {
test/features/voice_mode/voice_mode_model_guard_test.dart
new
+87
@@ -0,0 +1,87 @@
1
+import 'package:flutter_riverpod/flutter_riverpod.dart';
2
+import 'package:flutter_test/flutter_test.dart';
3
+import 'package:localmind/features/chat/providers/chat_notifier.dart';
4
+import 'package:localmind/features/chat/providers/model_selection_providers.dart';
5
+import 'package:localmind/core/models/enums.dart';
6
+import 'package:localmind/features/on_device/providers/on_device_providers.dart';
7
+import 'package:localmind/features/servers/data/models/server.dart';
8
+import 'package:localmind/features/servers/providers/server_providers.dart';
9
+import 'package:localmind/features/stt/providers/stt_providers.dart';
10
+import 'package:localmind/features/tts/providers/tts_providers.dart';
11
+import 'package:localmind/features/voice_mode/providers/voice_mode_provider.dart';
12
+
13
+void main() {
14
+ TestWidgetsFlutterBinding.ensureInitialized();
15
+
16
+ test('voice mode does not listen when no model is selected', () async {
17
+ final stt = _RecordingSttNotifier();
18
+ final container = ProviderContainer(
19
+ overrides: [
20
+ sttProvider.overrideWith(() => stt),
21
+ ttsProvider.overrideWith(_StubTtsNotifier.new),
22
+ chatProvider.overrideWith(_StubChatNotifier.new),
23
+ activeServerProvider.overrideWith(_ConnectedServerNotifier.new),
24
+ onDeviceEngineProvider.overrideWith(_EmptyEngineNotifier.new),
25
+ ],
26
+ );
27
+ addTearDown(container.dispose);
28
+
29
+ final notifier = container.read(voiceModeProvider.notifier);
30
+ notifier.startSession();
31
+
32
+ expect(container.read(voiceModeProvider).phase, VoiceModePhase.error);
33
+ expect(
34
+ container.read(voiceModeProvider).error,
35
+ modelSelectionRequiredMessage,
36
+ );
37
+ expect(stt.startCount, 0);
38
+
39
+ await notifier.startListening();
40
+ expect(stt.startCount, 0);
41
+ });
42
+}
43
+
44
+class _ConnectedServerNotifier extends ActiveServerNotifier {
45
+ @override
46
+ Server? build() => Server(
47
+ id: 'on-device',
48
+ name: 'On-Device',
49
+ type: ServerType.onDevice,
50
+ host: '',
51
+ port: 0,
52
+ createdAt: DateTime.utc(2026, 8, 13),
53
+ lastConnectedAt: DateTime.utc(2026, 8, 13),
54
+ status: ConnectionStatus.connected,
55
+ );
56
+}
57
+
58
+class _EmptyEngineNotifier extends OnDeviceEngineNotifier {
59
+ @override
60
+ OnDeviceEngineState build() => const OnDeviceEngineState();
61
+}
62
+
63
+class _RecordingSttNotifier extends SttNotifier {
64
+ int startCount = 0;
65
+
66
+ @override
67
+ SttState build() => const SttState(isAvailable: true);
68
+
69
+ @override
70
+ Future<void> startListening({
71
+ required void Function(String) onResult,
72
+ void Function(String)? onFinal,
73
+ SoundLevelChange? onSoundLevelChange,
74
+ }) async {
75
+ startCount++;
76
+ }
77
+}
78
+
79
+class _StubTtsNotifier extends TtsNotifier {
80
+ @override
81
+ TtsState build() => const TtsState();
82
+}
83
+
84
+class _StubChatNotifier extends ChatNotifier {
85
+ @override
86
+ ChatState build() => const ChatState();
87
+}
test/features/voice_mode/voice_target_chip_test.dart
new
+138
@@ -0,0 +1,138 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:flutter_test/flutter_test.dart';
4
+import 'package:localmind/core/device/device_memory_service.dart';
5
+import 'package:localmind/core/models/enums.dart';
6
+import 'package:localmind/core/providers/device_info_providers.dart';
7
+import 'package:localmind/core/providers/storage_providers.dart';
8
+import 'package:localmind/features/chat/providers/model_selection_providers.dart';
9
+import 'package:localmind/features/on_device/data/models/on_device_model.dart';
10
+import 'package:localmind/features/on_device/providers/on_device_providers.dart';
11
+import 'package:localmind/features/servers/data/models/server.dart';
12
+import 'package:localmind/features/servers/providers/server_providers.dart';
13
+import 'package:localmind/features/voice_mode/views/voice_mode_overlay.dart';
14
+import 'package:localmind/l10n/app_localizations.dart';
15
+import 'package:shared_preferences/shared_preferences.dart';
16
+
17
+void main() {
18
+ testWidgets('shows the effective server and model without overflowing', (
19
+ tester,
20
+ ) async {
21
+ final target = ActiveChatTarget(
22
+ server: Server(
23
+ id: 'on-device',
24
+ name: 'On-Device',
25
+ type: ServerType.onDevice,
26
+ host: '',
27
+ port: 0,
28
+ createdAt: DateTime.utc(2026, 8, 13),
29
+ lastConnectedAt: DateTime.utc(2026, 8, 13),
30
+ status: ConnectionStatus.connected,
31
+ ),
32
+ selectedModel: null,
33
+ effectiveModelId: 'smollm2-135m-instruct',
34
+ modelLabel: 'SmolLM 135M Instruct',
35
+ );
36
+
37
+ await tester.pumpWidget(
38
+ ProviderScope(
39
+ overrides: [activeChatTargetProvider.overrideWith((ref) => target)],
40
+ child: const MaterialApp(
41
+ home: Scaffold(
42
+ body: Align(
43
+ alignment: Alignment.topLeft,
44
+ child: SizedBox(width: 210, child: VoiceTargetChip()),
45
+ ),
46
+ ),
47
+ ),
48
+ ),
49
+ );
50
+
51
+ expect(find.text('On-Device'), findsOneWidget);
52
+ expect(find.text('SmolLM 135M Instruct'), findsOneWidget);
53
+ expect(tester.takeException(), isNull);
54
+ });
55
+
56
+ testWidgets('inline picker scrolls and selects without closing voice mode', (
57
+ tester,
58
+ ) async {
59
+ SharedPreferences.setMockInitialValues({});
60
+ final prefs = await SharedPreferences.getInstance();
61
+ final model = OnDeviceModel.curatedModels.firstWhere(
62
+ (candidate) => candidate.id == 'smollm2-135m-instruct',
63
+ );
64
+
65
+ final container = ProviderContainer(
66
+ overrides: [
67
+ sharedPreferencesProvider.overrideWithValue(prefs),
68
+ activeServerProvider.overrideWith(_OnDeviceServerNotifier.new),
69
+ onDeviceEngineProvider.overrideWith(_LoadedEngineNotifier.new),
70
+ onDeviceModelsProvider.overrideWith((ref) => [model]),
71
+ downloadedModelsProvider.overrideWith((ref) async => {model.id}),
72
+ deviceMemoryProvider.overrideWith(
73
+ (ref) async => const DeviceMemoryInfo(
74
+ totalMemoryMb: 8192,
75
+ availableMemoryMb: 8192,
76
+ ),
77
+ ),
78
+ ],
79
+ );
80
+ addTearDown(container.dispose);
81
+
82
+ await tester.pumpWidget(
83
+ UncontrolledProviderScope(
84
+ container: container,
85
+ child: MaterialApp(
86
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
87
+ supportedLocales: AppLocalizations.supportedLocales,
88
+ home: const Scaffold(
89
+ body: SizedBox(
90
+ width: 390,
91
+ height: 300,
92
+ child: VoiceModelPickerPanel(),
93
+ ),
94
+ ),
95
+ ),
96
+ ),
97
+ );
98
+ await tester.pumpAndSettle();
99
+
100
+ expect(
101
+ find.byKey(const ValueKey('voice-model-picker-panel')),
102
+ findsOneWidget,
103
+ );
104
+ expect(find.byType(Scrollable), findsWidgets);
105
+
106
+ await tester.tap(find.text('SmolLM 135M Instruct'));
107
+ await tester.pump();
108
+
109
+ expect(
110
+ find.byKey(const ValueKey('voice-model-picker-panel')),
111
+ findsOneWidget,
112
+ );
113
+ expect(container.read(selectedModelProvider)?.id, model.id);
114
+ expect(tester.takeException(), isNull);
115
+ });
116
+}
117
+
118
+class _OnDeviceServerNotifier extends ActiveServerNotifier {
119
+ @override
120
+ Server? build() => Server(
121
+ id: 'on-device',
122
+ name: 'On-Device',
123
+ type: ServerType.onDevice,
124
+ host: '',
125
+ port: 0,
126
+ createdAt: DateTime.utc(2026, 8, 13),
127
+ lastConnectedAt: DateTime.utc(2026, 8, 13),
128
+ status: ConnectionStatus.connected,
129
+ );
130
+}
131
+
132
+class _LoadedEngineNotifier extends OnDeviceEngineNotifier {
133
+ @override
134
+ OnDeviceEngineState build() => const OnDeviceEngineState(
135
+ status: OnDeviceEngineStatus.loaded,
136
+ loadedModelId: 'smollm2-135m-instruct',
137
+ );
138
+}