ML-173 Add a timer for long exposures (#174)

* wip

* added start/stop button

* animated timeline

* fixed timer stop state

* added reset button (wip)

* added `onExposurePairTap` callback

* integrated `TimerScreen` to navigation

* separated `TimerTimeline`

* fixed timeline flickering

* added milliseconds to timer

* synchronized timeline with actual timer

* reused `BottomControlsBar`

* fixed default scaffold background color

* moved center button size to the bar itself

* display selected exposure pair on timer screen

* separated reusable `AnimatedCircluarButton`

* release camera when timer is opened

* added `TimerInteractor`

* added `TimerBloc` test

* fixed hours parsing

* added scenarios for timer golden test

* adjusted timer timeline colors

* show iso & nd values on timer screen

* automatically close timer screen after timeout

* added timer autostart

* reverted theme changes

* updated goldens

* typo

* removed timer screen auto-dismiss

* increased timer vibration duration

* replaced outlined locks

* increased 1/3 values font size
This commit is contained in:
Vadim 2024-05-07 19:24:51 +02:00 committed by GitHub
parent bc7e6e14d0
commit 5c27f726c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 1403 additions and 355 deletions

View file

@ -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<void> _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}'),
);
}

View file

@ -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,

View file

@ -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),
);
}

View file

@ -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<void> 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<void> 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();
}

View file

@ -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),
},
),
);

View file

@ -9,7 +9,7 @@ class HapticsService {
Future<void> responseVibration() async => _tryVibrate(duration: 50, amplitude: 128);
Future<void> errorVibration() async => _tryVibrate(duration: 100, amplitude: 128);
Future<void> errorVibration() async => _tryVibrate(duration: 500, amplitude: 128);
Future<void> _tryVibrate({required int duration, required int amplitude}) async {
if (await _canVibrate()) {

View file

@ -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,

View file

@ -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<void> enableCaffeine(bool enable) async {
@ -31,12 +30,15 @@ class SettingsInteractor {
});
}
bool get isAutostartTimerEnabled => _userPreferencesService.autostartTimer;
void enableAutostartTimer(bool enable) => _userPreferencesService.autostartTimer = enable;
Future<void> disableVolumeHandling() async {
await _volumeEventsService.setVolumeHandling(false);
}
Future<void> restoreVolumeHandling() async {
await _volumeEventsService
.setVolumeHandling(_userPreferencesService.volumeAction != VolumeAction.none);
await _volumeEventsService.setVolumeHandling(_userPreferencesService.volumeAction != VolumeAction.none);
}
VolumeAction get volumeAction => _userPreferencesService.volumeAction;

View file

@ -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<void> startVibration() async {
if (_userPreferencesService.haptics) await _hapticsService.quickVibration();
}
/// Executes vibration if haptics are enabled in settings
Future<void> endVibration() async {
if (_userPreferencesService.haptics) await _hapticsService.errorVibration();
}
bool get isAutostartTimerEnabled => _userPreferencesService.autostartTimer;
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -72,6 +72,7 @@
"general": "Общие",
"keepScreenOn": "Запрет блокировки",
"haptics": "Вибрация",
"autostartTimer": "Автозапуск таймера",
"volumeKeysAction": "Затвор по кнопкам громкости",
"language": "Язык",
"chooseLanguage": "Выберите язык",
@ -117,5 +118,6 @@
"tooltipResetToZero": "Сбросить до 0",
"tooltipUseLightSensor": "Использовать датчик освещенности",
"tooltipUseCamera": "Использовать камеру",
"tooltipOpenSettings": "Открыть настройки"
"tooltipOpenSettings": "Открыть настройки",
"exposurePair": "Пара экспозиции"
}

View file

@ -72,6 +72,7 @@
"general": "通用",
"keepScreenOn": "保持屏幕常亮",
"haptics": "震动",
"autostartTimer": "自动启动定时器",
"volumeKeysAction": "音量键快门",
"language": "语言",
"chooseLanguage": "选择语言",
@ -117,5 +118,6 @@
"resetToZero": "重置为零",
"tooltipUseLightSensor": "使用光线传感器",
"tooltipUseCamera": "使用摄像头",
"tooltipOpenSettings": "打开设置"
"tooltipOpenSettings": "打开设置",
"exposurePair": "曝光对"
}

