diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index 1c1cacd..a06fefc 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -25,11 +25,14 @@ class MessageLookup extends MessageLookupByLibrary { "caffeine": MessageLookupByLibrary.simpleMessage("Caffeine"), "cancel": MessageLookupByLibrary.simpleMessage("Cancel"), "fastestExposurePair": MessageLookupByLibrary.simpleMessage("Fastest"), + "filmSpeed": MessageLookupByLibrary.simpleMessage("Film speed"), "haptics": MessageLookupByLibrary.simpleMessage("Haptics"), "iso": MessageLookupByLibrary.simpleMessage("ISO"), "keepsScreenOn": MessageLookupByLibrary.simpleMessage("Keeps screen on"), "nd": MessageLookupByLibrary.simpleMessage("ND"), + "ndFilterFactor": MessageLookupByLibrary.simpleMessage( + "Neutral density filter factor"), "none": MessageLookupByLibrary.simpleMessage("None"), "openSettings": MessageLookupByLibrary.simpleMessage("Open settings"), "permissionNeeded": diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index c7d7453..84b188e 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -110,6 +110,16 @@ class S { ); } + /// `Film speed` + String get filmSpeed { + return Intl.message( + 'Film speed', + name: 'filmSpeed', + desc: '', + args: [], + ); + } + /// `ND` String get nd { return Intl.message( @@ -120,6 +130,16 @@ class S { ); } + /// `Neutral density filter factor` + String get ndFilterFactor { + return Intl.message( + 'Neutral density filter factor', + name: 'ndFilterFactor', + desc: '', + args: [], + ); + } + /// `None` String get none { return Intl.message( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index adbf419..dcacbe8 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -6,7 +6,9 @@ "fastestExposurePair": "Fastest", "slowestExposurePair": "Slowest", "iso": "ISO", + "filmSpeed": "Film speed", "nd": "ND", + "ndFilterFactor": "Neutral density filter factor", "none": "None", "cancel": "Cancel", "select": "Select", diff --git a/lib/models/photography_value.dart b/lib/models/photography_value.dart index 59c8a07..45da5f9 100644 --- a/lib/models/photography_value.dart +++ b/lib/models/photography_value.dart @@ -1,14 +1,31 @@ +import 'dart:math'; + +import 'package:lightmeter/utils/log_2.dart'; + enum StopType { full, half, third } -abstract class PhotographyValue { +abstract class PhotographyValue { final T rawValue; const PhotographyValue(this.rawValue); T get value => rawValue; + + /// EV difference between `this` and `other` + double evDifference(PhotographyValue other) => log2(max(1, other.value) / max(1, value)); + + String toStringDifference(PhotographyValue other) { + final ev = log2(max(1, other.value) / max(1, value)); + final buffer = StringBuffer(); + if (ev > 0) { + buffer.write('+'); + } + buffer.write(ev.toStringAsFixed(1)); + return buffer.toString(); + } } -abstract class PhotographyStopValue extends PhotographyValue { +abstract class PhotographyStopValue extends PhotographyValue { final StopType stopType; const PhotographyStopValue(super.rawValue, this.stopType); diff --git a/lib/res/dimens.dart b/lib/res/dimens.dart index 3607a86..9d0ceed 100644 --- a/lib/res/dimens.dart +++ b/lib/res/dimens.dart @@ -9,6 +9,7 @@ class Dimens { static const double grid8 = 8; static const double grid16 = 16; static const double grid24 = 24; + static const double grid56 = 56; static const double grid168 = 168; static const double paddingM = 16; diff --git a/lib/screens/metering/components/topbar/components/dialog_picker.dart b/lib/screens/metering/components/topbar/components/dialog_picker.dart index 3401966..7b41e0a 100644 --- a/lib/screens/metering/components/topbar/components/dialog_picker.dart +++ b/lib/screens/metering/components/topbar/components/dialog_picker.dart @@ -1,20 +1,28 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/models/photography_value.dart'; import 'package:lightmeter/res/dimens.dart'; -class MeteringScreenDialogPicker extends StatefulWidget { +typedef DialogPickerItemBuilder = Widget Function(BuildContext, T); +typedef DialogPickerEvDifferenceBuilder = String Function(T selected, T other); + +class MeteringScreenDialogPicker extends StatefulWidget { final String title; + final String subtitle; final T initialValue; final List values; - final Widget Function(BuildContext context, T value) itemTitleBuilder; + final DialogPickerItemBuilder itemTitleBuilder; + final DialogPickerEvDifferenceBuilder evDifferenceBuilder; final VoidCallback onCancel; final ValueChanged onSelect; const MeteringScreenDialogPicker({ required this.title, + required this.subtitle, required this.initialValue, required this.values, required this.itemTitleBuilder, + required this.evDifferenceBuilder, required this.onCancel, required this.onSelect, super.key, @@ -24,34 +32,67 @@ class MeteringScreenDialogPicker extends StatefulWidget { State> createState() => _MeteringScreenDialogPickerState(); } -class _MeteringScreenDialogPickerState extends State> { +class _MeteringScreenDialogPickerState extends State> { late T _selectedValue = widget.initialValue; + final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollController.jumpTo(Dimens.grid56 * widget.values.indexOf(_selectedValue)); + }); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - Dimens.paddingL, - Dimens.paddingL, - Dimens.paddingL, - Dimens.paddingM, + ColoredBox( + color: Theme.of(context).colorScheme.primaryContainer, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + Dimens.paddingL, + Dimens.paddingL, + Dimens.paddingL, + Dimens.paddingM, + ), + child: Column( + children: [ + Text( + widget.title, + style: Theme.of(context).textTheme.headlineSmall!, + ), + const SizedBox(height: Dimens.grid16), + Text( + widget.subtitle, + style: Theme.of(context).textTheme.bodyMedium!, + ), + ], + ), + ), + Divider( + color: Theme.of(context).colorScheme.onPrimaryContainer, + height: 0, + ), + ], ), - child: Text( - widget.title, - style: Theme.of(context).textTheme.headlineSmall!, - ), - ), - Divider( - color: Theme.of(context).colorScheme.onPrimaryContainer, - height: 0, ), Expanded( child: ListView.builder( + controller: _scrollController, padding: EdgeInsets.zero, itemCount: widget.values.length, + itemExtent: Dimens.grid56, itemBuilder: (context, index) => RadioListTile( value: widget.values[index], groupValue: _selectedValue, @@ -59,6 +100,9 @@ class _MeteringScreenDialogPickerState extends State extends State widget.onSelect(_selectedValue), - child: Text(S.of(context).select), + Padding( + padding: const EdgeInsets.all(Dimens.paddingL), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.max, + children: [ + const Spacer(), + TextButton( + onPressed: widget.onCancel, + child: Text(S.of(context).cancel), + ), + const SizedBox(width: Dimens.grid16), + TextButton( + onPressed: () => widget.onSelect(_selectedValue), + child: Text(S.of(context).select), + ), + ], + ), ), ], ), diff --git a/lib/screens/metering/components/topbar/topbar.dart b/lib/screens/metering/components/topbar/topbar.dart index 3457c76..c342412 100644 --- a/lib/screens/metering/components/topbar/topbar.dart +++ b/lib/screens/metering/components/topbar/topbar.dart @@ -13,8 +13,6 @@ import 'models/reading_value.dart'; class MeteringTopBar extends StatelessWidget { static const _columnsCount = 3; - final _isoDialogKey = GlobalKey(); - final _ndDialogKey = GlobalKey(); final ExposurePair? fastest; final ExposurePair? slowest; @@ -24,7 +22,7 @@ class MeteringTopBar extends StatelessWidget { final ValueChanged onIsoChanged; final ValueChanged onNdChanged; - MeteringTopBar({ + const MeteringTopBar({ required this.fastest, required this.slowest, required this.ev, @@ -94,16 +92,13 @@ class MeteringTopBar extends StatelessWidget { SizedBox( width: columnWidth, child: _AnimatedDialogPicker( - key: _isoDialogKey, title: S.of(context).iso, + subtitle: S.of(context).filmSpeed, selectedValue: iso, values: isoValues, - itemTitleBuilder: (context, value) => Text( - value.value.toString(), - style: value.stopType == StopType.full - ? null // use default - : Theme.of(context).textTheme.bodySmall, - ), + itemTitleBuilder: (_, value) => Text(value.value.toString()), + // using ascending order, because increase in film speed rises EV + evDifferenceBuilder: (selected, other) => selected.toStringDifference(other), onChanged: onIsoChanged, ), ), @@ -130,13 +125,15 @@ class MeteringTopBar extends StatelessWidget { ), const _InnerPadding(), _AnimatedDialogPicker( - key: _ndDialogKey, title: S.of(context).nd, + subtitle: S.of(context).ndFilterFactor, selectedValue: nd, values: ndValues, - itemTitleBuilder: (context, value) => Text( + itemTitleBuilder: (_, value) => Text( value.value == 0 ? S.of(context).none : value.value.toString(), ), + // using descending order, because ND filter darkens image & lowers EV + evDifferenceBuilder: (selected, other) => other.toStringDifference(selected), onChanged: onNdChanged, ), ], @@ -156,33 +153,50 @@ class _InnerPadding extends SizedBox { const _InnerPadding() : super(height: Dimens.grid16, width: Dimens.grid16); } -class _AnimatedDialogPicker extends AnimatedDialog { +class _AnimatedDialogPicker extends StatelessWidget { + final _key = GlobalKey(); + final String title; + final String subtitle; + final T selectedValue; + final List values; + final DialogPickerItemBuilder itemTitleBuilder; + final DialogPickerEvDifferenceBuilder evDifferenceBuilder; + final ValueChanged onChanged; + _AnimatedDialogPicker({ - required GlobalKey key, - required String title, - required T selectedValue, - required List values, - required Widget Function(BuildContext, T) itemTitleBuilder, - required ValueChanged onChanged, - }) : super( - key: key, - closedChild: ReadingContainer.singleValue( - value: ReadingValue( - label: title, - value: selectedValue.value.toString(), - ), - ), - openedChild: MeteringScreenDialogPicker( - title: title, - initialValue: selectedValue, - values: values, - itemTitleBuilder: itemTitleBuilder, - onCancel: () { - key.currentState?.close(); - }, - onSelect: (value) { - key.currentState?.close().then((_) => onChanged(value)); - }, - ), - ); + required this.title, + required this.subtitle, + required this.selectedValue, + required this.values, + required this.itemTitleBuilder, + required this.evDifferenceBuilder, + required this.onChanged, + }) : super(); + + @override + Widget build(BuildContext context) { + return AnimatedDialog( + key: _key, + closedChild: ReadingContainer.singleValue( + value: ReadingValue( + label: title, + value: selectedValue.value.toString(), + ), + ), + openedChild: MeteringScreenDialogPicker( + title: title, + subtitle: subtitle, + initialValue: selectedValue, + values: values, + itemTitleBuilder: itemTitleBuilder, + evDifferenceBuilder: evDifferenceBuilder, + onCancel: () { + _key.currentState?.close(); + }, + onSelect: (value) { + _key.currentState?.close().then((_) => onChanged(value)); + }, + ), + ); + } } diff --git a/lib/screens/metering/metering_bloc.dart b/lib/screens/metering/metering_bloc.dart index 1ce65bb..49fa8c2 100644 --- a/lib/screens/metering/metering_bloc.dart +++ b/lib/screens/metering/metering_bloc.dart @@ -64,7 +64,7 @@ class MeteringBloc extends Bloc { final shutterSpeed = _shutterSpeedValues[_random.nextInt(_shutterSpeedValues.thirdStops().length)]; final iso = _isoValues[_random.nextInt(_isoValues.thirdStops().length)]; - final evAtSystemIso = log2(pow(aperture.value, 2).toDouble()) - log2(shutterSpeed.value); + final evAtSystemIso = log2(pow(aperture.value, 2).toDouble() / shutterSpeed.value); final ev = evAtSystemIso - log2(iso.value / state.iso.value); emit(MeteringState(