51 files changed
+1208
-201
ios/Podfile
+4
-3
@@ -1,6 +1,4 @@
1
-# Uncomment this line to define a global platform for your project
2
-# Bumped to 16.4 by llamadart_llama_cpp_flutter (0.0.4) for the GGUF import path.
3
-platform :ios, '16.4'
1
+platform :ios, '17.6'
2
3
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
4
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -40,6 +38,9 @@ end
38
post_install do |installer|
39
installer.pods_project.targets.each do |target|
40
flutter_additional_ios_build_settings(target)
41
+ target.build_configurations.each do |config|
42
+ config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '17.6'
43
+ end
44
end
45
46
# Add a build phase to Runner target to patch missing MinimumOSVersion in embedded frameworks
ios/Podfile.lock
+1
-1
@@ -36,6 +36,6 @@ SPEC CHECKSUMS:
36
objectbox_flutter_libs: bfbfc0486c06b0b29b130ca1ad9467e8314f32f1
37
sherpa_onnx_ios: 385d34da9ade98f8e012d9b518f1440bd8ba3636
38
39
-PODFILE CHECKSUM: 4187f32b7d7fb45db2ca41669d1a619a09f7e71b
39
+PODFILE CHECKSUM: 215ae534c1261f82e8c38d077336338426f826c5
40
41
COCOAPODS: 1.16.2
ios/Runner.xcodeproj/project.pbxproj
+3
-3
@@ -490,7 +490,7 @@
490
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
491
GCC_WARN_UNUSED_FUNCTION = YES;
492
GCC_WARN_UNUSED_VARIABLE = YES;
493
- IPHONEOS_DEPLOYMENT_TARGET = 16.4;
493
+ IPHONEOS_DEPLOYMENT_TARGET = 17.6;
494
MTL_ENABLE_DEBUG_INFO = NO;
495
SDKROOT = iphoneos;
496
SUPPORTED_PLATFORMS = iphoneos;
@@ -622,7 +622,7 @@
622
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
623
GCC_WARN_UNUSED_FUNCTION = YES;
624
GCC_WARN_UNUSED_VARIABLE = YES;
625
- IPHONEOS_DEPLOYMENT_TARGET = 16.4;
625
+ IPHONEOS_DEPLOYMENT_TARGET = 17.6;
626
MTL_ENABLE_DEBUG_INFO = YES;
627
ONLY_ACTIVE_ARCH = YES;
628
SDKROOT = iphoneos;
@@ -673,7 +673,7 @@
673
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
674
GCC_WARN_UNUSED_FUNCTION = YES;
675
GCC_WARN_UNUSED_VARIABLE = YES;
676
- IPHONEOS_DEPLOYMENT_TARGET = 16.4;
676
+ IPHONEOS_DEPLOYMENT_TARGET = 17.6;
677
MTL_ENABLE_DEBUG_INFO = NO;
678
SDKROOT = iphoneos;
679
SUPPORTED_PLATFORMS = iphoneos;
ios/Runner/Info.plist
+4
@@ -109,6 +109,10 @@
109
<string>Add calendar events through AI chat without reading existing events.</string>
110
<key>NSLocationWhenInUseUsageDescription</key>
111
<string>Share your current location with AI chat only when you ask, including a human-readable place name.</string>
112
+ <key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
113
+ <string>Share your current location with AI chat only when you ask, including a human-readable place name.</string>
114
+ <key>NSLocationAlwaysUsageDescription</key>
115
+ <string>Share your current location with AI chat only when you ask, including a human-readable place name.</string>
116
<key>ITSAppUsesNonExemptEncryption</key>
117
<false/>
118
</dict>
lib/bootstrap/bootstrap_host.dart
+5
-1
@@ -20,6 +20,7 @@ import '../core/logger/app_logger.dart';
20
import '../core/utils/locale_utils.dart';
21
import 'bootstrap_screen.dart';
22
import 'bootstrap_state.dart';
23
+import 'safe_riverpod_scope_host.dart';
24
25
class BootstrapHost extends StatefulWidget {
26
const BootstrapHost({super.key});
@@ -146,7 +147,10 @@ class _BootstrapHostState extends State<BootstrapHost> {
147
if (_container != null) {
148
return UncontrolledProviderScope(
149
container: _container!,
149
- child: const CloudSyncLifecycleHost(child: App()),
150
+ child: SafeRiverpodScopeHost(
151
+ container: _container!,
152
+ child: const CloudSyncLifecycleHost(child: App()),
153
+ ),
154
);
155
}
156
lib/bootstrap/safe_riverpod_scope_host.dart
new
+110
@@ -0,0 +1,110 @@
1
+// ignore_for_file: implementation_imports, invalid_use_of_internal_member
2
+
3
+import 'dart:async';
4
+
5
+import 'package:flutter/material.dart';
6
+import 'package:flutter/scheduler.dart';
7
+import 'package:flutter_riverpod/src/internals.dart';
8
+
9
+/// A [Vsync] proxy that intercepts Riverpod task scheduling and safely defers
10
+/// refreshes to the next event loop iteration if Flutter is currently in the
11
+/// middle of a build or render pass (`SchedulerPhase.persistentCallbacks`).
12
+///
13
+/// In Riverpod 3.x, out-of-view widgets are paused by default. When routes pop
14
+/// or transition, [TickerMode] toggles during Flutter's build phase
15
+/// (`_TickerModeState.didUpdateWidget`), synchronously resuming paused
16
+/// subscriptions. Resuming flushes providers that changed while paused.
17
+/// If any dependent provider invalidates during flush, Riverpod's default
18
+/// [UncontrolledProviderScope] vsync invokes `setState()` synchronously,
19
+/// crashing Flutter with "setState() or markNeedsBuild() called during build".
20
+///
21
+/// [SafeRiverpodVsync] eliminates this by checking if Flutter is currently
22
+/// building, and using [Timer.zero] to schedule the refresh immediately after
23
+/// the frame concludes.
24
+class SafeRiverpodVsync implements Vsync {
25
+ final Vsync delegate;
26
+
27
+ SafeRiverpodVsync(this.delegate);
28
+
29
+ @override
30
+ void Function()? scheduleRefresh(Task task) {
31
+ final phase = SchedulerBinding.instance.schedulerPhase;
32
+ final isBuilding =
33
+ phase == SchedulerPhase.persistentCallbacks ||
34
+ WidgetsBinding.instance.buildOwner?.debugBuilding == true;
35
+
36
+ if (isBuilding) {
37
+ final timer = Timer(Duration.zero, task.call);
38
+ return timer.cancel;
39
+ }
40
+
41
+ try {
42
+ return delegate.scheduleRefresh(task);
43
+ } catch (_) {
44
+ final timer = Timer(Duration.zero, task.call);
45
+ return timer.cancel;
46
+ }
47
+ }
48
+
49
+ @override
50
+ void Function()? scheduleDispose(Task task) {
51
+ return delegate.scheduleDispose(task);
52
+ }
53
+}
54
+
55
+/// Wraps the child of [UncontrolledProviderScope] and ensures all registered
56
+/// vsyncs in [ProviderContainer.scheduler.flutterVsyncs] are protected by
57
+/// [SafeRiverpodVsync].
58
+class SafeRiverpodScopeHost extends StatefulWidget {
59
+ final ProviderContainer container;
60
+ final Widget child;
61
+
62
+ const SafeRiverpodScopeHost({
63
+ super.key,
64
+ required this.container,
65
+ required this.child,
66
+ });
67
+
68
+ @override
69
+ State<SafeRiverpodScopeHost> createState() => _SafeRiverpodScopeHostState();
70
+}
71
+
72
+class _SafeRiverpodScopeHostState extends State<SafeRiverpodScopeHost> {
73
+ @override
74
+ void initState() {
75
+ super.initState();
76
+ _protectVsyncs();
77
+ }
78
+
79
+ @override
80
+ void didUpdateWidget(SafeRiverpodScopeHost oldWidget) {
81
+ super.didUpdateWidget(oldWidget);
82
+ _protectVsyncs();
83
+ }
84
+
85
+ void _protectVsyncs() {
86
+ final scheduler = widget.container.scheduler;
87
+ final vsyncs = scheduler.flutterVsyncs.toList();
88
+ var hasUnwrapped = false;
89
+ for (final v in vsyncs) {
90
+ if (v is! SafeRiverpodVsync) {
91
+ hasUnwrapped = true;
92
+ break;
93
+ }
94
+ }
95
+ if (hasUnwrapped) {
96
+ scheduler.flutterVsyncs.clear();
97
+ for (final v in vsyncs) {
98
+ scheduler.flutterVsyncs.add(
99
+ v is SafeRiverpodVsync ? v : SafeRiverpodVsync(v),
100
+ );
101
+ }
102
+ }
103
+ }
104
+
105
+ @override
106
+ Widget build(BuildContext context) {
107
+ _protectVsyncs();
108
+ return widget.child;
109
+ }
110
+}
lib/core/services/export_choice_dialog.dart
+2
-2
@@ -42,9 +42,9 @@ Future<void> showExportChoiceDialog(
42
if (choice == _ExportChoice.clipboard) {
43
await Clipboard.setData(ClipboardData(text: content));
44
if (context.mounted) {
45
- ScaffoldMessenger.of(
45
+ ScaffoldMessenger.maybeOf(
46
context,
47
- ).showSnackBar(SnackBar(content: Text(l10n.copied_to_clipboard)));
47
+ )?.showSnackBar(SnackBar(content: Text(l10n.copied_to_clipboard)));
48
}
49
} else {
50
await ShareService.shareText(content, subject: subject);
lib/core/utils/safe_file_picker.dart
+10
-10
@@ -96,9 +96,9 @@ class SafeFilePicker {
96
final l10n = AppLocalizations.of(context);
97
if (l10n != null) {
98
final msg = getErrorMessage(e, l10n);
99
- ScaffoldMessenger.of(
99
+ ScaffoldMessenger.maybeOf(
100
context,
101
- ).showSnackBar(SnackBar(content: Text(msg)));
101
+ )?.showSnackBar(SnackBar(content: Text(msg)));
102
}
103
} else if (onError != null) {
104
onError(e.toString());
@@ -135,9 +135,9 @@ class SafeFilePicker {
135
final l10n = AppLocalizations.of(context);
136
if (l10n != null) {
137
final msg = getErrorMessage(e, l10n);
138
- ScaffoldMessenger.of(
138
+ ScaffoldMessenger.maybeOf(
139
context,
140
- ).showSnackBar(SnackBar(content: Text(msg)));
140
+ )?.showSnackBar(SnackBar(content: Text(msg)));
141
}
142
} else if (onError != null) {
143
onError(e.toString());
@@ -166,9 +166,9 @@ class SafeFilePicker {
166
final l10n = AppLocalizations.of(context);
167
if (l10n != null) {
168
final msg = getErrorMessage(e, l10n);
169
- ScaffoldMessenger.of(
169
+ ScaffoldMessenger.maybeOf(
170
context,
171
- ).showSnackBar(SnackBar(content: Text(msg)));
171
+ )?.showSnackBar(SnackBar(content: Text(msg)));
172
}
173
} else if (onError != null) {
174
onError(e.toString());
@@ -200,9 +200,9 @@ class SafeFilePicker {
200
final l10n = AppLocalizations.of(context);
201
if (l10n != null) {
202
final msg = getErrorMessage(e, l10n);
203
- ScaffoldMessenger.of(
203
+ ScaffoldMessenger.maybeOf(
204
context,
205
- ).showSnackBar(SnackBar(content: Text(msg)));
205
+ )?.showSnackBar(SnackBar(content: Text(msg)));
206
}
207
} else if (onError != null) {
208
onError(e.toString());
@@ -238,9 +238,9 @@ class SafeFilePicker {
238
final l10n = AppLocalizations.of(context);
239
if (l10n != null) {
240
final msg = getErrorMessage(e, l10n);
241
- ScaffoldMessenger.of(
241
+ ScaffoldMessenger.maybeOf(
242
context,
243
- ).showSnackBar(SnackBar(content: Text(msg)));
243
+ )?.showSnackBar(SnackBar(content: Text(msg)));
244
}
245
} else if (onError != null) {
246
onError(e.toString());
lib/features/chat/views/chat_screen.dart
+4
-1
@@ -86,7 +86,10 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
86
chatProvider.select((s) => s.pendingToolApproval),
87
(previous, next) {
88
if (next != null && !_isApprovalDialogOpen) {
89
- _showToolApprovalDialog(context, next);
89
+ WidgetsBinding.instance.addPostFrameCallback((_) {
90
+ if (!mounted || _isApprovalDialogOpen) return;
91
+ _showToolApprovalDialog(context, next);
92
+ });
93
}
94
},
95
);
lib/features/chat/views/components/chat_bottom_bar.dart
+2
-2
@@ -112,9 +112,9 @@ class SmartReplyChipsWrapper extends ConsumerWidget {
112
onSend: (message) {
113
if (!ref.read(activeChatTargetProvider).isReady) {
114
final l10n = AppLocalizations.of(context)!;
115
- ScaffoldMessenger.of(
115
+ ScaffoldMessenger.maybeOf(
116
context,
117
- ).showSnackBar(SnackBar(content: Text(l10n.model_required_toast)));
117
+ )?.showSnackBar(SnackBar(content: Text(l10n.model_required_toast)));
118
showModalBottomSheet(
119
context: context,
120
isScrollControlled: true,
lib/features/chat/views/components/chat_bubble/attachment_list.dart
+2
-2
@@ -37,9 +37,9 @@ class _AttachmentItem extends StatelessWidget {
37
if (!context.mounted) return;
38
if (text == null) {
39
final l10n = AppLocalizations.of(context)!;
40
- ScaffoldMessenger.of(
40
+ ScaffoldMessenger.maybeOf(
41
context,
42
- ).showSnackBar(SnackBar(content: Text(l10n.could_not_read_file)));
42
+ )?.showSnackBar(SnackBar(content: Text(l10n.could_not_read_file)));
43
return;
44
}
45
showModalBottomSheet<void>(
lib/features/chat/views/components/chat_input_bar.dart
+22
-17
@@ -246,7 +246,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
246
} catch (e) {
247
if (mounted) {
248
final l10n = AppLocalizations.of(context)!;
249
- ScaffoldMessenger.of(context).showSnackBar(
249
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
250
SnackBar(
251
content: Text(
252
SafeFilePicker.getErrorMessage(
@@ -282,7 +282,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
282
} catch (e) {
283
if (mounted) {
284
final l10n = AppLocalizations.of(context)!;
285
- ScaffoldMessenger.of(context).showSnackBar(
285
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
286
SnackBar(
287
content: Text(
288
SafeFilePicker.getErrorMessage(
@@ -436,7 +436,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
436
onPressed: !modelReady
437
? () {
438
ref.read(appHapticsProvider).light();
439
- ScaffoldMessenger.of(context).showSnackBar(
439
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
440
SnackBar(content: Text(l10n.model_required_toast)),
441
);
442
showModalBottomSheet(
@@ -536,9 +536,9 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
536
bool _ensureChatTarget() {
537
if (ref.read(activeChatTargetProvider).isReady) return true;
538
final l10n = AppLocalizations.of(context)!;
539
- ScaffoldMessenger.of(
539
+ ScaffoldMessenger.maybeOf(
540
context,
541
- ).showSnackBar(SnackBar(content: Text(l10n.model_required_toast)));
541
+ )?.showSnackBar(SnackBar(content: Text(l10n.model_required_toast)));
542
showModalBottomSheet(
543
context: context,
544
isScrollControlled: true,
@@ -825,17 +825,20 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
825
826
ref.listen<String?>(widgetPendingPromptProvider, (previous, next) {
827
if (next != null && next.isNotEmpty) {
828
- _controller.text = next;
829
- _controller.selection = TextSelection.fromPosition(
830
- TextPosition(offset: _controller.text.length),
831
- );
832
- _activeFocus.requestFocus();
833
- ref.read(widgetPendingPromptProvider.notifier).consumePrompt();
828
+ WidgetsBinding.instance.addPostFrameCallback((_) {
829
+ if (!mounted) return;
830
+ _controller.text = next;
831
+ _controller.selection = TextSelection.fromPosition(
832
+ TextPosition(offset: _controller.text.length),
833
+ );
834
+ _activeFocus.requestFocus();
835
+ ref.read(widgetPendingPromptProvider.notifier).consumePrompt();
836
+ });
837
}
838
});
839
840
ref.listen<String?>(sttProvider.select((s) => s.error), (previous, next) {
838
- if (next != null) {
841
+ if (next != null && mounted) {
842
final voiceState = ref.read(voiceModeProvider);
843
final voiceActive =
844
voiceState.isActive || voiceState.phase != VoiceModePhase.idle;
@@ -844,9 +847,12 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
847
final message = _mapSttError(next);
848
849
if (message != null) {
847
- ScaffoldMessenger.of(
848
- context,
849
- ).showSnackBar(SnackBar(content: Text(message)));
850
+ WidgetsBinding.instance.addPostFrameCallback((_) {
851
+ if (!mounted) return;
852
+ ScaffoldMessenger.maybeOf(
853
+ context,
854
+ )?.showSnackBar(SnackBar(content: Text(message)));
855
+ });
856
}
857
}
858
});
@@ -864,8 +870,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
870
// Schedule the actual restart on the microtask queue instead.
871
ref.listen<bool>(isStreamingProvider, (previous, next) {
872
if (previous != true || next != false || !mounted) return;
867
- final shouldAutoSpeak =
868
- ref.read(settingsProvider).autoSpeakEnabled;
873
+ final shouldAutoSpeak = ref.read(settingsProvider).autoSpeakEnabled;
874
final voiceState = ref.read(voiceModeProvider);
875
final voiceActive =
876
voiceState.isActive || voiceState.phase != VoiceModePhase.idle;
lib/features/chat/views/components/chat_settings_sheet.dart
+1
-1
@@ -498,7 +498,7 @@ class _ChatSettingsSheetState extends ConsumerState<ChatSettingsSheet> {
498
.importIntegrationsFromJson(jsonStr);
499
Navigator.of(dialogContext).pop();
500
501
- ScaffoldMessenger.of(context).showSnackBar(
501
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
502
SnackBar(
503
content: Text(
504
importedCount > 0
lib/features/chat/views/components/message_action_bar.dart
+2
-2
@@ -116,7 +116,7 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
116
await Clipboard.setData(ClipboardData(text: widget.content));
117
widget.onCopy?.call();
118
if (context.mounted) {
119
- ScaffoldMessenger.of(context).showSnackBar(
119
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
120
SnackBar(
121
content: Text(l10n.copied_to_clipboard),
122
duration: const Duration(seconds: 2),
@@ -297,7 +297,7 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
297
onTap: () {
298
Navigator.pop(ctx);
299
Clipboard.setData(ClipboardData(text: widget.content));
300
- ScaffoldMessenger.of(context).showSnackBar(
300
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
301
SnackBar(content: Text(sheetL10n.copied_markdown)),
302
);
303
},
lib/features/chat/views/components/message_list/corrupted_state.dart
+1
-1
@@ -37,7 +37,7 @@ Expected: ${conversation.messageCount}
37
Error: $errorMessage
38
''';
39
Clipboard.setData(ClipboardData(text: data));
40
- ScaffoldMessenger.of(context).showSnackBar(
40
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
41
SnackBar(content: Text(debugL10n.copied_to_clipboard)),
42
);
43
},
lib/features/chat/views/components/message_list/message_area.dart
+1
-1
@@ -73,7 +73,7 @@ class MessageArea extends ConsumerWidget {
73
onQuickPrompt: (prompt) {
74
if (!ref.read(activeChatTargetProvider).isReady) {
75
final toastL10n = AppLocalizations.of(context)!;
76
- ScaffoldMessenger.of(context).showSnackBar(
76
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
77
SnackBar(content: Text(toastL10n.model_required_toast)),
78
);
79
showModalBottomSheet(
lib/features/chat/views/components/model_info_sheet.dart
+2
-2
@@ -94,9 +94,9 @@ class _ModelInfoRow extends StatelessWidget {
94
onPressed: () async {
95
await Clipboard.setData(ClipboardData(text: value));
96
if (context.mounted) {
97
- ScaffoldMessenger.of(
97
+ ScaffoldMessenger.maybeOf(
98
context,
99
- ).showSnackBar(SnackBar(content: Text(l10n.copied_to_clipboard)));
99
+ )?.showSnackBar(SnackBar(content: Text(l10n.copied_to_clipboard)));
100
}
101
},
102
),
lib/features/cloud_sync/views/cloud_sync_screen.dart
+3
-3
@@ -82,7 +82,7 @@ class _CloudSyncScreenState extends ConsumerState<CloudSyncScreen> {
82
await action();
83
if (mounted) {
84
final l10n = AppLocalizations.of(context);
85
- ScaffoldMessenger.of(context).showSnackBar(
85
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
86
SnackBar(
87
content: Text(
88
l10n?.s3_connection_succeeded ?? 'S3 connection succeeded.',
@@ -92,9 +92,9 @@ class _CloudSyncScreenState extends ConsumerState<CloudSyncScreen> {
92
}
93
} catch (error) {
94
if (mounted) {
95
- ScaffoldMessenger.of(
95
+ ScaffoldMessenger.maybeOf(
96
context,
97
- ).showSnackBar(SnackBar(content: Text(error.toString())));
97
+ )?.showSnackBar(SnackBar(content: Text(error.toString())));
98
}
99
} finally {
100
if (mounted) setState(() => _working = false);
lib/features/conversations/views/components/conversation_list.dart
+1
-1
@@ -112,7 +112,7 @@ class ConversationList extends ConsumerWidget {
112
ref
113
.read(conversationsProvider.notifier)
114
.duplicateConversation(conversation.id);
115
- ScaffoldMessenger.of(context).showSnackBar(
115
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
116
SnackBar(content: Text(l10n.duplicate_chat_success)),
117
);
118
},
lib/features/conversations/views/components/rename_conversation_dialog.dart
+1
-1
@@ -31,7 +31,7 @@ Future<void> showRenameConversationDialog(
31
if (title != null && title.isNotEmpty) {
32
controller.text = title;
33
} else {
34
- ScaffoldMessenger.of(context).showSnackBar(
34
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
35
SnackBar(content: Text(l10n.generate_title_failed)),
36
);
37
}
lib/features/lm_studio_catalog/views/lm_studio_model_browser_screen.dart
+3
-3
@@ -576,9 +576,9 @@ class _ModelDetailPanelState extends ConsumerState<_ModelDetailPanel> {
576
final l10n = AppLocalizations.of(context)!;
577
final quant = _effectiveQuant(detail.quants);
578
if (detail.quants.isNotEmpty && quant == null) {
579
- ScaffoldMessenger.of(
579
+ ScaffoldMessenger.maybeOf(
580
context,
581
- ).showSnackBar(SnackBar(content: Text(l10n.lm_studio_download_options)));
581
+ )?.showSnackBar(SnackBar(content: Text(l10n.lm_studio_download_options)));
582
return;
583
}
584
@@ -593,7 +593,7 @@ class _ModelDetailPanelState extends ConsumerState<_ModelDetailPanel> {
593
);
594
} catch (e) {
595
if (!mounted) return;
596
- ScaffoldMessenger.of(context).showSnackBar(
596
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
597
SnackBar(
598
content: Text(l10n.error_with_message(e.toString())),
599
backgroundColor: Colors.red,
lib/features/mcp/views/mcp_tools_screen.dart
+3
-3
@@ -119,7 +119,7 @@ class McpToolsScreen extends ConsumerWidget {
119
.setCalendarToolsEnabled(true);
120
} else {
121
if (context.mounted) {
122
- ScaffoldMessenger.of(context).showSnackBar(
122
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
123
SnackBar(
124
content: Text(
125
l10n.calendar_permission_denied,
@@ -149,7 +149,7 @@ class McpToolsScreen extends ConsumerWidget {
149
.setLocationToolsEnabled(true);
150
} else {
151
if (context.mounted) {
152
- ScaffoldMessenger.of(context).showSnackBar(
152
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
153
SnackBar(
154
content: Text(
155
l10n.location_permission_denied,
@@ -746,7 +746,7 @@ class __ConfiguredMcpServersCardState
746
Navigator.of(dialogContext).pop();
747
widget.onServersChanged();
748
749
- ScaffoldMessenger.of(context).showSnackBar(
749
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
750
SnackBar(
751
content: Text(
752
count > 0
lib/features/models/components/inline_thinking_selector.dart
+17
-5
@@ -42,6 +42,7 @@ class InlineThinkingSelector extends ConsumerWidget {
42
43
final accent = theme.colorScheme.primary;
44
final efforts = effortsForModel(supportedEfforts);
45
+ final hasGranularChoice = hasGranularReasoningChoice(supportedEfforts);
46
47
final options = <_ThinkingOption>[
48
if (!reasoningMandatory)
@@ -50,11 +51,18 @@ class InlineThinkingSelector extends ConsumerWidget {
51
isEnabled: false,
52
effort: ReasoningEffort.low,
53
),
53
- for (final effort in efforts)
54
+ if (hasGranularChoice)
55
+ for (final effort in efforts)
56
+ _ThinkingOption(
57
+ label: effort.shortLabel(l10n),
58
+ isEnabled: true,
59
+ effort: effort,
60
+ )
61
+ else
62
_ThinkingOption(
55
- label: effort.shortLabel(l10n),
63
+ label: l10n.model_reasoning_on,
64
isEnabled: true,
57
- effort: effort,
65
+ effort: ReasoningEffort.medium,
66
),
67
];
68
@@ -87,7 +95,9 @@ class InlineThinkingSelector extends ConsumerWidget {
95
final isActive =
96
isSelected &&
97
(opt.isEnabled == config.enabled) &&
90
- (!opt.isEnabled || opt.effort == config.effort);
98
+ (!opt.isEnabled ||
99
+ !hasGranularChoice ||
100
+ opt.effort == config.effort);
101
102
final chipBg = isActive
103
? accent.withValues(alpha: 0.2)
@@ -118,7 +128,9 @@ class InlineThinkingSelector extends ConsumerWidget {
128
notifier.setEnabled(false);
129
} else {
130
notifier.setEnabled(true);
121
- notifier.setEffort(opt.effort);
131
+ if (hasGranularChoice) {
132
+ notifier.setEffort(opt.effort);
133
+ }
134
}
135
},
136
borderRadius: BorderRadius.circular(6),
lib/features/models/components/model_list.dart
+4
-4
@@ -432,7 +432,7 @@ class _ModelListState extends ConsumerState<ModelList> {
432
ref.read(modelLoadingProvider.notifier).setLoaded();
433
}
434
if (context.mounted) {
435
- ScaffoldMessenger.of(context).showSnackBar(
435
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
436
SnackBar(
437
content: Text(l10n.model_load_failed(e.toString())),
438
backgroundColor: Colors.red,
@@ -476,13 +476,13 @@ class _ModelListState extends ConsumerState<ModelList> {
476
final message = activeServer.isOllamaFamily
477
? l10n.model_unloaded_ollama(model.name)
478
: l10n.model_unloaded_success(model.name);
479
- ScaffoldMessenger.of(
479
+ ScaffoldMessenger.maybeOf(
480
context,
481
- ).showSnackBar(SnackBar(content: Text(message)));
481
+ )?.showSnackBar(SnackBar(content: Text(message)));
482
}
483
} catch (e) {
484
if (context.mounted) {
485
- ScaffoldMessenger.of(context).showSnackBar(
485
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
486
SnackBar(
487
content: Text(l10n.model_unload_failed(e.toString())),
488
backgroundColor: Colors.red,
lib/features/models/components/thinking_mode_chip.dart
+3
-1
@@ -86,7 +86,9 @@ class ThinkingModeChip extends ConsumerWidget {
86
if (fullWidth) {
87
labelText = l10n.thinking_mode_title;
88
} else if (config.enabled || !canDisable) {
89
- labelText = _effortShortLabel(l10n, config.effort);
89
+ labelText = hasGranularChoice
90
+ ? _effortShortLabel(l10n, config.effort)
91
+ : l10n.model_reasoning_on;
92
} else {
93
labelText = l10n.reasoning_effort_off;
94
}
lib/features/models/data/models/model_info.dart
+2
-2
@@ -33,8 +33,8 @@ class ModelInfo {
33
final String? defaultReasoningEffort;
34
35
/// When true the model cannot run without reasoning (OpenRouter
36
- /// `reasoning.mandatory`, Ollama GPT-OSS); the "Off" toggle should be
37
- /// hidden.
36
+ /// `reasoning.mandatory`, Ollama GPT-OSS and reasoning-only renderers); the
37
+ /// "Off" toggle should be hidden.
38
final bool reasoningMandatory;
39
40
/// Input token price in USD per 1M tokens (OpenRouter `pricing.prompt`).
lib/features/models/views/model_picker_sheet.dart
+12
-9
@@ -61,13 +61,16 @@ class _ModelPickerSheetState extends ConsumerState<ModelPickerSheet> {
61
.map((m) => m.name)
62
.followedBy([loadedId])
63
.first;
64
- ScaffoldMessenger.of(context).showSnackBar(
65
- SnackBar(
66
- content: Text(
67
- l10n.model_loaded(loadedName, next.backend?.name ?? 'CPU'),
64
+ WidgetsBinding.instance.addPostFrameCallback((_) {
65
+ if (!context.mounted) return;
66
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
67
+ SnackBar(
68
+ content: Text(
69
+ l10n.model_loaded(loadedName, next.backend?.name ?? 'CPU'),
70
+ ),
71
),
69
- ),
70
- );
72
+ );
73
+ });
74
}
75
});
76
@@ -200,13 +203,13 @@ class _ModelPickerSheetState extends ConsumerState<ModelPickerSheet> {
203
if (context.mounted) {
204
ref.invalidate(loadedModelsProvider(activeServer));
205
ref.read(selectedModelProvider.notifier).clear();
203
- ScaffoldMessenger.of(
206
+ ScaffoldMessenger.maybeOf(
207
context,
205
- ).showSnackBar(SnackBar(content: Text(l10n.all_models_unloaded)));
208
+ )?.showSnackBar(SnackBar(content: Text(l10n.all_models_unloaded)));
209
}
210
} catch (e) {
211
if (context.mounted) {
209
- ScaffoldMessenger.of(context).showSnackBar(
212
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
213
SnackBar(
214
content: Text(l10n.model_unload_failed(e.toString())),
215
backgroundColor: Colors.red,
lib/features/on_device/components/model_card.dart
+2
-2
@@ -814,7 +814,7 @@ class _PausedDownloadActions extends ConsumerWidget {
814
.startDownload(model.id);
815
816
if (result == 'missing_huggingface_token' && context.mounted) {
817
- ScaffoldMessenger.of(context).showSnackBar(
817
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
818
SnackBar(
819
content: Text(l10n.model_missing_huggingface_token),
820
duration: const Duration(seconds: 6),
@@ -876,7 +876,7 @@ class _NotDownloadedActions extends ConsumerWidget {
876
.startDownload(model.id);
877
878
if (result == 'missing_huggingface_token' && context.mounted) {
879
- ScaffoldMessenger.of(context).showSnackBar(
879
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
880
SnackBar(
881
content: Text(l10n.model_missing_huggingface_token),
882
duration: const Duration(seconds: 6),
lib/features/on_device/components/on_device_picker_section.dart
+2
-2
@@ -249,7 +249,7 @@ class _OnDeviceModelTile extends ConsumerWidget {
249
return InkWell(
250
onTap: () {
251
if (isBuiltInUnsupported) {
252
- ScaffoldMessenger.of(context).showSnackBar(
252
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
253
SnackBar(
254
content: Text(l10n.builtin_ai_not_supported),
255
duration: const Duration(seconds: 4),
@@ -584,7 +584,7 @@ class _OnDeviceModelTile extends ConsumerWidget {
584
.startDownload(model.id);
585
if (result == 'missing_huggingface_token' &&
586
context.mounted) {
587
- ScaffoldMessenger.of(context).showSnackBar(
587
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
588
SnackBar(
589
content: Text(
590
l10n.model_missing_huggingface_token,
lib/features/on_device/views/components/imported_model_reasoning_setting.dart
+235
-33
@@ -1,6 +1,8 @@
1
import 'package:flutter/material.dart';
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:hugeicons/hugeicons.dart';
4
5
+import '../../../../core/theme/colors.dart';
6
import '../../../../l10n/app_localizations.dart';
7
import '../../data/models/on_device_model.dart';
8
import '../../providers/on_device_providers.dart';
@@ -22,8 +24,7 @@ class _ImportedModelReasoningSettingState
24
bool _saving = false;
25
String? _error;
26
25
- Future<void> _save(String? value) async {
26
- if (value == null) return;
27
+ Future<void> _save(String value) async {
28
setState(() {
29
_saving = true;
30
_error = null;
@@ -47,44 +48,245 @@ class _ImportedModelReasoningSettingState
48
@override
49
Widget build(BuildContext context) {
50
final l10n = AppLocalizations.of(context)!;
51
+ final theme = Theme.of(context);
52
+ final isDark = theme.brightness == Brightness.dark;
53
+ final accent = theme.colorScheme.primary;
54
+
55
+ final currentValue = switch (widget.model.reasoningEnabled) {
56
+ true => 'on',
57
+ false => 'off',
58
+ null => 'default',
59
+ };
60
+
61
+ final options = [
62
+ (value: 'default', label: l10n.model_reasoning_default),
63
+ (value: 'on', label: l10n.model_reasoning_on),
64
+ (value: 'off', label: l10n.reasoning_effort_off),
65
+ ];
66
+
67
return Padding(
51
- padding: const EdgeInsets.only(top: 12),
52
- child: Column(
53
- crossAxisAlignment: CrossAxisAlignment.start,
54
- children: [
55
- DropdownButtonFormField<String>(
56
- key: ValueKey('${widget.model.reasoningEnabled}-$_saving-$_error'),
57
- initialValue: switch (widget.model.reasoningEnabled) {
58
- true => 'on',
59
- false => 'off',
60
- null => 'default',
61
- },
62
- isExpanded: true,
63
- decoration: InputDecoration(
64
- labelText: l10n.thinking_mode_title,
65
- helperText: l10n.model_reasoning_help,
66
- helperMaxLines: 3,
67
- errorText: _error,
68
- errorMaxLines: 2,
68
+ padding: const EdgeInsets.only(top: 8),
69
+ child: Container(
70
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
71
+ decoration: BoxDecoration(
72
+ color: theme.colorScheme.surfaceContainerHighest.withValues(
73
+ alpha: 0.3,
74
+ ),
75
+ borderRadius: BorderRadius.circular(10),
76
+ border: Border.all(
77
+ color: theme.colorScheme.outline.withValues(alpha: 0.15),
78
+ ),
79
+ ),
80
+ child: Column(
81
+ crossAxisAlignment: CrossAxisAlignment.start,
82
+ children: [
83
+ LayoutBuilder(
84
+ builder: (context, constraints) {
85
+ final isCompact = constraints.maxWidth < 270;
86
+ final header = _ReasoningSettingHeader(
87
+ title: l10n.thinking_mode_title,
88
+ isDark: isDark,
89
+ saving: _saving,
90
+ accent: accent,
91
+ );
92
+
93
+ final chips = _ReasoningOptionChips(
94
+ options: options,
95
+ currentValue: currentValue,
96
+ saving: _saving,
97
+ isDark: isDark,
98
+ accent: accent,
99
+ onSelected: _save,
100
+ );
101
+
102
+ if (isCompact) {
103
+ return Column(
104
+ crossAxisAlignment: CrossAxisAlignment.start,
105
+ children: [header, const SizedBox(height: 8), chips],
106
+ );
107
+ }
108
+
109
+ return Row(
110
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
111
+ children: [
112
+ header,
113
+ const SizedBox(width: 8),
114
+ Flexible(
115
+ child: Align(
116
+ alignment: Alignment.centerRight,
117
+ child: chips,
118
+ ),
119
+ ),
120
+ ],
121
+ );
122
+ },
123
),
70
- items: [
71
- DropdownMenuItem(
72
- value: 'default',
73
- child: Text(l10n.model_reasoning_default),
74
- ),
75
- DropdownMenuItem(
76
- value: 'on',
77
- child: Text(l10n.model_reasoning_on),
78
- ),
79
- DropdownMenuItem(
80
- value: 'off',
81
- child: Text(l10n.reasoning_effort_off),
124
+ const SizedBox(height: 6),
125
+ Text(
126
+ l10n.model_reasoning_help,
127
+ style: TextStyle(
128
+ fontSize: 10,
129
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.58),
130
+ height: 1.25,
131
),
132
+ ),
133
+ if (_error != null) ...[
134
+ const SizedBox(height: 4),
135
+ _ReasoningErrorMessage(message: _error!),
136
],
84
- onChanged: _saving ? null : _save,
137
+ ],
138
+ ),
139
+ ),
140
+ );
141
+ }
142
+}
143
+
144
+class _ReasoningSettingHeader extends StatelessWidget {
145
+ const _ReasoningSettingHeader({
146
+ required this.title,
147
+ required this.isDark,
148
+ required this.saving,
149
+ required this.accent,
150
+ });
151
+
152
+ final String title;
153
+ final bool isDark;
154
+ final bool saving;
155
+ final Color accent;
156
+
157
+ @override
158
+ Widget build(BuildContext context) {
159
+ return Row(
160
+ mainAxisSize: MainAxisSize.min,
161
+ children: [
162
+ HugeIcon(
163
+ icon: HugeIcons.strokeRoundedBrain,
164
+ size: 14,
165
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
166
+ ),
167
+ const SizedBox(width: 6),
168
+ Text(
169
+ title,
170
+ style: TextStyle(
171
+ fontSize: 11,
172
+ fontWeight: FontWeight.w600,
173
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
174
+ ),
175
+ ),
176
+ if (saving) ...[
177
+ const SizedBox(width: 6),
178
+ SizedBox(
179
+ width: 10,
180
+ height: 10,
181
+ child: CircularProgressIndicator(
182
+ strokeWidth: 1.5,
183
+ valueColor: AlwaysStoppedAnimation<Color>(accent),
184
+ ),
185
),
186
],
187
+ ],
188
+ );
189
+ }
190
+}
191
+
192
+class _ReasoningOptionChips extends StatelessWidget {
193
+ const _ReasoningOptionChips({
194
+ required this.options,
195
+ required this.currentValue,
196
+ required this.saving,
197
+ required this.isDark,
198
+ required this.accent,
199
+ required this.onSelected,
200
+ });
201
+
202
+ final List<({String value, String label})> options;
203
+ final String currentValue;
204
+ final bool saving;
205
+ final bool isDark;
206
+ final Color accent;
207
+ final ValueChanged<String> onSelected;
208
+
209
+ @override
210
+ Widget build(BuildContext context) {
211
+ return SingleChildScrollView(
212
+ scrollDirection: Axis.horizontal,
213
+ child: Row(
214
+ mainAxisSize: MainAxisSize.min,
215
+ children: options.map((opt) {
216
+ final isActive = opt.value == currentValue;
217
+
218
+ final chipBg = isActive
219
+ ? accent.withValues(alpha: 0.2)
220
+ : (isDark
221
+ ? Colors.white.withValues(alpha: 0.05)
222
+ : Colors.black.withValues(alpha: 0.04));
223
+
224
+ final chipFg = isActive
225
+ ? accent
226
+ : (isDark ? AppColors.darkMutedText : AppColors.lightMutedText);
227
+
228
+ final borderColor = isActive
229
+ ? accent.withValues(alpha: 0.5)
230
+ : Colors.transparent;
231
+
232
+ return Padding(
233
+ padding: const EdgeInsets.only(right: 4),
234
+ child: Material(
235
+ color: Colors.transparent,
236
+ child: InkWell(
237
+ onTap: saving || isActive ? null : () => onSelected(opt.value),
238
+ borderRadius: BorderRadius.circular(6),
239
+ child: AnimatedContainer(
240
+ duration: const Duration(milliseconds: 150),
241
+ padding: const EdgeInsets.symmetric(
242
+ horizontal: 8,
243
+ vertical: 3,
244
+ ),
245
+ decoration: BoxDecoration(
246
+ color: chipBg,
247
+ borderRadius: BorderRadius.circular(6),
248
+ border: Border.all(color: borderColor, width: 1),
249
+ ),
250
+ child: Text(
251
+ opt.label,
252
+ style: TextStyle(
253
+ fontSize: 10,
254
+ fontWeight: isActive ? FontWeight.bold : FontWeight.w500,
255
+ color: chipFg,
256
+ ),
257
+ ),
258
+ ),
259
+ ),
260
+ ),
261
+ );
262
+ }).toList(),
263
),
264
);
265
}
266
}
267
+
268
+class _ReasoningErrorMessage extends StatelessWidget {
269
+ const _ReasoningErrorMessage({required this.message});
270
+
271
+ final String message;
272
+
273
+ @override
274
+ Widget build(BuildContext context) {
275
+ return Row(
276
+ children: [
277
+ HugeIcon(
278
+ icon: HugeIcons.strokeRoundedAlertCircle,
279
+ size: 12,
280
+ color: Colors.red[400],
281
+ ),
282
+ const SizedBox(width: 4),
283
+ Expanded(
284
+ child: Text(
285
+ message,
286
+ style: TextStyle(fontSize: 10, color: Colors.red[400]),
287
+ ),
288
+ ),
289
+ ],
290
+ );
291
+ }
292
+}
lib/features/on_device/views/model_manager_screen.dart
+13
-10
@@ -124,7 +124,7 @@ class _OnDeviceModelManagerScreenState
124
}
125
126
Future<void> _importLocalGguf(BuildContext context) async {
127
- final messenger = ScaffoldMessenger.of(context);
127
+ final messenger = ScaffoldMessenger.maybeOf(context);
128
final l10n = AppLocalizations.of(context)!;
129
try {
130
final result = await SafeFilePicker.pickFiles(
@@ -135,7 +135,8 @@ class _OnDeviceModelManagerScreenState
135
136
final path = result.files.single.path;
137
if (path == null || !path.toLowerCase().endsWith('.gguf')) {
138
- messenger.showSnackBar(
138
+ if (!mounted) return;
139
+ messenger?.showSnackBar(
140
SnackBar(content: Text(l10n.gguf_only_supported)),
141
);
142
return;
@@ -144,13 +145,15 @@ class _OnDeviceModelManagerScreenState
145
final model = await ref
146
.read(importedGgufModelsProvider.notifier)
147
.importModel(path);
147
- messenger.showSnackBar(
148
+ if (!mounted) return;
149
+ messenger?.showSnackBar(
150
SnackBar(
151
content: Text('${model.name} ${l10n.gguf_imported_from_local_file}'),
152
),
153
);
154
} catch (e) {
153
- messenger.showSnackBar(
155
+ if (!mounted) return;
156
+ messenger?.showSnackBar(
157
SnackBar(
158
content: Text(
159
'${l10n.gguf_import_failed}: ${_friendlyErrorForL10n(l10n, e)}',
@@ -161,7 +164,7 @@ class _OnDeviceModelManagerScreenState
164
}
165
166
Future<void> _importFromHuggingFace(BuildContext context) async {
164
- final messenger = ScaffoldMessenger.of(context);
167
+ final messenger = ScaffoldMessenger.maybeOf(context);
168
final l10n = AppLocalizations.of(context)!;
169
final importedModel = await showDialog<OnDeviceModel>(
170
context: context,
@@ -170,7 +173,7 @@ class _OnDeviceModelManagerScreenState
173
);
174
175
if (!mounted || importedModel == null) return;
173
- messenger.showSnackBar(
176
+ messenger?.showSnackBar(
177
SnackBar(
178
content: Text(
179
'${importedModel.name} ${l10n.gguf_imported_from_huggingface}',
@@ -927,7 +930,7 @@ class _HuggingFaceGgufImportDialogState
930
final text = data?.text?.trim();
931
if (!mounted) return;
932
if (text == null || text.isEmpty) {
930
- ScaffoldMessenger.of(context).showSnackBar(
933
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
934
SnackBar(content: Text(AppLocalizations.of(context)!.clipboard_empty)),
935
);
936
return;
@@ -946,14 +949,14 @@ class _HuggingFaceGgufImportDialogState
949
}
950
951
Future<void> _openHuggingFace(BuildContext context) async {
949
- final messenger = ScaffoldMessenger.of(context);
952
+ final messenger = ScaffoldMessenger.maybeOf(context);
953
try {
954
final launched = await launchUrl(
955
_huggingFaceModelsUri,
956
mode: LaunchMode.externalApplication,
957
);
958
if (!launched && context.mounted) {
956
- messenger.showSnackBar(
959
+ messenger?.showSnackBar(
960
SnackBar(
961
content: Text(
962
AppLocalizations.of(context)!.could_not_open_huggingface,
@@ -963,7 +966,7 @@ class _HuggingFaceGgufImportDialogState
966
}
967
} catch (_) {
968
if (context.mounted) {
966
- messenger.showSnackBar(
969
+ messenger?.showSnackBar(
970
SnackBar(
971
content: Text(
972
AppLocalizations.of(context)!.could_not_open_huggingface,
lib/features/onboarding/screens/onboarding_model_download_screen.dart
+2
-2
@@ -205,7 +205,7 @@ class _OnboardingModelDownloadScreenState
205
}
206
} catch (e) {
207
if (mounted) {
208
- ScaffoldMessenger.of(context).showSnackBar(
208
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
209
SnackBar(
210
content: Text(
211
AppLocalizations.of(context)!.error_with_message(e.toString()),
@@ -668,7 +668,7 @@ class _ModelCard extends ConsumerWidget {
668
.startDownload(model.id);
669
670
if (result == 'missing_huggingface_token' && context.mounted) {
671
- ScaffoldMessenger.of(context).showSnackBar(
671
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
672
SnackBar(
673
content: Text(
674
AppLocalizations.of(context)!.model_missing_huggingface_token,
lib/features/onboarding/screens/onboarding_notification_permission_screen.dart
+3
-1
@@ -21,6 +21,7 @@ class OnboardingNotificationPermissionScreen extends ConsumerStatefulWidget {
21
class _OnboardingNotificationPermissionScreenState
22
extends ConsumerState<OnboardingNotificationPermissionScreen> {
23
bool _isProcessing = false;
24
+ bool _hasNavigated = false;
25
26
Future<void> _completeOnboarding() async {
27
final settings = ref.read(settingsProvider);
@@ -34,6 +35,7 @@ class _OnboardingNotificationPermissionScreenState
35
);
36
37
if (mounted) {
38
+ _hasNavigated = true;
39
context.go(AppRoutes.home);
40
}
41
}
@@ -47,7 +49,7 @@ class _OnboardingNotificationPermissionScreenState
49
await notificationService.requestPermission();
50
await _completeOnboarding();
51
} finally {
50
- if (mounted) {
52
+ if (mounted && !_hasNavigated) {
53
setState(() => _isProcessing = false);
54
}
55
}
lib/features/onboarding/screens/onboarding_server_setup_screen.dart
+1
-1
@@ -227,7 +227,7 @@ class _OnboardingServerSetupScreenState
227
}
228
} catch (e) {
229
if (mounted) {
230
- ScaffoldMessenger.of(context).showSnackBar(
230
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
231
SnackBar(
232
content: Text(
233
AppLocalizations.of(context)?.error_with_message(e.toString()) ??
lib/features/onboarding/screens/onboarding_server_type_screen.dart
+4
-4
@@ -33,7 +33,7 @@ class _OnboardingServerTypeScreenState
33
}
34
35
Future<void> _openRepoUrl() async {
36
- final messenger = ScaffoldMessenger.of(context);
36
+ final messenger = ScaffoldMessenger.maybeOf(context);
37
final l10n = AppLocalizations.of(context)!;
38
try {
39
final launched = await launchUrl(
@@ -41,13 +41,13 @@ class _OnboardingServerTypeScreenState
41
mode: LaunchMode.externalApplication,
42
);
43
if (!launched && mounted) {
44
- messenger.showSnackBar(
44
+ messenger?.showSnackBar(
45
SnackBar(content: Text(l10n.could_not_open_github)),
46
);
47
}
48
} catch (_) {
49
if (mounted) {
50
- messenger.showSnackBar(
50
+ messenger?.showSnackBar(
51
SnackBar(content: Text(l10n.could_not_open_github)),
52
);
53
}
@@ -334,7 +334,7 @@ class _OnboardingServerTypeScreenState
334
onTap: disabled
335
? () {
336
if (disabledReason != null) {
337
- ScaffoldMessenger.of(context).showSnackBar(
337
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
338
SnackBar(
339
content: Text(disabledReason),
340
backgroundColor: theme.colorScheme.error,
lib/features/personas/views/create_persona_screen.dart
+24
-11
@@ -117,6 +117,7 @@ class _CreatePersonaScreenState extends ConsumerState<CreatePersonaScreen> {
117
if (topP != null) params['topP'] = topP;
118
}
119
120
+ var hasPopped = false;
121
try {
122
if (_isEditing) {
123
await notifier.updatePersona(
@@ -141,29 +142,41 @@ class _CreatePersonaScreenState extends ConsumerState<CreatePersonaScreen> {
142
}
143
144
if (mounted) {
144
- final l10n = AppLocalizations.of(context)!;
145
- context.pop();
146
- ScaffoldMessenger.of(context).showSnackBar(
147
- SnackBar(
148
- content: Text(
149
- _isEditing ? l10n.persona_updated : l10n.persona_created,
145
+ final l10n = AppLocalizations.of(context);
146
+ final messenger = ScaffoldMessenger.maybeOf(context);
147
+ hasPopped = true;
148
+ if (Navigator.canPop(context)) {
149
+ Navigator.pop(context);
150
+ } else {
151
+ context.pop();
152
+ }
153
+ if (l10n != null && messenger != null) {
154
+ messenger.showSnackBar(
155
+ SnackBar(
156
+ content: Text(
157
+ _isEditing ? l10n.persona_updated : l10n.persona_created,
158
+ ),
159
),
151
- ),
152
- );
160
+ );
161
+ }
162
}
163
} catch (e) {
164
+ hasPopped = false;
165
if (mounted) {
156
- ScaffoldMessenger.of(context).showSnackBar(
166
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
167
SnackBar(
168
content: Text(
159
- AppLocalizations.of(context)!.error_with_message(e.toString()),
169
+ AppLocalizations.of(context)?.error_with_message(e.toString()) ??
170
+ 'Error: $e',
171
),
172
backgroundColor: Colors.red,
173
),
174
);
175
}
176
} finally {
166
- if (mounted) setState(() => _isSaving = false);
177
+ if (mounted && !hasPopped) {
178
+ setState(() => _isSaving = false);
179
+ }
180
}
181
}
182
lib/features/saved_messages/views/saved_messages_screen.dart
+2
-2
@@ -197,7 +197,7 @@ class SavedMessagesScreen extends ConsumerWidget {
197
return;
198
}
199
if (saved.conversationId.isEmpty) {
200
- ScaffoldMessenger.of(context).showSnackBar(
200
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
201
SnackBar(
202
content: Text(
203
l10n.saved_message_temp_snap_unavailable,
@@ -241,7 +241,7 @@ class SavedMessagesScreen extends ConsumerWidget {
241
ClipboardData(text: saved.content),
242
);
243
if (context.mounted) {
244
- ScaffoldMessenger.of(context).showSnackBar(
244
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
245
SnackBar(content: Text(l10n.copied_to_clipboard)),
246
);
247
}
lib/features/servers/data/server_api_service.dart
+29
-5
@@ -570,7 +570,10 @@ class ServerApiService {
570
data: {'model': model.id},
571
options: Options(headers: buildServerAuthHeaders(server)),
572
);
573
- final capabilities = _parseOllamaCapabilityList(response.data);
573
+ final capabilities = _parseOllamaCapabilityList(
574
+ response.data,
575
+ modelId: model.id,
576
+ );
577
return model.copyWith(
578
supportsVision: capabilities.supportsVision,
579
supportsReasoning: capabilities.supportsReasoning,
@@ -597,7 +600,7 @@ class ServerApiService {
600
String? defaultReasoningEffort,
601
bool reasoningMandatory,
602
})
600
- _parseOllamaCapabilityList(dynamic data) {
603
+ _parseOllamaCapabilityList(dynamic data, {required String modelId}) {
604
if (data is! Map) {
605
return (
606
supportsVision: false,
@@ -632,15 +635,36 @@ class ServerApiService {
635
final isGptOss = familyNames.any(
636
(family) => family == 'gptoss' || family == 'gpt-oss',
637
);
635
- final supportsReasoning = capabilities.contains('thinking') || isGptOss;
636
- final reasoningMandatory = isGptOss;
638
+ final modelfile = data['modelfile']?.toString().toLowerCase() ?? '';
639
+ final normalizedModelId = modelId.trim().toLowerCase();
640
+ // Ollama currently advertises only the broad `thinking` capability, not
641
+ // whether a model can actually stop generating a reasoning trace. The
642
+ // LFM2.5 Thinking renderer is reasoning-only: `think: false` disables its
643
+ // parser and leaks the raw <think> block into message.content instead of
644
+ // disabling generation (ollama/ollama#14622). Treat that exact renderer
645
+ // as mandatory so LocalMind keeps the parser enabled and the channels
646
+ // separate. The model-id check covers older /api/show responses that omit
647
+ // renderer metadata.
648
+ final isLfmThinkingOnly =
649
+ familyNames.contains('lfm2') &&
650
+ (modelfile.contains('renderer lfm2-thinking') ||
651
+ modelfile.contains('parser lfm2-thinking') ||
652
+ normalizedModelId.startsWith('lfm2.5-thinking:') ||
653
+ normalizedModelId == 'lfm2.5-thinking');
654
+ final supportsReasoning =
655
+ capabilities.contains('thinking') || isGptOss || isLfmThinkingOnly;
656
+ final reasoningMandatory = isGptOss || isLfmThinkingOnly;
657
658
return (
659
supportsVision: capabilities.contains('vision'),
660
supportsReasoning: supportsReasoning,
661
supportsToolUse: capabilities.contains('tools'),
662
supportedReasoningEfforts: supportsReasoning
643
- ? (isGptOss ? const ['low', 'medium', 'high'] : const ['off', 'on'])
663
+ ? (isGptOss
664
+ ? const ['low', 'medium', 'high']
665
+ : isLfmThinkingOnly
666
+ ? const ['on']
667
+ : const ['off', 'on'])
668
: null,
669
defaultReasoningEffort: null,
670
reasoningMandatory: reasoningMandatory,
lib/features/servers/views/add_server_screen.dart
+13
-5
@@ -218,6 +218,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
218
_isSaving = true;
219
});
220
221
+ var hasPopped = false;
222
try {
223
final server = _buildServer();
224
@@ -238,9 +239,15 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
239
240
if (mounted) {
241
final l10n = AppLocalizations.of(context);
241
- context.pop();
242
- if (l10n != null) {
243
- ScaffoldMessenger.of(context).showSnackBar(
242
+ final messenger = ScaffoldMessenger.maybeOf(context);
243
+ hasPopped = true;
244
+ if (Navigator.canPop(context)) {
245
+ Navigator.pop(context);
246
+ } else {
247
+ context.pop();
248
+ }
249
+ if (l10n != null && messenger != null) {
250
+ messenger.showSnackBar(
251
SnackBar(
252
content: Text(
253
_isEditing ? l10n.server_updated : l10n.server_added,
@@ -251,8 +258,9 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
258
}
259
}
260
} catch (e) {
261
+ hasPopped = false;
262
if (mounted) {
255
- ScaffoldMessenger.of(context).showSnackBar(
263
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
264
SnackBar(
265
content: Text(
266
AppLocalizations.of(context)?.error_with_message(e.toString()) ??
@@ -263,7 +271,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
271
);
272
}
273
} finally {
266
- if (mounted) {
274
+ if (mounted && !hasPopped) {
275
setState(() {
276
_isSaving = false;
277
});
lib/features/servers/views/server_list_screen.dart
+3
-1
@@ -112,7 +112,9 @@ class _ServerListScreenState extends ConsumerState<ServerListScreen> {
112
ref
113
.read(activeServerIdProvider.notifier)
114
.setActiveServer(server);
115
- ScaffoldMessenger.of(context).showSnackBar(
115
+ ScaffoldMessenger.maybeOf(
116
+ context,
117
+ )?.showSnackBar(
118
SnackBar(
119
content: Text(
120
l10n.switched_to_server(server.name),
lib/features/settings/views/data_backup_actions.dart
+12
-12
@@ -40,14 +40,14 @@ class DataBackupActions extends ConsumerWidget {
40
);
41
if (saved == null) return;
42
if (context.mounted) {
43
- ScaffoldMessenger.of(
43
+ ScaffoldMessenger.maybeOf(
44
context,
45
- ).showSnackBar(SnackBar(content: Text(l10n.export_data_success)));
45
+ )?.showSnackBar(SnackBar(content: Text(l10n.export_data_success)));
46
}
47
} catch (e) {
48
if (context.mounted) {
49
final msg = SafeFilePicker.getErrorMessage(e, l10n);
50
- ScaffoldMessenger.of(context).showSnackBar(
50
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
51
SnackBar(
52
content: Text(
53
SafeFilePicker.isExplorerNotFoundError(e)
@@ -86,14 +86,14 @@ class DataBackupActions extends ConsumerWidget {
86
);
87
if (saved == null) return;
88
if (context.mounted) {
89
- ScaffoldMessenger.of(
89
+ ScaffoldMessenger.maybeOf(
90
context,
91
- ).showSnackBar(SnackBar(content: Text(l10n.export_data_success)));
91
+ )?.showSnackBar(SnackBar(content: Text(l10n.export_data_success)));
92
}
93
} catch (e) {
94
if (context.mounted) {
95
final msg = SafeFilePicker.getErrorMessage(e, l10n);
96
- ScaffoldMessenger.of(context).showSnackBar(
96
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
97
SnackBar(
98
content: Text(
99
SafeFilePicker.isExplorerNotFoundError(e)
@@ -151,14 +151,14 @@ class DataBackupActions extends ConsumerWidget {
151
await importer(json);
152
153
if (context.mounted) {
154
- ScaffoldMessenger.of(
154
+ ScaffoldMessenger.maybeOf(
155
context,
156
- ).showSnackBar(SnackBar(content: Text(l10n.import_data_success)));
156
+ )?.showSnackBar(SnackBar(content: Text(l10n.import_data_success)));
157
}
158
} catch (e) {
159
if (context.mounted) {
160
final msg = SafeFilePicker.getErrorMessage(e, l10n);
161
- ScaffoldMessenger.of(context).showSnackBar(
161
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
162
SnackBar(
163
content: Text(
164
SafeFilePicker.isExplorerNotFoundError(e)
@@ -234,14 +234,14 @@ class DataBackupActions extends ConsumerWidget {
234
});
235
236
if (context.mounted) {
237
- ScaffoldMessenger.of(
237
+ ScaffoldMessenger.maybeOf(
238
context,
239
- ).showSnackBar(SnackBar(content: Text(l10n.import_data_success)));
239
+ )?.showSnackBar(SnackBar(content: Text(l10n.import_data_success)));
240
}
241
} catch (e) {
242
if (context.mounted) {
243
final msg = SafeFilePicker.getErrorMessage(e, l10n);
244
- ScaffoldMessenger.of(context).showSnackBar(
244
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
245
SnackBar(
246
content: Text(
247
SafeFilePicker.isExplorerNotFoundError(e)
lib/features/settings/views/settings_screen.dart
+4
-4
@@ -368,7 +368,7 @@ class SettingsViews extends ConsumerWidget {
368
ref
369
.read(settingsProvider.notifier)
370
.setHuggingFaceToken(value);
371
- ScaffoldMessenger.of(context).showSnackBar(
371
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
372
SnackBar(
373
content: Text(
374
value == null || value.isEmpty
@@ -442,7 +442,7 @@ class SettingsViews extends ConsumerWidget {
442
.read(personasNotifierProvider.notifier)
443
.restoreBuiltInPersonas();
444
if (context.mounted) {
445
- ScaffoldMessenger.of(context).showSnackBar(
445
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
446
SnackBar(
447
content: Text(
448
l10n.restore_builtin_personas_success,
@@ -671,7 +671,7 @@ class _AndroidAssistantSettingState
671
await _refreshStatus();
672
} on PlatformException catch (e) {
673
if (mounted) {
674
- ScaffoldMessenger.of(context).showSnackBar(
674
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
675
SnackBar(
676
content: Text(
677
AppLocalizations.of(context)!.assistant_error(e.toString()),
@@ -1986,7 +1986,7 @@ class _DangerousAction extends StatelessWidget {
1986
onPressed: () {
1987
Navigator.pop(dialogContext);
1988
onConfirm();
1989
- ScaffoldMessenger.of(context).showSnackBar(
1989
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
1990
SnackBar(content: Text(l10n.label_completed(label))),
1991
);
1992
},
lib/features/tts/views/components/tts_player_bar.dart
+12
-9
@@ -87,13 +87,16 @@ class TtsPlayerBar extends ConsumerWidget {
87
88
ref.listen<tts.TtsState>(tts.ttsProvider, (prev, next) {
89
if (next.error != null && prev?.error != next.error) {
90
- ScaffoldMessenger.of(context).showSnackBar(
91
- SnackBar(
92
- content: Text(next.error!),
93
- backgroundColor: Colors.red,
94
- duration: const Duration(seconds: 3),
95
- ),
96
- );
90
+ WidgetsBinding.instance.addPostFrameCallback((_) {
91
+ if (!context.mounted) return;
92
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
93
+ SnackBar(
94
+ content: Text(next.error!),
95
+ backgroundColor: Colors.red,
96
+ duration: const Duration(seconds: 3),
97
+ ),
98
+ );
99
+ });
100
}
101
});
102
@@ -256,7 +259,7 @@ class TtsPlayerBar extends ConsumerWidget {
259
try {
260
final ok = await notifier.downloadCurrentAudio();
261
if (!context.mounted) return;
259
- ScaffoldMessenger.of(context).showSnackBar(
262
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
263
SnackBar(
264
content: Text(
265
ok
@@ -267,7 +270,7 @@ class TtsPlayerBar extends ConsumerWidget {
270
);
271
} catch (e) {
272
if (!context.mounted) return;
270
- ScaffoldMessenger.of(context).showSnackBar(
273
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
274
SnackBar(
275
content: Text(
276
SafeFilePicker.getErrorMessage(
lib/features/tts/views/tts_model_manager_screen.dart
+6
-2
@@ -889,7 +889,11 @@ class _VoiceChipsState extends ConsumerState<_VoiceChips> {
889
ref.listen<tts.TtsState>(tts.ttsProvider, (previous, next) {
890
if (!next.isSpeaking && (previous?.isSpeaking ?? false)) {
891
if (mounted && _playingVoice != null) {
892
- setState(() => _playingVoice = null);
892
+ WidgetsBinding.instance.addPostFrameCallback((_) {
893
+ if (mounted && _playingVoice != null) {
894
+ setState(() => _playingVoice = null);
895
+ }
896
+ });
897
}
898
}
899
});
@@ -1097,7 +1101,7 @@ class _VoiceChipsState extends ConsumerState<_VoiceChips> {
1101
} catch (e) {
1102
if (!mounted) return;
1103
setState(() => _playingVoice = null);
1100
- ScaffoldMessenger.of(context).showSnackBar(
1104
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
1105
SnackBar(content: Text(l10n.preview_failed(e.toString()))),
1106
);
1107
}
pubspec.yaml
+1
-1
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17
# In Windows, build-name is used as the major, minor, and patch parts
18
# of the product and file versions while build-number is used as the build suffix.
19
-version: 1.8.0+85
19
+version: 1.8.2+88
20
21
environment:
22
sdk: ">=3.12.0 <4.0.0"
release_message_sample.md
new
+25
@@ -0,0 +1,25 @@
1
+<en-US>
2
+- Bug fixes
3
+- Performance improvements
4
+- New features
5
+</en-US>
6
+<ar>
7
+- إصلاح الأخطاء
8
+- تحسينات الأداء
9
+- ميزات جديدة
10
+</ar>
11
+<bn-BD>
12
+- বাগ সংশোধন
13
+- পারফরম্যান্সের উন্নতি
14
+- নতুন ফিচারসমূহ
15
+</bn-BD>
16
+<en-GB>
17
+- Bug fixes
18
+- Performance improvements
19
+- New features
20
+</en-GB>
21
+<zh-CN>
22
+- Bug 修复
23
+- 性能提升
24
+- 新功能
25
+</zh-CN>
test/features/chat/mcp_tool_result_followup_test.dart
+4
-10
@@ -1,6 +1,7 @@
1
import 'package:flutter_test/flutter_test.dart';
2
import 'package:localmind/core/models/enums.dart';
3
-import 'package:localmind/features/chat/data/models/message.dart' as m
3
+import 'package:localmind/features/chat/data/models/message.dart'
4
+ as m
5
show Message, ToolCallData;
6
import 'package:localmind/features/chat/data/tools/tool_event.dart';
7
import 'package:localmind/features/chat/data/tools/tool_definition.dart';
@@ -23,10 +24,7 @@ import 'package:localmind/features/chat/data/tools/tool_definition.dart';
24
// follow-up request will be malformed and the model will not produce a
25
// final answer — the original symptom in #77.
26
26
-m.Message _previousAssistant({
27
- required String id,
28
- required String toolName,
29
-}) {
27
+m.Message _previousAssistant({required String id, required String toolName}) {
28
return m.Message(
29
id: id,
30
conversationId: 'conv-1',
@@ -223,11 +221,7 @@ void main() {
221
test('assistant stopReason is set to tool_use', () {
222
final assistant = _previousAssistant(id: 'asst-1', toolName: 't');
223
final events = [
226
- _completedEvent(
227
- sessionId: 's',
228
- toolName: 't',
229
- result: 'ok',
230
- ),
224
+ _completedEvent(sessionId: 's', toolName: 't', result: 'ok'),
225
];
226
227
final built = _buildFollowup(
test/features/issue_crash_fixes_test.dart
+371
@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
7
import 'package:flutter_test/flutter_test.dart';
8
import 'package:just_audio/just_audio.dart';
9
import 'package:just_audio_background/just_audio_background.dart';
10
+import 'package:localmind/bootstrap/safe_riverpod_scope_host.dart';
11
import 'package:localmind/core/models/enums.dart';
12
import 'package:localmind/core/providers/app_providers.dart';
13
import 'package:localmind/core/providers/service_providers.dart';
@@ -21,6 +22,9 @@ import 'package:localmind/features/servers/data/models/server.dart';
22
import 'package:localmind/features/servers/data/server_api_service.dart';
23
import 'package:localmind/features/servers/providers/server_providers.dart';
24
import 'package:localmind/features/onboarding/screens/onboarding_server_setup_screen.dart';
25
+import 'package:localmind/features/personas/data/models/persona.dart';
26
+import 'package:localmind/features/personas/providers/personas_providers.dart';
27
+import 'package:localmind/features/personas/views/create_persona_screen.dart';
28
import 'package:localmind/features/servers/views/add_server_screen.dart';
29
import 'package:flutter_localizations/flutter_localizations.dart';
30
import 'package:localmind/features/settings/data/models/app_settings.dart';
@@ -791,6 +795,301 @@ void main() {
795
},
796
);
797
});
798
+
799
+ group(
800
+ 'Issue Crash Fix: Riverpod UncontrolledProviderScope setState during build on route pop',
801
+ () {
802
+ testWidgets(
803
+ 'SafeRiverpodScopeHost prevents setState during build when resuming paused providers on route pop',
804
+ (tester) async {
805
+ final serverA = Server(
806
+ id: 'server-initial',
807
+ name: 'Initial Server',
808
+ host: '127.0.0.1',
809
+ port: 11434,
810
+ type: ServerType.ollama,
811
+ createdAt: now,
812
+ lastConnectedAt: now,
813
+ );
814
+
815
+ final serversNotifier = _MutableServersNotifier([serverA]);
816
+ final container = ProviderContainer(
817
+ overrides: [serversProvider.overrideWith(() => serversNotifier)],
818
+ );
819
+ addTearDown(container.dispose);
820
+
821
+ final navigatorKey = GlobalKey<NavigatorState>();
822
+
823
+ await tester.pumpWidget(
824
+ UncontrolledProviderScope(
825
+ container: container,
826
+ child: SafeRiverpodScopeHost(
827
+ container: container,
828
+ child: MaterialApp(
829
+ navigatorKey: navigatorKey,
830
+ home: Consumer(
831
+ builder: (context, ref, _) {
832
+ final servers = ref.watch(serversProvider);
833
+ return Scaffold(
834
+ body: Text('Count: ${servers.value?.length ?? 0}'),
835
+ );
836
+ },
837
+ ),
838
+ ),
839
+ ),
840
+ ),
841
+ );
842
+ await tester.pumpAndSettle();
843
+
844
+ expect(find.text('Count: 1'), findsOneWidget);
845
+
846
+ // Push a modal route on top (this pauses the home route's subscriptions in Riverpod 3.x)
847
+ navigatorKey.currentState!.push(
848
+ MaterialPageRoute(
849
+ builder: (context) => Scaffold(
850
+ body: ElevatedButton(
851
+ onPressed: () {
852
+ final serverB = Server(
853
+ id: 'server-added',
854
+ name: 'Added Server',
855
+ host: '127.0.0.1',
856
+ port: 1234,
857
+ type: ServerType.lmStudio,
858
+ createdAt: now,
859
+ lastConnectedAt: now,
860
+ );
861
+ serversNotifier.addServerSync(serverB);
862
+ Navigator.of(context).pop();
863
+ },
864
+ child: const Text('Add & Pop'),
865
+ ),
866
+ ),
867
+ ),
868
+ );
869
+ await tester.pumpAndSettle();
870
+
871
+ expect(find.text('Add & Pop'), findsOneWidget);
872
+
873
+ // Tap Add & Pop. This mutates serversProvider and pops while the home route was paused.
874
+ // Resuming during route pop triggers the build-phase flush.
875
+ await tester.tap(find.text('Add & Pop'));
876
+ await tester.pumpAndSettle();
877
+
878
+ expect(tester.takeException(), isNull);
879
+ expect(find.text('Count: 2'), findsOneWidget);
880
+ },
881
+ );
882
+
883
+ testWidgets(
884
+ 'AddServerScreen save flow and route pop does not crash with setState during build',
885
+ (tester) async {
886
+ SharedPreferences.setMockInitialValues({});
887
+ final prefs = await SharedPreferences.getInstance();
888
+
889
+ final serverA = Server(
890
+ id: 'server-existing',
891
+ name: 'Existing Server',
892
+ host: '127.0.0.1',
893
+ port: 11434,
894
+ type: ServerType.ollama,
895
+ createdAt: now,
896
+ lastConnectedAt: now,
897
+ );
898
+
899
+ final mockDio = Dio();
900
+ mockDio.httpClientAdapter = _MockHttpClientAdapter((options) {
901
+ return ResponseBody.fromString('{"models":[]}', 200);
902
+ });
903
+ final mockApiService = ServerApiService(mockDio);
904
+ final serversNotifier = _MutableServersNotifier([serverA]);
905
+
906
+ final container = ProviderContainer(
907
+ overrides: [
908
+ sharedPreferencesProvider.overrideWithValue(prefs),
909
+ settingsProvider.overrideWith(_TestSettingsNotifier.new),
910
+ chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
911
+ serverApiServiceProvider.overrideWithValue(mockApiService),
912
+ serversProvider.overrideWith(() => serversNotifier),
913
+ activeServerProvider.overrideWith(
914
+ () => _StubActiveServerNotifier(serverA),
915
+ ),
916
+ connectionStatusProvider.overrideWith(
917
+ _StubConnectedStatusNotifier.new,
918
+ ),
919
+ ],
920
+ );
921
+ addTearDown(container.dispose);
922
+
923
+ final navigatorKey = GlobalKey<NavigatorState>();
924
+
925
+ await tester.pumpWidget(
926
+ UncontrolledProviderScope(
927
+ container: container,
928
+ child: SafeRiverpodScopeHost(
929
+ container: container,
930
+ child: ShadTheme(
931
+ data: AppTheme.lightShadTheme,
932
+ child: MaterialApp(
933
+ navigatorKey: navigatorKey,
934
+ locale: const Locale('en'),
935
+ localizationsDelegates:
936
+ AppLocalizations.localizationsDelegates,
937
+ supportedLocales: AppLocalizations.supportedLocales,
938
+ home: Consumer(
939
+ builder: (context, ref, _) {
940
+ final servers = ref.watch(serversProvider);
941
+ return Scaffold(
942
+ body: Center(
943
+ child: ElevatedButton(
944
+ onPressed: () {
945
+ Navigator.of(context).push(
946
+ MaterialPageRoute(
947
+ builder: (_) => const AddServerScreen(),
948
+ ),
949
+ );
950
+ },
951
+ child: Text(
952
+ 'Open Add Screen (${servers.value?.length ?? 0})',
953
+ ),
954
+ ),
955
+ ),
956
+ );
957
+ },
958
+ ),
959
+ ),
960
+ ),
961
+ ),
962
+ ),
963
+ );
964
+ await tester.pumpAndSettle();
965
+
966
+ tester.view.physicalSize = const Size(800, 1800);
967
+ tester.view.devicePixelRatio = 1.0;
968
+ addTearDown(() {
969
+ tester.view.resetPhysicalSize();
970
+ tester.view.resetDevicePixelRatio();
971
+ });
972
+
973
+ // Open Add Server screen
974
+ navigatorKey.currentState!.push(
975
+ MaterialPageRoute(builder: (_) => const AddServerScreen()),
976
+ );
977
+ await tester.pumpAndSettle();
978
+
979
+ // Enter server details
980
+ final textFields = find.byType(TextFormField);
981
+ expect(textFields, findsWidgets);
982
+
983
+ await tester.enterText(textFields.at(0), 'New Ollama');
984
+ await tester.enterText(textFields.at(1), '127.0.0.1');
985
+ await tester.enterText(textFields.at(2), '11434');
986
+ await tester.pump();
987
+
988
+ // Tap Save Server
989
+ final saveButton = find.widgetWithText(ElevatedButton, 'Save Server');
990
+ expect(saveButton, findsOneWidget);
991
+
992
+ await tester.tap(saveButton);
993
+ await tester.pumpAndSettle();
994
+
995
+ expect(tester.takeException(), isNull);
996
+ expect(find.text('Open Add Screen (2)'), findsOneWidget);
997
+ },
998
+ );
999
+
1000
+ testWidgets(
1001
+ 'CreatePersonaScreen save flow and route pop does not crash with setState during build',
1002
+ (tester) async {
1003
+ SharedPreferences.setMockInitialValues({});
1004
+ final prefs = await SharedPreferences.getInstance();
1005
+
1006
+ final personasNotifier = _StubPersonasNotifier([]);
1007
+
1008
+ final container = ProviderContainer(
1009
+ overrides: [
1010
+ sharedPreferencesProvider.overrideWithValue(prefs),
1011
+ personasNotifierProvider.overrideWith(() => personasNotifier),
1012
+ ],
1013
+ );
1014
+ addTearDown(container.dispose);
1015
+
1016
+ final navigatorKey = GlobalKey<NavigatorState>();
1017
+
1018
+ await tester.pumpWidget(
1019
+ UncontrolledProviderScope(
1020
+ container: container,
1021
+ child: SafeRiverpodScopeHost(
1022
+ container: container,
1023
+ child: MaterialApp(
1024
+ navigatorKey: navigatorKey,
1025
+ locale: const Locale('en'),
1026
+ localizationsDelegates:
1027
+ AppLocalizations.localizationsDelegates,
1028
+ supportedLocales: AppLocalizations.supportedLocales,
1029
+ home: Consumer(
1030
+ builder: (context, ref, _) {
1031
+ final personas = ref.watch(personasNotifierProvider);
1032
+ return Scaffold(
1033
+ body: Center(
1034
+ child: ElevatedButton(
1035
+ onPressed: () {
1036
+ Navigator.of(context).push(
1037
+ MaterialPageRoute(
1038
+ builder: (_) => const CreatePersonaScreen(),
1039
+ ),
1040
+ );
1041
+ },
1042
+ child: Text(
1043
+ 'Open Persona Screen (${personas.value?.length ?? 0})',
1044
+ ),
1045
+ ),
1046
+ ),
1047
+ );
1048
+ },
1049
+ ),
1050
+ ),
1051
+ ),
1052
+ ),
1053
+ );
1054
+ await tester.pumpAndSettle();
1055
+
1056
+ tester.view.physicalSize = const Size(800, 1800);
1057
+ tester.view.devicePixelRatio = 1.0;
1058
+ addTearDown(() {
1059
+ tester.view.resetPhysicalSize();
1060
+ tester.view.resetDevicePixelRatio();
1061
+ });
1062
+
1063
+ // Open Create Persona screen
1064
+ navigatorKey.currentState!.push(
1065
+ MaterialPageRoute(builder: (_) => const CreatePersonaScreen()),
1066
+ );
1067
+ await tester.pumpAndSettle();
1068
+
1069
+ // Enter persona details
1070
+ final textFields = find.byType(TextFormField);
1071
+ expect(textFields, findsWidgets);
1072
+
1073
+ await tester.enterText(textFields.at(0), 'Assistant Persona');
1074
+ await tester.enterText(
1075
+ textFields.at(2),
1076
+ 'You are a helpful coding assistant.',
1077
+ );
1078
+ await tester.pump();
1079
+
1080
+ // Tap Create Persona button
1081
+ final createButton = find.widgetWithText(FilledButton, 'Create');
1082
+ expect(createButton, findsOneWidget);
1083
+
1084
+ await tester.tap(createButton);
1085
+ await tester.pumpAndSettle();
1086
+
1087
+ expect(tester.takeException(), isNull);
1088
+ expect(find.text('Open Persona Screen (1)'), findsOneWidget);
1089
+ },
1090
+ );
1091
+ },
1092
+ );
1093
}
1094
1095
class _MutableActiveServerNotifier extends ActiveServerNotifier {
@@ -825,6 +1124,45 @@ class _StubServersNotifier extends ServersNotifier {
1124
Future<List<Server>> build() async => [server];
1125
}
1126
1127
+class _MutableServersNotifier extends ServersNotifier {
1128
+ _MutableServersNotifier(this._servers);
1129
+
1130
+ List<Server> _servers;
1131
+
1132
+ @override
1133
+ Future<List<Server>> build() async => _servers;
1134
+
1135
+ void addServerSync(Server server) {
1136
+ _servers = [..._servers, server];
1137
+ state = AsyncData(_servers);
1138
+ }
1139
+
1140
+ @override
1141
+ Future<void> addServer(Server server) async {
1142
+ _servers = [..._servers, server];
1143
+ state = AsyncData(_servers);
1144
+ }
1145
+
1146
+ @override
1147
+ Future<void> updateServer(Server server) async {
1148
+ final index = _servers.indexWhere((s) => s.id == server.id);
1149
+ if (index != -1) {
1150
+ _servers[index] = server;
1151
+ } else {
1152
+ _servers = [..._servers, server];
1153
+ }
1154
+ state = AsyncData(_servers);
1155
+ }
1156
+
1157
+ @override
1158
+ Future<ConnectionStatus> testConnection(
1159
+ String serverId,
1160
+ dynamic apiService,
1161
+ ) async {
1162
+ return ConnectionStatus.connected;
1163
+ }
1164
+}
1165
+
1166
class _StubConnectedStatusNotifier extends ConnectionStatusNotifier {
1167
@override
1168
ConnectionStatus build() => ConnectionStatus.connected;
@@ -852,3 +1190,36 @@ class _TestSettingsNotifier extends SettingsNotifier {
1190
@override
1191
AppSettings build() => AppSettings(unloadModelsBeforeLoad: false);
1192
}
1193
+
1194
+class _StubPersonasNotifier extends PersonasNotifier {
1195
+ _StubPersonasNotifier(this._personas);
1196
+ final List<Persona> _personas;
1197
+
1198
+ @override
1199
+ Future<List<Persona>> build() async => _personas;
1200
+
1201
+ @override
1202
+ Future<Persona> createPersona({
1203
+ required String name,
1204
+ required String emoji,
1205
+ required String systemPrompt,
1206
+ String? description,
1207
+ String? category,
1208
+ Map<String, dynamic>? preferredParams,
1209
+ }) async {
1210
+ final persona = Persona(
1211
+ id: 'p-${DateTime.now().millisecondsSinceEpoch}',
1212
+ name: name,
1213
+ emoji: emoji,
1214
+ systemPrompt: systemPrompt,
1215
+ description: description,
1216
+ category: category,
1217
+ preferredParams: preferredParams,
1218
+ createdAt: DateTime.now(),
1219
+ updatedAt: DateTime.now(),
1220
+ );
1221
+ _personas.add(persona);
1222
+ state = AsyncData([..._personas]);
1223
+ return persona;
1224
+ }
1225
+}
test/features/models/thinking_controls_test.dart
new
+129
@@ -0,0 +1,129 @@
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/models/enums.dart';
5
+import 'package:localmind/features/chat/providers/chat_reasoning_providers.dart';
6
+import 'package:localmind/features/models/components/inline_thinking_selector.dart';
7
+import 'package:localmind/features/models/components/thinking_mode_chip.dart';
8
+import 'package:localmind/features/models/data/models/model_info.dart';
9
+import 'package:localmind/l10n/app_localizations.dart';
10
+
11
+Widget _app(Widget child) {
12
+ return ProviderScope(
13
+ child: MaterialApp(
14
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
15
+ supportedLocales: AppLocalizations.supportedLocales,
16
+ home: Scaffold(body: child),
17
+ ),
18
+ );
19
+}
20
+
21
+ModelInfo _model({
22
+ required List<String> supportedEfforts,
23
+ bool mandatory = false,
24
+}) {
25
+ return ModelInfo(
26
+ id: 'thinking-model',
27
+ name: 'Thinking model',
28
+ serverType: ServerType.ollama,
29
+ serverId: 'ollama',
30
+ supportsReasoning: true,
31
+ supportedReasoningEfforts: supportedEfforts,
32
+ reasoningMandatory: mandatory,
33
+ );
34
+}
35
+
36
+void main() {
37
+ testWidgets('binary model selector labels its boolean state On', (
38
+ tester,
39
+ ) async {
40
+ await tester.pumpWidget(
41
+ _app(
42
+ const InlineThinkingSelector(
43
+ isDark: false,
44
+ isSelected: true,
45
+ supportedEfforts: ['off', 'on'],
46
+ ),
47
+ ),
48
+ );
49
+
50
+ expect(find.text('Thinking mode'), findsOneWidget);
51
+ expect(find.text('Off'), findsOneWidget);
52
+ expect(find.text('On'), findsOneWidget);
53
+ expect(find.text('Medium'), findsNothing);
54
+ });
55
+
56
+ testWidgets('mandatory binary selector exposes only On', (tester) async {
57
+ await tester.pumpWidget(
58
+ _app(
59
+ const InlineThinkingSelector(
60
+ isDark: false,
61
+ isSelected: true,
62
+ supportedEfforts: ['on'],
63
+ reasoningMandatory: true,
64
+ ),
65
+ ),
66
+ );
67
+
68
+ expect(find.text('On'), findsOneWidget);
69
+ expect(find.text('Off'), findsNothing);
70
+ expect(find.text('Medium'), findsNothing);
71
+ });
72
+
73
+ testWidgets('compact binary chip displays On and toggles to Off', (
74
+ tester,
75
+ ) async {
76
+ await tester.pumpWidget(
77
+ _app(
78
+ ThinkingModeChip(
79
+ model: _model(supportedEfforts: const ['off', 'on']),
80
+ isDark: false,
81
+ ),
82
+ ),
83
+ );
84
+
85
+ expect(find.text('On'), findsOneWidget);
86
+ expect(find.text('Medium'), findsNothing);
87
+
88
+ await tester.tap(find.text('On'));
89
+ await tester.pump();
90
+
91
+ expect(find.text('Off'), findsOneWidget);
92
+ expect(find.text('On'), findsNothing);
93
+ });
94
+
95
+ testWidgets('mandatory compact binary chip stays On', (tester) async {
96
+ await tester.pumpWidget(
97
+ _app(
98
+ ThinkingModeChip(
99
+ model: _model(supportedEfforts: const ['on'], mandatory: true),
100
+ isDark: false,
101
+ ),
102
+ ),
103
+ );
104
+
105
+ expect(find.text('On'), findsOneWidget);
106
+ expect(find.text('Off'), findsNothing);
107
+ expect(find.text('Medium'), findsNothing);
108
+ });
109
+
110
+ test('mandatory model normalization can represent an On-only capability', () {
111
+ expect(hasGranularReasoningChoice(const ['on']), isFalse);
112
+ expect(
113
+ resolveOllamaThinkValue(
114
+ enabled: false,
115
+ effort: ReasoningEffort.medium,
116
+ allowedOptions: const ['on'],
117
+ ),
118
+ isNull,
119
+ );
120
+ expect(
121
+ resolveOllamaThinkValue(
122
+ enabled: true,
123
+ effort: ReasoningEffort.medium,
124
+ allowedOptions: const ['on'],
125
+ ),
126
+ isTrue,
127
+ );
128
+ });
129
+}
test/features/on_device/imported_model_reasoning_setting_test.dart
+1
-3
@@ -60,9 +60,7 @@ void main() {
60
'On': true,
61
'Model default': null,
62
}.entries) {
63
- await tester.tap(find.byType(DropdownButtonFormField<String>));
64
- await tester.pumpAndSettle();
65
- await tester.tap(find.text(entry.key).last);
63
+ await tester.tap(find.text(entry.key));
64
await tester.pumpAndSettle();
65
expect(repository.load().single.reasoningEnabled, entry.value);
66
expect(
test/features/servers/server_api_service_test.dart
+85
@@ -747,6 +747,91 @@ void main() {
747
},
748
);
749
750
+ test(
751
+ 'fetchModels treats the Ollama LFM2 thinking renderer as mandatory',
752
+ () async {
753
+ final ollamaServer = Server(
754
+ id: 'test-ollama',
755
+ name: 'Test Ollama',
756
+ type: ServerType.ollama,
757
+ host: 'localhost',
758
+ port: 11434,
759
+ createdAt: DateTime.now(),
760
+ lastConnectedAt: DateTime.now(),
761
+ );
762
+
763
+ final interceptor = RoutingInterceptor({
764
+ 'GET /api/tags': {
765
+ 'models': [
766
+ {
767
+ 'name': 'lfm2.5-thinking:latest',
768
+ 'size': 731163903,
769
+ 'details': {
770
+ 'parameter_size': '1.2B',
771
+ 'quantization_level': 'Q4_K_M',
772
+ 'family': 'lfm2',
773
+ },
774
+ },
775
+ ],
776
+ },
777
+ 'POST /api/show:lfm2.5-thinking:latest': {
778
+ 'capabilities': ['completion', 'tools', 'thinking'],
779
+ 'details': {
780
+ 'family': 'lfm2',
781
+ 'families': ['lfm2'],
782
+ },
783
+ 'modelfile':
784
+ 'TEMPLATE {{ .Prompt }}\n'
785
+ 'RENDERER lfm2-thinking\n'
786
+ 'PARSER lfm2-thinking',
787
+ },
788
+ });
789
+
790
+ final service = ServerApiService(Dio()..interceptors.add(interceptor));
791
+ final model = (await service.fetchModels(ollamaServer)).single;
792
+
793
+ expect(model.supportsReasoning, isTrue);
794
+ expect(model.supportedReasoningEfforts, ['on']);
795
+ expect(model.reasoningMandatory, isTrue);
796
+ expect(model.supportsToolUse, isTrue);
797
+ },
798
+ );
799
+
800
+ test(
801
+ 'fetchModels recognizes an older LFM2 thinking response by model id',
802
+ () async {
803
+ final ollamaServer = Server(
804
+ id: 'test-ollama',
805
+ name: 'Test Ollama',
806
+ type: ServerType.ollama,
807
+ host: 'localhost',
808
+ port: 11434,
809
+ createdAt: DateTime.now(),
810
+ lastConnectedAt: DateTime.now(),
811
+ );
812
+ final interceptor = RoutingInterceptor({
813
+ 'GET /api/tags': {
814
+ 'models': [
815
+ {
816
+ 'name': 'lfm2.5-thinking:latest',
817
+ 'details': {'family': 'lfm2'},
818
+ },
819
+ ],
820
+ },
821
+ 'POST /api/show:lfm2.5-thinking:latest': {
822
+ 'capabilities': ['completion'],
823
+ 'details': {'family': 'lfm2'},
824
+ },
825
+ });
826
+
827
+ final service = ServerApiService(Dio()..interceptors.add(interceptor));
828
+ final model = (await service.fetchModels(ollamaServer)).single;
829
+
830
+ expect(model.supportedReasoningEfforts, ['on']);
831
+ expect(model.reasoningMandatory, isTrue);
832
+ },
833
+ );
834
+
835
test(
836
'fetchModels handles absent or malformed Ollama capabilities',
837
() async {