mirror of
https://github.com/vodemn/m3_lightmeter.git
synced 2024-11-24 08:20: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