nice overview working

This commit is contained in:
davmar
2026-09-08 23:52:54 +02:00
parent e7a7b1a357
commit 2a741c7f2a
16 changed files with 3512 additions and 810 deletions
+283
View File
@@ -0,0 +1,283 @@
/// Compact indented label multi-select for list/chart filters.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/transaction_meta.dart';
import '../providers/tx_meta_provider.dart';
import 'label_parent_picker.dart';
/// Returns the selected label ids, or `null` if cancelled.
///
/// When [includeUncategorized] is true, [kUncategorizedFilterId] may be
/// selected. Long-press still opens move-parent when [allowMove] is true.
Future<Set<String>?> showLabelFilterDialog(
BuildContext context, {
required List<TxLabel> available,
required Set<String> initiallySelected,
String title = 'Filter by label',
String subtitle = 'Choose which labels to include.',
bool includeUncategorized = false,
bool allowMove = false,
}) {
return showDialog<Set<String>>(
context: context,
builder: (dialogContext) {
return _LabelFilterDialog(
available: available,
initiallySelected: initiallySelected,
title: title,
subtitle: subtitle,
includeUncategorized: includeUncategorized,
allowMove: allowMove,
);
},
);
}
class _LabelFilterDialog extends StatefulWidget {
const _LabelFilterDialog({
required this.available,
required this.initiallySelected,
required this.title,
required this.subtitle,
required this.includeUncategorized,
required this.allowMove,
});
final List<TxLabel> available;
final Set<String> initiallySelected;
final String title;
final String subtitle;
final bool includeUncategorized;
final bool allowMove;
@override
State<_LabelFilterDialog> createState() => _LabelFilterDialogState();
}
class _LabelFilterDialogState extends State<_LabelFilterDialog> {
late final Set<String> _selected;
@override
void initState() {
super.initState();
_selected = Set<String>.of(widget.initiallySelected);
}
void _toggle(String id, bool selected) {
setState(() {
if (selected) {
_selected.add(id);
} else {
_selected.remove(id);
}
});
}
void _selectAll() {
setState(() {
_selected
..clear()
..addAll(widget.available.map((l) => l.id));
if (widget.includeUncategorized) {
_selected.add(kUncategorizedFilterId);
}
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final meta = context.watch<TxMetaController>();
final entries = meta.treeOrderFor(widget.available);
final maxListHeight = MediaQuery.sizeOf(context).height * 0.45;
final uncategorizedChecked =
_selected.contains(kUncategorizedFilterId);
return AlertDialog(
title: Text(widget.title),
content: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(widget.subtitle, style: theme.textTheme.bodyMedium),
const SizedBox(height: 10),
ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxListHeight),
child: DecoratedBox(
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: colors.outlineVariant.withValues(alpha: 0.8),
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: entries.length +
(widget.includeUncategorized ? 1 : 0),
separatorBuilder: (_, _) => Divider(
height: 1,
thickness: 1,
indent: 40,
color:
colors.outlineVariant.withValues(alpha: 0.45),
),
itemBuilder: (context, index) {
if (widget.includeUncategorized && index == 0) {
return _FilterRow(
depth: 0,
icon: kUncategorizedFilterLabel.icon,
color: kUncategorizedFilterLabel.color,
name: kUncategorizedFilterLabel.name,
checked: uncategorizedChecked,
onToggle: (value) =>
_toggle(kUncategorizedFilterId, value),
);
}
final entry = entries[
widget.includeUncategorized ? index - 1 : index];
final label = entry.label;
final checked = _selected.contains(label.id);
return _FilterRow(
depth: entry.depth,
icon: label.icon,
color: label.color,
name: label.name,
checked: checked,
onToggle: (value) => _toggle(label.id, value),
onLongPress: widget.allowMove
? () async {
await showMoveLabelDialog(
context,
label: label,
);
if (mounted) setState(() {});
}
: null,
);
},
),
),
),
),
],
),
),
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
actions: [
SizedBox(
width: double.maxFinite,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: TextButton(
onPressed: _selectAll,
child: const Text('All'),
),
),
Expanded(
child: TextButton(
onPressed: () {
setState(() => _selected.clear());
},
child: const Text('None'),
),
),
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
),
],
),
const SizedBox(height: 4),
FilledButton(
onPressed: () =>
Navigator.pop(context, Set<String>.of(_selected)),
child: const Text('Apply'),
),
],
),
),
],
);
}
}
class _FilterRow extends StatelessWidget {
const _FilterRow({
required this.depth,
required this.icon,
required this.color,
required this.name,
required this.checked,
required this.onToggle,
this.onLongPress,
});
final int depth;
final IconData icon;
final Color color;
final String name;
final bool checked;
final ValueChanged<bool> onToggle;
final VoidCallback? onLongPress;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
return InkWell(
onTap: () => onToggle(!checked),
onLongPress: onLongPress,
child: Padding(
padding: EdgeInsets.fromLTRB(8 + depth * 16.0, 2, 4, 2),
child: Row(
children: [
if (depth > 0) ...[
Icon(
Icons.subdirectory_arrow_right,
size: 14,
color: colors.onSurfaceVariant,
),
const SizedBox(width: 4),
],
Icon(icon, size: 18, color: color),
const SizedBox(width: 10),
Expanded(
child: Text(
name,
style: theme.textTheme.bodyMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
SizedBox(
height: 32,
width: 32,
child: Checkbox(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
value: checked,
onChanged: (value) => onToggle(value ?? false),
),
),
],
),
),
);
}
}
+207
View File
@@ -0,0 +1,207 @@
/// Dialogs to nest or create labels.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/transaction_meta.dart';
import '../providers/tx_meta_provider.dart';
Future<void> showMoveLabelDialog(
BuildContext context, {
required TxLabel label,
}) async {
final meta = context.read<TxMetaController>();
final parents = meta.validParentsFor(label.id);
final currentParent = meta.parentIdOf(label.id);
final chosen = await showDialog<String?>(
context: context,
builder: (dialogContext) {
final theme = Theme.of(dialogContext);
final maxHeight = MediaQuery.sizeOf(dialogContext).height * 0.45;
return AlertDialog(
title: Text('Move “${label.name}”'),
titleTextStyle: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onSurface,
),
contentPadding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
content: SizedBox(
width: double.maxFinite,
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
child: ListView(
shrinkWrap: true,
padding: EdgeInsets.zero,
children: [
ListTile(
dense: true,
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -3,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
minLeadingWidth: 28,
leading: Icon(
currentParent == null
? Icons.check_circle
: Icons.circle_outlined,
size: 18,
),
title: Text(
'Top level',
style: theme.textTheme.bodyMedium,
),
onTap: () => Navigator.pop(dialogContext, ''),
),
Divider(
height: 1,
color: theme.colorScheme.outlineVariant.withValues(
alpha: 0.5,
),
),
for (final parent in parents)
ListTile(
dense: true,
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -3,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
minLeadingWidth: 28,
leading: Icon(
parent.id == currentParent
? Icons.check_circle
: parent.icon,
size: 18,
color: parent.color,
),
title: Text(
parent.name,
style: theme.textTheme.bodyMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: () => Navigator.pop(dialogContext, parent.id),
),
],
),
),
),
actionsPadding: const EdgeInsets.fromLTRB(12, 4, 12, 8),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
),
],
);
},
);
if (chosen == null || !context.mounted) return;
await meta.setLabelParent(label.id, chosen.isEmpty ? null : chosen);
}
/// Returns name + optional parent, or null if cancelled.
Future<({String name, String? parentId})?> showCreateLabelDialog(
BuildContext context, {
required List<TxLabel> parents,
String? initialParentId,
}) {
return showDialog<({String name, String? parentId})>(
context: context,
builder: (context) => _CreateLabelDialog(
parents: parents,
initialParentId: initialParentId,
),
);
}
class _CreateLabelDialog extends StatefulWidget {
const _CreateLabelDialog({
required this.parents,
this.initialParentId,
});
final List<TxLabel> parents;
final String? initialParentId;
@override
State<_CreateLabelDialog> createState() => _CreateLabelDialogState();
}
class _CreateLabelDialogState extends State<_CreateLabelDialog> {
late final TextEditingController _controller;
String? _parentId;
@override
void initState() {
super.initState();
_controller = TextEditingController();
_parentId = widget.initialParentId;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit() {
final name = _controller.text.trim();
if (name.isEmpty) return;
Navigator.pop(context, (name: name, parentId: _parentId));
}
@override
Widget build(BuildContext context) {
final parentChoices = List<TxLabel>.of(widget.parents)
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return AlertDialog(
title: const Text('New label'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _controller,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(hintText: 'e.g. Supermarket'),
onSubmitted: (_) => _submit(),
),
const SizedBox(height: 12),
DropdownButtonFormField<String?>(
initialValue: _parentId,
decoration: const InputDecoration(
labelText: 'Under label (optional)',
),
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('Top level'),
),
for (final parent in parentChoices)
DropdownMenuItem<String?>(
value: parent.id,
child: Text(parent.name),
),
],
onChanged: (value) => setState(() => _parentId = value),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _submit,
child: const Text('Create'),
),
],
);
}
}
+59
View File
@@ -0,0 +1,59 @@
/// Skips building while another route covers this one, then rebuilds on pop.
library;
import 'package:flutter/material.dart';
/// Shared observer — register on [MaterialApp.navigatorObservers].
final RouteObserver<ModalRoute<void>> appRouteObserver =
RouteObserver<ModalRoute<void>>();
/// Builds via [builder] only when this route is not covered by a pushed page.
class SkipBuildWhenCovered extends StatefulWidget {
const SkipBuildWhenCovered({
super.key,
required this.builder,
this.placeholder = const SizedBox.expand(),
});
final WidgetBuilder builder;
final Widget placeholder;
@override
State<SkipBuildWhenCovered> createState() => _SkipBuildWhenCoveredState();
}
class _SkipBuildWhenCoveredState extends State<SkipBuildWhenCovered>
with RouteAware {
var _covered = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of(context);
if (route is PageRoute) {
appRouteObserver.subscribe(this, route);
}
}
@override
void dispose() {
appRouteObserver.unsubscribe(this);
super.dispose();
}
@override
void didPushNext() {
if (!_covered) setState(() => _covered = true);
}
@override
void didPopNext() {
if (_covered) setState(() => _covered = false);
}
@override
Widget build(BuildContext context) {
if (_covered) return widget.placeholder;
return widget.builder(context);
}
}