From 12abf99e2f740c673e2ef84bce2ba97bcdaa4988 Mon Sep 17 00:00:00 2001 From: Vadim <44135514+vodemn@users.noreply.github.com> Date: Sun, 6 Oct 2024 20:27:33 +0200 Subject: [PATCH] separated `ExpandableSectionList` as widget --- .../components/widget_formula_input.dart | 166 ++++++++++++++++ .../widget_dialog_section_name.dart | 56 ++++++ .../widget_expandable_section_list_item.dart | 184 ++++++++++++++++++ .../widget_expandable_section_list.dart | 97 +++++++++ 4 files changed, 503 insertions(+) create mode 100644 lib/screens/settings/components/metering/components/films/components/widget_formula_input.dart create mode 100644 lib/screens/settings/components/shared/expandable_section_list/components/dialog_section_name/widget_dialog_section_name.dart create mode 100644 lib/screens/settings/components/shared/expandable_section_list/components/expandable_section_list_item/widget_expandable_section_list_item.dart create mode 100644 lib/screens/settings/components/shared/expandable_section_list/widget_expandable_section_list.dart diff --git a/lib/screens/settings/components/metering/components/films/components/widget_formula_input.dart b/lib/screens/settings/components/metering/components/films/components/widget_formula_input.dart new file mode 100644 index 0000000..b122e5a --- /dev/null +++ b/lib/screens/settings/components/metering/components/films/components/widget_formula_input.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lightmeter/res/dimens.dart'; + +class FormulaInput extends StatefulWidget { + const FormulaInput({super.key}); + + @override + State createState() => _FormulaInputState(); +} + +class _FormulaInputState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Column( + children: [ + TextFormField( + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp('x')), + ], + ), + const Spacer(), + Row( + children: [ + _Button( + title: '()', + onTap: () {}, + ), + _buttonsDivider, + _Button( + title: 'ln', + onTap: () {}, + ), + _buttonsDivider, + _Button( + title: 'lg', + onTap: () {}, + ), + _buttonsDivider, + _Button( + title: '^', + onTap: () {}, + ), + ], + ), + Row( + children: [ + _Button( + title: '7', + onTap: () {}, + ), + _buttonsDivider, + _Button( + title: '8', + onTap: () {}, + ), + _buttonsDivider, + _Button( + title: '9', + onTap: () {}, + ), + _buttonsDivider, + _Button( + title: '^', + onTap: () {}, + ), + ], + ), + Row( + children: [ + _Button( + title: '4', + onTap: () {}, + ), + _buttonsDivider, + _Button( + title: '5', + onTap: () {}, + ), + _buttonsDivider, + _Button( + title: '6', + onTap: () {}, + ), + _buttonsDivider, + _Button( + title: '^', + onTap: () {}, + ), + ], + ), + Row( + children: [ + _Button( + title: '1', + onTap: () {}, + ), + _buttonsDivider, + _Button( + title: '2', + onTap: () {}, + ), + _buttonsDivider, + _Button( + title: '3', + onTap: () {}, + ), + _buttonsDivider, + _Button( + title: '^', + onTap: () {}, + ), + ], + ), + Row( + children: [ + _Button( + title: '0', + onTap: () {}, + ), + _Button( + title: '.', + onTap: () {}, + ), + _Button( + title: '<', + onTap: () {}, + ), + _Button( + title: '^', + onTap: () {}, + ), + ], + ) + ], + ), + ), + ); + } +} + +class _Button extends StatelessWidget { + final String title; + final VoidCallback onTap; + + const _Button({super.key, required this.title, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + alignment: Alignment.center, + height: Dimens.grid48, + decoration: ShapeDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + shape: const StadiumBorder(), + ), + child: Text(title), + ), + ); + } +} + +const _buttonsDivider = SizedBox(width: Dimens.grid8, height: Dimens.grid8); diff --git a/lib/screens/settings/components/shared/expandable_section_list/components/dialog_section_name/widget_dialog_section_name.dart b/lib/screens/settings/components/shared/expandable_section_list/components/dialog_section_name/widget_dialog_section_name.dart new file mode 100644 index 0000000..4fa2edc --- /dev/null +++ b/lib/screens/settings/components/shared/expandable_section_list/components/dialog_section_name/widget_dialog_section_name.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/res/dimens.dart'; + +class ExpandableSectionNameDialog extends StatefulWidget { + final String title; + final String hint; + final String initialValue; + + const ExpandableSectionNameDialog({ + this.initialValue = '', + required this.title, + required this.hint, + super.key, + }); + + @override + State createState() => _ExpandableSectionNameDialogState(); +} + +class _ExpandableSectionNameDialogState extends State { + late final _nameController = TextEditingController(text: widget.initialValue); + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + icon: const Icon(Icons.edit_outlined), + titlePadding: Dimens.dialogIconTitlePadding, + title: Text(widget.title), + content: TextField( + autofocus: true, + controller: _nameController, + decoration: InputDecoration(hintText: widget.hint), + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text(S.of(context).cancel), + ), + ValueListenableBuilder( + valueListenable: _nameController, + builder: (_, value, __) => TextButton( + onPressed: value.text.isNotEmpty ? () => Navigator.of(context).pop(value.text) : null, + child: Text(S.of(context).save), + ), + ), + ], + ); + } +} diff --git a/lib/screens/settings/components/shared/expandable_section_list/components/expandable_section_list_item/widget_expandable_section_list_item.dart b/lib/screens/settings/components/shared/expandable_section_list/components/expandable_section_list_item/widget_expandable_section_list_item.dart new file mode 100644 index 0000000..28e1926 --- /dev/null +++ b/lib/screens/settings/components/shared/expandable_section_list/components/expandable_section_list_item/widget_expandable_section_list_item.dart @@ -0,0 +1,184 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/res/dimens.dart'; + +class ExpandableSectionListItem extends StatefulWidget { + final String title; + final VoidCallback onTitleTap; + final VoidCallback onExpand; + final List actions; + final List children; + + const ExpandableSectionListItem({ + required this.title, + required this.onTitleTap, + required this.onExpand, + required this.actions, + required this.children, + super.key, + }); + + static ExpandableSectionListItemState of(BuildContext context) { + return context.findAncestorStateOfType()!; + } + + @override + State createState() => ExpandableSectionListItemState(); +} + +class ExpandableSectionListItemState extends State with TickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + duration: Dimens.durationM, + vsync: this, + ); + bool get _expanded => _controller.isCompleted; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Dimens.paddingM), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM), + title: Row( + children: [ + _AnimatedNameLeading(controller: _controller), + const SizedBox(width: Dimens.grid8), + Flexible( + child: Text( + widget.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + trailing: _AnimatedArrowButton( + controller: _controller, + onPressed: () => _expanded ? collapse() : expand(), + ), + onTap: () => _expanded ? widget.onTitleTap() : expand(), + ), + _AnimatedContent( + controller: _controller, + actions: widget.actions, + children: widget.children, + ), + ], + ), + ), + ); + } + + void expand() { + widget.onExpand(); + _controller.forward(); + SchedulerBinding.instance.addPostFrameCallback((_) { + Future.delayed(_controller.duration!).then((_) { + Scrollable.ensureVisible( + context, + alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, + duration: _controller.duration!, + ); + }); + }); + } + + void collapse() { + _controller.reverse(); + } +} + +class _AnimatedNameLeading extends AnimatedWidget { + const _AnimatedNameLeading({required AnimationController controller}) : super(listenable: controller); + + Animation get _progress => listenable as Animation; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(right: _progress.value * Dimens.grid8), + child: Icon( + Icons.edit_outlined, + size: _progress.value * Dimens.grid24, + ), + ); + } +} + +class _AnimatedArrowButton extends AnimatedWidget { + final VoidCallback onPressed; + + const _AnimatedArrowButton({ + required AnimationController controller, + required this.onPressed, + }) : super(listenable: controller); + + Animation get _progress => listenable as Animation; + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: onPressed, + icon: Transform.rotate( + angle: _progress.value * pi, + child: const Icon(Icons.keyboard_arrow_down_outlined), + ), + tooltip: _progress.value == 0 ? S.of(context).tooltipExpand : S.of(context).tooltipCollapse, + ); + } +} + +class _AnimatedContent extends AnimatedWidget { + final List actions; + final List children; + + const _AnimatedContent({ + required AnimationController controller, + required this.actions, + required this.children, + }) : super(listenable: controller); + + Animation get _progress => listenable as Animation; + + @override + Widget build(BuildContext context) { + return SizedOverflowBox( + alignment: Alignment.topCenter, + size: Size( + double.maxFinite, + _progress.value * Dimens.grid56 * (children.length + 1), + ), + // https://github.com/gskinnerTeam/flutter-folio/pull/62 + child: Opacity( + opacity: _progress.value, + child: Column( + children: [ + ...children, + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM), + trailing: Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: actions, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/settings/components/shared/expandable_section_list/widget_expandable_section_list.dart b/lib/screens/settings/components/shared/expandable_section_list/widget_expandable_section_list.dart new file mode 100644 index 0000000..f360fda --- /dev/null +++ b/lib/screens/settings/components/shared/expandable_section_list/widget_expandable_section_list.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/settings/components/shared/expandable_section_list/components/expandable_section_list_item/widget_expandable_section_list_item.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +typedef _WidgetBuilder = W Function(BuildContext context, T value); + +class ExpandableSectionList extends StatefulWidget { + final List values; + final VoidCallback onSectionTitleTap; + final ExpandableSectionListItem Function(BuildContext context, int index) builder; + final _WidgetBuilder, T> contentBuilder; + final _WidgetBuilder, T> actionsBuilder; + + const ExpandableSectionList({ + required this.builder, + required this.values, + required this.onSectionTitleTap, + required this.contentBuilder, + required this.actionsBuilder, + super.key, + }); + + @override + State createState() => _ExpandableSectionListState(); +} + +class _ExpandableSectionListState extends State { + final Map> keysMap = {}; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _updateProfilesKeys(); + } + + @override + Widget build(BuildContext context) { + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final item = widget.values[index]; + return Padding( + padding: EdgeInsets.fromLTRB( + Dimens.paddingM, + index == 0 ? Dimens.paddingM : 0, + Dimens.paddingM, + Dimens.paddingM, + ), + child: ExpandableSectionListItem( + key: keysMap[item.id], + title: item.name, + onTitleTap: widget.onSectionTitleTap, + onExpand: () => _keepExpandedAt(index), + actions: widget.actionsBuilder(context, item), + children: widget.contentBuilder(context, item), + ), + ); + }, + childCount: widget.values.length, + ), + ); + } + + void _keepExpandedAt(int index) { + keysMap.values.toList().getRange(0, index).forEach((element) { + element.currentState?.collapse(); + }); + keysMap.values.toList().getRange(index + 1, keysMap.length).forEach((element) { + element.currentState?.collapse(); + }); + } + + void _updateProfilesKeys() { + if (widget.values.length > keysMap.length) { + // item added + final List idsToAdd = []; + for (final item in widget.values) { + if (!keysMap.keys.contains(item.id)) idsToAdd.add(item.id); + } + for (final id in idsToAdd) { + keysMap[id] = GlobalKey(debugLabel: id); + } + idsToAdd.clear(); + } else if (widget.values.length < keysMap.length) { + // item deleted + final List idsToDelete = []; + for (final id in keysMap.keys) { + if (!widget.values.any((p) => p.id == id)) idsToDelete.add(id); + } + idsToDelete.forEach(keysMap.remove); + idsToDelete.clear(); + } else { + // item updated, no need to updated keys + } + } +}