diff --git a/lib/application.dart b/lib/application.dart index 15eba3a..1311779 100644 --- a/lib/application.dart +++ b/lib/application.dart @@ -7,6 +7,7 @@ import 'package:lightmeter/platform_config.dart'; import 'package:lightmeter/providers/user_preferences_provider.dart'; import 'package:lightmeter/screens/metering/flow_metering.dart'; import 'package:lightmeter/screens/settings/flow_settings.dart'; +import 'package:lightmeter/screens/timer/flow_timer.dart'; class Application extends StatelessWidget { const Application({super.key}); @@ -38,10 +39,11 @@ class Application extends StatelessWidget { data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0), child: child!, ), - initialRoute: "metering", + initialRoute: "timer", routes: { "metering": (context) => const MeteringFlow(), "settings": (context) => const SettingsFlow(), + "timer": (context) => const TimerFlow(), }, ), ); diff --git a/lib/screens/shared/close_button/widget_button_close.dart b/lib/screens/shared/close_button/widget_button_close.dart new file mode 100644 index 0000000..4d52678 --- /dev/null +++ b/lib/screens/shared/close_button/widget_button_close.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/generated/l10n.dart'; + +class CloseButton extends StatelessWidget { + const CloseButton({super.key}); + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: Navigator.of(context).pop, + icon: const Icon(Icons.close), + tooltip: S.of(context).tooltipClose, + ); + } +} diff --git a/lib/screens/timer/bloc_timer.dart b/lib/screens/timer/bloc_timer.dart new file mode 100644 index 0000000..753a7e7 --- /dev/null +++ b/lib/screens/timer/bloc_timer.dart @@ -0,0 +1,201 @@ +import 'dart:async'; + +import 'package:bloc_concurrency/bloc_concurrency.dart'; +import 'package:flutter/foundation.dart'; +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/event_metering.dart'; +import 'package:lightmeter/screens/metering/state_metering.dart'; +import 'package:lightmeter/screens/metering/utils/notifier_volume_keys.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +class MeteringBloc extends Bloc { + final MeteringInteractor _meteringInteractor; + final VolumeKeysNotifier _volumeKeysNotifier; + final MeteringCommunicationBloc _communicationBloc; + late final StreamSubscription _communicationSubscription; + + MeteringBloc( + this._meteringInteractor, + this._volumeKeysNotifier, + this._communicationBloc, + ) : super( + MeteringDataState( + ev100: null, + iso: _meteringInteractor.iso, + nd: _meteringInteractor.ndFilter, + isMetering: false, + ), + ) { + _volumeKeysNotifier.addListener(onVolumeKey); + _communicationSubscription = _communicationBloc.stream + .where((state) => state is communication_states.ScreenState) + .map((state) => state as communication_states.ScreenState) + .listen(onCommunicationState); + + on(_onEquipmentProfileChanged); + on(_onIsoChanged); + on(_onNdChanged); + on(_onMeasure, transformer: droppable()); + on(_onMeasured); + on(_onMeasureError); + on(_onSettingsOpened); + on(_onSettingsClosed); + } + + @override + void onTransition(Transition transition) { + super.onTransition(transition); + if (transition.nextState is MeteringDataState) { + final nextState = transition.nextState as MeteringDataState; + if (transition.currentState is LoadingState || + transition.currentState is MeteringDataState && + (transition.currentState as MeteringDataState).ev != nextState.ev) { + if (nextState.hasError) { + _meteringInteractor.errorVibration(); + } else { + _meteringInteractor.responseVibration(); + } + } + } + } + + @override + Future close() async { + _volumeKeysNotifier.removeListener(onVolumeKey); + await _communicationSubscription.cancel(); + return super.close(); + } + + @visibleForTesting + void onCommunicationState(communication_states.ScreenState communicationState) { + if (communicationState is communication_states.MeasuredState) { + _handleEv100( + communicationState.ev100, + isMetering: communicationState is communication_states.MeteringInProgressState, + ); + } + } + + void _onEquipmentProfileChanged(EquipmentProfileChangedEvent event, Emitter emit) { + bool willUpdateMeasurements = false; + + /// Update selected ISO value and discard selected film, if selected equipment profile + /// doesn't contain currently selected value + IsoValue iso = state.iso; + if (!event.equipmentProfileData.isoValues.any((v) => state.iso.value == v.value)) { + _meteringInteractor.iso = event.equipmentProfileData.isoValues.first; + iso = event.equipmentProfileData.isoValues.first; + willUpdateMeasurements = true; + } + + /// The same for ND filter + NdValue nd = state.nd; + if (!event.equipmentProfileData.ndValues.any((v) => state.nd.value == v.value)) { + _meteringInteractor.ndFilter = event.equipmentProfileData.ndValues.first; + nd = event.equipmentProfileData.ndValues.first; + willUpdateMeasurements = true; + } + + if (willUpdateMeasurements) { + emit( + MeteringDataState( + ev100: state.ev100, + iso: iso, + nd: nd, + isMetering: state.isMetering, + ), + ); + } + } + + void _onIsoChanged(IsoChangedEvent event, Emitter emit) { + if (state.iso != event.isoValue) { + _meteringInteractor.iso = event.isoValue; + emit( + MeteringDataState( + ev100: state.ev100, + iso: event.isoValue, + nd: state.nd, + isMetering: state.isMetering, + ), + ); + } + } + + void _onNdChanged(NdChangedEvent event, Emitter emit) { + if (state.nd != event.ndValue) { + _meteringInteractor.ndFilter = event.ndValue; + emit( + MeteringDataState( + ev100: state.ev100, + iso: state.iso, + nd: event.ndValue, + isMetering: state.isMetering, + ), + ); + } + } + + void _onMeasure(MeasureEvent _, Emitter emit) { + _meteringInteractor.quickVibration(); + _communicationBloc.add(const communication_events.MeasureEvent()); + emit( + LoadingState( + iso: state.iso, + nd: state.nd, + ), + ); + } + + void _handleEv100(double? ev100, {required bool isMetering}) { + if (ev100 == null || ev100.isNaN || ev100.isInfinite) { + add(MeasureErrorEvent(isMetering: isMetering)); + } else { + add(MeasuredEvent(ev100, isMetering: isMetering)); + } + } + + void _onMeasured(MeasuredEvent event, Emitter emit) { + emit( + MeteringDataState( + ev100: event.ev100, + iso: state.iso, + nd: state.nd, + isMetering: event.isMetering, + ), + ); + } + + void _onMeasureError(MeasureErrorEvent event, Emitter emit) { + emit( + MeteringDataState( + ev100: null, + iso: state.iso, + nd: state.nd, + isMetering: event.isMetering, + ), + ); + } + + @visibleForTesting + void onVolumeKey() { + if (_meteringInteractor.volumeAction == VolumeAction.shutter) { + add(const MeasureEvent()); + } + } + + void _onSettingsOpened(SettingsOpenedEvent _, Emitter __) { + _communicationBloc.add(const communication_events.SettingsOpenedEvent()); + } + + void _onSettingsClosed(SettingsClosedEvent _, Emitter __) { + _communicationBloc.add(const communication_events.SettingsClosedEvent()); + } +} diff --git a/lib/screens/timer/event_timer.dart b/lib/screens/timer/event_timer.dart new file mode 100644 index 0000000..0a39844 --- /dev/null +++ b/lib/screens/timer/event_timer.dart @@ -0,0 +1,48 @@ +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +sealed class MeteringEvent { + const MeteringEvent(); +} + +class EquipmentProfileChangedEvent extends MeteringEvent { + final EquipmentProfile equipmentProfileData; + + const EquipmentProfileChangedEvent(this.equipmentProfileData); +} + +class IsoChangedEvent extends MeteringEvent { + final IsoValue isoValue; + + const IsoChangedEvent(this.isoValue); +} + +class NdChangedEvent extends MeteringEvent { + final NdValue ndValue; + + const NdChangedEvent(this.ndValue); +} + +class MeasureEvent extends MeteringEvent { + const MeasureEvent(); +} + +class MeasuredEvent extends MeteringEvent { + final double ev100; + final bool isMetering; + + const MeasuredEvent(this.ev100, {required this.isMetering}); +} + +class MeasureErrorEvent extends MeteringEvent { + final bool isMetering; + + const MeasureErrorEvent({required this.isMetering}); +} + +class SettingsOpenedEvent extends MeteringEvent { + const SettingsOpenedEvent(); +} + +class SettingsClosedEvent extends MeteringEvent { + const SettingsClosedEvent(); +} diff --git a/lib/screens/timer/flow_timer.dart b/lib/screens/timer/flow_timer.dart new file mode 100644 index 0000000..ee2136f --- /dev/null +++ b/lib/screens/timer/flow_timer.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lightmeter/interactors/metering_interactor.dart'; +import 'package:lightmeter/providers/services_provider.dart'; +import 'package:lightmeter/screens/metering/bloc_metering.dart'; +import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; +import 'package:lightmeter/screens/metering/screen_metering.dart'; +import 'package:lightmeter/screens/metering/utils/notifier_volume_keys.dart'; +import 'package:lightmeter/screens/timer/screen_timer.dart'; + +class TimerFlow extends StatelessWidget { + const TimerFlow({super.key}); + + @override + Widget build(BuildContext context) { + return MeteringInteractorProvider( + data: MeteringInteractor( + ServicesProvider.of(context).userPreferencesService, + ServicesProvider.of(context).caffeineService, + ServicesProvider.of(context).hapticsService, + ServicesProvider.of(context).permissionsService, + ServicesProvider.of(context).lightSensorService, + ServicesProvider.of(context).volumeEventsService, + )..initialize(), + child: MultiBlocProvider( + providers: [ + BlocProvider(create: (_) => MeteringCommunicationBloc()), + BlocProvider( + create: (context) => MeteringBloc( + MeteringInteractorProvider.of(context), + VolumeKeysNotifier(ServicesProvider.of(context).volumeEventsService), + context.read(), + ), + ), + ], + child: const TimerScreen(), + ), + ); + } +} + +class MeteringInteractorProvider extends InheritedWidget { + final MeteringInteractor data; + + const MeteringInteractorProvider({ + required this.data, + required super.child, + super.key, + }); + + static MeteringInteractor of(BuildContext context) { + return context.findAncestorWidgetOfExactType()!.data; + } + + @override + bool updateShouldNotify(MeteringInteractorProvider oldWidget) => false; +} diff --git a/lib/screens/timer/screen_timer.dart b/lib/screens/timer/screen_timer.dart new file mode 100644 index 0000000..5d203e5 --- /dev/null +++ b/lib/screens/timer/screen_timer.dart @@ -0,0 +1,211 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:material_color_utilities/material_color_utilities.dart'; + +class TimerScreen extends StatelessWidget { + const TimerScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + centerTitle: true, + elevation: 0, + title: Text( + 'Test', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontSize: Dimens.grid24, + ), + ), + actions: [const CloseButton()], + ), + body: SafeArea( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox.fromSize( + size: Size.square(MediaQuery.sizeOf(context).width - Dimens.paddingL * 4), + child: _Timer( + remainingSeconds: 5, + timerLength: 124, + ), + ), + ], + ), + ), + ), + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + floatingActionButton: FloatingActionButton( + onPressed: () {}, + ), + ); + } +} + +class _Timer extends StatelessWidget { + final int remainingSeconds; + final int timerLength; + + const _Timer({ + required this.remainingSeconds, + required this.timerLength, + }); + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _TimelinePainter( + backgroundColor: ElevationOverlay.applySurfaceTint( + Theme.of(context).cardTheme.color!, + Theme.of(context).cardTheme.surfaceTintColor, + Theme.of(context).cardTheme.elevation!, + ), + progressColor: Theme.of(context).colorScheme.primary, + progress: remainingSeconds / timerLength, + ), + willChange: true, + child: Center( + child: Text( + parseSeconds(), + style: Theme.of(context).textTheme.headlineLarge, + ), + ), + ); + } + + String parseSeconds() { + String addZeroIfNeeded(int value) { + if (value == 0) { + return '00'; + } else if (value < 10) { + return '0$value'; + } else { + return '$value'; + } + } + + final buffer = StringBuffer(); + int remainingSeconds = this.remainingSeconds; + // longer than 1 hours + if (timerLength >= 3600) { + final hours = remainingSeconds ~/ 3600; + buffer.writeAll([addZeroIfNeeded(hours), ':']); + remainingSeconds -= hours * 3600; + } + // longer than 1 minute + if (timerLength >= 60 || timerLength == 0) { + final minutes = remainingSeconds ~/ 60; + buffer.writeAll([addZeroIfNeeded(minutes), ':']); + remainingSeconds -= minutes * 60; + } + // longer than 1 second + buffer.write(addZeroIfNeeded(remainingSeconds)); + return buffer.toString(); + } +} + +class _TimelinePainter extends CustomPainter { + final Color progressColor; + final Color backgroundColor; + final double progress; + + late final double timelineEdgeRadius = strokeWidth / 2; + late final double radiansProgress = 2 * pi * progress; + 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) { + final radius = size.height / 2; + final timerCenter = Offset(radius, radius); + final timelineSegmentPath = Path.combine( + PathOperation.difference, + Path() + ..arcTo( + Rect.fromCenter( + center: timerCenter, + height: size.height, + width: size.width, + ), + radiansQuarterTurn, + radiansProgress, + false, + ) + ..lineTo(radius, radius) + ..lineTo(radius, 0), + 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( + Path.combine( + PathOperation.union, + timelineSegmentPath, + smoothEdgesPath, + ), + Paint()..color = progressColor, + ); + } + + @override + bool shouldRepaint(_TimelinePainter oldDelegate) => oldDelegate.progress != progress; +} diff --git a/lib/screens/timer/state_timer.dart b/lib/screens/timer/state_timer.dart new file mode 100644 index 0000000..74dc792 --- /dev/null +++ b/lib/screens/timer/state_timer.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +@immutable +abstract class MeteringState { + final double? ev100; + final IsoValue iso; + final NdValue nd; + final bool isMetering; + + const MeteringState({ + this.ev100, + required this.iso, + required this.nd, + required this.isMetering, + }); +} + +class LoadingState extends MeteringState { + const LoadingState({ + required super.iso, + required super.nd, + }) : super(isMetering: true); +} + +class MeteringDataState extends MeteringState { + const MeteringDataState({ + required super.ev100, + required super.iso, + required super.nd, + required super.isMetering, + }); + + double? get ev => ev100 != null ? ev100! + log2(iso.value / 100) - nd.stopReduction : null; + bool get hasError => ev == null; +}