View file

@ -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<MeteringEvent, MeteringState> {
on<MeasureEvent>(_onMeasure, transformer: droppable());
on<MeasuredEvent>(_onMeasured);
on<MeasureErrorEvent>(_onMeasureError);
on<SettingsOpenedEvent>(_onSettingsOpened);
on<SettingsClosedEvent>(_onSettingsClosed);
on<ScreenOnTopOpenedEvent>(_onSettingsOpened);
on<ScreenOnTopClosedEvent>(_onSettingsClosed);
}
@override
@ -191,11 +189,11 @@ class MeteringBloc extends Bloc<MeteringEvent, MeteringState> {
}
}
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());
}
}

View file

@ -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<MeteringCommunicationEvent, MeteringCommunicationState> {
class MeteringCommunicationBloc extends Bloc<MeteringCommunicationEvent, MeteringCommunicationState> {
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<MeasureEvent>((_, emit) => emit(MeasureState()));
on<MeteringInProgressEvent>((event, emit) => emit(MeteringInProgressState(event.ev100)));
on<MeteringEndedEvent>((event, emit) => emit(MeteringEndedState(event.ev100)));
on<SettingsOpenedEvent>((_, emit) => emit(const SettingsOpenedState()));
on<SettingsClosedEvent>((_, emit) => emit(const SettingsClosedState()));
on<ScreenOnTopOpenedEvent>((_, emit) => emit(const SettingsOpenedState()));
on<ScreenOnTopClosedEvent>((_, emit) => emit(const SettingsClosedState()));
}
}

View file

@ -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();
}

View file

@ -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<MeteringMeasureButton> createState() => _MeteringMeasureButtonState();
}
class _MeteringMeasureButtonState extends State<MeteringMeasureButton> {
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();
}
}

View file

@ -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,
),
);
}
}

View file

@ -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();
}
}

View file

@ -18,6 +18,7 @@ class CameraContainerProvider extends StatelessWidget {
final ValueChanged<IsoValue> onIsoChanged;
final ValueChanged<NdValue> onNdChanged;
final List<ExposurePair> exposurePairs;
final ValueChanged<ExposurePair> 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,
),
);
}

View file

@ -29,6 +29,7 @@ class CameraContainer extends StatelessWidget {
final ValueChanged<IsoValue> onIsoChanged;
final ValueChanged<NdValue> onNdChanged;
final List<ExposurePair> exposurePairs;
final ValueChanged<ExposurePair> 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),

View file

@ -15,6 +15,7 @@ class LightSensorContainerProvider extends StatelessWidget {
final ValueChanged<IsoValue> onIsoChanged;
final ValueChanged<NdValue> onNdChanged;
final List<ExposurePair> exposurePairs;
final ValueChanged<ExposurePair> 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,
),
);
}

View file

@ -14,6 +14,7 @@ class LightSensorContainer extends StatelessWidget {
final ValueChanged<IsoValue> onIsoChanged;
final ValueChanged<NdValue> onNdChanged;
final List<ExposurePair> exposurePairs;
final ValueChanged<ExposurePair> 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,
),
),
),
),
],

View file

@ -44,9 +44,8 @@ class ExposurePairsListItem<T extends PhotographyStopValue> 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<T extends PhotographyStopValue> extends StatelessWid
case StopType.full:
return Dimens.grid16;
case StopType.half:
return Dimens.grid8;
case StopType.third:
return Dimens.grid8;
}

View file

@ -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<ExposurePair> exposurePairs;
final ValueChanged<ExposurePair> 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),
),
),
),
),
),
),
],
),
],
);
},
),
),
],

