mirror of
https://github.com/vodemn/m3_lightmeter.git
synced 2024-11-23 07:50:42 +00:00
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:
parent
bc7e6e14d0
commit
5c27f726c5
54 changed files with 1403 additions and 355 deletions
|
@ -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}'),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
10
integration_test/utils/finder_actions.dart
Normal file
10
integration_test/utils/finder_actions.dart
Normal 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),
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
24
lib/interactors/timer_interactor.dart
Normal file
24
lib/interactors/timer_interactor.dart
Normal 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;
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -72,6 +72,7 @@
|
|||
"general": "Общие",
|
||||
"keepScreenOn": "Запрет блокировки",
|
||||
"haptics": "Вибрация",
|
||||
"autostartTimer": "Автозапуск таймера",
|
||||
"volumeKeysAction": "Затвор по кнопкам громкости",
|
||||
"language": "Язык",
|
||||
"chooseLanguage": "Выберите язык",
|
||||
|
@ -117,5 +118,6 @@
|
|||
"tooltipResetToZero": "Сбросить до 0",
|
||||
"tooltipUseLightSensor": "Использовать датчик освещенности",
|
||||
"tooltipUseCamera": "Использовать камеру",
|
||||
"tooltipOpenSettings": "Открыть настройки"
|
||||
"tooltipOpenSettings": "Открыть настройки",
|
||||
"exposurePair": "Пара экспозиции"
|
||||
}
|
|
@ -72,6 +72,7 @@
|
|||
"general": "通用",
|
||||
"keepScreenOn": "保持屏幕常亮",
|
||||
"haptics": "震动",
|
||||
"autostartTimer": "自动启动定时器",
|
||||
"volumeKeysAction": "音量键快门",
|
||||
"language": "语言",
|
||||
"chooseLanguage": "选择语言",
|
||||
|
@ -117,5 +118,6 @@
|
|||
"resetToZero": "重置为零",
|
||||
"tooltipUseLightSensor": "使用光线传感器",
|
||||
"tooltipUseCamera": "使用摄像头",
|
||||
"tooltipOpenSettings": "打开设置"
|
||||
"tooltipOpenSettings": "打开设置",
|
||||
"exposurePair": "曝光对"
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
],
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
15
lib/screens/shared/close_button/widget_button_close.dart
Normal file
15
lib/screens/shared/close_button/widget_button_close.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
41
lib/screens/timer/bloc_timer.dart
Normal file
41
lib/screens/timer/bloc_timer.dart
Normal 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());
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
56
lib/screens/timer/components/text/widget_text_timer.dart
Normal file
56
lib/screens/timer/components/text/widget_text_timer.dart
Normal 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';
|
||||
}
|
||||
}
|
142
lib/screens/timer/components/timeline/widget_timeline_timer.dart
Normal file
142
lib/screens/timer/components/timeline/widget_timeline_timer.dart
Normal 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;
|
||||
}
|
19
lib/screens/timer/event_timer.dart
Normal file
19
lib/screens/timer/event_timer.dart
Normal 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();
|
||||
}
|
67
lib/screens/timer/flow_timer.dart
Normal file
67
lib/screens/timer/flow_timer.dart
Normal 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;
|
||||
}
|
144
lib/screens/timer/screen_timer.dart
Normal file
144
lib/screens/timer/screen_timer.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
18
lib/screens/timer/state_timer.dart
Normal file
18
lib/screens/timer/state_timer.dart
Normal 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();
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
74
test/interactors/timer_interactor_test.dart
Normal file
74
test/interactors/timer_interactor_test.dart
Normal 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);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
|
@ -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: () => [],
|
||||
);
|
||||
|
|
|
@ -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 |
|
@ -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 |
77
test/screens/timer/bloc_timer_test.dart
Normal file
77
test/screens/timer/bloc_timer_test.dart
Normal 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>(),
|
||||
],
|
||||
);
|
||||
}
|
BIN
test/screens/timer/goldens/timer_screen.png
Normal file
BIN
test/screens/timer/goldens/timer_screen.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 854 KiB |
143
test/screens/timer/screen_timer_golden_test.dart
Normal file
143
test/screens/timer/screen_timer_golden_test.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue