mirror of
https://github.com/vodemn/m3_lightmeter.git
synced 2024-11-24 16:30:40 +00:00
separated ExpandableSectionList
as widget
This commit is contained in:
parent
4498167ddc
commit
12abf99e2f
4 changed files with 503 additions and 0 deletions
|
@ -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);
|
|
@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue