diff --git a/lib/interactors/timer_interactor.dart b/lib/interactors/timer_interactor.dart new file mode 100644 index 0000000..35ed8b9 --- /dev/null +++ b/lib/interactors/timer_interactor.dart @@ -0,0 +1,22 @@ +import 'package:lightmeter/data/haptics_service.dart'; +import 'package:lightmeter/data/shared_prefs_service.dart'; + +class TimerInteractor { + final UserPreferencesService _userPreferencesService; + final HapticsService _hapticsService; + + TimerInteractor( + this._userPreferencesService, + this._hapticsService, + ); + + /// Executes vibration if haptics are enabled in settings + Future quickVibration() async { + if (_userPreferencesService.haptics) await _hapticsService.quickVibration(); + } + + /// Executes vibration if haptics are enabled in settings + Future responseVibration() async { + if (_userPreferencesService.haptics) await _hapticsService.responseVibration(); + } +} diff --git a/lib/screens/timer/bloc_timer.dart b/lib/screens/timer/bloc_timer.dart index 79bc353..5a0a0e3 100644 --- a/lib/screens/timer/bloc_timer.dart +++ b/lib/screens/timer/bloc_timer.dart @@ -1,30 +1,35 @@ import 'dart:async'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:lightmeter/interactors/metering_interactor.dart'; +import 'package:lightmeter/interactors/timer_interactor.dart'; import 'package:lightmeter/screens/timer/event_timer.dart'; import 'package:lightmeter/screens/timer/state_timer.dart'; class TimerBloc extends Bloc { - final MeteringInteractor _meteringInteractor; + final TimerInteractor _timerInteractor; final Duration duration; - TimerBloc(this._meteringInteractor, this.duration) : super(const TimerStoppedState()) { + TimerBloc(this._timerInteractor, this.duration) : super(const TimerStoppedState()) { on(_onStartTimer); - on(_onSetTimeLeft); + on(_onTimerEnded); on(_onStopTimer); on(_onResetTimer); } Future _onStartTimer(StartTimerEvent _, Emitter emit) async { + _timerInteractor.quickVibration(); emit(const TimerResumedState()); } - Future _onSetTimeLeft(SetTimeLeftEvent event, Emitter emit) async { - emit(const TimerResumedState()); + Future _onTimerEnded(TimerEndedEvent event, Emitter emit) async { + if (state is! TimerResetState) { + _timerInteractor.responseVibration(); + emit(const TimerStoppedState()); + } } Future _onStopTimer(StopTimerEvent _, Emitter emit) async { + _timerInteractor.quickVibration(); emit(const TimerStoppedState()); } diff --git a/lib/screens/timer/event_timer.dart b/lib/screens/timer/event_timer.dart index d69b7c5..53e815d 100644 --- a/lib/screens/timer/event_timer.dart +++ b/lib/screens/timer/event_timer.dart @@ -10,10 +10,8 @@ class StopTimerEvent extends TimerEvent { const StopTimerEvent(); } -class SetTimeLeftEvent extends TimerEvent { - final Duration timeLeft; - - const SetTimeLeftEvent(this.timeLeft); +class TimerEndedEvent extends TimerEvent { + const TimerEndedEvent(); } class ResetTimerEvent extends TimerEvent { diff --git a/lib/screens/timer/flow_timer.dart b/lib/screens/timer/flow_timer.dart index 99cc696..3233dfe 100644 --- a/lib/screens/timer/flow_timer.dart +++ b/lib/screens/timer/flow_timer.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lightmeter/data/models/exposure_pair.dart'; import 'package:lightmeter/interactors/metering_interactor.dart'; +import 'package:lightmeter/interactors/timer_interactor.dart'; import 'package:lightmeter/providers/services_provider.dart'; import 'package:lightmeter/screens/timer/bloc_timer.dart'; import 'package:lightmeter/screens/timer/screen_timer.dart'; @@ -15,18 +16,14 @@ class TimerFlow extends StatelessWidget { @override Widget build(BuildContext context) { - return MeteringInteractorProvider( - data: MeteringInteractor( + return TimerInteractorProvider( + data: TimerInteractor( ServicesProvider.of(context).userPreferencesService, - ServicesProvider.of(context).caffeineService, ServicesProvider.of(context).hapticsService, - ServicesProvider.of(context).permissionsService, - ServicesProvider.of(context).lightSensorService, - ServicesProvider.of(context).volumeEventsService, - )..initialize(), + ), child: BlocProvider( create: (context) => TimerBloc( - MeteringInteractorProvider.of(context), + TimerInteractorProvider.of(context), _duration, ), child: TimerScreen( @@ -38,19 +35,19 @@ class TimerFlow extends StatelessWidget { } } -class MeteringInteractorProvider extends InheritedWidget { - final MeteringInteractor data; +class TimerInteractorProvider extends InheritedWidget { + final TimerInteractor data; - const MeteringInteractorProvider({ + const TimerInteractorProvider({ required this.data, required super.child, super.key, }); - static MeteringInteractor of(BuildContext context) { - return context.findAncestorWidgetOfExactType()!.data; + static TimerInteractor of(BuildContext context) { + return context.findAncestorWidgetOfExactType()!.data; } @override - bool updateShouldNotify(MeteringInteractorProvider oldWidget) => false; + bool updateShouldNotify(TimerInteractorProvider oldWidget) => false; } diff --git a/lib/screens/timer/screen_timer.dart b/lib/screens/timer/screen_timer.dart index cfeecdd..8d0e5d9 100644 --- a/lib/screens/timer/screen_timer.dart +++ b/lib/screens/timer/screen_timer.dart @@ -39,7 +39,7 @@ class _TimerScreenState extends State with TickerProviderStateMixin timelineAnimation = Tween(begin: 1, end: 0).animate(timelineController); timelineController.addStatusListener((status) { if (status == AnimationStatus.completed) { - context.read().add(const StopTimerEvent()); + context.read().add(const TimerEndedEvent()); } }); diff --git a/test/interactors/timer_interactor_test.dart b/test/interactors/timer_interactor_test.dart new file mode 100644 index 0000000..1116b74 --- /dev/null +++ b/test/interactors/timer_interactor_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/data/haptics_service.dart'; +import 'package:lightmeter/data/shared_prefs_service.dart'; +import 'package:lightmeter/interactors/timer_interactor.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockUserPreferencesService extends Mock implements UserPreferencesService {} + +class _MockHapticsService extends Mock implements HapticsService {} + +void main() { + late _MockUserPreferencesService mockUserPreferencesService; + late _MockHapticsService mockHapticsService; + + late TimerInteractor interactor; + + setUp(() { + mockUserPreferencesService = _MockUserPreferencesService(); + mockHapticsService = _MockHapticsService(); + + interactor = TimerInteractor( + mockUserPreferencesService, + mockHapticsService, + ); + }); + + group( + 'Haptics', + () { + test('quickVibration() - true', () async { + when(() => mockUserPreferencesService.haptics).thenReturn(true); + when(() => mockHapticsService.quickVibration()).thenAnswer((_) async {}); + interactor.quickVibration(); + verify(() => mockUserPreferencesService.haptics).called(1); + verify(() => mockHapticsService.quickVibration()).called(1); + }); + + test('quickVibration() - false', () async { + when(() => mockUserPreferencesService.haptics).thenReturn(false); + when(() => mockHapticsService.quickVibration()).thenAnswer((_) async {}); + interactor.quickVibration(); + verify(() => mockUserPreferencesService.haptics).called(1); + verifyNever(() => mockHapticsService.quickVibration()); + }); + + test('responseVibration() - true', () async { + when(() => mockUserPreferencesService.haptics).thenReturn(true); + when(() => mockHapticsService.responseVibration()).thenAnswer((_) async {}); + interactor.responseVibration(); + verify(() => mockUserPreferencesService.haptics).called(1); + verify(() => mockHapticsService.responseVibration()).called(1); + }); + + test('responseVibration() - false', () async { + when(() => mockUserPreferencesService.haptics).thenReturn(false); + when(() => mockHapticsService.responseVibration()).thenAnswer((_) async {}); + interactor.responseVibration(); + verify(() => mockUserPreferencesService.haptics).called(1); + verifyNever(() => mockHapticsService.responseVibration()); + }); + }, + ); +} diff --git a/test/screens/timer/screen_metering_golden_test.dart b/test/screens/timer/screen_metering_golden_test.dart new file mode 100644 index 0000000..8c7e15f --- /dev/null +++ b/test/screens/timer/screen_metering_golden_test.dart @@ -0,0 +1,144 @@ +import 'dart:convert'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.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/theme_type.dart'; +import 'package:lightmeter/data/shared_prefs_service.dart'; +import 'package:lightmeter/providers/user_preferences_provider.dart'; +import 'package:lightmeter/screens/metering/flow_metering.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../integration_test/utils/finder_actions.dart'; +import '../../../integration_test/utils/platform_channel_mock.dart'; +import '../../application_mock.dart'; + +class _MeteringScreenConfig { + final IAPProductStatus iapProductStatus; + final EvSourceType evSourceType; + + _MeteringScreenConfig( + this.iapProductStatus, + this.evSourceType, + ); + + @override + String toString() { + final buffer = StringBuffer(); + buffer.write(iapProductStatus.toString().split('.')[1]); + buffer.write(' - '); + buffer.write(evSourceType.toString().split('.')[1]); + return buffer.toString(); + } +} + +final _testScenarios = [IAPProductStatus.purchased, IAPProductStatus.purchasable].expand( + (iapProductStatus) => EvSourceType.values.map( + (evSourceType) => _MeteringScreenConfig(iapProductStatus, evSourceType), + ), +); + +void main() { + Future setEvSource(WidgetTester tester, Key scenarioWidgetKey, EvSourceType evSourceType) async { + final flow = find.descendant( + of: find.byKey(scenarioWidgetKey), + matching: find.byType(MeteringFlow), + ); + final BuildContext context = tester.element(flow); + if (UserPreferencesProvider.evSourceTypeOf(context) != evSourceType) { + UserPreferencesProvider.of(context).toggleEvSourceType(); + } + await tester.pumpAndSettle(); + } + + Future setTheme(WidgetTester tester, Key scenarioWidgetKey, ThemeType themeType) async { + final flow = find.descendant( + of: find.byKey(scenarioWidgetKey), + matching: find.byType(MeteringFlow), + ); + final BuildContext context = tester.element(flow); + UserPreferencesProvider.of(context).setThemeType(themeType); + await tester.pumpAndSettle(); + } + + Future takePhoto(WidgetTester tester, Key scenarioWidgetKey) async { + final button = find.descendant( + of: find.byKey(scenarioWidgetKey), + matching: find.measureButton(), + ); + await tester.tap(button); + await tester.pump(const Duration(seconds: 2)); // wait for circular progress indicator + await tester.pump(const Duration(seconds: 1)); // wait for circular progress indicator + await tester.pumpAndSettle(); + } + + Future toggleIncidentMetering(WidgetTester tester, Key scenarioWidgetKey, double ev) async { + final button = find.descendant( + of: find.byKey(scenarioWidgetKey), + matching: find.measureButton(), + ); + await tester.tap(button); + await sendMockIncidentEv(ev); + await tester.tap(button); + await tester.pumpAndSettle(); + } + + setUpAll(() { + SharedPreferences.setMockInitialValues({ + UserPreferencesService.evSourceTypeKey: EvSourceType.camera.index, + UserPreferencesService.meteringScreenLayoutKey: json.encode( + { + MeteringScreenLayoutFeature.equipmentProfiles: true, + MeteringScreenLayoutFeature.extremeExposurePairs: true, + MeteringScreenLayoutFeature.filmPicker: true, + }.toJson(), + ), + }); + }); + + testGoldens( + 'MeteringScreen golden test', + (tester) async { + final builder = DeviceBuilder(); + for (final scenario in _testScenarios) { + builder.addScenario( + name: scenario.toString(), + widget: _MockMeteringFlow(productStatus: scenario.iapProductStatus), + onCreate: (scenarioWidgetKey) async { + await setEvSource(tester, scenarioWidgetKey, scenario.evSourceType); + if (scenarioWidgetKey.toString().contains('Dark')) { + await setTheme(tester, scenarioWidgetKey, ThemeType.dark); + } + if (scenario.evSourceType == EvSourceType.camera) { + await takePhoto(tester, scenarioWidgetKey); + } else { + await toggleIncidentMetering(tester, scenarioWidgetKey, 7.3); + } + }, + ); + } + await tester.pumpDeviceBuilder(builder); + await screenMatchesGolden( + tester, + 'metering_screen', + ); + }, + ); +} + +class _MockMeteringFlow extends StatelessWidget { + final IAPProductStatus productStatus; + + const _MockMeteringFlow({required this.productStatus}); + + @override + Widget build(BuildContext context) { + return GoldenTestApplicationMock( + productStatus: productStatus, + child: const MeteringFlow(), + ); + } +}