separated ExpandableSectionList as widget

This commit is contained in:
Vadim 2024-10-06 20:27:33 +02:00
parent 4498167ddc
commit 12abf99e2f
4 changed files with 503 additions and 0 deletions

View file

@ -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<FormulaInput> createState() => _FormulaInputState();
}
class _FormulaInputState extends State<FormulaInput> {
@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);

View file

@ -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<ExpandableSectionNameDialog> createState() => _ExpandableSectionNameDialogState();
}
class _ExpandableSectionNameDialogState extends State<ExpandableSectionNameDialog> {
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),
),
),
],
);
}
}

View file

@ -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<IconButton> actions;
final List<Widget> 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<ExpandableSectionListItemState>()!;
}
@override
State<ExpandableSectionListItem> createState() => ExpandableSectionListItemState();
}
class ExpandableSectionListItemState extends State<ExpandableSectionListItem> 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<double> get _progress => listenable as Animation<double>;
@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<double> get _progress => listenable as Animation<double>;
@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<IconButton> actions;
final List<Widget> children;
const _AnimatedContent({
required AnimationController controller,
required this.actions,
required this.children,
}) : super(listenable: controller);
Animation<double> get _progress => listenable as Animation<double>;
@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,
),
),
],
),
),
);
}
}

View file

@ -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, T extends Identifiable> = W Function(BuildContext context, T value);
class ExpandableSectionList<T extends Identifiable> extends StatefulWidget {
final List<T> values;
final VoidCallback onSectionTitleTap;
final ExpandableSectionListItem Function(BuildContext context, int index) builder;
final _WidgetBuilder<List<Widget>, T> contentBuilder;
final _WidgetBuilder<List<IconButton>, T> actionsBuilder;
const ExpandableSectionList({
required this.builder,
required this.values,
required this.onSectionTitleTap,
required this.contentBuilder,
required this.actionsBuilder,
super.key,
});
@override
State<ExpandableSectionList> createState() => _ExpandableSectionListState();
}
class _ExpandableSectionListState extends State<ExpandableSectionList> {
final Map<String, GlobalKey<ExpandableSectionListItemState>> 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<String> 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<ExpandableSectionListItemState>(debugLabel: id);
}
idsToAdd.clear();
} else if (widget.values.length < keysMap.length) {
// item deleted
final List<String> 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
}
}
}