new favourites feature
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(' · ');
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user