Added zoom and exposure sliders

added `ZoomSlider` (wip)

wip

implemented horizontal slider

implemented vertical slider

integrated 2 sliders to metering screen (wip)

removed filled track

placed camera slider in the proper folder

temp fix for exposure list fonts

update exposure event

added ruler to `CameraExposureSlider`

moved slider sizes to dimens

moved `CameraZoomSlider` to the separate folder
This commit is contained in:
Vadim 2022-12-21 22:31:22 +03:00
parent 14bac950cf
commit abd07764fd
12 changed files with 420 additions and 12 deletions

View file

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

View file

@ -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<double> 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<CameraSlider> createState() => _CameraSliderState();
}
class _CameraSliderState extends State<CameraSlider> {
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,
),
),
);
}
}

View file

@ -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<CameraBloc, CameraState>(
builder: (context, state) {
if (state is CameraActiveState) {
return Column(
children: [
IconButton(
icon: const Icon(Icons.sync),
onPressed: state.currentExposureOffset != 0.0
? () => context.read<CameraBloc>().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<CameraBloc>().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),
],
);
},
),
);
}
}

View file

@ -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<CameraBloc, CameraState>(
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<CameraBloc>().add(ZoomChangedEvent(value));
},
);
}
return const SizedBox();
},
);
}
}

View file

@ -23,7 +23,7 @@ class ExposurePairsListItem<T extends PhotographyStopValue> 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<T extends PhotographyStopValue> 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;
}

View file

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

View file

@ -13,14 +13,14 @@ class CameraView extends StatelessWidget {
return AspectRatio(
aspectRatio: 3 / 4,
child: BlocBuilder<CameraBloc, CameraState>(
buildWhen: (previous, current) => current is CameraInitializedState,
builder: (context, state) {
if (state is CameraReadyState) {
if (state is CameraInitializedState) {
final value = state.controller.value;
return ValueListenableBuilder<CameraValue>(
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(),

View file

@ -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<CameraEvent, CameraState> {
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<CameraEvent, CameraState> {
WidgetsBinding.instance.addObserver(_observer);
on<InitializeEvent>(_onInitialize);
on<ZoomChangedEvent>(_onZoomChanged);
on<ExposureOffsetChangedEvent>(_onExposureOffsetChanged);
add(const InitializeEvent());
}
@ -66,7 +77,24 @@ class CameraBloc extends EvSourceBloc<CameraEvent, CameraState> {
await _cameraController!.initialize();
await _cameraController!.setFlashMode(FlashMode.off);
emit(CameraReadyState(_cameraController!));
_zoomRange = await Future.wait<double>([
_cameraController!.getMinZoomLevel(),
_cameraController!.getMaxZoomLevel(),
]).then((levels) => RangeValues(levels[0], min(_maxZoom, levels[1])));
_currentZoom = _zoomRange!.start;
_exposureOffsetRange = await Future.wait<double>([
_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<CameraEvent, CameraState> {
}
}
Future<void> _onZoomChanged(ZoomChangedEvent event, Emitter emit) async {
_cameraController!.setZoomLevel(event.value);
_currentZoom = event.value;
_emitActiveState(emit);
}
Future<void> _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<double?> _takePhoto() async {
if (_cameraController == null ||
!_cameraController!.value.isInitialized ||

View file

@ -4,4 +4,16 @@ abstract class CameraEvent {
class InitializeEvent extends CameraEvent {
const InitializeEvent();
}
}
class ZoomChangedEvent extends CameraEvent {
final double value;
const ZoomChangedEvent(this.value);
}
class ExposureOffsetChangedEvent extends CameraEvent {
final double value;
const ExposureOffsetChangedEvent(this.value);
}

View file

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

View file

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

View file

@ -0,0 +1,9 @@
extension SignedString on num {
String toStringSigned() {
if (this > 0) {
return "+${toString()}";
} else {
return toString();
}
}
}