View file

@ -14,11 +14,13 @@ class ReadingValue {
class ReadingValueContainer extends StatelessWidget implements AnimatedDialogClosedChild {
late final List<Widget> _items;
final bool locked;
final Color? color;
final Color? textColor;
ReadingValueContainer({
required List<ReadingValue> 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,
),
],
),
),
),

View file

@ -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();
}

View file

@ -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<MeteringBloc>().add(IsoChangedEvent(value)),
onNdChanged: (value) => context.read<MeteringBloc>().add(NdChangedEvent(value)),
onExposurePairTap: (value) => pushNamed(
context,
'timer',
arguments: TimerFlowArgs(
exposurePair: value,
isoValue: state.iso,
ndValue: state.nd,
),
),
),
),
),
BlocBuilder<MeteringBloc, MeteringState>(
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<MeteringBloc>().add(const MeasureEvent()),
onSettings: () {
context.read<MeteringBloc>().add(const SettingsOpenedEvent());
Navigator.pushNamed(context, 'settings').then((value) {
context.read<MeteringBloc>().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<MeteringBloc>().add(const ScreenOnTopOpenedEvent());
Navigator.pushNamed(context, routeName, arguments: arguments).then((_) {
context.read<MeteringBloc>().add(const ScreenOnTopClosedEvent());
});
}
}
class _InheritedListeners extends StatelessWidget {
@ -83,6 +95,7 @@ class MeteringContainerBuidler extends StatelessWidget {
final NdValue nd;
final ValueChanged<IsoValue> onIsoChanged;
final ValueChanged<NdValue> onNdChanged;
final ValueChanged<ExposurePair> 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,
);
}

View file

@ -0,0 +1,15 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:lightmeter/interactors/settings_interactor.dart';
class TimerListTileBloc extends Cubit<bool> {
final SettingsInteractor _settingsInteractor;
TimerListTileBloc(
this._settingsInteractor,
) : super(_settingsInteractor.isAutostartTimerEnabled);
void onChanged(bool value) {
_settingsInteractor.enableAutostartTimer(value);
emit(value);
}
}

View file

@ -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(),
);
}
}

View file

@ -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<TimerListTileBloc, bool>(
builder: (context, state) => SwitchListTile(
secondary: const Icon(Icons.timer_outlined),
title: Text(S.of(context).autostartTimer),
value: state && context.isPro,
onChanged: context.read<TimerListTileBloc>().onChanged,
contentPadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM),
),
),
);
}
}

View file

@ -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(),
],

View file

@ -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<AnimatedCircluarButton> createState() => _AnimatedCircluarButtonState();
}
class _AnimatedCircluarButtonState extends State<AnimatedCircluarButton> {
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,
),
),
],
),
);
}
}

View file

@ -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(),
],
),
),
),
),
),
);
}
}

View file

@ -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,
);
}
}

View file

@ -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<TimerEvent, TimerState> {
final TimerInteractor _timerInteractor;
final Duration duration;
TimerBloc(this._timerInteractor, this.duration) : super(const TimerStoppedState()) {
on<StartTimerEvent>(_onStartTimer);
on<TimerEndedEvent>(_onTimerEnded);
on<StopTimerEvent>(_onStopTimer);
on<ResetTimerEvent>(_onResetTimer);
if (_timerInteractor.isAutostartTimerEnabled) add(const StartTimerEvent());
}
Future<void> _onStartTimer(StartTimerEvent _, Emitter emit) async {
_timerInteractor.startVibration();
emit(const TimerResumedState());
}
Future<void> _onTimerEnded(TimerEndedEvent event, Emitter emit) async {
if (state is! TimerResetState) {
_timerInteractor.endVibration();
emit(const TimerStoppedState());
}
}
Future<void> _onStopTimer(StopTimerEvent _, Emitter emit) async {
_timerInteractor.startVibration();
emit(const TimerStoppedState());
}
Future<void> _onResetTimer(ResetTimerEvent _, Emitter emit) async {
emit(const TimerResetState());
}
}

