feat(flutter/theme): themeModeProvider with secure-storage persistence
AsyncNotifier wrapping flutter_secure_storage's "theme_mode" key. Returns AppThemeMode.system as the default. set(mode) writes the value + updates state. Used by MinstrelApp (next commit) to drive MaterialApp.themeMode and by the Settings appearance section to persist the operator's pick. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../auth/auth_provider.dart';
|
||||||
|
|
||||||
|
const _kThemeModeKey = 'theme_mode';
|
||||||
|
|
||||||
|
enum AppThemeMode { system, dark, light }
|
||||||
|
|
||||||
|
extension AppThemeModeMaterial on AppThemeMode {
|
||||||
|
ThemeMode get materialMode => switch (this) {
|
||||||
|
AppThemeMode.system => ThemeMode.system,
|
||||||
|
AppThemeMode.dark => ThemeMode.dark,
|
||||||
|
AppThemeMode.light => ThemeMode.light,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class ThemeModeController extends AsyncNotifier<AppThemeMode> {
|
||||||
|
@override
|
||||||
|
Future<AppThemeMode> build() async {
|
||||||
|
final storage = ref.watch(secureStorageProvider);
|
||||||
|
final raw = await storage.read(key: _kThemeModeKey);
|
||||||
|
return switch (raw) {
|
||||||
|
'dark' => AppThemeMode.dark,
|
||||||
|
'light' => AppThemeMode.light,
|
||||||
|
_ => AppThemeMode.system,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> set(AppThemeMode mode) async {
|
||||||
|
final storage = ref.read(secureStorageProvider);
|
||||||
|
await storage.write(key: _kThemeModeKey, value: mode.name);
|
||||||
|
state = AsyncData(mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final themeModeProvider =
|
||||||
|
AsyncNotifierProvider<ThemeModeController, AppThemeMode>(
|
||||||
|
ThemeModeController.new,
|
||||||
|
);
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
import 'package:minstrel/auth/auth_provider.dart';
|
||||||
|
import 'package:minstrel/theme/theme_mode_provider.dart';
|
||||||
|
|
||||||
|
class _MockStorage extends Mock implements FlutterSecureStorage {}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late _MockStorage storage;
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
registerFallbackValue('');
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
storage = _MockStorage();
|
||||||
|
when(() => storage.write(key: any(named: 'key'), value: any(named: 'value')))
|
||||||
|
.thenAnswer((_) async {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('defaults to system when nothing stored', () async {
|
||||||
|
when(() => storage.read(key: 'theme_mode')).thenAnswer((_) async => null);
|
||||||
|
final container = ProviderContainer(overrides: [
|
||||||
|
secureStorageProvider.overrideWithValue(storage),
|
||||||
|
]);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
final mode = await container.read(themeModeProvider.future);
|
||||||
|
expect(mode, AppThemeMode.system);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reads stored "dark" → AppThemeMode.dark', () async {
|
||||||
|
when(() => storage.read(key: 'theme_mode')).thenAnswer((_) async => 'dark');
|
||||||
|
final container = ProviderContainer(overrides: [
|
||||||
|
secureStorageProvider.overrideWithValue(storage),
|
||||||
|
]);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
final mode = await container.read(themeModeProvider.future);
|
||||||
|
expect(mode, AppThemeMode.dark);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reads stored "light" → AppThemeMode.light', () async {
|
||||||
|
when(() => storage.read(key: 'theme_mode')).thenAnswer((_) async => 'light');
|
||||||
|
final container = ProviderContainer(overrides: [
|
||||||
|
secureStorageProvider.overrideWithValue(storage),
|
||||||
|
]);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
final mode = await container.read(themeModeProvider.future);
|
||||||
|
expect(mode, AppThemeMode.light);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('set(.light) writes "light" to storage and updates state', () async {
|
||||||
|
when(() => storage.read(key: 'theme_mode')).thenAnswer((_) async => null);
|
||||||
|
final container = ProviderContainer(overrides: [
|
||||||
|
secureStorageProvider.overrideWithValue(storage),
|
||||||
|
]);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
await container.read(themeModeProvider.future);
|
||||||
|
await container.read(themeModeProvider.notifier).set(AppThemeMode.light);
|
||||||
|
|
||||||
|
verify(() => storage.write(key: 'theme_mode', value: 'light')).called(1);
|
||||||
|
expect(container.read(themeModeProvider).value, AppThemeMode.light);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('materialMode extension maps each enum to ThemeMode', () {
|
||||||
|
expect(AppThemeMode.system.materialMode.name, 'system');
|
||||||
|
expect(AppThemeMode.dark.materialMode.name, 'dark');
|
||||||
|
expect(AppThemeMode.light.materialMode.name, 'light');
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user