@tduncan / Cortana / commits / 38cde5b

1.8.2+88, and fix: use ScaffoldMessenger.maybeOf(context) to avoid errors when context is unmounted and add tests for reasoning controls.

abdulmominsakib committed Sep 9, 2026 at 21:13 UTC 38cde5bda5cd4aaf23934aa8fa1d23baf578831a
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 {