View file

@ -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,
),
),
],
),
),
],
),
),
);
}
}

View file

@ -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';
}
}

View file

@ -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;
}

View file

@ -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();
}

View file

@ -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<TimerInteractorProvider>()!.data;
}
@override
bool updateShouldNotify(TimerInteractorProvider oldWidget) => false;
}

View file

@ -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<TimerScreen> createState() => TimerScreenState();
}
@visibleForTesting
class TimerScreenState extends State<TimerScreen> with TickerProviderStateMixin {
late AnimationController timelineController;
late Animation<double> timelineAnimation;
late AnimationController startStopIconController;
late Animation<double> startStopIconAnimation;
@override
void initState() {
super.initState();
timelineController = AnimationController(vsync: this, duration: widget.duration);
timelineAnimation = Tween<double>(begin: 1, end: 0).animate(timelineController);
timelineController.addStatusListener((status) {
if (status == AnimationStatus.completed) {
context.read<TimerBloc>().add(const TimerEndedEvent());
}
});
startStopIconController = AnimationController(vsync: this, duration: Dimens.durationS);
startStopIconAnimation = Tween<double>(begin: 0, end: 1).animate(startStopIconController);
}
@override
void dispose() {
timelineController.dispose();
startStopIconController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocListener<TimerBloc, TimerState>(
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<TimerBloc>().add(const ResetTimerEvent());
},
icon: const Icon(Icons.restore),
),
center: BlocBuilder<TimerBloc, TimerState>(
builder: (_, state) => AnimatedCircluarButton(
isPressed: state is TimerResumedState,
onPressed: () {
if (timelineAnimation.value == 0) {
return;
}
final event = state is TimerStoppedState ? const StartTimerEvent() : const StopTimerEvent();
context.read<TimerBloc>().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();
}
}
}

View file

@ -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();
}

View file

@ -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);

View file

@ -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<Future<void>>());
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<Future<void>>());
verify(() => mockVolumeEventsService.setVolumeHandling(false)).called(1);

View file

@ -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);
});
},
);
}

View file

@ -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<communication_events.MeteringCommunicationEvent, communication_states.MeteringCommunicationState>
implements MeteringCommunicationBloc {}
void main() {
late _MockMeteringInteractor meteringInteractor;
@ -515,20 +513,18 @@ void main() {
);
group(
'`SettingOpenedEvent`/`SettingsClosedEvent`',
'`ScreenOnTopOpenedEvent`/`ScreenOnTopClosedEvent`',
() {
blocTest<MeteringBloc, MeteringState>(
'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: () => [],
);

View file

@ -100,20 +100,20 @@ void main() {
);
group(
'`SettingsOpenedEvent`/`SettingsClosedEvent`',
'`ScreenOnTopOpenedEvent`/`ScreenOnTopClosedEvent`',
() {
blocTest<MeteringCommunicationBloc, MeteringCommunicationState>(
'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<SettingsOpenedState>(),

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View file

@ -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<void> 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<void> 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);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 KiB

After

Width:  |  Height:  |  Size: 496 KiB

View file

@ -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<TimerBloc, TimerState>(
'Autostart',
build: () => TimerBloc(timerInteractor, const Duration(seconds: 1)),
verify: (_) {
verify(() => timerInteractor.startVibration()).called(1);
},
expect: () => [
isA<TimerResumedState>(),
],
);
blocTest<TimerBloc, TimerState>(
'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<TimerResumedState>(),
isA<TimerStoppedState>(),
isA<TimerResetState>(),
],
);
blocTest<TimerBloc, TimerState>(
'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<TimerResumedState>(),
isA<TimerStoppedState>(),
isA<TimerResumedState>(),
isA<TimerStoppedState>(),
],
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 KiB

View file

@ -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<void> 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<void> 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<void> 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),
),
),
);
}
}