new favourites feature

This commit is contained in:
davmar
2026-09-10 16:25:24 +02:00
parent aec89ac41d
commit 83295439d5
22 changed files with 3168 additions and 282 deletions
+165
View File
@@ -0,0 +1,165 @@
/// Home card for a saved transaction-filter widget.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/home_filter_widget.dart';
import '../providers/bank_provider.dart';
import '../providers/tx_meta_provider.dart';
import '../screens/transactions_screen.dart';
import '../utils/app_theme.dart';
import '../utils/helpers.dart';
import '../utils/tx_filter_metrics.dart';
class HomeFilterWidgetCard extends StatelessWidget {
const HomeFilterWidgetCard({
super.key,
required this.widgetConfig,
this.editing = false,
this.onRemove,
});
final HomeFilterWidget widgetConfig;
final bool editing;
final VoidCallback? onRemove;
void _open(BuildContext context) {
final filter = widgetConfig.filter;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => TransactionsScreen(
initialLabelIds: filter.labelIds,
initialSearchInMonthOnly: filter.monthOnly,
initialQuery: filter.query,
initialFlow: filter.flow,
initialAmountRange: filter.amountRange,
),
),
);
}
@override
Widget build(BuildContext context) {
final bank = context.watch<BankController>();
final meta = context.watch<TxMetaController>();
final theme = Theme.of(context);
final colors = theme.colorScheme;
final currency = bank.balance?.currency ??
(bank.transactions.isNotEmpty
? bank.transactions.first.currency
: 'EUR');
final metrics = computeTxFilterMetrics(
all: bank.transactions,
meta: meta,
filter: widgetConfig.filter,
);
return Card(
color: colors.surfaceContainer,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: editing ? null : () => _open(context),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 8, 12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widgetConfig.name,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 6,
children: [
if (widgetConfig.showAmountIn)
_Metric(
label: 'In',
value: formatMoney(metrics.amountIn, currency),
color: AppTheme.accent,
),
if (widgetConfig.showAmountOut)
_Metric(
label: 'Out',
value: formatMoney(metrics.amountOut, currency),
color: AppTheme.expense,
),
if (widgetConfig.showCount)
_Metric(
label: 'Txs',
value: '${metrics.count}',
color: colors.primary,
),
],
),
],
),
),
if (editing)
IconButton(
tooltip: 'Remove from Home',
onPressed: onRemove,
icon: Icon(
Icons.remove_circle_outline,
color: colors.error,
),
)
else
Icon(
Icons.chevron_right,
color: colors.onSurfaceVariant,
),
],
),
),
),
);
}
}
class _Metric extends StatelessWidget {
const _Metric({
required this.label,
required this.value,
required this.color,
});
final String label;
final String value;
final Color color;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
Text(
value,
style: theme.textTheme.titleSmall?.copyWith(
color: color,
fontWeight: FontWeight.w700,
),
),
],
);
}
}
+260
View File
@@ -0,0 +1,260 @@
/// Manage Home filter widgets: display, metrics, add/remove.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/home_filter_widget.dart';
import '../models/transaction_meta.dart';
import '../providers/bank_provider.dart';
import '../providers/preferences_provider.dart';
import '../providers/tx_meta_provider.dart';
import '../widgets/label_filter_dialog.dart';
import '../widgets/overview_favourites_sheet.dart' show promptFavouriteName;
Future<void> showHomeWidgetsSheet(BuildContext context) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(context).bottom,
),
child: const _HomeWidgetsSheet(),
);
},
);
}
Future<HomeFilterWidget?> composeHomeFilterWidget(
BuildContext context, {
TransactionFilterSelection? initialFilter,
HomeFilterWidget? existing,
bool skipFilterDialog = false,
}) async {
TransactionFilterSelection? filter = initialFilter ?? existing?.filter;
if (!skipFilterDialog || filter == null) {
final bank = context.read<BankController>();
final meta = context.read<TxMetaController>();
filter = await showTransactionFilterDialog(
context,
available: meta.allLabels,
transactions: bank.transactions,
initial: filter ?? const TransactionFilterSelection(labelIds: {}),
);
if (filter == null || !context.mounted) return null;
}
final name = await promptFavouriteName(
context,
initial: existing?.name ?? '',
title: existing == null ? 'Name this widget' : 'Rename widget',
);
if (name == null || !context.mounted) return null;
await Future<void>.delayed(Duration.zero);
if (!context.mounted) return null;
final draft = existing == null
? HomeFilterWidget.fromFilter(name: name, filter: filter)
: existing.copyWith(
name: name,
labelIds: filter.labelIds.toList(growable: false),
flow: filter.flow,
amountRange: filter.amountRange,
query: filter.query,
monthOnly: filter.monthOnly,
);
return _promptWidgetMetrics(context, draft);
}
Future<HomeFilterWidget?> _promptWidgetMetrics(
BuildContext context,
HomeFilterWidget draft,
) {
var showIn = draft.showAmountIn;
var showOut = draft.showAmountOut;
var showCount = draft.showCount;
return showDialog<HomeFilterWidget>(
context: context,
useRootNavigator: true,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: const Text('Widget metrics'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Amount in'),
value: showIn,
onChanged: (value) =>
setState(() => showIn = value ?? false),
),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Amount out'),
value: showOut,
onChanged: (value) =>
setState(() => showOut = value ?? false),
),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Number of transactions'),
value: showCount,
onChanged: (value) =>
setState(() => showCount = value ?? false),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
if (!showIn && !showOut && !showCount) return;
Navigator.of(context).pop(
draft.copyWith(
showAmountIn: showIn,
showAmountOut: showOut,
showCount: showCount,
),
);
},
child: const Text('Save'),
),
],
);
},
);
},
);
}
class _HomeWidgetsSheet extends StatelessWidget {
const _HomeWidgetsSheet();
@override
Widget build(BuildContext context) {
final prefs = context.watch<PreferencesController>();
final theme = Theme.of(context);
final widgets = prefs.homeFilterWidgets;
return SafeArea(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.72,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Home widgets', style: theme.textTheme.titleLarge),
const SizedBox(height: 4),
Text(
'Pin saved transaction filters on Home. Check Display to '
'show a widget; edit metrics or remove anytime.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: OutlinedButton.icon(
onPressed: () async {
final created = await composeHomeFilterWidget(context);
if (created == null || !context.mounted) return;
await prefs.addHomeFilterWidget(created);
},
icon: const Icon(Icons.add),
label: const Text('Add filter widget'),
),
),
const Divider(height: 1),
Expanded(
child: widgets.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'No widgets yet. Add a filter, or save one from '
'Transactions.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 24),
itemCount: widgets.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final widgetItem = widgets[index];
return CheckboxListTile(
value: widgetItem.display,
onChanged: (value) {
if (value == null) return;
prefs.setHomeFilterWidgetDisplay(
widgetItem.id,
value,
);
},
secondary: IconButton(
tooltip: 'Edit',
onPressed: () async {
final updated = await composeHomeFilterWidget(
context,
existing: widgetItem,
);
if (updated == null || !context.mounted) return;
await prefs.updateHomeFilterWidget(updated);
},
icon: const Icon(Icons.edit_outlined),
),
title: Text(widgetItem.name),
subtitle: Text(
'${_subtitle(widgetItem)}\n'
'Display on Home',
),
isThreeLine: true,
controlAffinity: ListTileControlAffinity.leading,
);
},
),
),
],
),
),
);
}
static String _subtitle(HomeFilterWidget w) {
final parts = <String>[
if (w.monthOnly) 'This month',
if (w.flow != TxFlowFilter.all) w.flow.name,
if (w.labelIds.isNotEmpty) '${w.labelIds.length} labels',
if (w.query.trim().isNotEmpty) '“${w.query.trim()}”',
if (w.showAmountIn) 'In',
if (w.showAmountOut) 'Out',
if (w.showCount) 'Count',
];
return parts.isEmpty ? 'All transactions' : parts.join(' · ');
}
}
+119 -22
View File
@@ -446,6 +446,8 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
onToggle: _toggle,
allowMove: widget.allowMove,
shrinkWrap: true,
parentOf: meta.parentIdOf,
expandAll: needle.isNotEmpty,
),
],
),
@@ -868,6 +870,8 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> {
onToggle: _toggle,
allowMove: false,
shrinkWrap: true,
parentOf: meta.parentIdOf,
expandAll: needle.isNotEmpty,
),
const SizedBox(height: 12),
],
@@ -914,14 +918,16 @@ List<LabelTreeEntry> _filterLabelEntries({
];
}
class _LabelChecklist extends StatelessWidget {
class _LabelChecklist extends StatefulWidget {
const _LabelChecklist({
required this.entries,
required this.showUncategorized,
required this.selected,
required this.onToggle,
required this.allowMove,
required this.parentOf,
this.shrinkWrap = false,
this.expandAll = false,
});
final List<LabelTreeEntry> entries;
@@ -929,13 +935,62 @@ class _LabelChecklist extends StatelessWidget {
final Set<String> selected;
final void Function(String id, bool selected) onToggle;
final bool allowMove;
final String? Function(String id) parentOf;
final bool shrinkWrap;
final bool expandAll;
@override
State<_LabelChecklist> createState() => _LabelChecklistState();
}
class _LabelChecklistState extends State<_LabelChecklist> {
final Set<String> _expanded = {};
@override
void didUpdateWidget(covariant _LabelChecklist oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.expandAll && !oldWidget.expandAll) {
_expanded
..clear()
..addAll({
for (final entry in widget.entries)
if (_hasChildren(entry.label.id)) entry.label.id,
});
}
}
bool _hasChildren(String id) {
for (final entry in widget.entries) {
if (widget.parentOf(entry.label.id) == id) return true;
}
return false;
}
bool _isVisible(String id) {
if (widget.expandAll) return true;
var parent = widget.parentOf(id);
while (parent != null) {
if (!_expanded.contains(parent)) return false;
parent = widget.parentOf(parent);
}
return true;
}
void _toggleExpanded(String id) {
setState(() {
if (_expanded.contains(id)) {
_expanded.remove(id);
} else {
_expanded.add(id);
}
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
if (entries.isEmpty && !showUncategorized) {
if (widget.entries.isEmpty && !widget.showUncategorized) {
return DecoratedBox(
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
@@ -958,6 +1013,11 @@ class _LabelChecklist extends StatelessWidget {
);
}
final visibleEntries = [
for (final entry in widget.entries)
if (_isVisible(entry.label.id)) entry,
];
return DecoratedBox(
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
@@ -969,12 +1029,13 @@ class _LabelChecklist extends StatelessWidget {
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: ListView.separated(
shrinkWrap: shrinkWrap,
physics: shrinkWrap
shrinkWrap: widget.shrinkWrap,
physics: widget.shrinkWrap
? const NeverScrollableScrollPhysics()
: null,
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: entries.length + (showUncategorized ? 1 : 0),
itemCount:
visibleEntries.length + (widget.showUncategorized ? 1 : 0),
separatorBuilder: (_, _) => Divider(
height: 1,
thickness: 1,
@@ -982,28 +1043,35 @@ class _LabelChecklist extends StatelessWidget {
color: colors.outlineVariant.withValues(alpha: 0.45),
),
itemBuilder: (context, index) {
if (showUncategorized && index == 0) {
if (widget.showUncategorized && index == 0) {
return _FilterRow(
depth: 0,
icon: kUncategorizedFilterLabel.icon,
color: kUncategorizedFilterLabel.color,
name: kUncategorizedFilterLabel.name,
checked: selected.contains(kUncategorizedFilterId),
checked: widget.selected.contains(kUncategorizedFilterId),
onToggle: (value) =>
onToggle(kUncategorizedFilterId, value),
widget.onToggle(kUncategorizedFilterId, value),
);
}
final entry =
entries[showUncategorized ? index - 1 : index];
final entry = visibleEntries[
widget.showUncategorized ? index - 1 : index];
final label = entry.label;
final hasChildren = _hasChildren(label.id);
final expanded =
widget.expandAll || _expanded.contains(label.id);
return _FilterRow(
depth: entry.depth,
icon: label.icon,
color: label.color,
name: label.name,
checked: selected.contains(label.id),
onToggle: (value) => onToggle(label.id, value),
onLongPress: allowMove
checked: widget.selected.contains(label.id),
onToggle: (value) => widget.onToggle(label.id, value),
hasChildren: hasChildren,
expanded: expanded,
onExpandToggle:
hasChildren ? () => _toggleExpanded(label.id) : null,
onLongPress: widget.allowMove
? () async {
await showMoveLabelDialog(context, label: label);
}
@@ -1134,6 +1202,9 @@ class _FilterRow extends StatelessWidget {
required this.checked,
required this.onToggle,
this.onLongPress,
this.hasChildren = false,
this.expanded = false,
this.onExpandToggle,
});
final int depth;
@@ -1143,26 +1214,52 @@ class _FilterRow extends StatelessWidget {
final bool checked;
final ValueChanged<bool> onToggle;
final VoidCallback? onLongPress;
final bool hasChildren;
final bool expanded;
final VoidCallback? onExpandToggle;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
return InkWell(
onTap: () => onToggle(!checked),
onTap: () {
if (hasChildren && onExpandToggle != null) {
onExpandToggle!();
} else {
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),
],
SizedBox(
width: 28,
height: 32,
child: hasChildren
? IconButton(
tooltip: expanded ? 'Collapse' : 'Expand',
onPressed: onExpandToggle,
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
icon: Icon(
expanded
? Icons.expand_more
: Icons.chevron_right,
size: 20,
color: colors.onSurfaceVariant,
),
)
: depth > 0
? Icon(
Icons.subdirectory_arrow_right,
size: 14,
color: colors.onSurfaceVariant,
)
: null,
),
Icon(icon, size: 18, color: color),
const SizedBox(width: 10),
Expanded(
+491
View File
@@ -0,0 +1,491 @@
/// Manage Overview favourites: display toggles, rename, delete, save current.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/overview_favourite.dart';
import '../models/transaction_meta.dart';
import '../providers/preferences_provider.dart';
import '../providers/tx_meta_provider.dart';
import 'label_filter_dialog.dart';
Future<void> showOverviewFavouritesSheet(
BuildContext context, {
required OverviewFavourite? Function() currentFilterSnapshot,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(context).bottom,
),
child: _OverviewFavouritesSheet(
currentFilterSnapshot: currentFilterSnapshot,
),
);
},
);
}
Future<String?> promptFavouriteName(
BuildContext context, {
String initial = '',
String title = 'Name this favourite',
}) {
return showDialog<String>(
context: context,
useRootNavigator: true,
builder: (dialogContext) {
return _FavouriteNameDialog(
initial: initial,
title: title,
);
},
);
}
/// Edit an existing favourite (name, metric, period, labels, display).
Future<void> showEditOverviewFavouriteSheet(
BuildContext context, {
required OverviewFavourite favourite,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(context).bottom,
),
child: _EditOverviewFavouriteSheet(favourite: favourite),
);
},
);
}
class _FavouriteNameDialog extends StatefulWidget {
const _FavouriteNameDialog({
required this.initial,
required this.title,
});
final String initial;
final String title;
@override
State<_FavouriteNameDialog> createState() => _FavouriteNameDialogState();
}
class _FavouriteNameDialogState extends State<_FavouriteNameDialog> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initial);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit() {
final name = _controller.text.trim();
if (name.isEmpty) return;
Navigator.of(context).pop(name);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: TextField(
controller: _controller,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Name',
hintText: 'e.g. Groceries this year',
),
onSubmitted: (_) => _submit(),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _submit,
child: const Text('Save'),
),
],
);
}
}
class _OverviewFavouritesSheet extends StatelessWidget {
const _OverviewFavouritesSheet({required this.currentFilterSnapshot});
final OverviewFavourite? Function() currentFilterSnapshot;
Future<void> _saveCurrent(BuildContext context) async {
final prefs = context.read<PreferencesController>();
final snap = currentFilterSnapshot();
if (snap == null) return;
final name = await promptFavouriteName(context);
if (name == null || !context.mounted) return;
// Let the dialog route + IME finish tearing down before notifying
// dependents (avoids wrong-build-scope / _dependents asserts).
await Future<void>.delayed(Duration.zero);
if (!context.mounted) return;
await prefs.addOverviewFavourite(snap.copyWith(name: name));
if (!context.mounted) return;
final messenger = ScaffoldMessenger.maybeOf(context);
messenger?.showSnackBar(SnackBar(content: Text('Saved “$name”')));
}
@override
Widget build(BuildContext context) {
final prefs = context.watch<PreferencesController>();
final theme = Theme.of(context);
final favourites = prefs.overviewFavourites;
return SafeArea(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.72,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Favourites', style: theme.textTheme.titleLarge),
const SizedBox(height: 4),
Text(
'Check Display for each favourite you want on the Favourites '
'view — all checked ones show as charts. Save as many filters '
'as you like.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: OutlinedButton.icon(
onPressed: () => _saveCurrent(context),
icon: const Icon(Icons.star_outline),
label: const Text('Save current filter'),
),
),
const Divider(height: 1),
Expanded(
child: favourites.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'No favourites yet. Set up Flow or Labels on Overview, '
'then save the current filter.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 24),
itemCount: favourites.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final fav = favourites[index];
return CheckboxListTile(
value: fav.display,
onChanged: (value) {
if (value == null) return;
prefs.setOverviewFavouriteDisplay(fav.id, value);
},
secondary: IconButton(
tooltip: 'Delete',
onPressed: () =>
prefs.removeOverviewFavourite(fav.id),
icon: const Icon(Icons.delete_outline),
),
title: Text(fav.name),
subtitle: Text(
'${_subtitle(fav)}\n'
'Display on Favourites view',
),
isThreeLine: true,
controlAffinity: ListTileControlAffinity.leading,
);
},
),
),
],
),
),
);
}
static String _subtitle(OverviewFavourite fav) {
final metric =
fav.metric == OverviewMetricMode.flow ? 'Flow' : 'Labels';
final period =
fav.period == OverviewPeriodMode.monthly ? 'Month' : 'Year';
if (fav.metric == OverviewMetricMode.flow) {
return '$metric · $period';
}
final flow = switch (fav.flow) {
TxFlowFilter.all => 'All',
TxFlowFilter.income => 'Income',
TxFlowFilter.expense => 'Expenses',
};
final labels = fav.labelIds.isEmpty
? 'All labels'
: '${fav.labelIds.length} labels';
return '$metric · $period · $flow · $labels';
}
}
class _EditOverviewFavouriteSheet extends StatefulWidget {
const _EditOverviewFavouriteSheet({required this.favourite});
final OverviewFavourite favourite;
@override
State<_EditOverviewFavouriteSheet> createState() =>
_EditOverviewFavouriteSheetState();
}
class _EditOverviewFavouriteSheetState
extends State<_EditOverviewFavouriteSheet> {
late OverviewFavourite _draft;
late final TextEditingController _nameController;
static const _segmentStyle = ButtonStyle(
visualDensity: VisualDensity(horizontal: -2, vertical: -2),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 12)),
minimumSize: WidgetStatePropertyAll(Size(0, 36)),
);
@override
void initState() {
super.initState();
_draft = widget.favourite;
_nameController = TextEditingController(text: _draft.name);
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
Future<void> _persist(OverviewFavourite next) async {
setState(() => _draft = next);
await context.read<PreferencesController>().updateOverviewFavourite(next);
}
Future<void> _editLabels() async {
final meta = context.read<TxMetaController>();
final available = meta.allLabels;
final initial = <String>{
if (_draft.labelIds.isEmpty)
...available.map((l) => l.id)
else ...[
for (final id in _draft.labelIds)
if (id == kUncategorizedFilterId ||
available.any((l) => l.id == id))
id,
],
};
final result = await showLabelFilterDialog(
context,
available: available,
initiallySelected: initial,
title: 'Labels on chart',
subtitle: 'Choose which labels appear on this favourite.',
allowMove: true,
includeUncategorized: true,
includeFlow: true,
initialFlow: _draft.flow,
includeBarStyle: true,
initialBarStyle: _draft.barStyle,
includeInvertChart: true,
initialInvertChart: _draft.invertExpensePerspective,
);
if (result == null || !mounted) return;
final allIds = available.map((l) => l.id).toSet();
final hasUncategorized =
result.labelIds.contains(kUncategorizedFilterId);
final realIds = result.labelIds
.where((id) => id != kUncategorizedFilterId)
.toSet();
final allRealSelected =
realIds.length == allIds.length && realIds.containsAll(allIds);
final labelIds = !hasUncategorized &&
(realIds.isEmpty || allRealSelected)
? const <String>[]
: result.labelIds.toList(growable: false);
await _persist(
_draft.copyWith(
metric: OverviewMetricMode.labels,
labelIds: labelIds,
flow: result.flow,
barStyle: result.barStyle,
invertExpensePerspective:
result.flow == TxFlowFilter.all && result.invertExpensePerspective,
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final prefs = context.read<PreferencesController>();
return SafeArea(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.85,
),
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
children: [
Text('Edit favourite', style: theme.textTheme.titleLarge),
const SizedBox(height: 16),
TextField(
controller: _nameController,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Name',
),
textInputAction: TextInputAction.done,
onEditingComplete: () {
FocusScope.of(context).unfocus();
},
),
const SizedBox(height: 20),
Text('Chart type', style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
SegmentedButton<OverviewMetricMode>(
showSelectedIcon: false,
style: _segmentStyle,
segments: const [
ButtonSegment(
value: OverviewMetricMode.flow,
label: Text('Flow'),
),
ButtonSegment(
value: OverviewMetricMode.labels,
label: Text('Labels'),
),
],
selected: {_draft.metric},
onSelectionChanged: (selection) {
_persist(_draft.copyWith(metric: selection.first));
},
),
const SizedBox(height: 16),
Text('Period', style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
SegmentedButton<OverviewPeriodMode>(
showSelectedIcon: false,
style: _segmentStyle,
segments: const [
ButtonSegment(
value: OverviewPeriodMode.monthly,
label: Text('Month'),
),
ButtonSegment(
value: OverviewPeriodMode.yearly,
label: Text('Year'),
),
],
selected: {_draft.period},
onSelectionChanged: (selection) {
_persist(_draft.copyWith(period: selection.first));
},
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Display on Favourites view'),
value: _draft.display,
onChanged: (value) {
_persist(_draft.copyWith(display: value));
},
),
if (_draft.metric == OverviewMetricMode.labels) ...[
const SizedBox(height: 8),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.filter_list),
title: const Text('Label filter'),
subtitle: Text(
_draft.labelIds.isEmpty
? 'All labels · ${_draft.flow.name}'
: '${_draft.labelIds.length} labels · ${_draft.flow.name}',
),
trailing: const Icon(Icons.chevron_right),
onTap: _editLabels,
),
],
const SizedBox(height: 16),
FilledButton(
onPressed: () async {
final name = _nameController.text.trim();
final next =
name.isEmpty ? _draft : _draft.copyWith(name: name);
await prefs.updateOverviewFavourite(next);
if (!context.mounted) return;
Navigator.of(context).pop();
},
child: const Text('Done'),
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: () async {
await prefs.removeOverviewFavourite(_draft.id);
if (!context.mounted) return;
Navigator.of(context).pop();
},
icon: Icon(
Icons.delete_outline,
color: theme.colorScheme.error,
),
label: Text(
'Delete favourite',
style: TextStyle(color: theme.colorScheme.error),
),
),
],
),
),
);
}
}
+13 -6
View File
@@ -8,6 +8,9 @@ final RouteObserver<ModalRoute<void>> appRouteObserver =
RouteObserver<ModalRoute<void>>();
/// Builds via [builder] only when this route is not covered by a pushed page.
///
/// Uses [ModalRoute.isCurrent] so nested dialogs above a bottom sheet do not
/// falsely "uncover" the page when the dialog pops (sheet still open).
class SkipBuildWhenCovered extends StatefulWidget {
const SkipBuildWhenCovered({
super.key,
@@ -26,6 +29,13 @@ class _SkipBuildWhenCoveredState extends State<SkipBuildWhenCovered>
with RouteAware {
var _covered = false;
void _syncCovered() {
final route = ModalRoute.of(context);
final covered = route != null && !route.isCurrent;
if (covered == _covered) return;
setState(() => _covered = covered);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
@@ -33,6 +43,7 @@ class _SkipBuildWhenCoveredState extends State<SkipBuildWhenCovered>
if (route is PageRoute) {
appRouteObserver.subscribe(this, route);
}
_syncCovered();
}
@override
@@ -42,14 +53,10 @@ class _SkipBuildWhenCoveredState extends State<SkipBuildWhenCovered>
}
@override
void didPushNext() {
if (!_covered) setState(() => _covered = true);
}
void didPushNext() => _syncCovered();
@override
void didPopNext() {
if (_covered) setState(() => _covered = false);
}
void didPopNext() => _syncCovered();
@override
Widget build(BuildContext context) {