custom filters
This commit is contained in:
@@ -17,14 +17,18 @@ class HomeFilterWidgetCard extends StatelessWidget {
|
||||
super.key,
|
||||
required this.widgetConfig,
|
||||
this.editing = false,
|
||||
this.onRemove,
|
||||
this.onToggleDisplay,
|
||||
this.onEdit,
|
||||
this.dragHandle,
|
||||
});
|
||||
|
||||
final HomeFilterWidget widgetConfig;
|
||||
final bool editing;
|
||||
final VoidCallback? onRemove;
|
||||
final ValueChanged<bool>? onToggleDisplay;
|
||||
final VoidCallback? onEdit;
|
||||
final Widget? dragHandle;
|
||||
|
||||
void _open(BuildContext context) {
|
||||
void _openTransactions(BuildContext context) {
|
||||
final filter = widgetConfig.filter;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
@@ -56,72 +60,90 @@ class HomeFilterWidgetCard extends StatelessWidget {
|
||||
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,
|
||||
final hidden = editing && !widgetConfig.display;
|
||||
|
||||
return Opacity(
|
||||
opacity: hidden ? 0.45 : 1,
|
||||
child: Card(
|
||||
color: colors.surfaceContainer,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: editing
|
||||
? onEdit
|
||||
: () => _openTransactions(context),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
14,
|
||||
12,
|
||||
editing ? 4 : 14,
|
||||
12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (dragHandle != null) ...[
|
||||
dragHandle!,
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widgetConfig.name,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
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,
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
if (editing && onToggleDisplay != null)
|
||||
IconButton(
|
||||
tooltip: widgetConfig.display
|
||||
? 'Hide from Home'
|
||||
: 'Show on Home',
|
||||
onPressed: () =>
|
||||
onToggleDisplay!(!widgetConfig.display),
|
||||
icon: Icon(
|
||||
widgetConfig.display
|
||||
? Icons.remove_circle_outline
|
||||
: Icons.add_circle_outline,
|
||||
color: widgetConfig.display
|
||||
? colors.error
|
||||
: colors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -34,28 +34,33 @@ Future<HomeFilterWidget?> composeHomeFilterWidget(
|
||||
HomeFilterWidget? existing,
|
||||
bool skipFilterDialog = false,
|
||||
}) async {
|
||||
// Capture before any modal opens — the caller's element may be rebuilt.
|
||||
final bank = context.read<BankController>();
|
||||
final meta = context.read<TxMetaController>();
|
||||
final navigator = Navigator.of(context);
|
||||
|
||||
TransactionFilterSelection? filter = initialFilter ?? existing?.filter;
|
||||
if (!skipFilterDialog || filter == null) {
|
||||
final bank = context.read<BankController>();
|
||||
final meta = context.read<TxMetaController>();
|
||||
filter = await showTransactionFilterDialog(
|
||||
context,
|
||||
navigator.context,
|
||||
available: meta.allLabels,
|
||||
transactions: bank.transactions,
|
||||
initial: filter ?? const TransactionFilterSelection(labelIds: {}),
|
||||
);
|
||||
if (filter == null || !context.mounted) return null;
|
||||
if (filter == null || !navigator.mounted) return null;
|
||||
}
|
||||
|
||||
if (!navigator.mounted) return null;
|
||||
final name = await promptFavouriteName(
|
||||
context,
|
||||
navigator.context,
|
||||
initial: existing?.name ?? '',
|
||||
title: existing == null ? 'Name this widget' : 'Rename widget',
|
||||
);
|
||||
if (name == null || !context.mounted) return null;
|
||||
if (name == null || !navigator.mounted) return null;
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
if (!context.mounted) return null;
|
||||
// Let the name dialog finish popping before presenting options.
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
if (!navigator.mounted) return null;
|
||||
|
||||
final draft = existing == null
|
||||
? HomeFilterWidget.fromFilter(name: name, filter: filter)
|
||||
@@ -68,7 +73,7 @@ Future<HomeFilterWidget?> composeHomeFilterWidget(
|
||||
monthOnly: filter.monthOnly,
|
||||
);
|
||||
|
||||
return _promptWidgetMetrics(context, draft);
|
||||
return _promptWidgetMetrics(navigator.context, draft);
|
||||
}
|
||||
|
||||
Future<HomeFilterWidget?> _promptWidgetMetrics(
|
||||
@@ -78,6 +83,7 @@ Future<HomeFilterWidget?> _promptWidgetMetrics(
|
||||
var showIn = draft.showAmountIn;
|
||||
var showOut = draft.showAmountOut;
|
||||
var showCount = draft.showCount;
|
||||
var halfWidth = draft.halfWidth;
|
||||
|
||||
return showDialog<HomeFilterWidget>(
|
||||
context: context,
|
||||
@@ -86,7 +92,7 @@ Future<HomeFilterWidget?> _promptWidgetMetrics(
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return AlertDialog(
|
||||
title: const Text('Widget metrics'),
|
||||
title: const Text('Widget options'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -111,6 +117,13 @@ Future<HomeFilterWidget?> _promptWidgetMetrics(
|
||||
onChanged: (value) =>
|
||||
setState(() => showCount = value ?? false),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Half width'),
|
||||
subtitle: const Text('Share the row with another widget'),
|
||||
value: halfWidth,
|
||||
onChanged: (value) => setState(() => halfWidth = value),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
@@ -126,6 +139,7 @@ Future<HomeFilterWidget?> _promptWidgetMetrics(
|
||||
showAmountIn: showIn,
|
||||
showAmountOut: showOut,
|
||||
showCount: showCount,
|
||||
halfWidth: halfWidth,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -161,11 +175,11 @@ class _HomeWidgetsSheet extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Home widgets', style: theme.textTheme.titleLarge),
|
||||
Text('Home layout', 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.',
|
||||
'Choose which built-in sections and filter widgets appear '
|
||||
'on Home. Check Display to show an item.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -174,12 +188,51 @@ class _HomeWidgetsSheet extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 0),
|
||||
child: Column(
|
||||
children: [
|
||||
CheckboxListTile(
|
||||
value: prefs.homeBuiltInSections.showTransactions,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
prefs.setHomeShowTransactions(value);
|
||||
},
|
||||
title: const Text('Transactions'),
|
||||
subtitle: const Text('Total transaction count'),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
CheckboxListTile(
|
||||
value: prefs.homeBuiltInSections.showCategorized,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
prefs.setHomeShowCategorized(value);
|
||||
},
|
||||
title: const Text('Categorized'),
|
||||
subtitle: const Text('Labeled transaction count'),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
CheckboxListTile(
|
||||
value: prefs.homeBuiltInSections.showRecent,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
prefs.setHomeShowRecent(value);
|
||||
},
|
||||
title: const Text('Recent transactions'),
|
||||
subtitle: const Text('Latest activity list'),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final preferences = context.read<PreferencesController>();
|
||||
final created = await composeHomeFilterWidget(context);
|
||||
if (created == null || !context.mounted) return;
|
||||
await prefs.addHomeFilterWidget(created);
|
||||
if (created == null) return;
|
||||
await preferences.addHomeFilterWidget(created);
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add filter widget'),
|
||||
@@ -192,8 +245,8 @@ class _HomeWidgetsSheet extends StatelessWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'No widgets yet. Add a filter, or save one from '
|
||||
'Transactions.',
|
||||
'No filter widgets yet. Add one here, or save a '
|
||||
'filter from Transactions.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
@@ -216,17 +269,35 @@ class _HomeWidgetsSheet extends StatelessWidget {
|
||||
value,
|
||||
);
|
||||
},
|
||||
secondary: IconButton(
|
||||
tooltip: 'Edit',
|
||||
onPressed: () async {
|
||||
final updated = await composeHomeFilterWidget(
|
||||
context,
|
||||
existing: widgetItem,
|
||||
);
|
||||
if (updated == null || !context.mounted) return;
|
||||
await prefs.updateHomeFilterWidget(updated);
|
||||
secondary: PopupMenuButton<String>(
|
||||
tooltip: 'Widget actions',
|
||||
onSelected: (value) async {
|
||||
if (value == 'edit') {
|
||||
final preferences =
|
||||
context.read<PreferencesController>();
|
||||
final updated =
|
||||
await composeHomeFilterWidget(
|
||||
context,
|
||||
existing: widgetItem,
|
||||
);
|
||||
if (updated == null) return;
|
||||
await preferences
|
||||
.updateHomeFilterWidget(updated);
|
||||
} else if (value == 'delete') {
|
||||
await prefs
|
||||
.removeHomeFilterWidget(widgetItem.id);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Text('Edit'),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(widgetItem.name),
|
||||
subtitle: Text(
|
||||
@@ -254,6 +325,7 @@ class _HomeWidgetsSheet extends StatelessWidget {
|
||||
if (w.showAmountIn) 'In',
|
||||
if (w.showAmountOut) 'Out',
|
||||
if (w.showCount) 'Count',
|
||||
if (w.halfWidth) 'Half',
|
||||
];
|
||||
return parts.isEmpty ? 'All transactions' : parts.join(' · ');
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@ final RouteObserver<ModalRoute<void>> appRouteObserver =
|
||||
|
||||
/// 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).
|
||||
/// Only [PageRoute]s count as covering (via [RouteAware]). Modal bottom sheets
|
||||
/// and dialogs must not tear down the subtree — async flows started from Home
|
||||
/// (e.g. compose widget → name → options) need a mounted context between steps.
|
||||
class SkipBuildWhenCovered extends StatefulWidget {
|
||||
const SkipBuildWhenCovered({
|
||||
super.key,
|
||||
@@ -29,13 +30,6 @@ 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();
|
||||
@@ -43,7 +37,6 @@ class _SkipBuildWhenCoveredState extends State<SkipBuildWhenCovered>
|
||||
if (route is PageRoute) {
|
||||
appRouteObserver.subscribe(this, route);
|
||||
}
|
||||
_syncCovered();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -53,10 +46,14 @@ class _SkipBuildWhenCoveredState extends State<SkipBuildWhenCovered>
|
||||
}
|
||||
|
||||
@override
|
||||
void didPushNext() => _syncCovered();
|
||||
void didPushNext() {
|
||||
if (!_covered) setState(() => _covered = true);
|
||||
}
|
||||
|
||||
@override
|
||||
void didPopNext() => _syncCovered();
|
||||
void didPopNext() {
|
||||
if (_covered) setState(() => _covered = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
Reference in New Issue
Block a user