Implemented metering dialog picker

typo

fixed closed container scale

fixed dialog title animation

implemented `AnimatedDialog`

added open/close children transition

clean up

close dialog
This commit is contained in:
Vadim 2022-12-01 23:49:06 +03:00
parent 7c20bfe80d
commit 00c0a6134d
12 changed files with 528 additions and 289 deletions

View file

@ -23,8 +23,10 @@ class MessageLookup extends MessageLookupByLibrary {
final messages = _notInlinedMessages(_notInlinedMessages);
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
"caffeine": MessageLookupByLibrary.simpleMessage("Caffeine"),
"cancel": MessageLookupByLibrary.simpleMessage("Cancel"),
"fastestExposurePair": MessageLookupByLibrary.simpleMessage("Fastest"),
"haptics": MessageLookupByLibrary.simpleMessage("Haptics"),
"iso": MessageLookupByLibrary.simpleMessage("ISO"),
"keepsScreenOn":
MessageLookupByLibrary.simpleMessage("Keeps screen on"),
"openSettings": MessageLookupByLibrary.simpleMessage("Open settings"),
@ -32,6 +34,7 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage("Permission needed"),
"permissionNeededMessage": MessageLookupByLibrary.simpleMessage(
"To use Lightmeter, turn on Camera permissions."),
"select": MessageLookupByLibrary.simpleMessage("Select"),
"settings": MessageLookupByLibrary.simpleMessage("Settings"),
"slowestExposurePair": MessageLookupByLibrary.simpleMessage("Slowest")
};

View file

@ -100,6 +100,36 @@ class S {
);
}
/// `ISO`
String get iso {
return Intl.message(
'ISO',
name: 'iso',
desc: '',
args: [],
);
}
/// `Cancel`
String get cancel {
return Intl.message(
'Cancel',
name: 'cancel',
desc: '',
args: [],
);
}
/// `Select`
String get select {
return Intl.message(
'Select',
name: 'select',
desc: '',
args: [],
);
}
/// `Settings`
String get settings {
return Intl.message(

View file

@ -5,6 +5,9 @@
"openSettings": "Open settings",
"fastestExposurePair": "Fastest",
"slowestExposurePair": "Slowest",
"iso": "ISO",
"cancel": "Cancel",
"select": "Select",
"settings": "Settings",
"caffeine": "Caffeine",
"keepsScreenOn": "Keeps screen on",

View file

@ -13,7 +13,6 @@ import 'res/dimens.dart';
import 'res/theme.dart';
import 'screens/metering/metering_bloc.dart';
import 'screens/metering/metering_screen.dart';
import 'screens/permissions_check/flow_permissions_check.dart';
import 'utils/stop_type_provider.dart';
void main() {

View file

@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/res/dimens.dart';
class MeteringScreenDialogPicker<T> extends StatefulWidget {
final String title;
final T initialValue;
final List<T> values;
final Widget Function(BuildContext context, T value) itemTitleBuilder;
final VoidCallback onCancel;
final ValueChanged onSelect;
const MeteringScreenDialogPicker({
required this.title,
required this.initialValue,
required this.values,
required this.itemTitleBuilder,
required this.onCancel,
required this.onSelect,
super.key,
});
@override
State<MeteringScreenDialogPicker<T>> createState() => _MeteringScreenDialogPickerState<T>();
}
class _MeteringScreenDialogPickerState<T> extends State<MeteringScreenDialogPicker<T>> {
late T _selectedValue = widget.initialValue;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
Dimens.paddingL,
Dimens.paddingL,
Dimens.paddingL,
Dimens.paddingM,
),
child: Text(
widget.title,
style: Theme.of(context).textTheme.headlineSmall!,
),
),
Divider(
color: Theme.of(context).colorScheme.onPrimaryContainer,
height: 0,
),
Expanded(
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: widget.values.length,
itemBuilder: (context, index) => RadioListTile(
value: widget.values[index],
groupValue: _selectedValue,
title: widget.itemTitleBuilder(context, widget.values[index]),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedValue = value;
});
}
},
),
),
),
Divider(
color: Theme.of(context).colorScheme.onPrimaryContainer,
height: 0,
),
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),
),
],
),
),
],
);
}
}

View file

