@tduncan / Cortana / commits / c1d90fa

refactor: migrate MCP tools list to Riverpod provider and improve UI state management

abdulmominsakib committed Sep 2, 2026 at 18:50 UTC c1d90fa99ed785e7c6f2faa62d4173220438297c
7 files changed +402 -71
lib/features/chat/data/chat_service.dart
+9 -7
@@ -222,21 +222,23 @@ class LMStudioChatService implements ChatService {
222 body['repeat_penalty'] = params.repeatPenalty;
223 }
224 // LM Studio's native /api/v1/chat expects `reasoning` as a *string*
225 - // ('off' | 'low' | 'medium' | 'high' | 'on'), and does NOT accept the
226 - // OpenAI-compatible `reasoning_effort` / `think` / `enable_thinking`
225 + // ('off' | 'low' | 'medium' | 'high' | 'xhigh'), and does NOT accept
226 + // the OpenAI-compatible `reasoning_effort` / `think` / `enable_thinking`
227 // keys that the shared [_applyReasoningControl] writes. Sending the
228 // object form causes HTTP 400 `invalid_request` for every
229 // reasoning-capable model (and ChatReasoningConfig defaults to
230 // enabled). We therefore apply reasoning in the native shape here.
231 //
232 - // 'on' is universally accepted by reasoning-capable models (it means
233 - // "use the model's default effort"); per-model effort strings such as
234 - // 'low'/'medium'/'high' are not guaranteed to be supported (e.g. Gemma
235 - // only allows 'off'/'on'), so we avoid them.
232 + // We send the user's selected [ReasoningEffort] (already snapped via
233 + // [resolveEffortForModel] to whatever the active model advertises)
234 + // rather than the generic 'on', because newer models — e.g.
235 + // meta/muse-glimmer — reject 'on' with HTTP 400 and only accept the
236 + // enum effort values. Older models that don't advertise efforts get
237 + // the classic 'low'/'medium'/'high' fallback, which they accept.
238 if (params.reasoningEnabled == false) {
239 body['reasoning'] = 'off';
240 } else if (params.reasoningEnabled == true) {
239 - body['reasoning'] = 'on';
241 + body['reasoning'] = params.reasoningEffort.apiValue;
242 }
243 if (params.contextLength > 0) {
244 body['context_length'] = params.contextLength;
lib/features/chat/providers/tooling_providers.dart
+8
@@ -3,6 +3,7 @@ import '../../../core/providers/app_providers.dart';
3 import '../data/tools/tool_registry.dart';
4 import '../data/tools/builtin_tool_provider.dart';
5 import '../data/tools/mcp_tool_provider.dart';
6 +import '../data/tools/tool_definition.dart';
7 import '../data/mcp_server_manager.dart';
8
9 final mcpServerManagerProvider = Provider<McpServerManager>((ref) {
@@ -26,3 +27,10 @@ final toolRegistryProvider = Provider<ToolRegistry>((ref) {
27 ],
28 );
29 });
30 +
31 +final availableToolsProvider = FutureProvider<List<ToolDefinition>>((
32 + ref,
33 +) async {
34 + final registry = ref.watch(toolRegistryProvider);
35 + return registry.listTools();
36 +});
lib/features/chat/views/components/notification_permission_banner.dart
+7 -1
@@ -22,7 +22,11 @@ class _NotificationPermissionBannerState
22 @override
23 void initState() {
24 super.initState();
25 - _checkStatus();
25 + WidgetsBinding.instance.addPostFrameCallback((_) {
26 + if (mounted) {
27 + _checkStatus();
28 + }
29 + });
30 }
31
32 Future<void> _checkStatus() async {
@@ -32,6 +36,8 @@ class _NotificationPermissionBannerState
36 final service = ref.read(notificationPermissionServiceProvider);
37 final granted = await service.isPermissionGranted();
38
39 + if (!mounted) return;
40 +
41 if (!granted) {
42 setState(() => _isVisible = true);
43 } else {
lib/features/mcp/views/mcp_tools_screen.dart
+34 -61
@@ -13,49 +13,26 @@ import '../../../core/providers/app_providers.dart';
13 import '../../../core/theme/colors.dart';
14 import '../../../core/utils/system_insets.dart';
15
16 -class McpToolsScreen extends ConsumerStatefulWidget {
16 +class McpToolsScreen extends ConsumerWidget {
17 const McpToolsScreen({super.key});
18
19 - @override
20 - ConsumerState<McpToolsScreen> createState() => _McpToolsScreenState();
21 -}
22 -
23 -class _McpToolsScreenState extends ConsumerState<McpToolsScreen> {
24 - Future<List<ToolDefinition>>? _toolsFuture;
25 -
26 - @override
27 - void initState() {
28 - super.initState();
29 - _toolsFuture = _loadTools();
30 - }
31 -
32 - Future<List<ToolDefinition>> _loadTools() {
33 - return ref.read(toolRegistryProvider).listTools();
34 - }
35 -
36 - void _refreshTools() {
37 - ref.invalidate(toolRegistryProvider);
38 - setState(() {
39 - _toolsFuture = _loadTools();
40 - });
41 - }
42 -
43 - void _toggleExampleServer() {
19 + void _toggleExampleServer(WidgetRef ref) {
20 final manager = ref.read(mcpServerManagerProvider);
21 if (manager.hasExampleServer()) {
22 manager.removeServer(exampleMcpServerLabel);
23 } else {
24 manager.addExampleServer();
25 }
50 - _refreshTools();
26 + ref.invalidate(availableToolsProvider);
27 }
28
29 @override
54 - Widget build(BuildContext context) {
30 + Widget build(BuildContext context, WidgetRef ref) {
31 final l10n = AppLocalizations.of(context)!;
32 final settings = ref.watch(settingsProvider);
33 final manager = ref.watch(mcpServerManagerProvider);
34 final hasExampleServer = manager.hasExampleServer();
35 + final toolsAsync = ref.watch(availableToolsProvider);
36 final isDark = Theme.of(context).brightness == Brightness.dark;
37 final topPadding = MediaQuery.of(context).padding.top;
38 final bottomInset = bottomSystemInset(context);
@@ -117,7 +94,6 @@ class _McpToolsScreenState extends ConsumerState<McpToolsScreen> {
94 badges: [_FeatureBadge(label: l10n.experimental_label)],
95 onChanged: (value) {
96 ref.read(settingsProvider.notifier).setMcpEnabled(value);
120 - _refreshTools();
97 },
98 ),
99 if (settings.mcpEnabled) ...[
@@ -140,7 +116,6 @@ class _McpToolsScreenState extends ConsumerState<McpToolsScreen> {
116 ref
117 .read(settingsProvider.notifier)
118 .setCalendarToolsEnabled(true);
143 - _refreshTools();
119 } else {
120 if (context.mounted) {
121 ScaffoldMessenger.of(context).showSnackBar(
@@ -156,7 +131,6 @@ class _McpToolsScreenState extends ConsumerState<McpToolsScreen> {
131 ref
132 .read(settingsProvider.notifier)
133 .setCalendarToolsEnabled(false);
159 - _refreshTools();
134 }
135 },
136 ),
@@ -164,7 +138,7 @@ class _McpToolsScreenState extends ConsumerState<McpToolsScreen> {
138 label: l10n.enable_example_server,
139 description: l10n.example_mcp_server_desc,
140 value: hasExampleServer,
167 - onChanged: (_) => _toggleExampleServer(),
141 + onChanged: (_) => _toggleExampleServer(ref),
142 ),
143 ],
144 ],
@@ -174,7 +148,9 @@ class _McpToolsScreenState extends ConsumerState<McpToolsScreen> {
148 title: l10n.available_tools,
149 accent: const Color(0xFF22C55E),
150 icon: HugeIcons.strokeRoundedPuzzle,
177 - children: [_buildToolsContent(l10n, settings.mcpEnabled)],
151 + children: [
152 + _buildToolsContent(l10n, settings.mcpEnabled, toolsAsync),
153 + ],
154 );
155
156 return ListView(
@@ -195,7 +171,8 @@ class _McpToolsScreenState extends ConsumerState<McpToolsScreen> {
171 if (settings.mcpEnabled) ...[
172 const SizedBox(height: 16),
173 _ConfiguredMcpServersCard(
198 - onServersChanged: _refreshTools,
174 + onServersChanged: () =>
175 + ref.invalidate(availableToolsProvider),
176 ),
177 ],
178 const SizedBox(height: 16),
@@ -213,7 +190,11 @@ class _McpToolsScreenState extends ConsumerState<McpToolsScreen> {
190 );
191 }
192
216 - Widget _buildToolsContent(AppLocalizations l10n, bool mcpEnabled) {
193 + Widget _buildToolsContent(
194 + AppLocalizations l10n,
195 + bool mcpEnabled,
196 + AsyncValue<List<ToolDefinition>> toolsAsync,
197 + ) {
198 if (!mcpEnabled) {
199 return _StatusPanel(
200 icon: HugeIcons.strokeRoundedAlertCircle,
@@ -222,33 +203,25 @@ class _McpToolsScreenState extends ConsumerState<McpToolsScreen> {
203 );
204 }
205
225 - return FutureBuilder<List<ToolDefinition>>(
226 - future: _toolsFuture,
227 - builder: (context, snapshot) {
228 - if (snapshot.connectionState == ConnectionState.waiting) {
229 - return _McpPanel(
230 - child: Padding(
231 - padding: const EdgeInsets.symmetric(vertical: 24),
232 - child: Center(
233 - child: SizedBox(
234 - width: 22,
235 - height: 22,
236 - child: const CircularProgressIndicator(strokeWidth: 2),
237 - ),
238 - ),
206 + return toolsAsync.when(
207 + loading: () => _McpPanel(
208 + child: const Padding(
209 + padding: EdgeInsets.symmetric(vertical: 24),
210 + child: Center(
211 + child: SizedBox(
212 + width: 22,
213 + height: 22,
214 + child: CircularProgressIndicator(strokeWidth: 2),
215 ),
240 - );
241 - }
242 -
243 - if (snapshot.hasError) {
244 - return _StatusPanel(
245 - icon: HugeIcons.strokeRoundedInformationCircle,
246 - title: l10n.unable_load_tools,
247 - body: snapshot.error.toString(),
248 - );
249 - }
250 -
251 - final tools = snapshot.data ?? const <ToolDefinition>[];
216 + ),
217 + ),
218 + ),
219 + error: (error, _) => _StatusPanel(
220 + icon: HugeIcons.strokeRoundedInformationCircle,
221 + title: l10n.unable_load_tools,
222 + body: error.toString(),
223 + ),
224 + data: (tools) {
225 if (tools.isEmpty) {
226 return _StatusPanel(
227 icon: HugeIcons.strokeRoundedPuzzle,
test/features/chat/lm_studio_mcp_integration_test.dart
+59 -2
@@ -8,6 +8,7 @@ import 'package:localmind/features/chat/data/chat_service.dart';
8 import 'package:localmind/features/chat/data/models/message.dart';
9 import 'package:localmind/features/chat/data/models/chat_parameters.dart';
10 import 'package:localmind/features/chat/data/models/mcp_integration.dart';
11 +import 'package:localmind/features/chat/providers/chat_reasoning_providers.dart';
12 import 'package:localmind/features/servers/data/models/server.dart';
13
14 class CapturingStreamInterceptor extends Interceptor {
@@ -173,7 +174,7 @@ void main() {
174 // object and extra OpenAI-compat keys (reasoning_effort/think/
175 // enable_thinking). LM Studio's native /api/v1/chat rejects all of
176 // those with HTTP 400, so LMStudioChatService must emit the native
176 - // string form ('off'|'low'|'medium'|'high'|'on') only.
177 + // string form ('off'|'low'|'medium'|'high'|'xhigh') only.
178 final interceptor = CapturingStreamInterceptor([
179 'event: chat.start',
180 'data: {"type":"chat.start","model_instance_id":"test-model"}',
@@ -192,6 +193,7 @@ void main() {
193 messages: [userMessage],
194 params: ChatParameters.defaults().copyWith(
195 reasoningEnabled: true,
196 + reasoningEffort: ReasoningEffort.medium,
197 ),
198 )
199 .toList();
@@ -204,7 +206,9 @@ void main() {
206 isA<String>(),
207 reason: 'native endpoint expects reasoning as a string',
208 );
207 - expect(body['reasoning'], equals('on'));
209 + // Must send the actual effort enum value (e.g. 'medium'), not the
210 + // generic 'on' which newer models like meta/muse-glimmer reject.
211 + expect(body['reasoning'], equals('medium'));
212
213 // The OpenAI-compatible reasoning keys must NOT be sent.
214 expect(body.containsKey('reasoning_effort'), isFalse);
@@ -213,6 +217,59 @@ void main() {
217 },
218 );
219
220 + test(
221 + 'reasoning enabled sends the requested enum value, not "on" '
222 + '(muse-glimmer regression)',
223 + () async {
224 + // Regression for https://github.com/abdulmominsakib/localmind/issues/75
225 + // meta/muse-glimmer accepts only 'low'|'medium'|'high'|'xhigh' and
226 + // rejects 'on'/'off' with HTTP 400. The native LM Studio chat
227 + // service must forward the user's selected effort instead of the
228 + // hard-coded 'on'.
229 + for (final effort in ReasoningEffort.values) {
230 + // 'max' isn't part of the LM Studio enum, so we only assert the
231 + // values the server actually accepts. Skip if the enum value
232 + // exceeds what LM Studio supports.
233 + if (effort == ReasoningEffort.max) continue;
234 +
235 + final interceptor = CapturingStreamInterceptor([
236 + 'event: chat.start',
237 + 'data: {"type":"chat.start","model_instance_id":"meta/muse-glimmer"}',
238 + '',
239 + 'event: chat.end',
240 + 'data: {"type":"chat.end","result":{"model_instance_id":"meta/muse-glimmer","output":[],"stats":{"input_tokens":1,"total_output_tokens":1}}}',
241 + '',
242 + ]);
243 + final dio = Dio()..interceptors.add(interceptor);
244 + final service = LMStudioChatService(dio);
245 +
246 + await service
247 + .sendMessage(
248 + server: server,
249 + modelId: 'meta/muse-glimmer',
250 + messages: [userMessage],
251 + params: ChatParameters.defaults().copyWith(
252 + reasoningEnabled: true,
253 + reasoningEffort: effort,
254 + ),
255 + )
256 + .toList();
257 +
258 + final body = interceptor.capturedRequest!.data
259 + as Map<String, dynamic>;
260 + expect(
261 + body['reasoning'],
262 + equals(effort.apiValue),
263 + reason:
264 + 'effort $effort should be forwarded as its enum string',
265 + );
266 + // And it must never be the generic 'on' that muse-glimmer
267 + // rejects.
268 + expect(body['reasoning'], isNot(equals('on')));
269 + }
270 + },
271 + );
272 +
273 test('reasoning disabled sends native "off" string', () async {
274 final interceptor = CapturingStreamInterceptor([
275 'event: chat.start',
test/features/chat/notification_permission_banner_test.dart new
+127
@@ -0,0 +1,127 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_localizations/flutter_localizations.dart';
3 +import 'package:flutter_riverpod/flutter_riverpod.dart';
4 +import 'package:flutter_test/flutter_test.dart';
5 +import 'package:localmind/core/providers/app_providers.dart';
6 +import 'package:localmind/core/theme/app_theme.dart';
7 +import 'package:localmind/features/chat/views/components/notification_permission_banner.dart';
8 +import 'package:localmind/features/on_device/data/notification_permission_service.dart';
9 +import 'package:localmind/features/on_device/providers/on_device_providers.dart';
10 +import 'package:localmind/features/settings/data/models/app_settings.dart';
11 +import 'package:localmind/l10n/app_localizations.dart';
12 +import 'package:shadcn_ui/shadcn_ui.dart';
13 +import 'package:shared_preferences/shared_preferences.dart';
14 +
15 +class _FakeNotificationPermissionService
16 + implements NotificationPermissionService {
17 + _FakeNotificationPermissionService({this.granted = false});
18 +
19 + final bool granted;
20 + bool requestCalled = false;
21 +
22 + @override
23 + Future<void> init() async {}
24 +
25 + @override
26 + Future<bool> isPermissionGranted() async => granted;
27 +
28 + @override
29 + Future<bool> requestPermission() async {
30 + requestCalled = true;
31 + return true;
32 + }
33 +}
34 +
35 +class _TestSettingsNotifier extends SettingsNotifier {
36 + _TestSettingsNotifier(this._initial);
37 + final AppSettings _initial;
38 +
39 + @override
40 + AppSettings build() => _initial;
41 +}
42 +
43 +Widget _wrapWithApp({required Widget child, List<dynamic>? overrides}) {
44 + return ProviderScope(
45 + overrides: [...?overrides?.cast()],
46 + child: ShadTheme(
47 + data: AppTheme.lightShadTheme,
48 + child: MaterialApp(
49 + locale: const Locale('en'),
50 + localizationsDelegates: const [
51 + AppLocalizations.delegate,
52 + GlobalMaterialLocalizations.delegate,
53 + GlobalWidgetsLocalizations.delegate,
54 + GlobalCupertinoLocalizations.delegate,
55 + ],
56 + supportedLocales: AppLocalizations.supportedLocales,
57 + home: Scaffold(body: child),
58 + ),
59 + ),
60 + );
61 +}
62 +
63 +void main() {
64 + TestWidgetsFlutterBinding.ensureInitialized();
65 +
66 + setUp(() {
67 + SharedPreferences.setMockInitialValues({});
68 + });
69 +
70 + group('NotificationPermissionBanner Widget Tests', () {
71 + testWidgets(
72 + 'mounts cleanly and displays banner when permission not granted',
73 + (tester) async {
74 + final fakeService = _FakeNotificationPermissionService(granted: false);
75 +
76 + await tester.pumpWidget(
77 + _wrapWithApp(
78 + overrides: [
79 + notificationPermissionServiceProvider.overrideWithValue(
80 + fakeService,
81 + ),
82 + settingsProvider.overrideWith(
83 + () => _TestSettingsNotifier(
84 + AppSettings(hasAskedForNotifications: false),
85 + ),
86 + ),
87 + ],
88 + child: const NotificationPermissionBanner(),
89 + ),
90 + );
91 +
92 + // Verify no exception on first frame / mount
93 + expect(tester.takeException(), isNull);
94 +
95 + await tester.pumpAndSettle();
96 +
97 + final l10n = lookupAppLocalizations(const Locale('en'));
98 + expect(find.text(l10n.enable_notifications), findsOneWidget);
99 + },
100 + );
101 +
102 + testWidgets('does not show banner when user already asked', (tester) async {
103 + final fakeService = _FakeNotificationPermissionService(granted: false);
104 +
105 + await tester.pumpWidget(
106 + _wrapWithApp(
107 + overrides: [
108 + notificationPermissionServiceProvider.overrideWithValue(
109 + fakeService,
110 + ),
111 + settingsProvider.overrideWith(
112 + () => _TestSettingsNotifier(
113 + AppSettings(hasAskedForNotifications: true),
114 + ),
115 + ),
116 + ],
117 + child: const NotificationPermissionBanner(),
118 + ),
119 + );
120 +
121 + await tester.pumpAndSettle();
122 +
123 + final l10n = lookupAppLocalizations(const Locale('en'));
124 + expect(find.text(l10n.enable_notifications), findsNothing);
125 + });
126 + });
127 +}
test/features/mcp/mcp_tools_screen_test.dart new
+158
@@ -0,0 +1,158 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_localizations/flutter_localizations.dart';
3 +import 'package:flutter_riverpod/flutter_riverpod.dart';
4 +import 'package:flutter_test/flutter_test.dart';
5 +import 'package:localmind/core/providers/app_providers.dart';
6 +import 'package:localmind/core/theme/app_theme.dart';
7 +import 'package:localmind/features/chat/data/tools/tool_definition.dart';
8 +import 'package:localmind/features/chat/providers/tooling_providers.dart';
9 +import 'package:localmind/features/mcp/views/mcp_tools_screen.dart';
10 +import 'package:localmind/features/settings/data/models/app_settings.dart';
11 +import 'package:localmind/l10n/app_localizations.dart';
12 +import 'package:shadcn_ui/shadcn_ui.dart';
13 +import 'package:shared_preferences/shared_preferences.dart';
14 +
15 +Widget _wrapWithApp({required Widget child, List<dynamic>? overrides}) {
16 + return ProviderScope(
17 + overrides: [...?overrides?.cast()],
18 + child: ShadTheme(
19 + data: AppTheme.lightShadTheme,
20 + child: MaterialApp(
21 + locale: const Locale('en'),
22 + localizationsDelegates: const [
23 + AppLocalizations.delegate,
24 + GlobalMaterialLocalizations.delegate,
25 + GlobalWidgetsLocalizations.delegate,
26 + GlobalCupertinoLocalizations.delegate,
27 + ],
28 + supportedLocales: AppLocalizations.supportedLocales,
29 + home: Scaffold(body: child),
30 + ),
31 + ),
32 + );
33 +}
34 +
35 +void main() {
36 + TestWidgetsFlutterBinding.ensureInitialized();
37 +
38 + setUp(() {
39 + SharedPreferences.setMockInitialValues({});
40 + });
41 +
42 + group('McpToolsScreen Widget Tests', () {
43 + testWidgets('renders without throwing setState during build exception', (
44 + tester,
45 + ) async {
46 + final List<ToolDefinition> tools = [
47 + const ToolDefinition(
48 + name: 'get_current_weather',
49 + description: 'Get weather for location',
50 + inputSchema: {},
51 + providerType: ToolProviderType.mcp,
52 + providerRef: 'weather_server',
53 + ),
54 + ];
55 +
56 + await tester.pumpWidget(
57 + _wrapWithApp(
58 + overrides: [
59 + availableToolsProvider.overrideWith((ref) => Future.value(tools)),
60 + settingsProvider.overrideWith(
61 + () => _TestSettingsNotifier(
62 + AppSettings(mcpEnabled: true, newChatMcpEnabled: true),
63 + ),
64 + ),
65 + ],
66 + child: const McpToolsScreen(),
67 + ),
68 + );
69 +
70 + // Verify no exceptions were thrown during first build/mount
71 + expect(tester.takeException(), isNull);
72 +
73 + await tester.pumpAndSettle();
74 +
75 + expect(find.text('get_current_weather'), findsOneWidget);
76 + expect(find.text('Get weather for location'), findsOneWidget);
77 + expect(find.text('weather_server'), findsOneWidget);
78 + expect(find.text('MCP'), findsOneWidget);
79 + });
80 +
81 + testWidgets('displays disabled warning when mcpEnabled is false', (
82 + tester,
83 + ) async {
84 + await tester.pumpWidget(
85 + _wrapWithApp(
86 + overrides: [
87 + settingsProvider.overrideWith(
88 + () => _TestSettingsNotifier(AppSettings(mcpEnabled: false)),
89 + ),
90 + ],
91 + child: const McpToolsScreen(),
92 + ),
93 + );
94 +
95 + await tester.pumpAndSettle();
96 +
97 + final l10n = lookupAppLocalizations(const Locale('en'));
98 + expect(find.text(l10n.mcp_disabled_warning), findsOneWidget);
99 + });
100 +
101 + testWidgets('displays empty state when tools list is empty', (
102 + tester,
103 + ) async {
104 + await tester.pumpWidget(
105 + _wrapWithApp(
106 + overrides: [
107 + availableToolsProvider.overrideWith(
108 + (ref) => Future.value(const <ToolDefinition>[]),
109 + ),
110 + settingsProvider.overrideWith(
111 + () => _TestSettingsNotifier(
112 + AppSettings(mcpEnabled: true, newChatMcpEnabled: true),
113 + ),
114 + ),
115 + ],
116 + child: const McpToolsScreen(),
117 + ),
118 + );
119 +
120 + await tester.pumpAndSettle();
121 +
122 + final l10n = lookupAppLocalizations(const Locale('en'));
123 + expect(find.text(l10n.no_tools_registered), findsOneWidget);
124 + });
125 +
126 + testWidgets('displays error panel when availableToolsProvider fails', (
127 + tester,
128 + ) async {
129 + await tester.pumpWidget(
130 + _wrapWithApp(
131 + overrides: [
132 + availableToolsProvider.overrideWith(
133 + (ref) => Future.error('Connection failed to MCP socket'),
134 + ),
135 + settingsProvider.overrideWith(
136 + () => _TestSettingsNotifier(
137 + AppSettings(mcpEnabled: true, newChatMcpEnabled: true),
138 + ),
139 + ),
140 + ],
141 + child: const McpToolsScreen(),
142 + ),
143 + );
144 +
145 + await tester.pumpAndSettle();
146 +
147 + expect(find.text('Connection failed to MCP socket'), findsOneWidget);
148 + });
149 + });
150 +}
151 +
152 +class _TestSettingsNotifier extends SettingsNotifier {
153 + _TestSettingsNotifier(this._initial);
154 + final AppSettings _initial;
155 +
156 + @override
157 + AppSettings build() => _initial;
158 +}