diff --git a/flutter_client/lib/settings/settings_screen.dart b/flutter_client/lib/settings/settings_screen.dart index 9b2786b7..cccf59ba 100644 --- a/flutter_client/lib/settings/settings_screen.dart +++ b/flutter_client/lib/settings/settings_screen.dart @@ -9,6 +9,7 @@ import '../library/library_providers.dart' show dioProvider; import '../models/my_profile.dart'; import '../shared/widgets/main_app_bar_actions.dart'; import '../theme/theme_extension.dart'; +import '../theme/theme_mode_provider.dart'; final _settingsApiProvider = FutureProvider((ref) async { return SettingsApi(await ref.watch(dioProvider.future)); @@ -45,6 +46,8 @@ class SettingsScreen extends ConsumerWidget { children: const [ _ProfileSection(), _Divider(), + _AppearanceSection(), + _Divider(), _PasswordSection(), _Divider(), _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()!; + final current = ref.watch(themeModeProvider).value ?? AppThemeMode.system; + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const _SectionHeader('Appearance'), + for (final mode in AppThemeMode.values) + RadioListTile( + 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', + }; +} diff --git a/flutter_client/test/settings/appearance_section_test.dart b/flutter_client/test/settings/appearance_section_test.dart new file mode 100644 index 00000000..f3792463 --- /dev/null +++ b/flutter_client/test/settings/appearance_section_test.dart @@ -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 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>( + 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); + }); +}