@ -1,212 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:lightmeter/res/dimens.dart';
class ReadingValue {
final String label;
final String value;
const ReadingValue({
required this.label,
required this.value,
});
}
class ReadingContainerWithDialog extends StatefulWidget {
final ReadingValue value;
final Widget Function(BuildContext context) dialogBuilder;
const ReadingContainerWithDialog({
required this.value,
required this.dialogBuilder,
super.key,
});
@override
State<ReadingContainerWithDialog> createState() => _ReadingContainerWithDialogState();
}
class _ReadingContainerWithDialogState extends State<ReadingContainerWithDialog> with SingleTickerProviderStateMixin {
final GlobalKey _key = GlobalKey();
final LayerLink _layerLink = LayerLink();
OverlayEntry? _overlayEntry;
late final _animationController = AnimationController(
duration: Dimens.durationL,
reverseDuration: Dimens.durationML,
vsync: this,
);
late final _defaultCurve = CurvedAnimation(parent: _animationController, curve: Curves.linear);
late final _colorAnimation = ColorTween(
begin: Colors.transparent,
end: Colors.black54,
).animate(_defaultCurve);
late final _borderRadiusAnimation = Tween<double>(
begin: Dimens.borderRadiusM,
end: Dimens.borderRadiusXL,
).animate(_defaultCurve);
late final _itemOpacityAnimation = Tween<double>(
begin: 1,
end: 0,
).animate(CurvedAnimation(
parent: _animationController,
curve: const Interval(0, 0.5, curve: Curves.linear),
));
late final _dialogOpacityAnimation = Tween<double>(
begin: 0,
end: 1,
).animate(CurvedAnimation(
parent: _animationController,
curve: const Interval(0.5, 1.0, curve: Curves.linear),
));
late final SizeTween _sizeTween;
late final Animation<Size?> _sizeAnimation;
late final Animation<Size?> _offsetAnimation;
@override
void initState() {
super.initState();
//timeDilation = 5.0;
WidgetsBinding.instance.addPostFrameCallback((_) {
final mediaQuery = MediaQuery.of(context);
final itemWidth = _key.currentContext!.size!.width;
final itemHeight = _key.currentContext!.size!.height;
_sizeTween = SizeTween(
begin: Size(
itemWidth,
itemHeight,
),
end: Size(
mediaQuery.size.width - mediaQuery.padding.horizontal - Dimens.paddingL * 2,
mediaQuery.size.height - mediaQuery.padding.vertical - Dimens.paddingL * 2,
),
);
_sizeAnimation = _sizeTween.animate(_defaultCurve);
final renderBox = _key.currentContext!.findRenderObject() as RenderBox;
final offset = renderBox.localToGlobal(Offset.zero);
_offsetAnimation = SizeTween(
begin: Size(
offset.dx + itemWidth / 2,
offset.dy + itemHeight / 2,
),
end: Size(
mediaQuery.size.width / 2,
mediaQuery.size.height / 2 + mediaQuery.padding.top / 2 - mediaQuery.padding.bottom / 2,
),
).animate(_defaultCurve);
});
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return InkWell(
key: _key,
onTap: _openDialog,
child: CompositedTransformTarget(
link: _layerLink,
child: ClipRRect(
borderRadius: BorderRadius.circular(Dimens.borderRadiusM),
child: ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(Dimens.paddingM),
child: _ReadingValueBuilder(widget.value),
),
),
),
),
);
}
void _openDialog() {
final RenderBox renderBox = _key.currentContext!.findRenderObject() as RenderBox;
final offset = renderBox.localToGlobal(Offset.zero);
_overlayEntry = OverlayEntry(
builder: (context) => CompositedTransformFollower(
offset: Offset(-offset.dx, -offset.dy),
link: _layerLink,
showWhenUnlinked: false,
child: SizedBox.fromSize(
size: MediaQuery.of(context).size,
child: AnimatedBuilder(
animation: _animationController,
builder: (context, _) => Stack(
children: [
Positioned.fill(
child: GestureDetector(
onTap: _closeDialog,
child: ColoredBox(color: _colorAnimation.value!),
),
),
Positioned.fromRect(
rect: Rect.fromCenter(
center: Offset(
_offsetAnimation.value!.width,
_offsetAnimation.value!.height,
),
width: _sizeAnimation.value!.width,
height: _sizeAnimation.value!.height,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(_borderRadiusAnimation.value),
child: ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
child: Center(
child: Stack(
children: [
Opacity(
opacity: _itemOpacityAnimation.value,
child: Transform.scale(
scale: _sizeAnimation.value!.width / _sizeTween.begin!.width,
child: SizedBox(
width: _sizeTween.begin!.width,
child: Padding(
padding: const EdgeInsets.all(Dimens.paddingM),
child: _ReadingValueBuilder(widget.value),
),
),
),
),
Opacity(
opacity: _dialogOpacityAnimation.value,
child: Transform.scale(
scale: _sizeAnimation.value!.width / _sizeTween.end!.width,
child: widget.dialogBuilder(context),
),
),
],
),
),
),
),
),
],
),
),
),
),
);
Overlay.of(context)?.insert(_overlayEntry!);
_animationController.forward();
}
void _closeDialog() {
_animationController.reverse();
Future.delayed(_animationController.reverseDuration! * timeDilation).then((_) {
_overlayEntry?.remove();
});
}
}
import 'package:lightmeter/screens/metering/components/topbar/models/reading_value.dart';
class ReadingContainer extends StatelessWidget {
final List<_ReadingValueBuilder> _items;

View file

@ -0,0 +1,283 @@
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:lightmeter/res/dimens.dart';
class AnimatedDialog extends StatefulWidget {
final Size? openedSize;
final Widget? closedChild;
final Widget? openedChild;
final Widget? child;
const AnimatedDialog({
this.openedSize,
this.closedChild,
this.openedChild,
this.child,
super.key,
});
@override
State<AnimatedDialog> createState() => AnimatedDialogState();
}
class AnimatedDialogState extends State<AnimatedDialog> with SingleTickerProviderStateMixin {
final GlobalKey _key = GlobalKey();
final LayerLink _layerLink = LayerLink();
late final Size _closedSize;
late final Offset _closedOffset;
late final AnimationController _animationController;
late final CurvedAnimation _defaultCurvedAnimation;
late final Animation<Color?> _barrierColorAnimation;
late final SizeTween _sizeTween;
late final Animation<Size?> _sizeAnimation;
late final Animation<Size?> _offsetAnimation;
late final Animation<double> _borderRadiusAnimation;
late final Animation<double> _closedOpacityAnimation;
late final Animation<double> _openedOpacityAnimation;
OverlayEntry? _overlayEntry;
@override
void initState() {
super.initState();
//timeDilation = 10.0;
_animationController = AnimationController(
duration: Dimens.durationL,
reverseDuration: Dimens.durationML,
vsync: this,
);
_defaultCurvedAnimation = CurvedAnimation(parent: _animationController, curve: Curves.easeInOut);
_barrierColorAnimation = ColorTween(
begin: Colors.transparent,
end: Colors.black54,
).animate(_defaultCurvedAnimation);
_borderRadiusAnimation = Tween<double>(
begin: Dimens.borderRadiusM,
end: Dimens.borderRadiusXL,
).animate(_defaultCurvedAnimation);
_closedOpacityAnimation = Tween<double>(
begin: 1,
end: 0,
).animate(CurvedAnimation(
parent: _animationController,
curve: const Interval(
0,
0.8,
curve: Curves.ease,
),
));
_openedOpacityAnimation = Tween<double>(
begin: 0,
end: 1,
).animate(CurvedAnimation(
parent: _animationController,
curve: const Interval(
0.8,
1.0,
curve: Curves.easeInOut,
),
));
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
final mediaQuery = MediaQuery.of(context);
_closedSize = _key.currentContext!.size!;
_sizeTween = SizeTween(
begin: _closedSize,
end: widget.openedSize ??
Size(
mediaQuery.size.width - mediaQuery.padding.horizontal - Dimens.paddingM * 4,
mediaQuery.size.height - mediaQuery.padding.vertical - Dimens.paddingM * 4,
),
);
_sizeAnimation = _sizeTween.animate(_defaultCurvedAnimation);
final renderBox = _key.currentContext!.findRenderObject() as RenderBox;
_closedOffset = renderBox.localToGlobal(Offset.zero);
_offsetAnimation = SizeTween(
begin: Size(
_closedOffset.dx + _closedSize.width / 2,
_closedOffset.dy + _closedSize.height / 2,
),
end: Size(
mediaQuery.size.width / 2,
mediaQuery.size.height / 2 + mediaQuery.padding.top / 2 - mediaQuery.padding.bottom / 2,
),
).animate(_defaultCurvedAnimation);
});
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return InkWell(
key: _key,
onTap: _openDialog,
child: CompositedTransformTarget(
link: _layerLink,
child: Opacity(
opacity: _overlayEntry != null ? 0 : 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(Dimens.borderRadiusM),
child: ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
child: widget.child ?? widget.closedChild,
),
),
),
),
);
}
void _openDialog() {
setState(() {
_overlayEntry = OverlayEntry(
builder: (context) => CompositedTransformFollower(
offset: Offset(-_closedOffset.dx, -_closedOffset.dy),
link: _layerLink,
showWhenUnlinked: false,
child: _AnimatedOverlay(
controller: _animationController,
barrierColorAnimation: _barrierColorAnimation,
sizeAnimation: _sizeAnimation,
offsetAnimation: _offsetAnimation,
borderRadiusAnimation: _borderRadiusAnimation,
onDismiss: close,
builder: widget.closedChild != null && widget.openedChild != null
? (_) => _AnimatedSwitcher(
sizeAnimation: _sizeAnimation,
closedOpacityAnimation: _closedOpacityAnimation,
openedOpacityAnimation: _openedOpacityAnimation,
closedSize: _sizeTween.begin!,
openedSize: _sizeTween.end!,
closedChild: widget.closedChild!,
openedChild: widget.openedChild!,
)
: null,
child: widget.child,
),
),
);
});
Overlay.of(context)?.insert(_overlayEntry!);
_animationController.forward();
}
Future<void> close() async {
_animationController.reverse();
await Future.delayed(_animationController.reverseDuration! * timeDilation);
_overlayEntry?.remove();
setState(() {
_overlayEntry = null;
});
}
}
class _AnimatedOverlay extends StatelessWidget {
final AnimationController controller;
final Animation<Color?> barrierColorAnimation;
final Animation<Size?> sizeAnimation;
final Animation<Size?> offsetAnimation;
final Animation<double> borderRadiusAnimation;
final VoidCallback onDismiss;
final Widget? child;
final Widget Function(BuildContext context)? builder;
const _AnimatedOverlay({
required this.controller,
required this.barrierColorAnimation,
required this.sizeAnimation,
required this.offsetAnimation,
required this.borderRadiusAnimation,
required this.onDismiss,
this.child,
this.builder,
});
@override
Widget build(BuildContext context) {
return SizedBox.fromSize(
size: MediaQuery.of(context).size,
child: AnimatedBuilder(
animation: controller,
builder: (context, _) => Stack(
children: [
Positioned.fill(
child: GestureDetector(
onTap: onDismiss,
child: ColoredBox(color: barrierColorAnimation.value!),
),
),
Positioned.fromRect(
rect: Rect.fromCenter(
center: Offset(
offsetAnimation.value!.width,
offsetAnimation.value!.height,
),
width: sizeAnimation.value!.width,
height: sizeAnimation.value!.height,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(borderRadiusAnimation.value),
child: Material(
color: Theme.of(context).colorScheme.primaryContainer,
child: builder?.call(context) ?? child,
),
),
),
],
),
),
);
}
}
class _AnimatedSwitcher extends StatelessWidget {
final Animation<Size?> sizeAnimation;
final Animation<double> closedOpacityAnimation;
final Animation<double> openedOpacityAnimation;
final Size closedSize;
final Size openedSize;
final Widget closedChild;
final Widget openedChild;
const _AnimatedSwitcher({
required this.sizeAnimation,
required this.closedOpacityAnimation,
required this.openedOpacityAnimation,
required this.closedSize,
required this.openedSize,
required this.closedChild,
required this.openedChild,
});
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
Opacity(
opacity: closedOpacityAnimation.value,
child: Transform.scale(
scale: sizeAnimation.value!.width / closedSize.width,
child: SizedBox(
width: closedSize.width,
child: closedChild,
),
),
),
Opacity(
opacity: openedOpacityAnimation.value,
child: openedChild,
),
],
);
}
}

