feat(flutter/settings): Appearance section with System/Light/Dark picker
Three RadioListTiles between Profile and Password sections. Tap →
themeModeProvider.notifier.set(...) → MaterialApp rebuilds with the
new theme via the existing themeMode wiring.
System has the only subtitle ("Match the device setting") — the
light/dark options are self-explanatory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import '../library/library_providers.dart' show dioProvider;
|
|||||||
import '../models/my_profile.dart';
|
import '../models/my_profile.dart';
|
||||||
import '../shared/widgets/main_app_bar_actions.dart';
|
import '../shared/widgets/main_app_bar_actions.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
|
import '../theme/theme_mode_provider.dart';
|
||||||
|
|
||||||
final _settingsApiProvider = FutureProvider<SettingsApi>((ref) async {
|
final _settingsApiProvider = FutureProvider<SettingsApi>((ref) async {
|
||||||
return SettingsApi(await ref.watch(dioProvider.future));
|
return SettingsApi(await ref.watch(dioProvider.future));
|
||||||
@@ -45,6 +46,8 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
children: const [
|
children: const [
|
||||||
_ProfileSection(),
|
_ProfileSection(),
|
||||||
_Divider(),
|
_Divider(),
|
||||||
|
_AppearanceSection(),
|
||||||
|
_Divider(),
|
||||||
_PasswordSection(),
|
_PasswordSection(),
|
||||||
_Divider(),
|
_Divider(),
|
||||||
_ListenBrainzSection(),
|
_ListenBrainzSection(),
|
||||||
@@ -492,3 +495,39 @@ InputDecoration _inputDecoration(FabledSwordTheme fs, String label) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _AppearanceSection extends ConsumerWidget {
|
||||||
|
const _AppearanceSection();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
final current = ref.watch(themeModeProvider).value ?? AppThemeMode.system;
|
||||||
|
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
const _SectionHeader('Appearance'),
|
||||||
|
for (final mode in AppThemeMode.values)
|
||||||
|
RadioListTile<AppThemeMode>(
|
||||||
|
key: Key('appearance_${mode.name}'),
|
||||||
|
title: Text(_label(mode), style: TextStyle(color: fs.parchment)),
|
||||||
|
subtitle: mode == AppThemeMode.system
|
||||||
|
? Text('Match the device setting',
|
||||||
|
style: TextStyle(color: fs.ash))
|
||||||
|
: null,
|
||||||
|
value: mode,
|
||||||
|
groupValue: current,
|
||||||
|
activeColor: fs.accent,
|
||||||
|
onChanged: (m) {
|
||||||
|
if (m != null) {
|
||||||
|
ref.read(themeModeProvider.notifier).set(m);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _label(AppThemeMode m) => switch (m) {
|
||||||
|
AppThemeMode.system => 'System',
|
||||||
|
AppThemeMode.light => 'Light',
|
||||||
|
AppThemeMode.dark => 'Dark',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
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/settings/settings_screen.dart';
|
||||||
|
import 'package:minstrel/theme/theme_data.dart';
|
||||||
|
import 'package:minstrel/theme/theme_mode_provider.dart';
|
||||||
|
|
||||||
|
class _MockStorage extends Mock implements FlutterSecureStorage {}
|
||||||
|
|
||||||
|
class _StubTheme extends ThemeModeController {
|
||||||
|
_StubTheme(this._initial);
|
||||||
|
final AppThemeMode _initial;
|
||||||
|
@override
|
||||||
|
Future<AppThemeMode> build() async => _initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late _MockStorage storage;
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
registerFallbackValue('');
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
storage = _MockStorage();
|
||||||
|
when(() => storage.read(key: any(named: 'key'))).thenAnswer((_) async => null);
|
||||||
|
when(() => storage.write(key: any(named: 'key'), value: any(named: 'value')))
|
||||||
|
.thenAnswer((_) async {});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders three appearance radios + reflects the current mode', (t) async {
|
||||||
|
await t.pumpWidget(ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
secureStorageProvider.overrideWithValue(storage),
|
||||||
|
themeModeProvider.overrideWith(() => _StubTheme(AppThemeMode.dark)),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: buildDarkTheme(),
|
||||||
|
home: const SettingsScreen(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('appearance_system')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('appearance_light')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('appearance_dark')), findsOneWidget);
|
||||||
|
|
||||||
|
// The dark radio should be selected.
|
||||||
|
final darkRadio = t.widget<RadioListTile<AppThemeMode>>(
|
||||||
|
find.byKey(const Key('appearance_dark')),
|
||||||
|
);
|
||||||
|
expect(darkRadio.groupValue, AppThemeMode.dark);
|
||||||
|
expect(darkRadio.value, AppThemeMode.dark);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping the light radio writes "light" to storage', (t) async {
|
||||||
|
await t.pumpWidget(ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
secureStorageProvider.overrideWithValue(storage),
|
||||||
|
themeModeProvider.overrideWith(() => _StubTheme(AppThemeMode.system)),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: buildDarkTheme(),
|
||||||
|
home: const SettingsScreen(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
|
||||||
|
await t.tap(find.byKey(const Key('appearance_light')));
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
|
||||||
|
verify(() => storage.write(key: 'theme_mode', value: 'light')).called(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user