diff --git a/integration_test/e2e_test.dart b/integration_test/e2e_test.dart index ed9de80..419db8e 100644 --- a/integration_test/e2e_test.dart +++ b/integration_test/e2e_test.dart @@ -7,7 +7,7 @@ import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; import 'package:lightmeter/data/shared_prefs_service.dart'; import 'package:lightmeter/generated/l10n.dart'; import 'package:lightmeter/res/dimens.dart'; -import 'package:lightmeter/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.dart'; +import 'package:lightmeter/screens/metering/components/bottom_controls/widget_bottom_controls.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/iso_picker/widget_picker_iso.dart'; @@ -329,7 +329,7 @@ Future _expectMeteringStateAndMeasure( void expectMeasureButton(double ev) { find.descendant( - of: find.byType(MeteringMeasureButton), + of: find.byType(MeteringBottomControls), matching: find.text('${ev.toStringAsFixed(1)}\n${S.current.ev}'), ); } diff --git a/integration_test/purchases_test.dart b/integration_test/purchases_test.dart index 234a147..f001a5c 100644 --- a/integration_test/purchases_test.dart +++ b/integration_test/purchases_test.dart @@ -6,7 +6,6 @@ import 'package:lightmeter/data/models/ev_source_type.dart'; import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; import 'package:lightmeter/data/shared_prefs_service.dart'; import 'package:lightmeter/generated/l10n.dart'; -import 'package:lightmeter/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/extreme_exposure_pairs_container/widget_container_extreme_exposure_pairs.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart'; @@ -21,6 +20,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../integration_test/utils/widget_tester_actions.dart'; import 'mocks/iap_products_mock.dart'; +import 'utils/finder_actions.dart'; @isTest void testPurchases(String description) { @@ -84,7 +84,7 @@ void _expectProMeteringScreen({required bool enabled}) { expect(find.byType(NdValuePicker), findsOneWidget); expect( find.descendant( - of: find.byType(MeteringMeasureButton), + of: find.measureButton(), matching: find.byWidgetPredicate((widget) => widget is Text && widget.data!.contains('\u2081\u2080\u2080')), ), enabled ? findsOneWidget : findsNothing, diff --git a/integration_test/utils/finder_actions.dart b/integration_test/utils/finder_actions.dart new file mode 100644 index 0000000..507876d --- /dev/null +++ b/integration_test/utils/finder_actions.dart @@ -0,0 +1,10 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/screens/metering/components/bottom_controls/widget_bottom_controls.dart'; +import 'package:lightmeter/screens/shared/animated_circular_button/widget_button_circular_animated.dart'; + +extension CommonFindersExtension on CommonFinders { + Finder measureButton() => find.descendant( + of: find.byType(MeteringBottomControls), + matching: find.byType(AnimatedCircluarButton), + ); +} diff --git a/integration_test/utils/widget_tester_actions.dart b/integration_test/utils/widget_tester_actions.dart index 7732d1a..8515c1d 100644 --- a/integration_test/utils/widget_tester_actions.dart +++ b/integration_test/utils/widget_tester_actions.dart @@ -5,7 +5,6 @@ import 'package:lightmeter/application_wrapper.dart'; import 'package:lightmeter/environment.dart'; import 'package:lightmeter/generated/l10n.dart'; import 'package:lightmeter/res/dimens.dart'; -import 'package:lightmeter/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.dart'; import 'package:lightmeter/screens/metering/components/shared/exposure_pairs_list/widget_list_exposure_pairs.dart'; import 'package:lightmeter/screens/metering/screen_metering.dart'; import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; @@ -13,6 +12,7 @@ import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; import '../mocks/iap_products_mock.dart'; import '../mocks/paid_features_mock.dart'; +import 'finder_actions.dart'; import 'platform_channel_mock.dart'; const mockPhotoEv100 = 8.3; @@ -46,16 +46,16 @@ extension WidgetTesterCommonActions on WidgetTester { } Future takePhoto() async { - await tap(find.byType(MeteringMeasureButton)); + await tap(find.measureButton()); await pump(const Duration(seconds: 2)); // wait for circular progress indicator await pump(const Duration(seconds: 1)); // wait for circular progress indicator await pumpAndSettle(); } Future toggleIncidentMetering(double ev) async { - await tap(find.byType(MeteringMeasureButton)); + await tap(find.measureButton()); await sendMockIncidentEv(ev); - await tap(find.byType(MeteringMeasureButton)); + await tap(find.measureButton()); await pumpAndSettle(); } diff --git a/lib/application.dart b/lib/application.dart index 15eba3a..5afcde9 100644 --- a/lib/application.dart +++ b/lib/application.dart @@ -7,6 +7,7 @@ import 'package:lightmeter/platform_config.dart'; import 'package:lightmeter/providers/user_preferences_provider.dart'; import 'package:lightmeter/screens/metering/flow_metering.dart'; import 'package:lightmeter/screens/settings/flow_settings.dart'; +import 'package:lightmeter/screens/timer/flow_timer.dart'; class Application extends StatelessWidget { const Application({super.key}); @@ -40,8 +41,9 @@ class Application extends StatelessWidget { ), initialRoute: "metering", routes: { - "metering": (context) => const MeteringFlow(), - "settings": (context) => const SettingsFlow(), + "metering": (_) => const MeteringFlow(), + "settings": (_) => const SettingsFlow(), + "timer": (context) => TimerFlow(args: ModalRoute.of(context)!.settings.arguments! as TimerFlowArgs), }, ), ); diff --git a/lib/data/haptics_service.dart b/lib/data/haptics_service.dart index f1bdc6b..09113b4 100644 --- a/lib/data/haptics_service.dart +++ b/lib/data/haptics_service.dart @@ -9,7 +9,7 @@ class HapticsService { Future responseVibration() async => _tryVibrate(duration: 50, amplitude: 128); - Future errorVibration() async => _tryVibrate(duration: 100, amplitude: 128); + Future errorVibration() async => _tryVibrate(duration: 500, amplitude: 128); Future _tryVibrate({required int duration, required int amplitude}) async { if (await _canVibrate()) { diff --git a/lib/data/shared_prefs_service.dart b/lib/data/shared_prefs_service.dart index 3bb6dfb..57f947d 100644 --- a/lib/data/shared_prefs_service.dart +++ b/lib/data/shared_prefs_service.dart @@ -24,6 +24,7 @@ class UserPreferencesService { static const caffeineKey = "caffeine"; static const hapticsKey = "haptics"; + static const autostartTimerKey = "autostartTimer"; static const volumeActionKey = "volumeAction"; static const localeKey = "locale"; @@ -127,6 +128,9 @@ class UserPreferencesService { bool get haptics => _sharedPreferences.getBool(hapticsKey) ?? true; set haptics(bool value) => _sharedPreferences.setBool(hapticsKey, value); + bool get autostartTimer => _sharedPreferences.getBool(autostartTimerKey) ?? true; + set autostartTimer(bool value) => _sharedPreferences.setBool(autostartTimerKey, value); + VolumeAction get volumeAction => VolumeAction.values.firstWhere( (e) => e.toString() == _sharedPreferences.getString(volumeActionKey), orElse: () => VolumeAction.shutter, diff --git a/lib/interactors/settings_interactor.dart b/lib/interactors/settings_interactor.dart index 8f74dd2..d5669c9 100644 --- a/lib/interactors/settings_interactor.dart +++ b/lib/interactors/settings_interactor.dart @@ -21,8 +21,7 @@ class SettingsInteractor { void setCameraEvCalibration(double value) => _userPreferencesService.cameraEvCalibration = value; double get lightSensorEvCalibration => _userPreferencesService.lightSensorEvCalibration; - void setLightSensorEvCalibration(double value) => - _userPreferencesService.lightSensorEvCalibration = value; + void setLightSensorEvCalibration(double value) => _userPreferencesService.lightSensorEvCalibration = value; bool get isCaffeineEnabled => _userPreferencesService.caffeine; Future enableCaffeine(bool enable) async { @@ -31,12 +30,15 @@ class SettingsInteractor { }); } + bool get isAutostartTimerEnabled => _userPreferencesService.autostartTimer; + void enableAutostartTimer(bool enable) => _userPreferencesService.autostartTimer = enable; + Future disableVolumeHandling() async { await _volumeEventsService.setVolumeHandling(false); } + Future restoreVolumeHandling() async { - await _volumeEventsService - .setVolumeHandling(_userPreferencesService.volumeAction != VolumeAction.none); + await _volumeEventsService.setVolumeHandling(_userPreferencesService.volumeAction != VolumeAction.none); } VolumeAction get volumeAction => _userPreferencesService.volumeAction; diff --git a/lib/interactors/timer_interactor.dart b/lib/interactors/timer_interactor.dart new file mode 100644 index 0000000..08b88ed --- /dev/null +++ b/lib/interactors/timer_interactor.dart @@ -0,0 +1,24 @@ +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 startVibration() async { + if (_userPreferencesService.haptics) await _hapticsService.quickVibration(); + } + + /// Executes vibration if haptics are enabled in settings + Future endVibration() async { + if (_userPreferencesService.haptics) await _hapticsService.errorVibration(); + } + + bool get isAutostartTimerEnabled => _userPreferencesService.autostartTimer; +} diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index b041500..34247a6 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -72,6 +72,7 @@ "general": "General", "keepScreenOn": "Keep screen on", "haptics": "Haptics", + "autostartTimer": "Autostart timer", "volumeKeysAction": "Shutter by volume keys", "language": "Language", "chooseLanguage": "Choose language", @@ -117,5 +118,6 @@ "tooltipResetToZero": "Reset to zero", "tooltipUseLightSensor": "Use lightsensor", "tooltipUseCamera": "Use camera", - "tooltipOpenSettings": "Open settings" + "tooltipOpenSettings": "Open settings", + "exposurePair": "Exposure pair" } \ No newline at end of file diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index c3941ba..3d0a684 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -72,6 +72,7 @@ "general": "Général", "keepScreenOn": "Garder l'écran allumé", "haptics": "Haptiques", + "autostartTimer": "Minuterie de démarrage automatique", "volumeKeysAction": "Obturateur par boutons de volume", "language": "Langue", "chooseLanguage": "Choisissez la langue", @@ -117,5 +118,6 @@ "tooltipResetToZero": "Remise à zéro", "tooltipUseLightSensor": "Utiliser un capteur de lumière", "tooltipUseCamera": "Utiliser la caméra", - "tooltipOpenSettings": "Ouvrir les paramètres" + "tooltipOpenSettings": "Ouvrir les paramètres", + "exposurePair": "Paire d'exposition" } \ No newline at end of file diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index ef182d8..d005f77 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -72,6 +72,7 @@ "general": "Общие", "keepScreenOn": "Запрет блокировки", "haptics": "Вибрация", + "autostartTimer": "Автозапуск таймера", "volumeKeysAction": "Затвор по кнопкам громкости", "language": "Язык", "chooseLanguage": "Выберите язык", @@ -117,5 +118,6 @@ "tooltipResetToZero": "Сбросить до 0", "tooltipUseLightSensor": "Использовать датчик освещенности", "tooltipUseCamera": "Использовать камеру", - "tooltipOpenSettings": "Открыть настройки" + "tooltipOpenSettings": "Открыть настройки", + "exposurePair": "Пара экспозиции" } \ No newline at end of file diff --git a/lib/l10n/intl_zh.arb b/lib/l10n/intl_zh.arb index 5d26833..aa17748 100644 --- a/lib/l10n/intl_zh.arb +++ b/lib/l10n/intl_zh.arb @@ -72,6 +72,7 @@ "general": "通用", "keepScreenOn": "保持屏幕常亮", "haptics": "震动", + "autostartTimer": "自动启动定时器", "volumeKeysAction": "音量键快门", "language": "语言", "chooseLanguage": "选择语言", @@ -117,5 +118,6 @@ "resetToZero": "重置为零", "tooltipUseLightSensor": "使用光线传感器", "tooltipUseCamera": "使用摄像头", - "tooltipOpenSettings": "打开设置" + "tooltipOpenSettings": "打开设置", + "exposurePair": "曝光对" } \ No newline at end of file diff --git a/lib/screens/metering/bloc_metering.dart b/lib/screens/metering/bloc_metering.dart index 753a7e7..6c3ab15 100644 --- a/lib/screens/metering/bloc_metering.dart +++ b/lib/screens/metering/bloc_metering.dart @@ -6,10 +6,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lightmeter/data/models/volume_action.dart'; import 'package:lightmeter/interactors/metering_interactor.dart'; import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; -import 'package:lightmeter/screens/metering/communication/event_communication_metering.dart' - as communication_events; -import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart' - as communication_states; +import 'package:lightmeter/screens/metering/communication/event_communication_metering.dart' as communication_events; +import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart' as communication_states; import 'package:lightmeter/screens/metering/event_metering.dart'; import 'package:lightmeter/screens/metering/state_metering.dart'; import 'package:lightmeter/screens/metering/utils/notifier_volume_keys.dart'; @@ -45,8 +43,8 @@ class MeteringBloc extends Bloc { on(_onMeasure, transformer: droppable()); on(_onMeasured); on(_onMeasureError); - on(_onSettingsOpened); - on(_onSettingsClosed); + on(_onSettingsOpened); + on(_onSettingsClosed); } @override @@ -191,11 +189,11 @@ class MeteringBloc extends Bloc { } } - void _onSettingsOpened(SettingsOpenedEvent _, Emitter __) { - _communicationBloc.add(const communication_events.SettingsOpenedEvent()); + void _onSettingsOpened(ScreenOnTopOpenedEvent _, Emitter __) { + _communicationBloc.add(const communication_events.ScreenOnTopOpenedEvent()); } - void _onSettingsClosed(SettingsClosedEvent _, Emitter __) { - _communicationBloc.add(const communication_events.SettingsClosedEvent()); + void _onSettingsClosed(ScreenOnTopClosedEvent _, Emitter __) { + _communicationBloc.add(const communication_events.ScreenOnTopClosedEvent()); } } diff --git a/lib/screens/metering/communication/bloc_communication_metering.dart b/lib/screens/metering/communication/bloc_communication_metering.dart index 11ebe37..f80866c 100644 --- a/lib/screens/metering/communication/bloc_communication_metering.dart +++ b/lib/screens/metering/communication/bloc_communication_metering.dart @@ -3,15 +3,14 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lightmeter/screens/metering/communication/event_communication_metering.dart'; import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart'; -class MeteringCommunicationBloc - extends Bloc { +class MeteringCommunicationBloc extends Bloc { MeteringCommunicationBloc() : super(const InitState()) { // `MeasureState` is not const, so that `Bloc` treats each state as new and updates state stream // ignore: prefer_const_constructors on((_, emit) => emit(MeasureState())); on((event, emit) => emit(MeteringInProgressState(event.ev100))); on((event, emit) => emit(MeteringEndedState(event.ev100))); - on((_, emit) => emit(const SettingsOpenedState())); - on((_, emit) => emit(const SettingsClosedState())); + on((_, emit) => emit(const SettingsOpenedState())); + on((_, emit) => emit(const SettingsClosedState())); } } diff --git a/lib/screens/metering/communication/event_communication_metering.dart b/lib/screens/metering/communication/event_communication_metering.dart index c7e0fd8..8872fa0 100644 --- a/lib/screens/metering/communication/event_communication_metering.dart +++ b/lib/screens/metering/communication/event_communication_metering.dart @@ -48,10 +48,10 @@ class MeteringEndedEvent extends MeasuredEvent { int get hashCode => Object.hash(ev100, runtimeType); } -class SettingsOpenedEvent extends ScreenEvent { - const SettingsOpenedEvent(); +class ScreenOnTopOpenedEvent extends ScreenEvent { + const ScreenOnTopOpenedEvent(); } -class SettingsClosedEvent extends ScreenEvent { - const SettingsClosedEvent(); +class ScreenOnTopClosedEvent extends ScreenEvent { + const ScreenOnTopClosedEvent(); } diff --git a/lib/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.dart b/lib/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.dart deleted file mode 100644 index e918557..0000000 --- a/lib/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:lightmeter/generated/l10n.dart'; -import 'package:lightmeter/providers/user_preferences_provider.dart'; -import 'package:lightmeter/res/dimens.dart'; -import 'package:lightmeter/screens/shared/filled_circle/widget_circle_filled.dart'; -import 'package:lightmeter/utils/context_utils.dart'; - -const String _subscript100 = '\u2081\u2080\u2080'; - -class MeteringMeasureButton extends StatefulWidget { - final double? ev; - final double? ev100; - final bool isMetering; - final VoidCallback onTap; - - const MeteringMeasureButton({ - required this.ev, - required this.ev100, - required this.isMetering, - required this.onTap, - super.key, - }); - - @override - State createState() => _MeteringMeasureButtonState(); -} - -class _MeteringMeasureButtonState extends State { - bool _isPressed = false; - - @override - void didUpdateWidget(covariant MeteringMeasureButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.isMetering != widget.isMetering) { - _isPressed = widget.isMetering; - } - } - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: widget.onTap, - onTapDown: (_) { - setState(() { - _isPressed = true; - }); - }, - onTapUp: (_) { - setState(() { - _isPressed = false; - }); - }, - onTapCancel: () { - setState(() { - _isPressed = false; - }); - }, - child: SizedBox.fromSize( - size: const Size.square(Dimens.grid72), - child: Stack( - children: [ - Center( - child: AnimatedScale( - duration: Dimens.durationS, - scale: _isPressed ? 0.9 : 1.0, - child: FilledCircle( - color: Theme.of(context).colorScheme.onSurface, - size: Dimens.grid72 - Dimens.grid8, - child: Center( - child: widget.ev != null ? _EvValueText(ev: widget.ev!, ev100: widget.ev100!) : null, - ), - ), - ), - ), - Positioned.fill( - child: CircularProgressIndicator( - /// This key is needed to make indicator start from the same point every time - key: ValueKey(widget.isMetering), - color: Theme.of(context).colorScheme.onSurface, - value: widget.isMetering ? null : 1, - ), - ), - ], - ), - ), - ); - } -} - -class _EvValueText extends StatelessWidget { - final double ev; - final double ev100; - - const _EvValueText({ - required this.ev, - required this.ev100, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Text( - _text(context), - style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.surface), - textAlign: TextAlign.center, - ); - } - - String _text(BuildContext context) { - final bool showEv100 = context.isPro && UserPreferencesProvider.showEv100Of(context); - final StringBuffer buffer = StringBuffer() - ..writeAll([ - (showEv100 ? ev100 : ev).toStringAsFixed(1), - '\n', - S.of(context).ev, - if (showEv100) _subscript100, - ]); - return buffer.toString(); - } -} diff --git a/lib/screens/metering/components/bottom_controls/provider_bottom_controls.dart b/lib/screens/metering/components/bottom_controls/provider_bottom_controls.dart deleted file mode 100644 index f9d6d47..0000000 --- a/lib/screens/metering/components/bottom_controls/provider_bottom_controls.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:lightmeter/res/dimens.dart'; - -import 'package:lightmeter/screens/metering/components/bottom_controls/widget_bottom_controls.dart'; - -class MeteringBottomControlsProvider extends StatelessWidget { - final double? ev; - final double? ev100; - final bool isMetering; - final VoidCallback? onSwitchEvSourceType; - final VoidCallback onMeasure; - final VoidCallback onSettings; - - const MeteringBottomControlsProvider({ - required this.ev, - required this.ev100, - required this.isMetering, - required this.onSwitchEvSourceType, - required this.onMeasure, - required this.onSettings, - super.key, - }); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return IconButtonTheme( - data: IconButtonThemeData( - style: ButtonStyle( - backgroundColor: MaterialStatePropertyAll(scheme.surface), - elevation: const MaterialStatePropertyAll(4), - iconColor: MaterialStatePropertyAll(scheme.onSurface), - shadowColor: const MaterialStatePropertyAll(Colors.transparent), - surfaceTintColor: MaterialStatePropertyAll(scheme.surfaceTint), - fixedSize: const MaterialStatePropertyAll(Size(Dimens.grid48, Dimens.grid48)), - ), - ), - child: MeteringBottomControls( - ev: ev, - ev100: ev100, - isMetering: isMetering, - onSwitchEvSourceType: onSwitchEvSourceType, - onMeasure: onMeasure, - onSettings: onSettings, - ), - ); - } -} diff --git a/lib/screens/metering/components/bottom_controls/widget_bottom_controls.dart b/lib/screens/metering/components/bottom_controls/widget_bottom_controls.dart index 0eef568..957d0b7 100644 --- a/lib/screens/metering/components/bottom_controls/widget_bottom_controls.dart +++ b/lib/screens/metering/components/bottom_controls/widget_bottom_controls.dart @@ -2,8 +2,9 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/data/models/ev_source_type.dart'; import 'package:lightmeter/generated/l10n.dart'; import 'package:lightmeter/providers/user_preferences_provider.dart'; -import 'package:lightmeter/res/dimens.dart'; -import 'package:lightmeter/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.dart'; +import 'package:lightmeter/screens/shared/animated_circular_button/widget_button_circular_animated.dart'; +import 'package:lightmeter/screens/shared/bottom_controls_bar/widget_bottom_controls_bar.dart'; +import 'package:lightmeter/utils/context_utils.dart'; class MeteringBottomControls extends StatelessWidget { final double? ev; @@ -25,59 +26,64 @@ class MeteringBottomControls extends StatelessWidget { @override Widget build(BuildContext context) { - return ClipRRect( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(Dimens.borderRadiusL), - topRight: Radius.circular(Dimens.borderRadiusL), + return BottomControlsBar( + left: onSwitchEvSourceType != null + ? IconButton( + onPressed: onSwitchEvSourceType, + icon: Icon( + UserPreferencesProvider.evSourceTypeOf(context) != EvSourceType.camera + ? Icons.camera_rear + : Icons.wb_incandescent, + ), + tooltip: UserPreferencesProvider.evSourceTypeOf(context) != EvSourceType.camera + ? S.of(context).tooltipUseCamera + : S.of(context).tooltipUseLightSensor, + ) + : null, + center: AnimatedCircluarButton( + progress: isMetering ? null : 1.0, + isPressed: isMetering, + onPressed: onMeasure, + child: ev != null ? _EvValueText(ev: ev!, ev100: ev100!) : null, ), - child: ColoredBox( - color: Theme.of(context).colorScheme.surface, - child: SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: Dimens.paddingL), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - if (onSwitchEvSourceType != null) - Expanded( - child: Center( - child: IconButton( - onPressed: onSwitchEvSourceType, - icon: Icon( - UserPreferencesProvider.evSourceTypeOf(context) != EvSourceType.camera - ? Icons.camera_rear - : Icons.wb_incandescent, - ), - tooltip: - UserPreferencesProvider.evSourceTypeOf(context) != EvSourceType.camera - ? S.of(context).tooltipUseCamera - : S.of(context).tooltipUseLightSensor, - ), - ), - ) - else - const Spacer(), - MeteringMeasureButton( - ev: ev, - ev100: ev100, - isMetering: isMetering, - onTap: onMeasure, - ), - Expanded( - child: Center( - child: IconButton( - onPressed: onSettings, - icon: const Icon(Icons.settings), - tooltip: S.of(context).tooltipOpenSettings, - ), - ), - ), - ], - ), - ), - ), + right: IconButton( + onPressed: onSettings, + icon: const Icon(Icons.settings), + tooltip: S.of(context).tooltipOpenSettings, ), ); } } + +class _EvValueText extends StatelessWidget { + static const String _subscript100 = '\u2081\u2080\u2080'; + final double ev; + final double ev100; + + const _EvValueText({ + required this.ev, + required this.ev100, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Text( + _text(context), + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.surface), + textAlign: TextAlign.center, + ); + } + + String _text(BuildContext context) { + final bool showEv100 = context.isPro && UserPreferencesProvider.showEv100Of(context); + final StringBuffer buffer = StringBuffer() + ..writeAll([ + (showEv100 ? ev100 : ev).toStringAsFixed(1), + '\n', + S.of(context).ev, + if (showEv100) _subscript100, + ]); + return buffer.toString(); + } +} diff --git a/lib/screens/metering/components/camera_container/provider_container_camera.dart b/lib/screens/metering/components/camera_container/provider_container_camera.dart index a4eec21..b5f68e9 100644 --- a/lib/screens/metering/components/camera_container/provider_container_camera.dart +++ b/lib/screens/metering/components/camera_container/provider_container_camera.dart @@ -18,6 +18,7 @@ class CameraContainerProvider extends StatelessWidget { final ValueChanged onIsoChanged; final ValueChanged onNdChanged; final List exposurePairs; + final ValueChanged onExposurePairTap; const CameraContainerProvider({ required this.fastest, @@ -27,6 +28,7 @@ class CameraContainerProvider extends StatelessWidget { required this.onIsoChanged, required this.onNdChanged, required this.exposurePairs, + required this.onExposurePairTap, super.key, }); @@ -54,6 +56,7 @@ class CameraContainerProvider extends StatelessWidget { onIsoChanged: onIsoChanged, onNdChanged: onNdChanged, exposurePairs: exposurePairs, + onExposurePairTap: onExposurePairTap, ), ); } diff --git a/lib/screens/metering/components/camera_container/widget_container_camera.dart b/lib/screens/metering/components/camera_container/widget_container_camera.dart index 95cff57..4d83b0e 100644 --- a/lib/screens/metering/components/camera_container/widget_container_camera.dart +++ b/lib/screens/metering/components/camera_container/widget_container_camera.dart @@ -29,6 +29,7 @@ class CameraContainer extends StatelessWidget { final ValueChanged onIsoChanged; final ValueChanged onNdChanged; final List exposurePairs; + final ValueChanged onExposurePairTap; const CameraContainer({ required this.fastest, @@ -38,6 +39,7 @@ class CameraContainer extends StatelessWidget { required this.onIsoChanged, required this.onNdChanged, required this.exposurePairs, + required this.onExposurePairTap, super.key, }); @@ -81,7 +83,10 @@ class CameraContainer extends StatelessWidget { Expanded( child: Padding( padding: EdgeInsets.only(top: topBarOverflow >= 0 ? topBarOverflow : 0), - child: ExposurePairsList(exposurePairs), + child: ExposurePairsList( + exposurePairs, + onExposurePairTap: onExposurePairTap, + ), ), ), const SizedBox(width: Dimens.grid8), diff --git a/lib/screens/metering/components/light_sensor_container/provider_container_light_sensor.dart b/lib/screens/metering/components/light_sensor_container/provider_container_light_sensor.dart index 1d7f822..5fcbedc 100644 --- a/lib/screens/metering/components/light_sensor_container/provider_container_light_sensor.dart +++ b/lib/screens/metering/components/light_sensor_container/provider_container_light_sensor.dart @@ -15,6 +15,7 @@ class LightSensorContainerProvider extends StatelessWidget { final ValueChanged onIsoChanged; final ValueChanged onNdChanged; final List exposurePairs; + final ValueChanged onExposurePairTap; const LightSensorContainerProvider({ required this.fastest, @@ -24,6 +25,7 @@ class LightSensorContainerProvider extends StatelessWidget { required this.onIsoChanged, required this.onNdChanged, required this.exposurePairs, + required this.onExposurePairTap, super.key, }); @@ -43,6 +45,7 @@ class LightSensorContainerProvider extends StatelessWidget { onIsoChanged: onIsoChanged, onNdChanged: onNdChanged, exposurePairs: exposurePairs, + onExposurePairTap: onExposurePairTap, ), ); } diff --git a/lib/screens/metering/components/light_sensor_container/widget_container_light_sensor.dart b/lib/screens/metering/components/light_sensor_container/widget_container_light_sensor.dart index 2151ee0..b0af83f 100644 --- a/lib/screens/metering/components/light_sensor_container/widget_container_light_sensor.dart +++ b/lib/screens/metering/components/light_sensor_container/widget_container_light_sensor.dart @@ -14,6 +14,7 @@ class LightSensorContainer extends StatelessWidget { final ValueChanged onIsoChanged; final ValueChanged onNdChanged; final List exposurePairs; + final ValueChanged onExposurePairTap; const LightSensorContainer({ required this.fastest, @@ -23,6 +24,7 @@ class LightSensorContainer extends StatelessWidget { required this.onIsoChanged, required this.onNdChanged, required this.exposurePairs, + required this.onExposurePairTap, super.key, }); @@ -43,7 +45,12 @@ class LightSensorContainer extends StatelessWidget { Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM), - child: Center(child: ExposurePairsList(exposurePairs)), + child: Center( + child: ExposurePairsList( + exposurePairs, + onExposurePairTap: onExposurePairTap, + ), + ), ), ), ], diff --git a/lib/screens/metering/components/shared/exposure_pairs_list/components/exposure_pairs_list_item/widget_item_list_exposure_pairs.dart b/lib/screens/metering/components/shared/exposure_pairs_list/components/exposure_pairs_list_item/widget_item_list_exposure_pairs.dart index ece28b0..9aeb108 100644 --- a/lib/screens/metering/components/shared/exposure_pairs_list/components/exposure_pairs_list_item/widget_item_list_exposure_pairs.dart +++ b/lib/screens/metering/components/shared/exposure_pairs_list/components/exposure_pairs_list_item/widget_item_list_exposure_pairs.dart @@ -44,9 +44,8 @@ class ExposurePairsListItem extends StatelessWid case StopType.full: return Theme.of(context).textTheme.bodyLarge!; case StopType.half: - return Theme.of(context).textTheme.bodyMedium!; case StopType.third: - return Theme.of(context).textTheme.bodySmall!; + return Theme.of(context).textTheme.bodyMedium!; } } @@ -55,7 +54,6 @@ class ExposurePairsListItem extends StatelessWid case StopType.full: return Dimens.grid16; case StopType.half: - return Dimens.grid8; case StopType.third: return Dimens.grid8; } diff --git a/lib/screens/metering/components/shared/exposure_pairs_list/widget_list_exposure_pairs.dart b/lib/screens/metering/components/shared/exposure_pairs_list/widget_list_exposure_pairs.dart index cb78275..2f2188d 100644 --- a/lib/screens/metering/components/shared/exposure_pairs_list/widget_list_exposure_pairs.dart +++ b/lib/screens/metering/components/shared/exposure_pairs_list/widget_list_exposure_pairs.dart @@ -5,11 +5,13 @@ import 'package:lightmeter/providers/films_provider.dart'; import 'package:lightmeter/res/dimens.dart'; import 'package:lightmeter/screens/metering/components/shared/exposure_pairs_list/components/exposure_pairs_list_item/widget_item_list_exposure_pairs.dart'; import 'package:lightmeter/screens/shared/icon_placeholder/widget_icon_placeholder.dart'; +import 'package:lightmeter/utils/context_utils.dart'; class ExposurePairsList extends StatelessWidget { final List exposurePairs; + final ValueChanged onExposurePairTap; - const ExposurePairsList(this.exposurePairs, {super.key}); + const ExposurePairsList(this.exposurePairs, {required this.onExposurePairTap, super.key}); @override Widget build(BuildContext context) { @@ -28,58 +30,64 @@ class ExposurePairsList extends StatelessWidget { key: ValueKey(exposurePairs.hashCode), padding: const EdgeInsets.symmetric(vertical: Dimens.paddingL), itemCount: exposurePairs.length, - itemBuilder: (_, index) => Stack( - alignment: Alignment.center, - children: [ - Row( - key: ValueKey(index), - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: ExposurePairsListItem( - exposurePairs[index].aperture, - tickOnTheLeft: false, + itemBuilder: (_, index) { + final exposurePair = ExposurePair( + exposurePairs[index].aperture, + Films.selectedOf(context).reciprocityFailure(exposurePairs[index].shutterSpeed), + ); + return Stack( + alignment: Alignment.center, + children: [ + GestureDetector( + onTap: context.isPro ? () => onExposurePairTap(exposurePair) : null, + child: Row( + key: ValueKey(index), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: ExposurePairsListItem( + exposurePair.aperture, + tickOnTheLeft: false, + ), + ), ), - ), - ), - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: ExposurePairsListItem( - Films.selectedOf(context) - .reciprocityFailure(exposurePairs[index].shutterSpeed), - tickOnTheLeft: true, + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: ExposurePairsListItem( + exposurePair.shutterSpeed, + tickOnTheLeft: true, + ), + ), ), - ), + ], ), - ], - ), - Positioned( - top: 0, - bottom: 0, - child: LayoutBuilder( - builder: (context, constraints) => Align( - alignment: index == 0 - ? Alignment.bottomCenter - : (index == exposurePairs.length - 1 - ? Alignment.topCenter - : Alignment.center), - child: SizedBox( - height: index == 0 || index == exposurePairs.length - 1 - ? constraints.maxHeight / 2 - : constraints.maxHeight, - child: ColoredBox( - color: Theme.of(context).colorScheme.onBackground, - child: const SizedBox(width: 1), + ), + Positioned( + top: 0, + bottom: 0, + child: LayoutBuilder( + builder: (context, constraints) => Align( + alignment: index == 0 + ? Alignment.bottomCenter + : (index == exposurePairs.length - 1 ? Alignment.topCenter : Alignment.center), + child: SizedBox( + height: index == 0 || index == exposurePairs.length - 1 + ? constraints.maxHeight / 2 + : constraints.maxHeight, + child: ColoredBox( + color: Theme.of(context).colorScheme.onBackground, + child: const SizedBox(width: 1), + ), ), ), ), ), - ), - ], - ), + ], + ); + }, ), ), ], diff --git a/lib/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart b/lib/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart index 0abc0f4..c35d5d6 100644 --- a/lib/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart +++ b/lib/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart @@ -14,11 +14,13 @@ class ReadingValue { class ReadingValueContainer extends StatelessWidget implements AnimatedDialogClosedChild { late final List _items; + final bool locked; final Color? color; final Color? textColor; ReadingValueContainer({ required List values, + this.locked = false, this.color, this.textColor, super.key, @@ -34,6 +36,7 @@ class ReadingValueContainer extends StatelessWidget implements AnimatedDialogClo ReadingValueContainer.singleValue({ required ReadingValue value, + this.locked = false, this.color, this.textColor, super.key, @@ -50,10 +53,24 @@ class ReadingValueContainer extends StatelessWidget implements AnimatedDialogClo color: backgroundColor(context), child: Padding( padding: const EdgeInsets.all(Dimens.paddingM), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: _items, + child: Stack( + children: [ + if (locked) + Positioned( + top: 0, + right: 0, + child: Icon( + Icons.lock, + size: Theme.of(context).textTheme.labelMedium!.fontSize, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: _items, + ), + ], ), ), ), diff --git a/lib/screens/metering/event_metering.dart b/lib/screens/metering/event_metering.dart index 0a39844..107eac2 100644 --- a/lib/screens/metering/event_metering.dart +++ b/lib/screens/metering/event_metering.dart @@ -39,10 +39,10 @@ class MeasureErrorEvent extends MeteringEvent { const MeasureErrorEvent({required this.isMetering}); } -class SettingsOpenedEvent extends MeteringEvent { - const SettingsOpenedEvent(); +class ScreenOnTopOpenedEvent extends MeteringEvent { + const ScreenOnTopOpenedEvent(); } -class SettingsClosedEvent extends MeteringEvent { - const SettingsClosedEvent(); +class ScreenOnTopClosedEvent extends MeteringEvent { + const ScreenOnTopClosedEvent(); } diff --git a/lib/screens/metering/screen_metering.dart b/lib/screens/metering/screen_metering.dart index 4a0aa83..2cd828c 100644 --- a/lib/screens/metering/screen_metering.dart +++ b/lib/screens/metering/screen_metering.dart @@ -8,12 +8,13 @@ import 'package:lightmeter/providers/equipment_profile_provider.dart'; import 'package:lightmeter/providers/services_provider.dart'; import 'package:lightmeter/providers/user_preferences_provider.dart'; import 'package:lightmeter/screens/metering/bloc_metering.dart'; -import 'package:lightmeter/screens/metering/components/bottom_controls/provider_bottom_controls.dart'; +import 'package:lightmeter/screens/metering/components/bottom_controls/widget_bottom_controls.dart'; import 'package:lightmeter/screens/metering/components/camera_container/provider_container_camera.dart'; import 'package:lightmeter/screens/metering/components/light_sensor_container/provider_container_light_sensor.dart'; import 'package:lightmeter/screens/metering/event_metering.dart'; import 'package:lightmeter/screens/metering/state_metering.dart'; import 'package:lightmeter/screens/metering/utils/listener_equipment_profiles.dart'; +import 'package:lightmeter/screens/timer/flow_timer.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class MeteringScreen extends StatelessWidget { @@ -34,11 +35,20 @@ class MeteringScreen extends StatelessWidget { nd: state.nd, onIsoChanged: (value) => context.read().add(IsoChangedEvent(value)), onNdChanged: (value) => context.read().add(NdChangedEvent(value)), + onExposurePairTap: (value) => pushNamed( + context, + 'timer', + arguments: TimerFlowArgs( + exposurePair: value, + isoValue: state.iso, + ndValue: state.nd, + ), + ), ), ), ), BlocBuilder( - builder: (context, state) => MeteringBottomControlsProvider( + builder: (context, state) => MeteringBottomControls( ev: state is MeteringDataState ? state.ev : null, ev100: state is MeteringDataState ? state.ev100 : null, isMetering: state.isMetering, @@ -46,12 +56,7 @@ class MeteringScreen extends StatelessWidget { ? UserPreferencesProvider.of(context).toggleEvSourceType : null, onMeasure: () => context.read().add(const MeasureEvent()), - onSettings: () { - context.read().add(const SettingsOpenedEvent()); - Navigator.pushNamed(context, 'settings').then((value) { - context.read().add(const SettingsClosedEvent()); - }); - }, + onSettings: () => pushNamed(context, 'settings'), ), ), ], @@ -59,6 +64,13 @@ class MeteringScreen extends StatelessWidget { ), ); } + + void pushNamed(BuildContext context, String routeName, {Object? arguments}) { + context.read().add(const ScreenOnTopOpenedEvent()); + Navigator.pushNamed(context, routeName, arguments: arguments).then((_) { + context.read().add(const ScreenOnTopClosedEvent()); + }); + } } class _InheritedListeners extends StatelessWidget { @@ -83,6 +95,7 @@ class MeteringContainerBuidler extends StatelessWidget { final NdValue nd; final ValueChanged onIsoChanged; final ValueChanged onNdChanged; + final ValueChanged onExposurePairTap; const MeteringContainerBuidler({ required this.ev, @@ -90,6 +103,7 @@ class MeteringContainerBuidler extends StatelessWidget { required this.nd, required this.onIsoChanged, required this.onNdChanged, + required this.onExposurePairTap, }); @override @@ -113,6 +127,7 @@ class MeteringContainerBuidler extends StatelessWidget { onIsoChanged: onIsoChanged, onNdChanged: onNdChanged, exposurePairs: exposurePairs, + onExposurePairTap: onExposurePairTap, ) : LightSensorContainerProvider( fastest: fastest, @@ -122,6 +137,7 @@ class MeteringContainerBuidler extends StatelessWidget { onIsoChanged: onIsoChanged, onNdChanged: onNdChanged, exposurePairs: exposurePairs, + onExposurePairTap: onExposurePairTap, ); } diff --git a/lib/screens/settings/components/general/components/timer/bloc_list_tile_timer.dart b/lib/screens/settings/components/general/components/timer/bloc_list_tile_timer.dart new file mode 100644 index 0000000..f67cebd --- /dev/null +++ b/lib/screens/settings/components/general/components/timer/bloc_list_tile_timer.dart @@ -0,0 +1,15 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lightmeter/interactors/settings_interactor.dart'; + +class TimerListTileBloc extends Cubit { + final SettingsInteractor _settingsInteractor; + + TimerListTileBloc( + this._settingsInteractor, + ) : super(_settingsInteractor.isAutostartTimerEnabled); + + void onChanged(bool value) { + _settingsInteractor.enableAutostartTimer(value); + emit(value); + } +} diff --git a/lib/screens/settings/components/general/components/timer/provider_list_tile_timer.dart b/lib/screens/settings/components/general/components/timer/provider_list_tile_timer.dart new file mode 100644 index 0000000..e359949 --- /dev/null +++ b/lib/screens/settings/components/general/components/timer/provider_list_tile_timer.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:lightmeter/screens/settings/components/general/components/timer/bloc_list_tile_timer.dart'; +import 'package:lightmeter/screens/settings/components/general/components/timer/widget_list_tile_timer.dart'; +import 'package:lightmeter/screens/settings/flow_settings.dart'; + +class TimerListTileProvider extends StatelessWidget { + const TimerListTileProvider({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => TimerListTileBloc(SettingsInteractorProvider.of(context)), + child: const TimerListTile(), + ); + } +} diff --git a/lib/screens/settings/components/general/components/timer/widget_list_tile_timer.dart b/lib/screens/settings/components/general/components/timer/widget_list_tile_timer.dart new file mode 100644 index 0000000..0fdaf52 --- /dev/null +++ b/lib/screens/settings/components/general/components/timer/widget_list_tile_timer.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/settings/components/general/components/timer/bloc_list_tile_timer.dart'; +import 'package:lightmeter/screens/settings/components/shared/disable/widget_disable.dart'; +import 'package:lightmeter/utils/context_utils.dart'; + +class TimerListTile extends StatelessWidget { + const TimerListTile({super.key}); + + @override + Widget build(BuildContext context) { + return Disable( + disable: !context.isPro, + child: BlocBuilder( + builder: (context, state) => SwitchListTile( + secondary: const Icon(Icons.timer_outlined), + title: Text(S.of(context).autostartTimer), + value: state && context.isPro, + onChanged: context.read().onChanged, + contentPadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM), + ), + ), + ); + } +} diff --git a/lib/screens/settings/components/general/widget_settings_section_general.dart b/lib/screens/settings/components/general/widget_settings_section_general.dart index bc123d7..278d342 100644 --- a/lib/screens/settings/components/general/widget_settings_section_general.dart +++ b/lib/screens/settings/components/general/widget_settings_section_general.dart @@ -5,6 +5,7 @@ import 'package:lightmeter/generated/l10n.dart'; import 'package:lightmeter/screens/settings/components/general/components/caffeine/provider_list_tile_caffeine.dart'; import 'package:lightmeter/screens/settings/components/general/components/haptics/provider_list_tile_haptics.dart'; import 'package:lightmeter/screens/settings/components/general/components/language/widget_list_tile_language.dart'; +import 'package:lightmeter/screens/settings/components/general/components/timer/provider_list_tile_timer.dart'; import 'package:lightmeter/screens/settings/components/general/components/volume_actions/provider_list_tile_volume_actions.dart'; import 'package:lightmeter/screens/settings/components/shared/settings_section/widget_settings_section.dart'; @@ -18,6 +19,7 @@ class GeneralSettingsSection extends StatelessWidget { children: [ const CaffeineListTileProvider(), const HapticsListTileProvider(), + const TimerListTileProvider(), if (Platform.isAndroid) const VolumeActionsListTileProvider(), const LanguageListTile(), ], diff --git a/lib/screens/shared/animated_circular_button/widget_button_circular_animated.dart b/lib/screens/shared/animated_circular_button/widget_button_circular_animated.dart new file mode 100644 index 0000000..5057ca4 --- /dev/null +++ b/lib/screens/shared/animated_circular_button/widget_button_circular_animated.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/shared/filled_circle/widget_circle_filled.dart'; + +class AnimatedCircluarButton extends StatefulWidget { + final double? progress; + final bool isPressed; + final VoidCallback onPressed; + final Widget? child; + + const AnimatedCircluarButton({ + this.progress = 1.0, + required this.isPressed, + required this.onPressed, + this.child, + super.key, + }); + + @override + State createState() => _AnimatedCircluarButtonState(); +} + +class _AnimatedCircluarButtonState extends State { + bool _isPressed = false; + + @override + void didUpdateWidget(covariant AnimatedCircluarButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isPressed != widget.isPressed) { + _isPressed = widget.isPressed; + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: widget.onPressed, + onTapDown: (_) { + setState(() { + _isPressed = true; + }); + }, + onTapUp: (_) { + setState(() { + _isPressed = false; + }); + }, + onTapCancel: () { + setState(() { + _isPressed = false; + }); + }, + child: Stack( + children: [ + Center( + child: AnimatedScale( + duration: Dimens.durationS, + scale: _isPressed ? 0.9 : 1.0, + child: FilledCircle( + color: Theme.of(context).colorScheme.onSurface, + size: Dimens.grid72 - Dimens.grid8, + child: Center( + child: widget.child, + ), + ), + ), + ), + Positioned.fill( + child: CircularProgressIndicator( + /// This key is needed to make indicator start from the same point every time + key: ValueKey(widget.progress), + color: Theme.of(context).colorScheme.onSurface, + value: widget.progress, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/shared/bottom_controls_bar/widget_bottom_controls_bar.dart b/lib/screens/shared/bottom_controls_bar/widget_bottom_controls_bar.dart new file mode 100644 index 0000000..bbe9c98 --- /dev/null +++ b/lib/screens/shared/bottom_controls_bar/widget_bottom_controls_bar.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/res/dimens.dart'; + +class BottomControlsBar extends StatelessWidget { + final Widget center; + final Widget? left; + final Widget? right; + + const BottomControlsBar({ + required this.center, + this.left, + this.right, + super.key, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return IconButtonTheme( + data: IconButtonThemeData( + style: ButtonStyle( + backgroundColor: MaterialStatePropertyAll(scheme.surface), + elevation: const MaterialStatePropertyAll(4), + iconColor: MaterialStatePropertyAll(scheme.onSurface), + shadowColor: const MaterialStatePropertyAll(Colors.transparent), + surfaceTintColor: MaterialStatePropertyAll(scheme.surfaceTint), + fixedSize: const MaterialStatePropertyAll(Size(Dimens.grid48, Dimens.grid48)), + ), + ), + child: ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(Dimens.borderRadiusL), + topRight: Radius.circular(Dimens.borderRadiusL), + ), + child: ColoredBox( + color: Theme.of(context).colorScheme.surface, + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Dimens.paddingL), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (left != null) Expanded(child: Center(child: left)) else const Spacer(), + SizedBox.fromSize( + size: const Size.square(Dimens.grid72), + child: center, + ), + if (right != null) Expanded(child: Center(child: right)) else const Spacer(), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/shared/close_button/widget_button_close.dart b/lib/screens/shared/close_button/widget_button_close.dart new file mode 100644 index 0000000..4d52678 --- /dev/null +++ b/lib/screens/shared/close_button/widget_button_close.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/generated/l10n.dart'; + +class CloseButton extends StatelessWidget { + const CloseButton({super.key}); + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: Navigator.of(context).pop, + icon: const Icon(Icons.close), + tooltip: S.of(context).tooltipClose, + ); + } +} diff --git a/lib/screens/timer/bloc_timer.dart b/lib/screens/timer/bloc_timer.dart new file mode 100644 index 0000000..eacdf47 --- /dev/null +++ b/lib/screens/timer/bloc_timer.dart @@ -0,0 +1,41 @@ +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.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 TimerInteractor _timerInteractor; + final Duration duration; + + TimerBloc(this._timerInteractor, this.duration) : super(const TimerStoppedState()) { + on(_onStartTimer); + on(_onTimerEnded); + on(_onStopTimer); + on(_onResetTimer); + + if (_timerInteractor.isAutostartTimerEnabled) add(const StartTimerEvent()); + } + + Future _onStartTimer(StartTimerEvent _, Emitter emit) async { + _timerInteractor.startVibration(); + emit(const TimerResumedState()); + } + + Future _onTimerEnded(TimerEndedEvent event, Emitter emit) async { + if (state is! TimerResetState) { + _timerInteractor.endVibration(); + emit(const TimerStoppedState()); + } + } + + Future _onStopTimer(StopTimerEvent _, Emitter emit) async { + _timerInteractor.startVibration(); + emit(const TimerStoppedState()); + } + + Future _onResetTimer(ResetTimerEvent _, Emitter emit) async { + emit(const TimerResetState()); + } +} diff --git a/lib/screens/timer/components/metering_config/widget_metering_config_timer.dart b/lib/screens/timer/components/metering_config/widget_metering_config_timer.dart new file mode 100644 index 0000000..8d2e199 --- /dev/null +++ b/lib/screens/timer/components/metering_config/widget_metering_config_timer.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/data/models/exposure_pair.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +class TimerMeteringConfig extends StatelessWidget { + final ExposurePair exposurePair; + final IsoValue isoValue; + final NdValue ndValue; + + const TimerMeteringConfig({ + required this.exposurePair, + required this.isoValue, + required this.ndValue, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(Dimens.borderRadiusL), + bottomRight: Radius.circular(Dimens.borderRadiusL), + ), + ), + padding: const EdgeInsets.all(Dimens.paddingM), + child: SafeArea( + bottom: false, + child: Row( + children: [ + Expanded( + child: ReadingValueContainer.singleValue( + value: ReadingValue( + label: S.of(context).exposurePair, + value: exposurePair.toString(), + ), + ), + ), + const SizedBox(width: Dimens.grid8), + Expanded( + child: Row( + children: [ + Expanded( + child: ReadingValueContainer.singleValue( + value: ReadingValue( + label: S.of(context).iso, + value: isoValue.toString(), + ), + locked: true, + ), + ), + const SizedBox(width: Dimens.grid8), + Expanded( + child: ReadingValueContainer.singleValue( + value: ReadingValue( + label: S.of(context).nd, + value: ndValue.value == 0 ? S.of(context).none : ndValue.value.toString(), + ), + locked: true, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/timer/components/text/widget_text_timer.dart b/lib/screens/timer/components/text/widget_text_timer.dart new file mode 100644 index 0000000..35765ba --- /dev/null +++ b/lib/screens/timer/components/text/widget_text_timer.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +class TimerText extends StatelessWidget { + final Duration timeLeft; + final Duration duration; + + const TimerText({ + required this.timeLeft, + required this.duration, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + parseSeconds(), + style: Theme.of(context).textTheme.displayMedium, + ), + Text( + addZeroIfNeeded(timeLeft.inMilliseconds % 1000, 3), + style: Theme.of(context).textTheme.displaySmall, + ), + ], + ); + } + + String parseSeconds() { + final buffer = StringBuffer(); + int remainingMs = timeLeft.inMilliseconds; + // longer than 1 hours + if (duration.inMilliseconds >= Duration.millisecondsPerHour) { + final hours = remainingMs ~/ Duration.millisecondsPerHour; + buffer.writeAll([addZeroIfNeeded(hours), ':']); + remainingMs -= hours * Duration.millisecondsPerHour; + } + // longer than 1 minute + final minutes = remainingMs ~/ Duration.millisecondsPerMinute; + buffer.writeAll([addZeroIfNeeded(minutes), ':']); + remainingMs -= minutes * Duration.millisecondsPerMinute; + + // longer than 1 second + final seconds = remainingMs ~/ Duration.millisecondsPerSecond; + buffer.writeAll([addZeroIfNeeded(seconds)]); + remainingMs -= seconds * Duration.millisecondsPerSecond; + + return buffer.toString(); + } + + String addZeroIfNeeded(int value, [int charactersCount = 2]) { + final zerosCount = charactersCount - value.toString().length; + return '${"0" * zerosCount}$value'; + } +} diff --git a/lib/screens/timer/components/timeline/widget_timeline_timer.dart b/lib/screens/timer/components/timeline/widget_timeline_timer.dart new file mode 100644 index 0000000..24acff8 --- /dev/null +++ b/lib/screens/timer/components/timeline/widget_timeline_timer.dart @@ -0,0 +1,142 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:lightmeter/res/dimens.dart'; + +class TimerTimeline extends StatelessWidget { + final double progress; + final Widget child; + + const TimerTimeline({ + required this.progress, + required this.child, + }) : assert(progress >= 0 && progress <= 1); + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _TimelinePainter( + backgroundColor: Theme.of(context).colorScheme.surface, + progressColor: Theme.of(context).colorScheme.primary, + progress: progress, + ), + willChange: true, + child: Center(child: child), + ); + } +} + +class _TimelinePainter extends CustomPainter { + final Color progressColor; + final Color backgroundColor; + final double progress; + + late final double timelineEdgeRadius = strokeWidth / 2; + static const double radiansQuarterTurn = -pi / 2; + static const double strokeWidth = Dimens.grid8; + + _TimelinePainter({ + required this.progressColor, + required this.backgroundColor, + required this.progress, + }); + + @override + void paint(Canvas canvas, Size size) { + late final double radiansProgress = 2 * pi * progress; + final radius = size.height / 2; + final timerCenter = Offset(radius, radius); + + final timelineSegmentPath = Path(); + if (progress == 1) { + timelineSegmentPath.addOval( + Rect.fromCenter( + center: timerCenter, + height: size.height, + width: size.width, + ), + ); + } else { + timelineSegmentPath + ..arcTo( + Rect.fromCenter( + center: timerCenter, + height: size.height, + width: size.width, + ), + radiansQuarterTurn, + radiansProgress, + false, + ) + ..lineTo(radius, radius) + ..lineTo(radius, 0); + } + + final timelinePath = Path.combine( + PathOperation.difference, + timelineSegmentPath, + Path() + ..addOval( + Rect.fromCircle( + center: timerCenter, + radius: radius - strokeWidth, + ), + ), + ); + + final smoothEdgesPath = Path.combine( + PathOperation.union, + Path() + ..addOval( + Rect.fromCircle( + center: Offset(radius, timelineEdgeRadius), + radius: timelineEdgeRadius, + ), + ), + Path() + ..addOval( + Rect.fromCircle( + center: Offset( + (radius - timelineEdgeRadius) * cos(radiansProgress + radiansQuarterTurn) + radius, + (radius - timelineEdgeRadius) * sin(radiansProgress + radiansQuarterTurn) + radius, + ), + radius: timelineEdgeRadius, + ), + ), + ); + + canvas.drawPath( + Path.combine( + PathOperation.difference, + Path() + ..addOval( + Rect.fromCircle( + center: timerCenter, + radius: radius, + ), + ), + Path() + ..addOval( + Rect.fromCircle( + center: timerCenter, + radius: radius - strokeWidth, + ), + ), + ), + Paint()..color = backgroundColor, + ); + + canvas.drawPath( + timelinePath, + Paint()..color = progressColor, + ); + + canvas.drawPath( + smoothEdgesPath, + Paint()..color = progressColor, + ); + } + + @override + bool shouldRepaint(_TimelinePainter oldDelegate) => oldDelegate.progress != progress; +} diff --git a/lib/screens/timer/event_timer.dart b/lib/screens/timer/event_timer.dart new file mode 100644 index 0000000..53e815d --- /dev/null +++ b/lib/screens/timer/event_timer.dart @@ -0,0 +1,19 @@ +sealed class TimerEvent { + const TimerEvent(); +} + +class StartTimerEvent extends TimerEvent { + const StartTimerEvent(); +} + +class StopTimerEvent extends TimerEvent { + const StopTimerEvent(); +} + +class TimerEndedEvent extends TimerEvent { + const TimerEndedEvent(); +} + +class ResetTimerEvent extends TimerEvent { + const ResetTimerEvent(); +} diff --git a/lib/screens/timer/flow_timer.dart b/lib/screens/timer/flow_timer.dart new file mode 100644 index 0000000..112c12b --- /dev/null +++ b/lib/screens/timer/flow_timer.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lightmeter/data/models/exposure_pair.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'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +class TimerFlowArgs { + final ExposurePair exposurePair; + final IsoValue isoValue; + final NdValue ndValue; + + const TimerFlowArgs({ + required this.exposurePair, + required this.isoValue, + required this.ndValue, + }); +} + +class TimerFlow extends StatelessWidget { + final TimerFlowArgs args; + late final _duration = + Duration(milliseconds: (args.exposurePair.shutterSpeed.value * Duration.millisecondsPerSecond).toInt()); + + TimerFlow({required this.args, super.key}); + + @override + Widget build(BuildContext context) { + return TimerInteractorProvider( + data: TimerInteractor( + ServicesProvider.of(context).userPreferencesService, + ServicesProvider.of(context).hapticsService, + ), + child: BlocProvider( + create: (context) => TimerBloc( + TimerInteractorProvider.of(context), + _duration, + ), + child: TimerScreen( + exposurePair: args.exposurePair, + isoValue: args.isoValue, + ndValue: args.ndValue, + duration: _duration, + ), + ), + ); + } +} + +class TimerInteractorProvider extends InheritedWidget { + final TimerInteractor data; + + const TimerInteractorProvider({ + required this.data, + required super.child, + super.key, + }); + + static TimerInteractor of(BuildContext context) { + return context.findAncestorWidgetOfExactType()!.data; + } + + @override + bool updateShouldNotify(TimerInteractorProvider oldWidget) => false; +} diff --git a/lib/screens/timer/screen_timer.dart b/lib/screens/timer/screen_timer.dart new file mode 100644 index 0000000..b76899a --- /dev/null +++ b/lib/screens/timer/screen_timer.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lightmeter/data/models/exposure_pair.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/shared/animated_circular_button/widget_button_circular_animated.dart'; +import 'package:lightmeter/screens/shared/bottom_controls_bar/widget_bottom_controls_bar.dart'; +import 'package:lightmeter/screens/timer/bloc_timer.dart'; +import 'package:lightmeter/screens/timer/components/metering_config/widget_metering_config_timer.dart'; +import 'package:lightmeter/screens/timer/components/text/widget_text_timer.dart'; +import 'package:lightmeter/screens/timer/components/timeline/widget_timeline_timer.dart'; +import 'package:lightmeter/screens/timer/event_timer.dart'; +import 'package:lightmeter/screens/timer/state_timer.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +class TimerScreen extends StatefulWidget { + final ExposurePair exposurePair; + final IsoValue isoValue; + final NdValue ndValue; + final Duration duration; + + const TimerScreen({ + required this.exposurePair, + required this.isoValue, + required this.ndValue, + required this.duration, + super.key, + }); + + @override + State createState() => TimerScreenState(); +} + +@visibleForTesting +class TimerScreenState extends State with TickerProviderStateMixin { + late AnimationController timelineController; + late Animation timelineAnimation; + late AnimationController startStopIconController; + late Animation startStopIconAnimation; + + @override + void initState() { + super.initState(); + + timelineController = AnimationController(vsync: this, duration: widget.duration); + timelineAnimation = Tween(begin: 1, end: 0).animate(timelineController); + timelineController.addStatusListener((status) { + if (status == AnimationStatus.completed) { + context.read().add(const TimerEndedEvent()); + } + }); + + startStopIconController = AnimationController(vsync: this, duration: Dimens.durationS); + startStopIconAnimation = Tween(begin: 0, end: 1).animate(startStopIconController); + } + + @override + void dispose() { + timelineController.dispose(); + startStopIconController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listenWhen: (previous, current) => previous.runtimeType != current.runtimeType, + listener: (context, state) => _updateAnimations(state), + child: Scaffold( + backgroundColor: Theme.of(context).colorScheme.background, + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TimerMeteringConfig( + exposurePair: widget.exposurePair, + isoValue: widget.isoValue, + ndValue: widget.ndValue, + ), + const Spacer(), + Padding( + padding: const EdgeInsets.all(Dimens.paddingL), + child: SizedBox.fromSize( + size: Size.square(MediaQuery.sizeOf(context).width - Dimens.paddingL * 4), + child: ValueListenableBuilder( + valueListenable: timelineAnimation, + builder: (_, value, child) => TimerTimeline( + progress: value, + child: TimerText( + timeLeft: Duration(milliseconds: (widget.duration.inMilliseconds * value).toInt()), + duration: widget.duration, + ), + ), + ), + ), + ), + const Spacer(), + BottomControlsBar( + left: IconButton( + onPressed: () { + context.read().add(const ResetTimerEvent()); + }, + icon: const Icon(Icons.restore), + ), + center: BlocBuilder( + builder: (_, state) => AnimatedCircluarButton( + isPressed: state is TimerResumedState, + onPressed: () { + if (timelineAnimation.value == 0) { + return; + } + final event = state is TimerStoppedState ? const StartTimerEvent() : const StopTimerEvent(); + context.read().add(event); + }, + child: AnimatedIcon( + icon: AnimatedIcons.play_pause, + progress: startStopIconAnimation, + color: Theme.of(context).colorScheme.surface, + ), + ), + ), + right: const CloseButton(), + ), + ], + ), + ), + ), + ); + } + + void _updateAnimations(TimerState state) { + switch (state) { + case TimerResetState(): + startStopIconController.reverse(); + timelineController.stop(); + timelineController.animateTo(0, duration: Dimens.durationS); + case TimerResumedState(): + startStopIconController.forward(); + timelineController.forward(); + case TimerStoppedState(): + startStopIconController.reverse(); + timelineController.stop(); + } + } +} diff --git a/lib/screens/timer/state_timer.dart b/lib/screens/timer/state_timer.dart new file mode 100644 index 0000000..f088382 --- /dev/null +++ b/lib/screens/timer/state_timer.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +@immutable +sealed class TimerState { + const TimerState(); +} + +class TimerStoppedState extends TimerState { + const TimerStoppedState(); +} + +class TimerResumedState extends TimerState { + const TimerResumedState(); +} + +class TimerResetState extends TimerStoppedState { + const TimerResetState(); +} diff --git a/test/data/shared_prefs_service_test.dart b/test/data/shared_prefs_service_test.dart index 269d002..f4b480b 100644 --- a/test/data/shared_prefs_service_test.dart +++ b/test/data/shared_prefs_service_test.dart @@ -326,6 +326,26 @@ void main() { verify(() => sharedPreferences.setBool(UserPreferencesService.hapticsKey, false)).called(1); }); }); + + group('autostartTimer', () { + test('get default', () { + when(() => sharedPreferences.getBool(UserPreferencesService.autostartTimerKey)).thenReturn(null); + expect(service.autostartTimer, true); + }); + + test('get', () { + when(() => sharedPreferences.getBool(UserPreferencesService.autostartTimerKey)).thenReturn(true); + expect(service.autostartTimer, true); + }); + + test('set', () { + when(() => sharedPreferences.setBool(UserPreferencesService.autostartTimerKey, false)) + .thenAnswer((_) => Future.value(true)); + service.autostartTimer = false; + verify(() => sharedPreferences.setBool(UserPreferencesService.autostartTimerKey, false)).called(1); + }); + }); + group('volumeAction', () { test('get default', () { when(() => sharedPreferences.getBool(UserPreferencesService.volumeActionKey)).thenReturn(null); diff --git a/test/interactors/settings_interactor_test.dart b/test/interactors/settings_interactor_test.dart index 03437dc..982345a 100644 --- a/test/interactors/settings_interactor_test.dart +++ b/test/interactors/settings_interactor_test.dart @@ -85,6 +85,28 @@ void main() { }, ); + group( + 'AutostartTimer', + () { + test('isAutostartTimerEnabled', () { + when(() => mockUserPreferencesService.autostartTimer).thenReturn(true); + expect(interactor.isAutostartTimerEnabled, true); + when(() => mockUserPreferencesService.autostartTimer).thenReturn(false); + expect(interactor.isAutostartTimerEnabled, false); + verify(() => mockUserPreferencesService.autostartTimer).called(2); + }); + + test('enableAutostartTimer(true)', () { + when(() => mockUserPreferencesService.autostartTimer = true).thenReturn(true); + interactor.enableAutostartTimer(true); + verify(() => mockUserPreferencesService.autostartTimer = true).called(1); + when(() => mockUserPreferencesService.autostartTimer = true).thenReturn(false); + interactor.enableAutostartTimer(false); + verify(() => mockUserPreferencesService.autostartTimer = false).called(1); + }); + }, + ); + group( 'Volume action', () { @@ -123,8 +145,7 @@ void main() { }); test('setVolumeAction(VolumeAction.shutter)', () async { - when(() => mockUserPreferencesService.volumeAction = VolumeAction.shutter) - .thenReturn(VolumeAction.shutter); + when(() => mockUserPreferencesService.volumeAction = VolumeAction.shutter).thenReturn(VolumeAction.shutter); when(() => mockVolumeEventsService.setVolumeHandling(true)).thenAnswer((_) async => true); expectLater(interactor.setVolumeAction(VolumeAction.shutter), isA>()); verify(() => mockVolumeEventsService.setVolumeHandling(true)).called(1); @@ -132,8 +153,7 @@ void main() { }); test('setVolumeAction(VolumeAction.none)', () async { - when(() => mockUserPreferencesService.volumeAction = VolumeAction.none) - .thenReturn(VolumeAction.none); + when(() => mockUserPreferencesService.volumeAction = VolumeAction.none).thenReturn(VolumeAction.none); when(() => mockVolumeEventsService.setVolumeHandling(false)).thenAnswer((_) async => false); expectLater(interactor.setVolumeAction(VolumeAction.none), isA>()); verify(() => mockVolumeEventsService.setVolumeHandling(false)).called(1); diff --git a/test/interactors/timer_interactor_test.dart b/test/interactors/timer_interactor_test.dart new file mode 100644 index 0000000..7ebf350 --- /dev/null +++ b/test/interactors/timer_interactor_test.dart @@ -0,0 +1,74 @@ +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('startVibration() - true', () async { + when(() => mockUserPreferencesService.haptics).thenReturn(true); + when(() => mockHapticsService.quickVibration()).thenAnswer((_) async {}); + interactor.startVibration(); + verify(() => mockUserPreferencesService.haptics).called(1); + verify(() => mockHapticsService.quickVibration()).called(1); + }); + + test('startVibration() - false', () async { + when(() => mockUserPreferencesService.haptics).thenReturn(false); + when(() => mockHapticsService.quickVibration()).thenAnswer((_) async {}); + interactor.startVibration(); + verify(() => mockUserPreferencesService.haptics).called(1); + verifyNever(() => mockHapticsService.quickVibration()); + }); + + test('endVibration() - true', () async { + when(() => mockUserPreferencesService.haptics).thenReturn(true); + when(() => mockHapticsService.errorVibration()).thenAnswer((_) async {}); + interactor.endVibration(); + verify(() => mockUserPreferencesService.haptics).called(1); + verify(() => mockHapticsService.errorVibration()).called(1); + }); + + test('endVibration() - false', () async { + when(() => mockUserPreferencesService.haptics).thenReturn(false); + when(() => mockHapticsService.errorVibration()).thenAnswer((_) async {}); + interactor.endVibration(); + verify(() => mockUserPreferencesService.haptics).called(1); + verifyNever(() => mockHapticsService.errorVibration()); + }); + }, + ); + + group( + 'AutostartTimer', + () { + test('isAutostartTimerEnabled', () { + when(() => mockUserPreferencesService.autostartTimer).thenReturn(true); + expect(interactor.isAutostartTimerEnabled, true); + verify(() => mockUserPreferencesService.autostartTimer).called(1); + }); + }, + ); +} diff --git a/test/screens/metering/bloc_metering_test.dart b/test/screens/metering/bloc_metering_test.dart index f07d35c..2e94aef 100644 --- a/test/screens/metering/bloc_metering_test.dart +++ b/test/screens/metering/bloc_metering_test.dart @@ -3,10 +3,8 @@ import 'package:lightmeter/data/models/volume_action.dart'; import 'package:lightmeter/interactors/metering_interactor.dart'; import 'package:lightmeter/screens/metering/bloc_metering.dart'; import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; -import 'package:lightmeter/screens/metering/communication/event_communication_metering.dart' - as communication_events; -import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart' - as communication_states; +import 'package:lightmeter/screens/metering/communication/event_communication_metering.dart' as communication_events; +import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart' as communication_states; import 'package:lightmeter/screens/metering/event_metering.dart'; import 'package:lightmeter/screens/metering/state_metering.dart'; import 'package:lightmeter/screens/metering/utils/notifier_volume_keys.dart'; @@ -18,9 +16,9 @@ class _MockMeteringInteractor extends Mock implements MeteringInteractor {} class _MockVolumeKeysNotifier extends Mock implements VolumeKeysNotifier {} -class _MockMeteringCommunicationBloc extends MockBloc< - communication_events.MeteringCommunicationEvent, - communication_states.MeteringCommunicationState> implements MeteringCommunicationBloc {} +class _MockMeteringCommunicationBloc + extends MockBloc + implements MeteringCommunicationBloc {} void main() { late _MockMeteringInteractor meteringInteractor; @@ -515,20 +513,18 @@ void main() { ); group( - '`SettingOpenedEvent`/`SettingsClosedEvent`', + '`ScreenOnTopOpenedEvent`/`ScreenOnTopClosedEvent`', () { blocTest( 'Settings opened & closed', build: () => bloc, act: (bloc) async { - bloc.add(const SettingsOpenedEvent()); - bloc.add(const SettingsClosedEvent()); + bloc.add(const ScreenOnTopOpenedEvent()); + bloc.add(const ScreenOnTopClosedEvent()); }, verify: (_) { - verify(() => communicationBloc.add(const communication_events.SettingsOpenedEvent())) - .called(1); - verify(() => communicationBloc.add(const communication_events.SettingsClosedEvent())) - .called(1); + verify(() => communicationBloc.add(const communication_events.ScreenOnTopOpenedEvent())).called(1); + verify(() => communicationBloc.add(const communication_events.ScreenOnTopClosedEvent())).called(1); }, expect: () => [], ); diff --git a/test/screens/metering/communication/bloc_communication_metering_test.dart b/test/screens/metering/communication/bloc_communication_metering_test.dart index a04aab7..2609258 100644 --- a/test/screens/metering/communication/bloc_communication_metering_test.dart +++ b/test/screens/metering/communication/bloc_communication_metering_test.dart @@ -100,20 +100,20 @@ void main() { ); group( - '`SettingsOpenedEvent`/`SettingsClosedEvent`', + '`ScreenOnTopOpenedEvent`/`ScreenOnTopClosedEvent`', () { blocTest( 'Multiple consequtive settings events', build: () => bloc, act: (bloc) async { - bloc.add(const SettingsOpenedEvent()); - bloc.add(const SettingsOpenedEvent()); - bloc.add(const SettingsOpenedEvent()); - bloc.add(const SettingsClosedEvent()); - bloc.add(const SettingsClosedEvent()); - bloc.add(const SettingsClosedEvent()); - bloc.add(const SettingsOpenedEvent()); - bloc.add(const SettingsClosedEvent()); + bloc.add(const ScreenOnTopOpenedEvent()); + bloc.add(const ScreenOnTopOpenedEvent()); + bloc.add(const ScreenOnTopOpenedEvent()); + bloc.add(const ScreenOnTopClosedEvent()); + bloc.add(const ScreenOnTopClosedEvent()); + bloc.add(const ScreenOnTopClosedEvent()); + bloc.add(const ScreenOnTopOpenedEvent()); + bloc.add(const ScreenOnTopClosedEvent()); }, expect: () => [ isA(), diff --git a/test/screens/metering/goldens/metering_screen.png b/test/screens/metering/goldens/metering_screen.png index 05ccb4e..04e2c41 100644 Binary files a/test/screens/metering/goldens/metering_screen.png and b/test/screens/metering/goldens/metering_screen.png differ diff --git a/test/screens/metering/screen_metering_golden_test.dart b/test/screens/metering/screen_metering_golden_test.dart index d17cb43..8c7e15f 100644 --- a/test/screens/metering/screen_metering_golden_test.dart +++ b/test/screens/metering/screen_metering_golden_test.dart @@ -8,11 +8,11 @@ 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/components/bottom_controls/components/measure_button/widget_button_measure.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'; @@ -67,7 +67,7 @@ void main() { Future takePhoto(WidgetTester tester, Key scenarioWidgetKey) async { final button = find.descendant( of: find.byKey(scenarioWidgetKey), - matching: find.byType(MeteringMeasureButton), + matching: find.measureButton(), ); await tester.tap(button); await tester.pump(const Duration(seconds: 2)); // wait for circular progress indicator @@ -78,7 +78,7 @@ void main() { Future toggleIncidentMetering(WidgetTester tester, Key scenarioWidgetKey, double ev) async { final button = find.descendant( of: find.byKey(scenarioWidgetKey), - matching: find.byType(MeteringMeasureButton), + matching: find.measureButton(), ); await tester.tap(button); await sendMockIncidentEv(ev); diff --git a/test/screens/settings/goldens/settings_screen.png b/test/screens/settings/goldens/settings_screen.png index dc29f2b..0a6016e 100644 Binary files a/test/screens/settings/goldens/settings_screen.png and b/test/screens/settings/goldens/settings_screen.png differ diff --git a/test/screens/timer/bloc_timer_test.dart b/test/screens/timer/bloc_timer_test.dart new file mode 100644 index 0000000..35d795b --- /dev/null +++ b/test/screens/timer/bloc_timer_test.dart @@ -0,0 +1,77 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:lightmeter/interactors/timer_interactor.dart'; +import 'package:lightmeter/screens/timer/bloc_timer.dart'; +import 'package:lightmeter/screens/timer/event_timer.dart'; +import 'package:lightmeter/screens/timer/state_timer.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class _MockTimerInteractor extends Mock implements TimerInteractor {} + +void main() { + late _MockTimerInteractor timerInteractor; + + setUpAll(() { + timerInteractor = _MockTimerInteractor(); + when(() => timerInteractor.isAutostartTimerEnabled).thenReturn(true); + when(timerInteractor.startVibration).thenAnswer((_) async {}); + when(timerInteractor.endVibration).thenAnswer((_) async {}); + }); + + blocTest( + 'Autostart', + build: () => TimerBloc(timerInteractor, const Duration(seconds: 1)), + verify: (_) { + verify(() => timerInteractor.startVibration()).called(1); + }, + expect: () => [ + isA(), + ], + ); + + blocTest( + 'Start -> wait till the end -> reset', + build: () => TimerBloc(timerInteractor, const Duration(seconds: 1)), + setUp: () { + when(() => timerInteractor.isAutostartTimerEnabled).thenReturn(false); + }, + act: (bloc) { + bloc.add(const StartTimerEvent()); + bloc.add(const TimerEndedEvent()); + bloc.add(const ResetTimerEvent()); + }, + verify: (_) { + verify(() => timerInteractor.startVibration()).called(1); + verify(() => timerInteractor.endVibration()).called(1); + }, + expect: () => [ + isA(), + isA(), + isA(), + ], + ); + + blocTest( + 'Start -> stop -> start -> wait till the end', + build: () => TimerBloc(timerInteractor, const Duration(seconds: 1)), + setUp: () { + when(() => timerInteractor.isAutostartTimerEnabled).thenReturn(false); + }, + act: (bloc) async { + bloc.add(const StartTimerEvent()); + bloc.add(const StopTimerEvent()); + bloc.add(const StartTimerEvent()); + bloc.add(const TimerEndedEvent()); + }, + verify: (_) { + verify(() => timerInteractor.startVibration()).called(3); + verify(() => timerInteractor.endVibration()).called(1); + }, + expect: () => [ + isA(), + isA(), + isA(), + isA(), + ], + ); +} diff --git a/test/screens/timer/goldens/timer_screen.png b/test/screens/timer/goldens/timer_screen.png new file mode 100644 index 0000000..f08e7ca Binary files /dev/null and b/test/screens/timer/goldens/timer_screen.png differ diff --git a/test/screens/timer/screen_timer_golden_test.dart b/test/screens/timer/screen_timer_golden_test.dart new file mode 100644 index 0000000..41b682a --- /dev/null +++ b/test/screens/timer/screen_timer_golden_test.dart @@ -0,0 +1,143 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:lightmeter/data/models/exposure_pair.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/dimens.dart'; +import 'package:lightmeter/screens/shared/animated_circular_button/widget_button_circular_animated.dart'; +import 'package:lightmeter/screens/timer/flow_timer.dart'; +import 'package:lightmeter/screens/timer/screen_timer.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../application_mock.dart'; + +class _TimerScreenConfig { + final ShutterSpeedValue shutterSpeedValue; + final bool isStopped; + + _TimerScreenConfig({ + required this.shutterSpeedValue, + required this.isStopped, + }); + + @override + String toString() { + final buffer = StringBuffer(); + buffer.write(shutterSpeedValue.toString()); + buffer.write(' - '); + buffer.write(isStopped ? 'stopped' : 'resumed'); + return buffer.toString(); + } +} + +final _testScenarios = [ + const ShutterSpeedValue( + 74, + false, + StopType.full, + ), + const ShutterSpeedValue( + 3642, + false, + StopType.full, + ), +].expand( + (shutterSpeedValue) => [ + _TimerScreenConfig(shutterSpeedValue: shutterSpeedValue, isStopped: true), + _TimerScreenConfig(shutterSpeedValue: shutterSpeedValue, isStopped: false), + ], +); + +void main() { + Future setTheme(WidgetTester tester, Key scenarioWidgetKey, ThemeType themeType) async { + final flow = find.descendant( + of: find.byKey(scenarioWidgetKey), + matching: find.byType(TimerFlow), + ); + final BuildContext context = tester.element(flow); + UserPreferencesProvider.of(context).setThemeType(themeType); + await tester.pumpAndSettle(); + } + + Future toggleTimer(WidgetTester tester, Key scenarioWidgetKey) async { + final button = find.descendant( + of: find.byKey(scenarioWidgetKey), + matching: find.byType(AnimatedCircluarButton), + ); + await tester.tap(button); + await tester.pump(Dimens.durationS); + } + + Future mockResumedState(WidgetTester tester, Key scenarioWidgetKey) async { + final screen = find.descendant( + of: find.byKey(scenarioWidgetKey), + matching: find.byType(TimerScreen), + ); + final TimerScreenState state = tester.state(screen); + state.startStopIconController.stop(); + state.timelineController.stop(); + await tester.pump(); + } + + setUpAll(() { + SharedPreferences.setMockInitialValues({ + UserPreferencesService.autostartTimerKey: false, + }); + }); + + testGoldens( + 'TimerScreen golden test', + (tester) async { + final builder = DeviceBuilder(); + for (final scenario in _testScenarios) { + builder.addScenario( + name: scenario.toString(), + widget: _MockTimerFlow(scenario.shutterSpeedValue), + onCreate: (scenarioWidgetKey) async { + if (scenarioWidgetKey.toString().contains('Dark')) { + await setTheme(tester, scenarioWidgetKey, ThemeType.dark); + } + if (!scenario.isStopped) { + await toggleTimer(tester, scenarioWidgetKey); + late final skipTimerDuration = Duration( + milliseconds: (scenario.shutterSpeedValue.value * 0.35 * Duration.millisecondsPerSecond).toInt(), + ); + await tester.pump(skipTimerDuration); + await mockResumedState(tester, scenarioWidgetKey); + } + }, + ); + } + await tester.pumpDeviceBuilder(builder); + await screenMatchesGolden( + tester, + 'timer_screen', + ); + }, + ); +} + +class _MockTimerFlow extends StatelessWidget { + final ShutterSpeedValue shutterSpeedValue; + + const _MockTimerFlow(this.shutterSpeedValue); + + @override + Widget build(BuildContext context) { + return GoldenTestApplicationMock( + child: TimerFlow( + args: TimerFlowArgs( + exposurePair: ExposurePair( + ApertureValue.values.first, + shutterSpeedValue, + ), + isoValue: const IsoValue(100, StopType.full), + ndValue: const NdValue(0), + ), + ), + ); + } +}