nice overview working
This commit is contained in:
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user