diff --git a/lib/res/dimens.dart b/lib/res/dimens.dart index 9d0ceed..4ad38b3 100644 --- a/lib/res/dimens.dart +++ b/lib/res/dimens.dart @@ -20,4 +20,10 @@ class Dimens { static const Duration durationM = Duration(milliseconds: 200); static const Duration durationML = Duration(milliseconds: 250); static const Duration durationL = Duration(milliseconds: 300); + + // `CameraSlider` + static const double cameraSliderTrackHeight = grid4; + static const double cameraSliderTrackRadius = cameraSliderTrackHeight / 2; + static const double cameraSliderHandleSize = 32; + static const double cameraSliderHandleIconSize = cameraSliderHandleSize * 2 / 3; } diff --git a/lib/screens/metering/components/camera/shared/widget_slider_camera.dart b/lib/screens/metering/components/camera/shared/widget_slider_camera.dart new file mode 100644 index 0000000..643e502 --- /dev/null +++ b/lib/screens/metering/components/camera/shared/widget_slider_camera.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/res/dimens.dart'; + +class CameraSlider extends StatefulWidget { + final Icon icon; + final double value; + final double min; + final double max; + final ValueChanged onChanged; + final bool isVertical; + + const CameraSlider({ + required this.icon, + required this.value, + required this.min, + required this.max, + required this.onChanged, + this.isVertical = false, + super.key, + }); + + @override + State createState() => _CameraSliderState(); +} + +class _CameraSliderState extends State { + double relativeValue = 0.0; + + @override + void initState() { + super.initState(); + relativeValue = (widget.value - widget.min) / (widget.max - widget.min); + } + + @override + void didUpdateWidget(CameraSlider oldWidget) { + super.didUpdateWidget(oldWidget); + relativeValue = (widget.value - widget.min) / (widget.max - widget.min); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final biggestSize = widget.isVertical ? constraints.maxHeight : constraints.maxWidth; + final handleDistance = biggestSize - Dimens.cameraSliderHandleSize; + return RotatedBox( + quarterTurns: widget.isVertical ? -1 : 0, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTapUp: (details) => _updateHandlePosition(details.localPosition.dx, handleDistance), + onHorizontalDragUpdate: (details) => _updateHandlePosition(details.localPosition.dx, handleDistance), + child: SizedBox( + height: Dimens.cameraSliderHandleSize, + width: biggestSize, + child: _Slider( + handleDistance: handleDistance, + handleSize: Dimens.cameraSliderHandleSize, + trackThickness: Dimens.cameraSliderTrackHeight, + value: relativeValue, + icon: RotatedBox( + quarterTurns: widget.isVertical ? 1 : 0, + child: widget.icon, + ), + ), + ), + ), + ); + }, + ); + } + + void _updateHandlePosition(double offset, double handleDistance) { + if (offset <= Dimens.cameraSliderHandleSize / 2) { + relativeValue = 0; + } else if (offset >= handleDistance + Dimens.cameraSliderHandleSize / 2) { + relativeValue = 1; + } else { + relativeValue = (offset - Dimens.cameraSliderHandleSize / 2) / handleDistance; + } + setState(() {}); + widget.onChanged(_clampToRange(relativeValue)); + } + + double _clampToRange(double relativeValue) => relativeValue * (widget.max - widget.min) + widget.min; +} + +class _Slider extends StatelessWidget { + final double handleSize; + final double trackThickness; + final double handleDistance; + final double value; + final Widget icon; + + const _Slider({ + required this.handleSize, + required this.trackThickness, + required this.handleDistance, + required this.value, + required this.icon, + }); + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.center, + children: [ + Positioned( + height: trackThickness, + width: handleDistance + trackThickness, // add thickness to maintain radius overlap with handle + child: ClipRRect( + borderRadius: BorderRadius.circular(trackThickness / 2), + child: ColoredBox(color: Theme.of(context).colorScheme.surfaceVariant), + ), + ), + AnimatedPositioned.fromRect( + duration: Dimens.durationM, + rect: Rect.fromCenter( + center: Offset( + handleSize / 2 + handleDistance * value, + handleSize / 2, + ), + width: handleSize, + height: handleSize, + ), + child: _Handle( + color: Theme.of(context).colorScheme.primary, + size: handleSize, + child: IconTheme( + data: Theme.of(context).iconTheme.copyWith( + color: Theme.of(context).colorScheme.onPrimary, + size: Dimens.cameraSliderHandleIconSize, + ), + child: icon, + ), + ), + ), + ], + ); + } +} + +class _Handle extends StatelessWidget { + final Color color; + final double size; + final Widget? child; + + const _Handle({ + required this.color, + required this.size, + this.child, + }); + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(size / 2), + child: SizedBox( + height: size, + width: size, + child: ColoredBox( + color: color, + child: child, + ), + ), + ); + } +} diff --git a/lib/screens/metering/components/camera/widget_exposure_slider.dart b/lib/screens/metering/components/camera/widget_exposure_slider.dart new file mode 100644 index 0000000..85ba8c9 --- /dev/null +++ b/lib/screens/metering/components/camera/widget_exposure_slider.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/metering/ev_source/camera/bloc_camera.dart'; +import 'package:lightmeter/screens/metering/ev_source/camera/event_camera.dart'; +import 'package:lightmeter/screens/metering/ev_source/camera/state_camera.dart'; +import 'package:lightmeter/utils/to_string_signed.dart'; + +import 'shared/widget_slider_camera.dart'; + +class CameraExposureSlider extends StatelessWidget { + const CameraExposureSlider({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is CameraActiveState) { + return Column( + children: [ + IconButton( + icon: const Icon(Icons.sync), + onPressed: state.currentExposureOffset != 0.0 + ? () => context.read().add(const ExposureOffsetChangedEvent(0)) + : null, + ), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: Dimens.grid8), + child: _Ruler(state.minExposureOffset, state.maxExposureOffset), + ), + CameraSlider( + isVertical: true, + icon: const Icon(Icons.light_mode), + value: state.currentExposureOffset, + min: state.minExposureOffset, + max: state.maxExposureOffset, + onChanged: (value) { + context.read().add(ExposureOffsetChangedEvent(value)); + }, + ), + ], + ), + ), + ], + ); + } + return const SizedBox(); + }, + ); + } +} + +class _Ruler extends StatelessWidget { + final double min; + final double max; + + const _Ruler(this.min, this.max); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate( + (max - min + 1).toInt(), + (index) { + final bool showValue = index % 2 == 0.0 || index == 0.0; + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (showValue) + Text( + (index + min).toStringSigned(), + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(width: Dimens.grid8), + ColoredBox( + color: Theme.of(context).colorScheme.onBackground, + child: SizedBox( + height: 1, + width: showValue ? Dimens.grid16 : Dimens.grid8, + ), + ), + const SizedBox(width: Dimens.grid8), + ], + ); + }, + ), + ); + } +} diff --git a/lib/screens/metering/components/camera/widget_zoom_camera.dart b/lib/screens/metering/components/camera/widget_zoom_camera.dart new file mode 100644 index 0000000..310c6ef --- /dev/null +++ b/lib/screens/metering/components/camera/widget_zoom_camera.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lightmeter/screens/metering/ev_source/camera/bloc_camera.dart'; +import 'package:lightmeter/screens/metering/ev_source/camera/event_camera.dart'; +import 'package:lightmeter/screens/metering/ev_source/camera/state_camera.dart'; + +import 'shared/widget_slider_camera.dart'; + +class CameraZoomSlider extends StatelessWidget { + const CameraZoomSlider({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is CameraActiveState) { + return CameraSlider( + icon: const Icon(Icons.search), + value: state.currentZoom, + min: state.minZoom, + max: state.maxZoom, + onChanged: (value) { + context.read().add(ZoomChangedEvent(value)); + }, + ); + } + return const SizedBox(); + }, + ); + } +} diff --git a/lib/screens/metering/components/exposure_pairs_list/components/widget_item_list_exposure_pairs.dart b/lib/screens/metering/components/exposure_pairs_list/components/widget_item_list_exposure_pairs.dart index c5fbc0d..358b0b9 100644 --- a/lib/screens/metering/components/exposure_pairs_list/components/widget_item_list_exposure_pairs.dart +++ b/lib/screens/metering/components/exposure_pairs_list/components/widget_item_list_exposure_pairs.dart @@ -23,7 +23,7 @@ class ExposurePairsListItem extends StatelessWid width: tickLength(), ), ), - if (value.stopType != StopType.full) const SizedBox(width: Dimens.grid8), + if (value.stopType != StopType.full) const SizedBox(width: Dimens.grid4), ]; return Row( mainAxisAlignment: tickOnTheLeft ? MainAxisAlignment.start : MainAxisAlignment.end, @@ -45,9 +45,9 @@ class ExposurePairsListItem extends StatelessWid double tickLength() { switch (value.stopType) { case StopType.full: - return Dimens.grid24; - case StopType.half: return Dimens.grid16; + case StopType.half: + return Dimens.grid8; case StopType.third: return Dimens.grid8; } diff --git a/lib/screens/metering/components/exposure_pairs_list/widget_list_exposure_pairs.dart b/lib/screens/metering/components/exposure_pairs_list/widget_list_exposure_pairs.dart index 91dc2d6..d1533f4 100644 --- a/lib/screens/metering/components/exposure_pairs_list/widget_list_exposure_pairs.dart +++ b/lib/screens/metering/components/exposure_pairs_list/widget_list_exposure_pairs.dart @@ -16,7 +16,7 @@ class ExposurePairsList extends StatelessWidget { Positioned.fill( child: ListView.builder( key: ValueKey(exposurePairs.hashCode), - padding: const EdgeInsets.all(Dimens.paddingL), + padding: const EdgeInsets.symmetric(vertical: Dimens.paddingL), itemCount: exposurePairs.length, itemBuilder: (_, index) => Stack( alignment: Alignment.center, diff --git a/lib/screens/metering/components/topbar/components/widget_camera_preview.dart b/lib/screens/metering/components/topbar/components/widget_camera_preview.dart index 8074c3b..d11e09d 100644 --- a/lib/screens/metering/components/topbar/components/widget_camera_preview.dart +++ b/lib/screens/metering/components/topbar/components/widget_camera_preview.dart @@ -13,14 +13,14 @@ class CameraView extends StatelessWidget { return AspectRatio( aspectRatio: 3 / 4, child: BlocBuilder( + buildWhen: (previous, current) => current is CameraInitializedState, builder: (context, state) { - if (state is CameraReadyState) { + if (state is CameraInitializedState) { final value = state.controller.value; return ValueListenableBuilder( valueListenable: state.controller, builder: (_, __, ___) => AspectRatio( - aspectRatio: - _isLandscape(value) ? value.aspectRatio : (1 / value.aspectRatio), + aspectRatio: _isLandscape(value) ? value.aspectRatio : (1 / value.aspectRatio), child: RotatedBox( quarterTurns: _getQuarterTurns(value), child: state.controller.buildPreview(), diff --git a/lib/screens/metering/ev_source/camera/bloc_camera.dart b/lib/screens/metering/ev_source/camera/bloc_camera.dart index b68a00c..a98b8a6 100644 --- a/lib/screens/metering/ev_source/camera/bloc_camera.dart +++ b/lib/screens/metering/ev_source/camera/bloc_camera.dart @@ -4,7 +4,7 @@ import 'dart:math'; import 'dart:typed_data'; import 'package:camera/camera.dart'; import 'package:exif/exif.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lightmeter/screens/metering/ev_source/ev_source_bloc.dart'; import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; @@ -20,6 +20,15 @@ class CameraBloc extends EvSourceBloc { CameraController? _cameraController; CameraController? get cameraController => _cameraController; + static const _maxZoom = 7.0; + RangeValues? _zoomRange; + double _currentZoom = 0.0; + + static const _exposureMaxRange = RangeValues(-4, 4); + RangeValues? _exposureOffsetRange; + double _exposureStep = 0.0; + double _currentExposureOffset = 0.0; + CameraBloc(MeteringCommunicationBloc communicationBloc) : super( communicationBloc, @@ -29,6 +38,8 @@ class CameraBloc extends EvSourceBloc { WidgetsBinding.instance.addObserver(_observer); on(_onInitialize); + on(_onZoomChanged); + on(_onExposureOffsetChanged); add(const InitializeEvent()); } @@ -66,7 +77,24 @@ class CameraBloc extends EvSourceBloc { await _cameraController!.initialize(); await _cameraController!.setFlashMode(FlashMode.off); - emit(CameraReadyState(_cameraController!)); + + _zoomRange = await Future.wait([ + _cameraController!.getMinZoomLevel(), + _cameraController!.getMaxZoomLevel(), + ]).then((levels) => RangeValues(levels[0], min(_maxZoom, levels[1]))); + _currentZoom = _zoomRange!.start; + + _exposureOffsetRange = await Future.wait([ + _cameraController!.getMinExposureOffset(), + _cameraController!.getMaxExposureOffset(), + ]).then((levels) => RangeValues(max(_exposureMaxRange.start, levels[0]), min(_exposureMaxRange.end, levels[1]))); + await _cameraController!.getExposureOffsetStepSize().then((value) { + _exposureStep = value == 0 ? 0.1 : value; + }); + + emit(CameraInitializedState(_cameraController!)); + + _emitActiveState(emit); _takePhoto().then((ev100) { if (ev100 != null) { communicationBloc.add(communication_event.MeasuredEvent(ev100)); @@ -77,6 +105,30 @@ class CameraBloc extends EvSourceBloc { } } + Future _onZoomChanged(ZoomChangedEvent event, Emitter emit) async { + _cameraController!.setZoomLevel(event.value); + _currentZoom = event.value; + _emitActiveState(emit); + } + + Future _onExposureOffsetChanged(ExposureOffsetChangedEvent event, Emitter emit) async { + _cameraController!.setExposureOffset(event.value); + _currentExposureOffset = event.value; + _emitActiveState(emit); + } + + void _emitActiveState(Emitter emit) { + emit(CameraActiveState( + minZoom: _zoomRange!.start, + maxZoom: _zoomRange!.end, + currentZoom: _currentZoom, + minExposureOffset: _exposureOffsetRange!.start, + maxExposureOffset: _exposureOffsetRange!.end, + exposureOffsetStep: _exposureStep, + currentExposureOffset: _currentExposureOffset, + )); + } + Future _takePhoto() async { if (_cameraController == null || !_cameraController!.value.isInitialized || diff --git a/lib/screens/metering/ev_source/camera/event_camera.dart b/lib/screens/metering/ev_source/camera/event_camera.dart index 099f150..0a30d07 100644 --- a/lib/screens/metering/ev_source/camera/event_camera.dart +++ b/lib/screens/metering/ev_source/camera/event_camera.dart @@ -4,4 +4,16 @@ abstract class CameraEvent { class InitializeEvent extends CameraEvent { const InitializeEvent(); -} \ No newline at end of file +} + +class ZoomChangedEvent extends CameraEvent { + final double value; + + const ZoomChangedEvent(this.value); +} + +class ExposureOffsetChangedEvent extends CameraEvent { + final double value; + + const ExposureOffsetChangedEvent(this.value); +} diff --git a/lib/screens/metering/ev_source/camera/state_camera.dart b/lib/screens/metering/ev_source/camera/state_camera.dart index 1364310..cf2c2a7 100644 --- a/lib/screens/metering/ev_source/camera/state_camera.dart +++ b/lib/screens/metering/ev_source/camera/state_camera.dart @@ -12,10 +12,30 @@ class CameraLoadingState extends CameraState { const CameraLoadingState(); } -class CameraReadyState extends CameraState { +class CameraInitializedState extends CameraState { final CameraController controller; - const CameraReadyState(this.controller); + const CameraInitializedState(this.controller); +} + +class CameraActiveState extends CameraState { + final double minZoom; + final double maxZoom; + final double currentZoom; + final double minExposureOffset; + final double maxExposureOffset; + final double? exposureOffsetStep; + final double currentExposureOffset; + + const CameraActiveState({ + required this.minZoom, + required this.maxZoom, + required this.currentZoom, + required this.minExposureOffset, + required this.maxExposureOffset, + required this.exposureOffsetStep, + required this.currentExposureOffset, + }); } class CameraErrorState extends CameraState { diff --git a/lib/screens/metering/screen_metering.dart b/lib/screens/metering/screen_metering.dart index c113a4d..8aab0da 100644 --- a/lib/screens/metering/screen_metering.dart +++ b/lib/screens/metering/screen_metering.dart @@ -5,6 +5,8 @@ import 'package:lightmeter/res/dimens.dart'; import 'package:lightmeter/screens/settings/screen_settings.dart'; import 'components/bottom_controls/widget_bottom_controls.dart'; +import 'components/camera/widget_exposure_slider.dart'; +import 'components/camera/widget_zoom_camera.dart'; import 'components/exposure_pairs_list/widget_list_exposure_pairs.dart'; import 'components/topbar/widget_topbar.dart'; import 'bloc_metering.dart'; @@ -42,6 +44,19 @@ class _MeteringScreenState extends State { child: Row( children: [ Expanded(child: ExposurePairsList(state.exposurePairs)), + const SizedBox(width: Dimens.grid8), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Dimens.paddingM), + child: Column( + children: const [ + Expanded(child: CameraExposureSlider()), + SizedBox(height: Dimens.grid24), + CameraZoomSlider(), + ], + ), + ), + ), ], ), ), diff --git a/lib/utils/to_string_signed.dart b/lib/utils/to_string_signed.dart new file mode 100644 index 0000000..91936e7 --- /dev/null +++ b/lib/utils/to_string_signed.dart @@ -0,0 +1,9 @@ +extension SignedString on num { + String toStringSigned() { + if (this > 0) { + return "+${toString()}"; + } else { + return toString(); + } + } +}