diff --git a/lib/application_wrapper.dart b/lib/application_wrapper.dart index 364f9a2..d2975d6 100644 --- a/lib/application_wrapper.dart +++ b/lib/application_wrapper.dart @@ -30,20 +30,23 @@ class ApplicationWrapper extends StatelessWidget { builder: (_, snapshot) { if (snapshot.data != null) { final iapService = IAPStorageService(snapshot.data![0] as SharedPreferences); + final userPreferencesService = UserPreferencesService(snapshot.data![0] as SharedPreferences); + final hasLightSensor = snapshot.data![1] as bool; return ServicesProvider( caffeineService: const CaffeineService(), - environment: env.copyWith(hasLightSensor: snapshot.data![1] as bool), + environment: env.copyWith(hasLightSensor: hasLightSensor), hapticsService: const HapticsService(), lightSensorService: const LightSensorService(LocalPlatform()), permissionsService: const PermissionsService(), - userPreferencesService: - UserPreferencesService(snapshot.data![0] as SharedPreferences), + userPreferencesService: userPreferencesService, volumeEventsService: const VolumeEventsService(LocalPlatform()), child: EquipmentProfileProvider( storageService: iapService, child: FilmsProvider( storageService: iapService, child: UserPreferencesProvider( + hasLightSensor: hasLightSensor, + userPreferencesService: userPreferencesService, child: child, ), ), diff --git a/lib/providers/user_preferences_provider.dart b/lib/providers/user_preferences_provider.dart index af644d1..3a4111c 100644 --- a/lib/providers/user_preferences_provider.dart +++ b/lib/providers/user_preferences_provider.dart @@ -8,14 +8,20 @@ import 'package:lightmeter/data/models/supported_locale.dart'; import 'package:lightmeter/data/models/theme_type.dart'; import 'package:lightmeter/data/shared_prefs_service.dart'; import 'package:lightmeter/generated/l10n.dart'; -import 'package:lightmeter/providers/services_provider.dart'; import 'package:lightmeter/res/theme.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class UserPreferencesProvider extends StatefulWidget { + final bool hasLightSensor; + final UserPreferencesService userPreferencesService; final Widget child; - const UserPreferencesProvider({required this.child, super.key}); + const UserPreferencesProvider({ + required this.hasLightSensor, + required this.userPreferencesService, + required this.child, + super.key, + }); static _UserPreferencesProviderState of(BuildContext context) { return context.findAncestorStateOfType<_UserPreferencesProviderState>()!; @@ -38,8 +44,7 @@ class UserPreferencesProvider extends StatefulWidget { } static bool meteringScreenFeatureOf(BuildContext context, MeteringScreenLayoutFeature feature) { - return InheritedModel.inheritFrom<_MeteringScreenLayoutModel>(context, aspect: feature)! - .data[feature]!; + return InheritedModel.inheritFrom<_MeteringScreenLayoutModel>(context, aspect: feature)!.data[feature]!; } static StopType stopTypeOf(BuildContext context) { @@ -65,28 +70,20 @@ class UserPreferencesProvider extends StatefulWidget { State createState() => _UserPreferencesProviderState(); } -class _UserPreferencesProviderState extends State - with WidgetsBindingObserver { - UserPreferencesService get userPreferencesService => - ServicesProvider.of(context).userPreferencesService; - - late bool dynamicColor = userPreferencesService.dynamicColor; - late EvSourceType evSourceType; - late MeteringScreenLayoutConfig meteringScreenLayout = - userPreferencesService.meteringScreenLayout; - late Color primaryColor = userPreferencesService.primaryColor; - late StopType stopType = userPreferencesService.stopType; - late SupportedLocale locale = userPreferencesService.locale; - late ThemeType themeType = userPreferencesService.themeType; +class _UserPreferencesProviderState extends State with WidgetsBindingObserver { + late EvSourceType _evSourceType; + late StopType _stopType = widget.userPreferencesService.stopType; + late MeteringScreenLayoutConfig _meteringScreenLayout = widget.userPreferencesService.meteringScreenLayout; + late SupportedLocale _locale = widget.userPreferencesService.locale; + late ThemeType _themeType = widget.userPreferencesService.themeType; + late Color _primaryColor = widget.userPreferencesService.primaryColor; + late bool _dynamicColor = widget.userPreferencesService.dynamicColor; @override void initState() { super.initState(); - evSourceType = userPreferencesService.evSourceType; - evSourceType = evSourceType == EvSourceType.sensor && - !ServicesProvider.of(context).environment.hasLightSensor - ? EvSourceType.camera - : evSourceType; + _evSourceType = widget.userPreferencesService.evSourceType; + _evSourceType = _evSourceType == EvSourceType.sensor && !widget.hasLightSensor ? EvSourceType.camera : _evSourceType; WidgetsBinding.instance.addObserver(this); } @@ -109,9 +106,8 @@ class _UserPreferencesProviderState extends State late final DynamicColorState state; late final Color? dynamicPrimaryColor; if (lightDynamic != null && darkDynamic != null) { - if (dynamicColor) { - dynamicPrimaryColor = - (_themeBrightness == Brightness.light ? lightDynamic : darkDynamic).primary; + if (_dynamicColor) { + dynamicPrimaryColor = (_themeBrightness == Brightness.light ? lightDynamic : darkDynamic).primary; state = DynamicColorState.enabled; } else { dynamicPrimaryColor = null; @@ -124,13 +120,13 @@ class _UserPreferencesProviderState extends State return _UserPreferencesModel( brightness: _themeBrightness, dynamicColorState: state, - evSourceType: evSourceType, - locale: locale, - primaryColor: dynamicPrimaryColor ?? primaryColor, - stopType: stopType, - themeType: themeType, + evSourceType: _evSourceType, + locale: _locale, + primaryColor: dynamicPrimaryColor ?? _primaryColor, + stopType: _stopType, + themeType: _themeType, child: _MeteringScreenLayoutModel( - data: meteringScreenLayout, + data: _meteringScreenLayout, child: widget.child, ), ); @@ -140,65 +136,65 @@ class _UserPreferencesProviderState extends State void enableDynamicColor(bool enable) { setState(() { - dynamicColor = enable; + _dynamicColor = enable; }); - userPreferencesService.dynamicColor = enable; + widget.userPreferencesService.dynamicColor = enable; } void toggleEvSourceType() { - if (!ServicesProvider.of(context).environment.hasLightSensor) { + if (!widget.hasLightSensor) { return; } setState(() { - switch (evSourceType) { + switch (_evSourceType) { case EvSourceType.camera: - evSourceType = EvSourceType.sensor; + _evSourceType = EvSourceType.sensor; case EvSourceType.sensor: - evSourceType = EvSourceType.camera; + _evSourceType = EvSourceType.camera; } }); - userPreferencesService.evSourceType = evSourceType; + widget.userPreferencesService.evSourceType = _evSourceType; } void setLocale(SupportedLocale locale) { S.load(Locale(locale.intlName)).then((value) { setState(() { - this.locale = locale; + _locale = locale; }); - userPreferencesService.locale = locale; + widget.userPreferencesService.locale = locale; }); } void setMeteringScreenLayout(MeteringScreenLayoutConfig config) { setState(() { - meteringScreenLayout = config; + _meteringScreenLayout = config; }); - userPreferencesService.meteringScreenLayout = meteringScreenLayout; + widget.userPreferencesService.meteringScreenLayout = _meteringScreenLayout; } void setPrimaryColor(Color primaryColor) { setState(() { - this.primaryColor = primaryColor; + _primaryColor = primaryColor; }); - userPreferencesService.primaryColor = primaryColor; + widget.userPreferencesService.primaryColor = primaryColor; } void setStopType(StopType stopType) { setState(() { - this.stopType = stopType; + _stopType = stopType; }); - userPreferencesService.stopType = stopType; + widget.userPreferencesService.stopType = stopType; } void setThemeType(ThemeType themeType) { setState(() { - this.themeType = themeType; + _themeType = themeType; }); - userPreferencesService.themeType = themeType; + widget.userPreferencesService.themeType = themeType; } Brightness get _themeBrightness { - switch (themeType) { + switch (_themeType) { case ThemeType.light: return Brightness.light; case ThemeType.dark: @@ -258,8 +254,7 @@ class _UserPreferencesModel extends InheritedModel<_Aspect> { _UserPreferencesModel oldWidget, Set<_Aspect> dependencies, ) { - return (dependencies.contains(_Aspect.dynamicColorState) && - dynamicColorState != oldWidget.dynamicColorState) || + return (dependencies.contains(_Aspect.dynamicColorState) && dynamicColorState != oldWidget.dynamicColorState) || (dependencies.contains(_Aspect.evSourceType) && evSourceType != oldWidget.evSourceType) || (dependencies.contains(_Aspect.locale) && locale != oldWidget.locale) || (dependencies.contains(_Aspect.stopType) && stopType != oldWidget.stopType) || diff --git a/test/providers/user_preferences_provider_test.dart b/test/providers/user_preferences_provider_test.dart new file mode 100644 index 0000000..ffa7993 --- /dev/null +++ b/test/providers/user_preferences_provider_test.dart @@ -0,0 +1,376 @@ +import 'package:dynamic_color/test_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/data/models/dynamic_colors_state.dart'; +import 'package:lightmeter/data/models/ev_source_type.dart'; +import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; +import 'package:lightmeter/data/models/supported_locale.dart'; +import 'package:lightmeter/data/models/theme_type.dart'; +import 'package:lightmeter/data/shared_prefs_service.dart'; +import 'package:lightmeter/providers/user_preferences_provider.dart'; +import 'package:lightmeter/res/theme.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; +import 'package:material_color_utilities/material_color_utilities.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockUserPreferencesService extends Mock implements UserPreferencesService {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _MockUserPreferencesService mockUserPreferencesService; + + setUpAll(() { + mockUserPreferencesService = _MockUserPreferencesService(); + }); + + setUp(() { + when(() => mockUserPreferencesService.evSourceType).thenReturn(EvSourceType.camera); + when(() => mockUserPreferencesService.stopType).thenReturn(StopType.third); + when(() => mockUserPreferencesService.meteringScreenLayout).thenReturn({ + MeteringScreenLayoutFeature.extremeExposurePairs: true, + MeteringScreenLayoutFeature.filmPicker: true, + MeteringScreenLayoutFeature.equipmentProfiles: true, + MeteringScreenLayoutFeature.histogram: true, + }); + when(() => mockUserPreferencesService.locale).thenReturn(SupportedLocale.en); + when(() => mockUserPreferencesService.themeType).thenReturn(ThemeType.light); + when(() => mockUserPreferencesService.primaryColor).thenReturn(primaryColorsList[5]); + when(() => mockUserPreferencesService.dynamicColor).thenReturn(false); + }); + + tearDown(() { + reset(mockUserPreferencesService); + }); + + Future pumpTestWidget( + WidgetTester tester, { + bool hasLightSensor = true, + required WidgetBuilder builder, + }) async { + await tester.pumpWidget( + UserPreferencesProvider( + hasLightSensor: hasLightSensor, + userPreferencesService: mockUserPreferencesService, + child: _Application(builder: builder), + ), + ); + } + + group('[evSourceType]', () { + Future pumpEvTestApplication(WidgetTester tester, {required bool hasLightSensor}) async { + await pumpTestWidget( + tester, + hasLightSensor: hasLightSensor, + builder: (context) => Column( + children: [ + Text('EV source type: ${UserPreferencesProvider.evSourceTypeOf(context)}'), + ElevatedButton( + onPressed: UserPreferencesProvider.of(context).toggleEvSourceType, + child: const Text('toggleEvSourceType'), + ), + ], + ), + ); + } + + void expectEvSource(EvSourceType evSourceType) { + expect(find.text("EV source type: $evSourceType"), findsOneWidget); + } + + testWidgets( + 'Init evSourceType when has sensor & stored sensor', + (tester) async { + when(() => mockUserPreferencesService.evSourceType).thenReturn(EvSourceType.sensor); + await pumpEvTestApplication(tester, hasLightSensor: true); + expectEvSource(EvSourceType.sensor); + }, + ); + + testWidgets( + 'Init evSourceType when has no sensor & stored camera', + (tester) async { + when(() => mockUserPreferencesService.evSourceType).thenReturn(EvSourceType.camera); + await pumpEvTestApplication(tester, hasLightSensor: false); + expectEvSource(EvSourceType.camera); + }, + ); + + testWidgets( + 'Init evSourceType when has no sensor & stored sensor -> Reset to camera', + (tester) async { + when(() => mockUserPreferencesService.evSourceType).thenReturn(EvSourceType.sensor); + await pumpEvTestApplication(tester, hasLightSensor: false); + expectEvSource(EvSourceType.camera); + }, + ); + + testWidgets( + 'Try toggleEvSourceType() when has no sensor', + (tester) async { + when(() => mockUserPreferencesService.evSourceType).thenReturn(EvSourceType.camera); + await pumpEvTestApplication(tester, hasLightSensor: false); + await tester.tap(find.text('toggleEvSourceType')); + await tester.pumpAndSettle(); + verifyNever(() => mockUserPreferencesService.evSourceType = EvSourceType.sensor); + }, + ); + + testWidgets( + 'Try toggleEvSourceType() when has sensor', + (tester) async { + when(() => mockUserPreferencesService.evSourceType).thenReturn(EvSourceType.camera); + await pumpEvTestApplication(tester, hasLightSensor: true); + + await tester.tap(find.text('toggleEvSourceType')); + await tester.pumpAndSettle(); + expectEvSource(EvSourceType.sensor); + verify(() => mockUserPreferencesService.evSourceType = EvSourceType.sensor).called(1); + + await tester.tap(find.text('toggleEvSourceType')); + await tester.pumpAndSettle(); + expectEvSource(EvSourceType.camera); + verify(() => mockUserPreferencesService.evSourceType = EvSourceType.camera).called(1); + }, + ); + }); + + testWidgets( + 'Set different stop type', + (tester) async { + when(() => mockUserPreferencesService.stopType).thenReturn(StopType.third); + await pumpTestWidget( + tester, + builder: (context) => Column( + children: [ + Text('Stop type: ${UserPreferencesProvider.stopTypeOf(context)}'), + ElevatedButton( + onPressed: () => UserPreferencesProvider.of(context).setStopType(StopType.full), + child: const Text('setStopType'), + ), + ], + ), + ); + expect(find.text("Stop type: ${StopType.third}"), findsOneWidget); + + await tester.tap(find.text('setStopType')); + await tester.pumpAndSettle(); + expect(find.text("Stop type: ${StopType.full}"), findsOneWidget); + verify(() => mockUserPreferencesService.stopType = StopType.full).called(1); + }, + ); + + testWidgets( + 'Set metering screen layout config', + (tester) async { + await pumpTestWidget( + tester, + builder: (context) { + final config = UserPreferencesProvider.meteringScreenConfigOf(context); + return Column( + children: [ + ...List.generate( + config.length, + (index) => Text('${config.keys.toList()[index]}: ${config.values.toList()[index]}'), + ), + ...List.generate( + MeteringScreenLayoutFeature.values.length, + (index) => Text( + '${MeteringScreenLayoutFeature.values[index]}: ${UserPreferencesProvider.meteringScreenFeatureOf(context, MeteringScreenLayoutFeature.values[index])}', + ), + ), + ElevatedButton( + onPressed: () => UserPreferencesProvider.of(context).setMeteringScreenLayout({ + MeteringScreenLayoutFeature.equipmentProfiles: true, + MeteringScreenLayoutFeature.extremeExposurePairs: false, + MeteringScreenLayoutFeature.filmPicker: false, + MeteringScreenLayoutFeature.histogram: true, + }), + child: const Text(''), + ), + ], + ); + }, + ); + // Match `findsNWidgets(2)` to verify that `meteringScreenFeatureOf` specific results are the same as the whole config + expect(find.text("${MeteringScreenLayoutFeature.equipmentProfiles}: true"), findsNWidgets(2)); + expect(find.text("${MeteringScreenLayoutFeature.extremeExposurePairs}: true"), findsNWidgets(2)); + expect(find.text("${MeteringScreenLayoutFeature.filmPicker}: true"), findsNWidgets(2)); + expect(find.text("${MeteringScreenLayoutFeature.histogram}: true"), findsNWidgets(2)); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.text("${MeteringScreenLayoutFeature.equipmentProfiles}: true"), findsNWidgets(2)); + expect(find.text("${MeteringScreenLayoutFeature.extremeExposurePairs}: false"), findsNWidgets(2)); + expect(find.text("${MeteringScreenLayoutFeature.filmPicker}: false"), findsNWidgets(2)); + expect(find.text("${MeteringScreenLayoutFeature.histogram}: true"), findsNWidgets(2)); + verify( + () => mockUserPreferencesService.meteringScreenLayout = { + MeteringScreenLayoutFeature.extremeExposurePairs: false, + MeteringScreenLayoutFeature.filmPicker: false, + MeteringScreenLayoutFeature.equipmentProfiles: true, + MeteringScreenLayoutFeature.histogram: true, + }, + ).called(1); + }, + ); + + testWidgets( + 'Set different locale', + (tester) async { + when(() => mockUserPreferencesService.locale).thenReturn(SupportedLocale.en); + await pumpTestWidget( + tester, + builder: (context) => ElevatedButton( + onPressed: () => UserPreferencesProvider.of(context).setLocale(SupportedLocale.fr), + child: Text('${UserPreferencesProvider.localeOf(context)}'), + ), + ); + expect(find.text("${SupportedLocale.en}"), findsOneWidget); + + await tester.tap(find.text("${SupportedLocale.en}")); + await tester.pumpAndSettle(); + expect(find.text("${SupportedLocale.fr}"), findsOneWidget); + verify(() => mockUserPreferencesService.locale = SupportedLocale.fr).called(1); + }, + ); + + group('[theme]', () { + testWidgets( + 'Set dark theme type', + (tester) async { + when(() => mockUserPreferencesService.themeType).thenReturn(ThemeType.light); + await pumpTestWidget( + tester, + builder: (context) => Column( + children: [ + ElevatedButton( + onPressed: () => UserPreferencesProvider.of(context).setThemeType(ThemeType.dark), + child: Text('${UserPreferencesProvider.themeTypeOf(context)}'), + ), + Text('${Theme.of(context).colorScheme.brightness}') + ], + ), + ); + expect(find.text("${ThemeType.light}"), findsOneWidget); + expect(find.text("${Brightness.light}"), findsOneWidget); + + await tester.tap(find.text("${ThemeType.light}")); + await tester.pumpAndSettle(); + expect(find.text("${ThemeType.dark}"), findsOneWidget); + expect(find.text("${Brightness.dark}"), findsOneWidget); + verify(() => mockUserPreferencesService.themeType = ThemeType.dark).called(1); + }, + ); + + testWidgets( + 'Set systemDefault theme type and toggle platform brightness', + (tester) async { + when(() => mockUserPreferencesService.themeType).thenReturn(ThemeType.light); + await pumpTestWidget( + tester, + builder: (context) => Column( + children: [ + ElevatedButton( + onPressed: () => UserPreferencesProvider.of(context).setThemeType(ThemeType.systemDefault), + child: Text('${UserPreferencesProvider.themeTypeOf(context)}'), + ), + Text('${Theme.of(context).colorScheme.brightness}') + ], + ), + ); + TestWidgetsFlutterBinding.instance.platformDispatcher.platformBrightnessTestValue = Brightness.dark; + expect(find.text("${ThemeType.light}"), findsOneWidget); + expect(find.text("${Brightness.light}"), findsOneWidget); + + await tester.tap(find.text("${ThemeType.light}")); + await tester.pumpAndSettle(); + expect(find.text("${ThemeType.systemDefault}"), findsOneWidget); + expect(find.text("${Brightness.dark}"), findsOneWidget); + verify(() => mockUserPreferencesService.themeType = ThemeType.systemDefault).called(1); + + TestWidgetsFlutterBinding.instance.platformDispatcher.platformBrightnessTestValue = Brightness.light; + await tester.pumpAndSettle(); + expect(find.text("${ThemeType.systemDefault}"), findsOneWidget); + expect(find.text("${Brightness.light}"), findsOneWidget); + }, + ); + + testWidgets( + 'Set primary color', + (tester) async { + when(() => mockUserPreferencesService.primaryColor).thenReturn(primaryColorsList[5]); + await pumpTestWidget( + tester, + builder: (context) => ElevatedButton( + onPressed: () => UserPreferencesProvider.of(context).setPrimaryColor(primaryColorsList[7]), + child: Text('${UserPreferencesProvider.themeOf(context).primaryColor}'), + ), + ); + expect(find.text("${primaryColorsList[5]}"), findsOneWidget); + + await tester.tap(find.text("${primaryColorsList[5]}")); + await tester.pumpAndSettle(); + expect(find.text("${primaryColorsList[7]}"), findsOneWidget); + verify(() => mockUserPreferencesService.primaryColor = primaryColorsList[7]).called(1); + }, + ); + + testWidgets( + 'Dynamic colors not available', + (tester) async { + when(() => mockUserPreferencesService.dynamicColor).thenReturn(true); + await pumpTestWidget( + tester, + builder: (context) => ElevatedButton( + onPressed: () => UserPreferencesProvider.of(context).enableDynamicColor(false), + child: Text('${UserPreferencesProvider.dynamicColorStateOf(context)}'), + ), + ); + await tester.pumpAndSettle(); + expect( + find.text("${DynamicColorState.unavailable}"), + findsOneWidget, + reason: + "Even though dynamic colors usage is enabled, the core palette can be unavailable. Therefore `DynamicColorState` is also unavailable.", + ); + }, + ); + + testWidgets( + 'Toggle dynamic color state', + (tester) async { + DynamicColorTestingUtils.setMockDynamicColors(corePalette: CorePalette.of(0xffffffff)); + when(() => mockUserPreferencesService.dynamicColor).thenReturn(true); + await pumpTestWidget( + tester, + builder: (context) => ElevatedButton( + onPressed: () => UserPreferencesProvider.of(context).enableDynamicColor(false), + child: Text('${UserPreferencesProvider.dynamicColorStateOf(context)}'), + ), + ); + await tester.pumpAndSettle(); + expect(find.text("${DynamicColorState.enabled}"), findsOneWidget); + + await tester.tap(find.text("${DynamicColorState.enabled}")); + await tester.pumpAndSettle(); + expect(find.text("${DynamicColorState.disabled}"), findsOneWidget); + verify(() => mockUserPreferencesService.dynamicColor = false).called(1); + }, + ); + }); +} + +class _Application extends StatelessWidget { + final WidgetBuilder builder; + + const _Application({required this.builder}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: UserPreferencesProvider.themeOf(context), + home: Scaffold(body: Center(child: Builder(builder: builder))), + ); + } +}