View file

@ -0,0 +1,9 @@
class ReadingValue {
final String label;
final String value;
const ReadingValue({
required this.label,
required this.value,
});
}

View file

@ -1,20 +1,25 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/models/exposure_pair.dart';
import 'package:lightmeter/models/photography_value.dart';
import 'package:lightmeter/res/dimens.dart';
import 'components/shared/animated_dialog.dart';
import 'components/dialog_picker.dart';
import 'components/reading_container.dart';
import 'models/reading_value.dart';
class MeteringTopBar extends StatelessWidget {
static const _columnsCount = 3;
final _isoDialogKey = GlobalKey<AnimatedDialogState>();
final ExposurePair? fastest;
final ExposurePair? slowest;
final double ev;
final int iso;
final IsoValue iso;
final double nd;
const MeteringTopBar({
MeteringTopBar({
required this.fastest,
required this.slowest,
required this.ev,
@ -38,6 +43,8 @@ class MeteringTopBar extends StatelessWidget {
padding: const EdgeInsets.all(Dimens.paddingM),
child: SafeArea(
bottom: false,
child: MediaQuery(
data: MediaQuery.of(context),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
@ -79,14 +86,30 @@ class MeteringTopBar extends StatelessWidget {
const _InnerPadding(),
SizedBox(
width: columnWidth,
child: MediaQuery(
data: MediaQuery.of(context),
child: ReadingContainerWithDialog(
child: AnimatedDialog(
key: _isoDialogKey,
closedChild: ReadingContainer.singleValue(
value: ReadingValue(
label: 'ISO',
value: iso.toString(),
label: S.of(context).iso,
value: iso.value.toString(),
),
dialogBuilder: (context) => SizedBox(),
),
openedChild: MeteringScreenDialogPicker(
title: S.of(context).iso,
initialValue: iso,
values: isoValues,
itemTitleBuilder: (context, value) => Text(
value.value.toString(),
style: value.stopType == StopType.full
? Theme.of(context).textTheme.bodyLarge
: Theme.of(context).textTheme.bodySmall,
),
onCancel: () {
_isoDialogKey.currentState?.close();
},
onSelect: (value) {
_isoDialogKey.currentState?.close();
},
),
),
),
@ -101,23 +124,22 @@ class MeteringTopBar extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(Dimens.borderRadiusM),
AnimatedDialog(
openedSize: Size(
MediaQuery.of(context).size.width - Dimens.paddingM * 2,
(MediaQuery.of(context).size.width - Dimens.paddingM * 2) / 3 * 4,
),
child: const AspectRatio(
aspectRatio: 3 / 4,
child: ColoredBox(color: Colors.black),
),
),
const _InnerPadding(),
MediaQuery(
data: MediaQuery.of(context),
child: ReadingContainerWithDialog(
ReadingContainer.singleValue(
value: ReadingValue(
label: 'ND',
value: nd.toString(),
),
dialogBuilder: (context) => SizedBox(),
),
),
],
),
@ -127,6 +149,7 @@ class MeteringTopBar extends StatelessWidget {
),
),
),
),
);
}
}

View file

@ -16,8 +16,8 @@ class MeteringBloc extends Bloc<MeteringEvent, MeteringState> {
MeteringBloc(this.stopType)
: super(
const MeteringState(
iso: 100,
MeteringState(
iso: isoValues.where((element) => element.value == 100).first,
ev: 21.3,
evCompensation: 0.0,
nd: 0.0,
@ -38,7 +38,7 @@ class MeteringBloc extends Bloc<MeteringEvent, MeteringState> {
final iso = _isoValues[_random.nextInt(_isoValues.thirdStops().length)];
final evAtSystemIso = log2(pow(aperture.value, 2).toDouble()) - log2(shutterSpeed.value);
final ev = evAtSystemIso - log2(iso.value / state.iso);
final ev = evAtSystemIso - log2(iso.value / state.iso.value);
final exposurePairs = _buildExposureValues(ev);
emit(MeteringState(

View file

@ -1,9 +1,10 @@
import 'package:lightmeter/models/exposure_pair.dart';
import 'package:lightmeter/models/photography_value.dart';
class MeteringState {
final double ev;
final double evCompensation;
final int iso;
final IsoValue iso;
final double nd;
final List<ExposurePair> exposurePairs;

View file

@ -2,7 +2,6 @@ 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/settings_screen.dart';
import 'bloc_permissions_check.dart';
import 'state_permissions_check.dart';