From 903a2980dece3750f9d234a6809f61155c22d4de Mon Sep 17 00:00:00 2001 From: davmar Date: Wed, 9 Sep 2026 11:40:05 +0200 Subject: [PATCH] fix tx listing --- lib/models/transaction_meta.dart | 73 ++ lib/providers/tx_meta_provider.dart | 64 +- lib/screens/overview_screen.dart | 47 +- lib/screens/transaction_detail_screen.dart | 213 +++-- lib/screens/transactions_screen.dart | 332 +++++--- lib/services/ingtrak_api.dart | 2 +- lib/widgets/label_filter_dialog.dart | 901 +++++++++++++++++++-- lib/widgets/similar_match_dialog.dart | 287 +++++++ 8 files changed, 1638 insertions(+), 281 deletions(-) create mode 100644 lib/widgets/similar_match_dialog.dart diff --git a/lib/models/transaction_meta.dart b/lib/models/transaction_meta.dart index 949922a..829cfaa 100644 --- a/lib/models/transaction_meta.dart +++ b/lib/models/transaction_meta.dart @@ -3,6 +3,8 @@ library; import 'package:flutter/material.dart'; +import 'bank.dart'; + /// A spend/income label shown on transactions. class TxLabel { const TxLabel({ @@ -380,6 +382,77 @@ String merchantKeyFromDescription(String description) { String defaultSimilarPattern(String description) => merchantKeyFromDescription(description); +/// Income / expense / any — used by list filters and similar matching. +enum TxFlowFilter { all, income, expense } + +/// Absolute-amount filter for transaction lists (exact and/or range). +class TxAmountRange { + const TxAmountRange({this.exact, this.min, this.max}); + + /// Exact absolute amount; when set, takes precedence over [min]/[max]. + final double? exact; + + /// Inclusive lower bound on absolute amount; null = no lower bound. + final double? min; + + /// Inclusive upper bound on absolute amount; null = no upper bound. + final double? max; + + bool get isActive => exact != null || min != null || max != null; +} + +bool txMatchesAmountRange(BankTransaction tx, TxAmountRange range) { + if (!range.isActive) return true; + final abs = tx.amount.abs(); + if (range.exact != null) { + // Match to the nearest cent. + return (abs - range.exact!).abs() < 0.005; + } + if (range.min != null && abs < range.min!) return false; + if (range.max != null && abs > range.max!) return false; + return true; +} + +/// Criteria for finding similar transactions (apply-to-similar). +class SimilarMatchCriteria { + const SimilarMatchCriteria({ + required this.pattern, + this.flow = TxFlowFilter.all, + }); + + /// Plain contains needle, or `/regex/`. + final String pattern; + + /// Limit matches to income, expense, or either. + final TxFlowFilter flow; + + SimilarMatchCriteria copyWith({ + String? pattern, + TxFlowFilter? flow, + }) { + return SimilarMatchCriteria( + pattern: pattern ?? this.pattern, + flow: flow ?? this.flow, + ); + } +} + +bool txMatchesFlow(BankTransaction tx, TxFlowFilter flow) { + switch (flow) { + case TxFlowFilter.all: + return true; + case TxFlowFilter.income: + return tx.amount > 0; + case TxFlowFilter.expense: + return tx.amount < 0; + } +} + +bool txMatchesSimilarCriteria(BankTransaction tx, SimilarMatchCriteria criteria) { + if (!txMatchesFlow(tx, criteria.flow)) return false; + return descriptionMatchesPattern(tx.description, criteria.pattern); +} + /// Match [description] against a simple contains needle or `/regex/`. bool descriptionMatchesPattern(String description, String pattern) { final raw = pattern.trim(); diff --git a/lib/providers/tx_meta_provider.dart b/lib/providers/tx_meta_provider.dart index b71a642..152f05d 100644 --- a/lib/providers/tx_meta_provider.dart +++ b/lib/providers/tx_meta_provider.dart @@ -322,26 +322,30 @@ class TxMetaController extends ChangeNotifier { BankTransaction prototype, List all, { String? pattern, + SimilarMatchCriteria? criteria, }) { - final needle = (pattern ?? prototype.merchantKey).trim(); - if (needle.isEmpty) return 1; - var count = 0; - for (final tx in all) { - if (descriptionMatchesPattern(tx.description, needle)) count++; - } - return count == 0 ? 1 : count; + return similarTo( + prototype, + all, + pattern: pattern, + criteria: criteria, + ).length; } List similarTo( BankTransaction prototype, List all, { String? pattern, + SimilarMatchCriteria? criteria, }) { - final needle = (pattern ?? prototype.merchantKey).trim(); - if (needle.isEmpty) return [prototype]; + final query = criteria ?? + SimilarMatchCriteria( + pattern: (pattern ?? prototype.merchantKey).trim(), + ); + if (query.pattern.trim().isEmpty) return [prototype]; final matches = [ for (final tx in all) - if (descriptionMatchesPattern(tx.description, needle)) tx, + if (txMatchesSimilarCriteria(tx, query)) tx, ]; return matches.isEmpty ? [prototype] : matches; } @@ -432,6 +436,46 @@ class TxMetaController extends ChangeNotifier { ); } + /// Replace labels on every transaction in [txs] (per-id overrides). + Future setLabelsForMany( + List txs, + List labelIds, + ) async { + if (txs.isEmpty) return; + + final expanded = expandWithAncestors([ + for (final id in labelIds) + if (id.isNotEmpty && id != kUncategorizedFilterId) id, + ]); + final unique = []; + for (final id in expanded) { + if (!unique.contains(id)) unique.add(id); + } + + final nextById = Map.of(_byId); + for (final tx in txs) { + final stored = nextById[tx.id]; + final current = stored ?? metaFor(tx); + nextById[tx.id] = unique.isEmpty + ? current.copyWith(clearLabels: true) + : current.copyWith(labelIds: unique); + } + _byId = nextById; + _schedulePersist(); + } + + /// Label ids shared by every transaction in [txs]. + Set commonLabelIds(List txs) { + if (txs.isEmpty) return {}; + Set? common; + for (final tx in txs) { + final ids = metaFor(tx).labelIds.toSet(); + common = common == null ? ids : common.intersection(ids); + if (common.isEmpty) break; + } + return common ?? {}; + } + Future createLabel(String name, {String? parentId}) async { final trimmed = name.trim(); if (trimmed.isEmpty) { diff --git a/lib/screens/overview_screen.dart b/lib/screens/overview_screen.dart index cc5821c..9ad5d3b 100644 --- a/lib/screens/overview_screen.dart +++ b/lib/screens/overview_screen.dart @@ -42,6 +42,9 @@ class _OverviewScreenState extends State { /// Empty = include every label. Non-empty = only these ids on the Labels chart. final Set _includedLabelIds = {}; + /// Labels chart flow filter (defaults to expenses / labeled spend). + var _labelFlow = TxFlowFilter.expense; + /// Selected calendar year for Monthly mode (`DateTime(year)`). late DateTime _cursor; @@ -100,13 +103,12 @@ class _OverviewScreenState extends State { }); } - Future _pickChartLabels( - BuildContext context, - List available, - ) async { + Future _pickChartLabels(BuildContext context) async { + final meta = context.read(); + final available = meta.allLabels; if (available.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No labels in use for this period yet')), + const SnackBar(content: Text('No labels yet — create some first')), ); return; } @@ -125,18 +127,22 @@ class _OverviewScreenState extends State { subtitle: 'Choose which labels appear. Long-press to nest under another.', allowMove: true, + includeFlow: true, + initialFlow: _labelFlow, ); if (result == null || !mounted) return; setState(() { + _labelFlow = result.flow; final allIds = available.map((l) => l.id).toSet(); - if (result.isEmpty || - (result.length == allIds.length && result.containsAll(allIds))) { + if (result.labelIds.isEmpty || + (result.labelIds.length == allIds.length && + result.labelIds.containsAll(allIds))) { _includedLabelIds.clear(); } else { _includedLabelIds ..clear() - ..addAll(result); + ..addAll(result.labelIds); } _metric = _MetricMode.labels; }); @@ -203,7 +209,6 @@ class _OverviewScreenState extends State { // all txs 3–4 times and resolving full meta each time. Map monthly = const {}; var labelSpend = const <_LabelSpend>[]; - var availableLabels = const []; Map> labelAmountByPeriod = const {}; if (_metric == _MetricMode.flow) { @@ -215,9 +220,9 @@ class _OverviewScreenState extends State { periods, _period, includedLabelIds: _includedLabelIds, + flow: _labelFlow, ); labelSpend = labels.spend; - availableLabels = [for (final row in labels.allSpend) row.label]; labelAmountByPeriod = labels.amountByPeriod; } @@ -253,11 +258,14 @@ class _OverviewScreenState extends State { labelSpend: labelSpend, labelAmountByMonth: labelAmountByPeriod, currency: currency, - includedLabelCount: _includedLabelIds.isEmpty + includedLabelCount: _includedLabelIds.isEmpty && + _labelFlow == TxFlowFilter.expense ? null - : _includedLabelIds.length, - availableLabelCount: availableLabels.length, - onPickLabels: () => _pickChartLabels(context, availableLabels), + : (_includedLabelIds.isEmpty + ? meta.allLabels.length + : _includedLabelIds.length), + availableLabelCount: meta.allLabels.length, + onPickLabels: () => _pickChartLabels(context), onLabelTap: (label) { Navigator.of(context).push( MaterialPageRoute( @@ -309,7 +317,11 @@ class _OverviewScreenState extends State { labelSpend.fold(0, (s, e) => s + e.amount), currency, ), - label: 'Labeled spend · $_periodTitle', + label: switch (_labelFlow) { + TxFlowFilter.income => 'Labeled income · $_periodTitle', + TxFlowFilter.expense => 'Labeled spend · $_periodTitle', + TxFlowFilter.all => 'Labeled activity · $_periodTitle', + }, ), ], const SizedBox(height: 20), @@ -518,13 +530,14 @@ class _LabelAggregates { int get availableCount => allSpend.length; } -/// One pass over outflows: totals, per-period series, and history amounts. +/// One pass over labeled amounts: totals, per-period series, and history. _LabelAggregates _labelAggregates( List txs, TxMetaController meta, List periods, _PeriodMode mode, { Set includedLabelIds = const {}, + TxFlowFilter flow = TxFlowFilter.expense, }) { final keys = {for (final p in periods) _periodKey(p, mode)}; final allByLabel = {}; @@ -537,7 +550,7 @@ _LabelAggregates _labelAggregates( final filtering = includedLabelIds.isNotEmpty; for (final tx in txs) { - if (tx.amount >= 0) continue; + if (!txMatchesFlow(tx, flow)) continue; final date = _txDate(tx); if (date == null) continue; final key = _periodKey(date, mode); diff --git a/lib/screens/transaction_detail_screen.dart b/lib/screens/transaction_detail_screen.dart index 58e4036..0c8ea30 100644 --- a/lib/screens/transaction_detail_screen.dart +++ b/lib/screens/transaction_detail_screen.dart @@ -11,6 +11,7 @@ import '../providers/tx_meta_provider.dart'; import '../utils/helpers.dart'; import '../widgets/label_parent_picker.dart'; import '../widgets/responsive_body.dart'; +import '../widgets/similar_match_dialog.dart'; class TransactionDetailScreen extends StatefulWidget { const TransactionDetailScreen({super.key, required this.tx}); @@ -24,8 +25,10 @@ class TransactionDetailScreen extends StatefulWidget { class _TransactionDetailScreenState extends State { late final TextEditingController _notesController; - late final TextEditingController _patternController; + late SimilarMatchCriteria _matchCriteria; + late Set _draftLabelIds; var _dirty = false; + var _labelsDirty = false; late bool _applyToSimilar; var _busy = false; String? _busyMessage; @@ -36,13 +39,21 @@ class _TransactionDetailScreenState extends State { final meta = context.read(); final notes = meta.metaFor(widget.tx).notes; _notesController = TextEditingController(text: notes); - final pattern = defaultSimilarPattern(widget.tx.description); - _patternController = TextEditingController(text: pattern); + final tx = widget.tx; + _matchCriteria = SimilarMatchCriteria( + pattern: defaultSimilarPattern(tx.description), + flow: tx.amount < 0 + ? TxFlowFilter.expense + : tx.amount > 0 + ? TxFlowFilter.income + : TxFlowFilter.all, + ); + _draftLabelIds = meta.metaFor(tx).labelIds.toSet(); final all = context.read().transactions; final similarCount = meta.countSimilar( - widget.tx, + tx, all, - pattern: pattern, + criteria: _matchCriteria, ); // Default on when there are peers — otherwise users never notice the toggle. _applyToSimilar = similarCount > 1; @@ -51,16 +62,35 @@ class _TransactionDetailScreenState extends State { @override void dispose() { _notesController.dispose(); - _patternController.dispose(); super.dispose(); } - String get _pattern => _patternController.text.trim(); + String get _pattern => _matchCriteria.pattern; List _similarScope(TxMetaController meta) { final all = context.read().transactions; if (!_applyToSimilar) return const []; - return meta.similarTo(widget.tx, all, pattern: _pattern); + return meta.similarTo(widget.tx, all, criteria: _matchCriteria); + } + + Future _editMatchCriteria() async { + final all = context.read().transactions; + final result = await showSimilarMatchDialog( + context, + prototype: widget.tx, + all: all, + initial: _matchCriteria, + ); + if (result == null || !mounted) return; + setState(() { + _matchCriteria = result; + final count = context.read().countSimilar( + widget.tx, + all, + criteria: result, + ); + if (count > 1) _applyToSimilar = true; + }); } Future _runBusy( @@ -116,47 +146,57 @@ class _TransactionDetailScreenState extends State { ); } - Future _toggleLabel(String labelId) async { + Future _toggleDraftLabel(String labelId) async { + final meta = context.read(); + setState(() { + if (_draftLabelIds.contains(labelId)) { + final remove = {labelId, ...meta.descendantIdsOf(labelId)}; + _draftLabelIds.removeWhere(remove.contains); + } else { + _draftLabelIds.add(labelId); + _draftLabelIds.addAll(meta.ancestorIdsOf(labelId)); + } + _labelsDirty = true; + }); + } + + Future _applyLabels({required bool toMatched}) async { await _runBusy( () async { final meta = context.read(); - final similar = _similarScope(meta); - final count = similar.isEmpty ? 1 : similar.length; - await meta.toggleLabel( - widget.tx, - labelId, - applyToSimilar: similar, - matchPattern: _pattern, + final similar = toMatched ? _similarScope(meta) : const []; + final targets = similar.isEmpty ? [widget.tx] : similar; + await meta.setLabelsForMany( + targets, + _draftLabelIds.toList(growable: false), ); if (!mounted) return; - if (count > 1) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Labels updated on $count similar transactions'), + setState(() { + _labelsDirty = false; + _draftLabelIds = meta.metaFor(widget.tx).labelIds.toSet(); + }); + final count = targets.length; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + count == 1 + ? 'Labels saved' + : 'Labels updated on $count transactions', ), - ); - } + ), + ); }, - message: _applyToSimilar + message: toMatched ? 'Updating labels on matching transactions…' - : null, + : 'Saving labels…', ); } - Future _clearLabels() async { - await _runBusy( - () { - final meta = context.read(); - return meta.clearLabels( - widget.tx, - applyToSimilar: _similarScope(meta), - matchPattern: _pattern, - ); - }, - message: _applyToSimilar - ? 'Clearing labels on matching transactions…' - : 'Clearing labels…', - ); + Future _clearDraftLabels() async { + setState(() { + _draftLabelIds.clear(); + _labelsDirty = true; + }); } Future _createLabel() async { @@ -171,7 +211,7 @@ class _TransactionDetailScreenState extends State { parentId: result.parentId, ); if (!mounted) return; - await _toggleLabel(label.id); + await _toggleDraftLabel(label.id); } Future _labelActions(TxLabel label) async { @@ -246,12 +286,11 @@ class _TransactionDetailScreenState extends State { final similarCount = metaController.countSimilar( tx, all, - pattern: _pattern, + criteria: _matchCriteria, ); final theme = Theme.of(context); final colors = theme.colorScheme; final amountColor = tx.amount >= 0 ? colors.primary : colors.error; - final selectedIds = meta.labelIds.toSet(); final labelEntries = metaController.labelsInTreeOrder(); return Scaffold( @@ -304,36 +343,28 @@ class _TransactionDetailScreenState extends State { style: theme.textTheme.bodyMedium, ), const SizedBox(height: 16), - Text('Match similar', style: theme.textTheme.titleSmall), - const SizedBox(height: 8), - TextField( - controller: _patternController, - textInputAction: TextInputAction.done, - enabled: !_busy, - decoration: InputDecoration( - hintText: 'e.g. albert heijn or /albert\\s*heijn/', - helperText: - 'Plain text = contains. Wrap with /…/ for regex.', - helperMaxLines: 2, - suffixIcon: IconButton( - tooltip: 'Reset default', - onPressed: _busy - ? null - : () { - setState(() { - _patternController.text = - defaultSimilarPattern(tx.description); - }); - }, - icon: const Icon(Icons.refresh), + Row( + children: [ + Text('Match similar', style: theme.textTheme.titleSmall), + const Spacer(), + TextButton.icon( + onPressed: _busy ? null : _editMatchCriteria, + icon: const Icon(Icons.tune, size: 18), + label: const Text('Edit'), ), + ], + ), + const SizedBox(height: 4), + Text( + '“${_matchCriteria.pattern}”', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, ), - onChanged: (_) => setState(() {}), ), SwitchListTile( contentPadding: EdgeInsets.zero, secondary: const Icon(Icons.copy_all_outlined), - title: const Text('Apply to all similar'), + title: const Text('Include matched transactions'), subtitle: Text( similarCount <= 1 ? 'No other matches for this pattern' @@ -344,6 +375,14 @@ class _TransactionDetailScreenState extends State { ? null : (value) => setState(() => _applyToSimilar = value), ), + if (_applyToSimilar && similarCount > 1) + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: _busy ? null : _editMatchCriteria, + child: Text('Review $similarCount matches'), + ), + ), const SizedBox(height: 8), SwitchListTile( contentPadding: EdgeInsets.zero, @@ -366,9 +405,9 @@ class _TransactionDetailScreenState extends State { onPressed: _busy ? null : _createLabel, child: const Text('New'), ), - if (meta.hasLabel) + if (_draftLabelIds.isNotEmpty) TextButton( - onPressed: _busy ? null : _clearLabels, + onPressed: _busy ? null : _clearDraftLabels, child: const Text('Clear'), ), ], @@ -394,7 +433,8 @@ class _TransactionDetailScreenState extends State { color: colors.outlineVariant.withValues(alpha: 0.45), ), InkWell( - onTap: _busy ? null : () => _toggleLabel(entry.label.id), + onTap: + _busy ? null : () => _toggleDraftLabel(entry.label.id), onLongPress: _busy ? null : () => _labelActions(entry.label), child: Padding( @@ -435,10 +475,11 @@ class _TransactionDetailScreenState extends State { materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: VisualDensity.compact, - value: selectedIds.contains(entry.label.id), + value: _draftLabelIds.contains(entry.label.id), onChanged: _busy ? null - : (_) => _toggleLabel(entry.label.id), + : (_) => + _toggleDraftLabel(entry.label.id), ), ), ], @@ -450,6 +491,35 @@ class _TransactionDetailScreenState extends State { ), ), ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: FilledButton( + onPressed: _busy || !_labelsDirty + ? null + : () => _applyLabels(toMatched: false), + child: const Text('Apply'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton.tonal( + onPressed: _busy || + !_labelsDirty || + !_applyToSimilar || + similarCount <= 1 + ? null + : () => _applyLabels(toMatched: true), + child: Text( + similarCount > 1 + ? 'Apply to matched ($similarCount)' + : 'Apply to matched', + ), + ), + ), + ], + ), const SizedBox(height: 24), Text('Notes', style: theme.textTheme.titleSmall), const SizedBox(height: 8), @@ -478,7 +548,8 @@ class _TransactionDetailScreenState extends State { const SizedBox(height: 16), Text( 'Sub-labels also select their parents. Unchecking a parent ' - 'clears its sub-labels. Long-press a row to move or delete.', + 'clears its sub-labels. Long-press a row to move or delete. ' + 'Use Apply to save label changes.', style: theme.textTheme.labelMedium, ), ], diff --git a/lib/screens/transactions_screen.dart b/lib/screens/transactions_screen.dart index 4c0419a..5ceab37 100644 --- a/lib/screens/transactions_screen.dart +++ b/lib/screens/transactions_screen.dart @@ -58,6 +58,8 @@ class _TransactionsScreenState extends State { late final Set _labelFilters; int? _yearFilter; var _showCalendar = false; + var _flowFilter = TxFlowFilter.all; + var _amountRange = const TxAmountRange(); /// Query actually used for filtering (Enter, or live once ≥3 chars). String _activeQuery = ''; @@ -75,7 +77,8 @@ class _TransactionsScreenState extends State { _month = start.isAfter(now) ? now : start; } _labelFilters = Set.of(widget.initialLabelIds); - _searchInMonthOnly = widget.initialSearchInMonthOnly ?? false; + // Default to current month so the list, navigator, and filter popup agree. + _searchInMonthOnly = widget.initialSearchInMonthOnly ?? true; _yearFilter = widget.initialYear; } @@ -89,6 +92,15 @@ class _TransactionsScreenState extends State { bool get _isSearching => _activeQuery.trim().isNotEmpty || _labelFilters.isNotEmpty; + bool get _hasListFilters => + _labelFilters.isNotEmpty || + _flowFilter != TxFlowFilter.all || + _amountRange.isActive; + + bool _matchesExtraFilters(BankTransaction tx) => + txMatchesFlow(tx, _flowFilter) && + txMatchesAmountRange(tx, _amountRange); + void _goMonth(int delta) { final next = addMonths(_month, delta); final current = monthStart(DateTime.now()); @@ -141,31 +153,72 @@ class _TransactionsScreenState extends State { setState(() => _activeQuery = ''); } - Future _pickLabels(List available) async { - if (available.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No labels in use yet')), - ); - return; - } - - final result = await showLabelFilterDialog( + Future _pickFilters(List available) async { + final bank = context.read(); + final result = await showTransactionFilterDialog( context, available: available, - initiallySelected: Set.of(_labelFilters), - title: 'Filter transactions', - subtitle: 'Show transactions with any of these labels.', - includeUncategorized: true, + transactions: bank.transactions, + currentMonth: _month, + initial: TransactionFilterSelection( + labelIds: Set.of(_labelFilters), + flow: _flowFilter, + amountRange: _amountRange, + query: _activeQuery, + monthOnly: _searchInMonthOnly, + ), ); if (result == null || !mounted) return; setState(() { _labelFilters ..clear() - ..addAll(result); + ..addAll(result.labelIds); + _flowFilter = result.flow; + _amountRange = result.amountRange; + _searchInMonthOnly = result.monthOnly; + if (!_searchInMonthOnly) { + _showCalendar = false; + _selectedDay = null; + } + _activeQuery = result.query.trim(); + _searchController.text = _activeQuery; + if (_activeQuery.isNotEmpty) { + _searchDebounceTimer?.cancel(); + } }); } + Future _editLabelsForSubset(List subset) async { + if (subset.isEmpty) return; + final meta = context.read(); + final initial = meta.commonLabelIds(subset); + final result = await showLabelFilterDialog( + context, + available: meta.allLabels, + initiallySelected: initial, + title: 'Edit labels', + subtitle: + 'Apply to ${subset.length} transaction${subset.length == 1 ? '' : 's'}. ' + 'Checked labels replace the current ones.', + includeUncategorized: false, + allowCreate: true, + ); + if (result == null || !mounted) return; + + await meta.setLabelsForMany(subset, result.labelIds.toList(growable: false)); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.labelIds.isEmpty + ? 'Cleared labels on ${subset.length} transactions' + : 'Updated labels on ${subset.length} transactions', + ), + ), + ); + } + @override Widget build(BuildContext context) { final bank = context.watch(); @@ -272,39 +325,34 @@ class _TransactionsScreenState extends State { } final counts = txCountsByDay(monthDates); - final searchPool = !_isSearching - ? calendarScoped - : _searchInMonthOnly - ? monthScoped - : _yearScoped(txs); + // Period always drives the pool (not only when text/labels are active), + // so Income/Expense with Period=All searches the full list. + final searchPool = _searchInMonthOnly + ? (_selectedDay != null ? calendarScoped : monthScoped) + : _yearScoped(txs); - // Text hits first (no label filter) so refine chips stay available while selecting. - final textMatched = _activeQuery.trim().isEmpty - ? const [] - : metaController.search(searchPool, _activeQuery); + // Apply text + label filters, then flow / amount. + final filtered = [ + for (final tx in metaController.search( + searchPool, + _activeQuery, + labelIds: _labelFilters, + )) + if (_matchesExtraFilters(tx)) tx, + ]; - final filtered = metaController.search( - searchPool, - _activeQuery, - labelIds: _labelFilters, - ); - - final refineLabels = _activeQuery.trim().isEmpty - ? const [] - : metaController.usedLabels(textMatched); - final showUncategorizedRefine = _activeQuery.trim().isNotEmpty && - textMatched.any((tx) => !metaController.metaFor(tx).hasLabel); - final refineChoices = [ - if (showUncategorizedRefine && - !_labelFilters.contains(kUncategorizedFilterId)) - kUncategorizedFilterLabel, - for (final label in refineLabels) + // Labels on the current result that aren't part of the active label filter. + final otherLabels = [ + for (final label in metaController.usedLabels(filtered)) if (!_labelFilters.contains(label.id)) label, ]; + final showUncategorizedOther = filtered.isNotEmpty && + filtered.any((tx) => !metaController.metaFor(tx).hasLabel) && + !_labelFilters.contains(kUncategorizedFilterId); var moneyOut = 0.0; var moneyIn = 0.0; - for (final tx in calendarScoped) { + for (final tx in filtered) { if (tx.amount < 0) { moneyOut += tx.amount.abs(); } else if (tx.amount > 0) { @@ -395,19 +443,19 @@ class _TransactionsScreenState extends State { ), const SizedBox(width: 2), IconButton( - tooltip: _labelFilters.isEmpty - ? 'Filter by label' - : 'Filter by label (${_labelFilters.length})', - onPressed: () => _pickLabels( + tooltip: _hasListFilters + ? 'Filters active' + : 'Filter transactions', + onPressed: () => _pickFilters( metaController.usedLabels(txs), ), style: IconButton.styleFrom( - backgroundColor: _labelFilters.isEmpty - ? null - : colors.secondaryContainer, + backgroundColor: _hasListFilters + ? colors.secondaryContainer + : null, ), icon: Badge( - isLabelVisible: _labelFilters.isNotEmpty, + isLabelVisible: _hasListFilters, smallSize: 6, child: const Icon(Icons.filter_list), ), @@ -416,43 +464,61 @@ class _TransactionsScreenState extends State { tooltip: _showCalendar ? 'Hide calendar' : 'Show calendar', onPressed: () { setState(() { - _showCalendar = !_showCalendar; - if (!_showCalendar) _selectedDay = null; + if (_showCalendar) { + _showCalendar = false; + _selectedDay = null; + } else { + _showCalendar = true; + // Calendar navigation requires month scope. + _searchInMonthOnly = true; + } }); }, style: IconButton.styleFrom( - backgroundColor: _showCalendar + backgroundColor: _showCalendar && _searchInMonthOnly ? colors.secondaryContainer : null, ), icon: Icon( - _showCalendar + _showCalendar && _searchInMonthOnly ? Icons.calendar_month : Icons.calendar_month_outlined, ), ), - Tooltip( - message: 'Search only in calendar month', - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Checkbox( - value: _searchInMonthOnly, - onChanged: (value) { - setState( - () => _searchInMonthOnly = value ?? false, - ); - }, + ValueListenableBuilder( + valueListenable: _searchController, + builder: (context, value, _) { + if (value.text.trim().isEmpty && !_showCalendar) { + return const SizedBox.shrink(); + } + return Tooltip( + message: 'Limit results to calendar month', + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox( + value: _searchInMonthOnly, + onChanged: (checked) { + setState(() { + _searchInMonthOnly = checked ?? false; + if (!_searchInMonthOnly) { + _showCalendar = false; + _selectedDay = null; + } + }); + }, + ), + Text('Month', style: theme.textTheme.labelMedium), + ], ), - Text('Month', style: theme.textTheme.labelMedium), - ], - ), + ); + }, ), ], ), ), ), - if (_showCalendar) ...[ + if (_searchInMonthOnly && _showCalendar) ...[ SliverToBoxAdapter( child: TxHeatmap( month: _month, @@ -477,7 +543,7 @@ class _TransactionsScreenState extends State { ), ), ), - ] else + ] else if (_searchInMonthOnly) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(0, 4, 0, 8), @@ -531,28 +597,70 @@ class _TransactionsScreenState extends State { ), ), ), - if (_isSearching || _labelFilters.isNotEmpty) + if (_isSearching || _hasListFilters) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(bottom: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (_isSearching) - Text( - _yearFilter != null - ? 'Filtered to $_yearFilter' - : _searchInMonthOnly - ? 'Searching in ${formatMonthYear(_month)}' - : 'Searching all transactions', - style: theme.textTheme.labelMedium, - ), - if (_labelFilters.isNotEmpty) ...[ - if (_isSearching) const SizedBox(height: 8), + Text( + _yearFilter != null + ? 'Filtered to $_yearFilter' + : _searchInMonthOnly + ? 'Showing ${formatMonthYear(_month)}' + : 'Showing all transactions', + style: theme.textTheme.labelMedium, + ), + if (_hasListFilters) ...[ + const SizedBox(height: 8), Wrap( spacing: 6, runSpacing: 6, children: [ + if (_flowFilter == TxFlowFilter.income) + InputChip( + visualDensity: VisualDensity.compact, + avatar: const Icon(Icons.south_west, size: 14), + label: const Text('Income'), + onDeleted: () { + setState(() => _flowFilter = TxFlowFilter.all); + }, + ), + if (_flowFilter == TxFlowFilter.expense) + InputChip( + visualDensity: VisualDensity.compact, + avatar: const Icon(Icons.north_east, size: 14), + label: const Text('Expense'), + onDeleted: () { + setState(() => _flowFilter = TxFlowFilter.all); + }, + ), + if (_amountRange.isActive) + InputChip( + visualDensity: VisualDensity.compact, + label: Text( + _amountRange.exact != null + ? '= ${_amountRange.exact!.toStringAsFixed( + _amountRange.exact! == + _amountRange.exact! + .roundToDouble() + ? 0 + : 2, + )}' + : [ + if (_amountRange.min != null) + '≥ ${_amountRange.min!.toStringAsFixed(0)}', + if (_amountRange.max != null) + '≤ ${_amountRange.max!.toStringAsFixed(0)}', + ].join(' '), + ), + onDeleted: () { + setState( + () => _amountRange = const TxAmountRange(), + ); + }, + ), for (final id in _labelFilters) if (id == kUncategorizedFilterId) InputChip( @@ -586,10 +694,10 @@ class _TransactionsScreenState extends State { ], ), ], - if (refineChoices.isNotEmpty) ...[ + if (otherLabels.isNotEmpty || showUncategorizedOther) ...[ const SizedBox(height: 8), Text( - 'Refine by label', + 'Other labels found', style: theme.textTheme.labelSmall?.copyWith( color: colors.onSurfaceVariant, ), @@ -599,7 +707,26 @@ class _TransactionsScreenState extends State { spacing: 6, runSpacing: 6, children: [ - for (final label in refineChoices) + if (showUncategorizedOther) + FilterChip( + visualDensity: VisualDensity.compact, + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + avatar: Icon( + kUncategorizedFilterLabel.icon, + size: 14, + color: kUncategorizedFilterLabel.color, + ), + label: const Text('Uncategorized'), + selected: false, + onSelected: (_) { + setState( + () => + _labelFilters.add(kUncategorizedFilterId), + ); + }, + ), + for (final label in otherLabels) FilterChip( visualDensity: VisualDensity.compact, materialTapTargetSize: @@ -625,13 +752,26 @@ class _TransactionsScreenState extends State { SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(bottom: 8), - child: Text( - filtered.length == 1 - ? '1 transaction' - : '${filtered.length} transactions', - style: theme.textTheme.titleSmall?.copyWith( - color: colors.onSurfaceVariant, - ), + child: Row( + children: [ + Expanded( + child: Text( + filtered.length == 1 + ? '1 transaction' + : '${filtered.length} transactions', + style: theme.textTheme.titleSmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + if ((_hasListFilters || _activeQuery.trim().isNotEmpty) && + filtered.isNotEmpty) + IconButton( + tooltip: 'Edit labels for these transactions', + onPressed: () => _editLabelsForSubset(filtered), + icon: const Icon(Icons.edit_outlined), + ), + ], ), ), ), @@ -640,11 +780,13 @@ class _TransactionsScreenState extends State { child: Padding( padding: const EdgeInsets.symmetric(vertical: 24), child: Text( - _isSearching - ? 'No transactions match this search.' + _isSearching || _hasListFilters + ? 'No transactions match these filters.' : _selectedDay != null ? 'No transactions on this day.' - : 'No transactions this month.', + : _searchInMonthOnly + ? 'No transactions this month.' + : 'No transactions yet.', textAlign: TextAlign.center, style: theme.textTheme.bodyMedium, ), diff --git a/lib/services/ingtrak_api.dart b/lib/services/ingtrak_api.dart index ed1aa2c..928ce82 100644 --- a/lib/services/ingtrak_api.dart +++ b/lib/services/ingtrak_api.dart @@ -137,7 +137,7 @@ class IngtrakApi { return BankBalance.fromJson(await _decodeObject(response)); } - Future> fetchTransactions({int days = 90}) async { + Future> fetchTransactions({int days = 89}) async { final response = await _client.get( _uri('/api/transactions', {'days': '$days'}), headers: const {'Accept': 'application/json'}, diff --git a/lib/widgets/label_filter_dialog.dart b/lib/widgets/label_filter_dialog.dart index cfe20b1..daac06f 100644 --- a/lib/widgets/label_filter_dialog.dart +++ b/lib/widgets/label_filter_dialog.dart @@ -4,15 +4,50 @@ library; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../models/bank.dart'; import '../models/transaction_meta.dart'; import '../providers/tx_meta_provider.dart'; +import '../utils/helpers.dart'; +import '../utils/tx_calendar.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?> showLabelFilterDialog( +/// Result of the transactions list filter dialog. +class TransactionFilterSelection { + const TransactionFilterSelection({ + required this.labelIds, + this.flow = TxFlowFilter.all, + this.amountRange = const TxAmountRange(), + this.query = '', + this.monthOnly = false, + }); + + final Set labelIds; + final TxFlowFilter flow; + final TxAmountRange amountRange; + final String query; + + /// When true, list is limited to the selected calendar month. + final bool monthOnly; + + bool get hasActiveExtras => + flow != TxFlowFilter.all || + amountRange.isActive || + query.trim().isNotEmpty; +} + +/// Result of the simple label multi-select dialog. +class LabelFilterSelection { + const LabelFilterSelection({ + required this.labelIds, + this.flow = TxFlowFilter.all, + }); + + final Set labelIds; + final TxFlowFilter flow; +} + +/// Returns the selection, or `null` if cancelled. +Future showLabelFilterDialog( BuildContext context, { required List available, required Set initiallySelected, @@ -20,30 +55,64 @@ Future?> showLabelFilterDialog( String subtitle = 'Choose which labels to include.', bool includeUncategorized = false, bool allowMove = false, + bool allowCreate = false, + bool includeFlow = false, + TxFlowFilter initialFlow = TxFlowFilter.all, }) { - return showDialog>( + return showDialog( context: context, builder: (dialogContext) { - return _LabelFilterDialog( + return _SimpleLabelFilterDialog( available: available, initiallySelected: initiallySelected, title: title, subtitle: subtitle, includeUncategorized: includeUncategorized, allowMove: allowMove, + allowCreate: allowCreate, + includeFlow: includeFlow, + initialFlow: initialFlow, ); }, ); } -class _LabelFilterDialog extends StatefulWidget { - const _LabelFilterDialog({ +/// Full transactions filter: text, period, flow, amount, labels. +Future showTransactionFilterDialog( + BuildContext context, { + required List available, + required List transactions, + required TransactionFilterSelection initial, + DateTime? currentMonth, +}) { + return showDialog( + context: context, + builder: (dialogContext) { + return _TransactionFilterDialog( + available: available, + transactions: transactions, + initial: initial, + currentMonth: currentMonth ?? monthStart(DateTime.now()), + ); + }, + ); +} + +// --------------------------------------------------------------------------- +// Simple label-only dialog (overview chart / bulk edit) +// --------------------------------------------------------------------------- + +class _SimpleLabelFilterDialog extends StatefulWidget { + const _SimpleLabelFilterDialog({ required this.available, required this.initiallySelected, required this.title, required this.subtitle, required this.includeUncategorized, required this.allowMove, + required this.allowCreate, + required this.includeFlow, + required this.initialFlow, }); final List available; @@ -52,18 +121,32 @@ class _LabelFilterDialog extends StatefulWidget { final String subtitle; final bool includeUncategorized; final bool allowMove; + final bool allowCreate; + final bool includeFlow; + final TxFlowFilter initialFlow; @override - State<_LabelFilterDialog> createState() => _LabelFilterDialogState(); + State<_SimpleLabelFilterDialog> createState() => + _SimpleLabelFilterDialogState(); } -class _LabelFilterDialogState extends State<_LabelFilterDialog> { +class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> { late final Set _selected; + late TxFlowFilter _flow; + late final TextEditingController _labelSearchController; @override void initState() { super.initState(); _selected = Set.of(widget.initiallySelected); + _flow = widget.initialFlow; + _labelSearchController = TextEditingController(); + } + + @override + void dispose() { + _labelSearchController.dispose(); + super.dispose(); } void _toggle(String id, bool selected) { @@ -76,14 +159,21 @@ class _LabelFilterDialogState extends State<_LabelFilterDialog> { }); } - void _selectAll() { + Future _createLabel() async { + final meta = context.read(); + final result = await showCreateLabelDialog( + context, + parents: meta.allLabels, + ); + if (result == null || !mounted) return; + final label = await meta.createLabel( + result.name, + parentId: result.parentId, + ); + if (!mounted) return; setState(() { - _selected - ..clear() - ..addAll(widget.available.map((l) => l.id)); - if (widget.includeUncategorized) { - _selected.add(kUncategorizedFilterId); - } + _selected.add(label.id); + _labelSearchController.clear(); }); } @@ -92,10 +182,18 @@ class _LabelFilterDialogState extends State<_LabelFilterDialog> { final theme = Theme.of(context); final colors = theme.colorScheme; final meta = context.watch(); - final entries = meta.treeOrderFor(widget.available); + final needle = _labelSearchController.text.trim().toLowerCase(); + // Live catalog when creating so new labels appear immediately. + final available = widget.allowCreate ? meta.allLabels : widget.available; + final entries = _filterLabelEntries( + meta: meta, + available: available, + needle: needle, + ); + final showUncategorized = widget.includeUncategorized && + (needle.isEmpty || + kUncategorizedFilterLabel.name.toLowerCase().contains(needle)); final maxListHeight = MediaQuery.sizeOf(context).height * 0.45; - final uncategorizedChecked = - _selected.contains(kUncategorizedFilterId); return AlertDialog( title: Text(widget.title), @@ -105,68 +203,81 @@ class _LabelFilterDialogState extends State<_LabelFilterDialog> { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(widget.subtitle, style: theme.textTheme.bodyMedium), + Text( + widget.subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + if (widget.includeFlow) ...[ + const SizedBox(height: 12), + Text( + 'Income / expense', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + _RectToggleBar( + value: _flow, + onChanged: (value) => setState(() => _flow = value), + options: const [ + _RectToggleOption( + value: TxFlowFilter.all, + label: 'All', + ), + _RectToggleOption( + value: TxFlowFilter.income, + label: 'In', + icon: Icons.south_west, + ), + _RectToggleOption( + value: TxFlowFilter.expense, + label: 'Out', + icon: Icons.north_east, + ), + ], + ), + ], const SizedBox(height: 10), + TextField( + controller: _labelSearchController, + decoration: InputDecoration( + isDense: true, + hintText: 'Filter labels', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: needle.isEmpty + ? null + : IconButton( + onPressed: () { + _labelSearchController.clear(); + setState(() {}); + }, + icon: const Icon(Icons.clear, size: 18), + ), + ), + onChanged: (_) => setState(() {}), + ), + if (widget.allowCreate) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: _createLabel, + icon: const Icon(Icons.add, size: 18), + label: const Text('New label'), + ), + ), + ], + const SizedBox(height: 8), 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, - ); - }, - ), - ), + child: _LabelChecklist( + entries: entries, + showUncategorized: showUncategorized, + selected: _selected, + onToggle: _toggle, + allowMove: widget.allowMove, ), ), ], @@ -183,15 +294,22 @@ class _LabelFilterDialogState extends State<_LabelFilterDialog> { children: [ Expanded( child: TextButton( - onPressed: _selectAll, + onPressed: () { + setState(() { + _selected + ..clear() + ..addAll(entries.map((e) => e.label.id)); + if (showUncategorized) { + _selected.add(kUncategorizedFilterId); + } + }); + }, child: const Text('All'), ), ), Expanded( child: TextButton( - onPressed: () { - setState(() => _selected.clear()); - }, + onPressed: () => setState(() => _selected.clear()), child: const Text('None'), ), ), @@ -205,8 +323,13 @@ class _LabelFilterDialogState extends State<_LabelFilterDialog> { ), const SizedBox(height: 4), FilledButton( - onPressed: () => - Navigator.pop(context, Set.of(_selected)), + onPressed: () => Navigator.pop( + context, + LabelFilterSelection( + labelIds: Set.of(_selected), + flow: widget.includeFlow ? _flow : TxFlowFilter.all, + ), + ), child: const Text('Apply'), ), ], @@ -217,6 +340,608 @@ class _LabelFilterDialogState extends State<_LabelFilterDialog> { } } +// --------------------------------------------------------------------------- +// Full transactions filter dialog +// --------------------------------------------------------------------------- + +class _TransactionFilterDialog extends StatefulWidget { + const _TransactionFilterDialog({ + required this.available, + required this.transactions, + required this.initial, + required this.currentMonth, + }); + + final List available; + final List transactions; + final TransactionFilterSelection initial; + final DateTime currentMonth; + + @override + State<_TransactionFilterDialog> createState() => + _TransactionFilterDialogState(); +} + +class _TransactionFilterDialogState extends State<_TransactionFilterDialog> { + late final Set _selected; + late TxFlowFilter _flow; + late bool _monthOnly; + late final TextEditingController _queryController; + late final TextEditingController _labelSearchController; + late final TextEditingController _exactAmountController; + late double _amountCeil; + late RangeValues _amountRange; + var _rangeTouched = false; + + @override + void initState() { + super.initState(); + final initial = widget.initial; + _selected = Set.of(initial.labelIds); + _flow = initial.flow; + _monthOnly = initial.monthOnly; + _queryController = TextEditingController(text: initial.query); + _labelSearchController = TextEditingController(); + + var maxAbs = 0.0; + for (final tx in widget.transactions) { + final abs = tx.amount.abs(); + if (abs > maxAbs) maxAbs = abs; + } + _amountCeil = maxAbs <= 0 ? 100.0 : (maxAbs * 1.05).ceilToDouble(); + if (_amountCeil < 1) _amountCeil = 1; + + final exact = initial.amountRange.exact; + _exactAmountController = TextEditingController( + text: exact == null ? '' : _formatAmountInput(exact), + ); + + final min = initial.amountRange.min ?? 0; + final max = initial.amountRange.max ?? _amountCeil; + _amountRange = RangeValues( + min.clamp(0, _amountCeil), + max.clamp(0, _amountCeil), + ); + _rangeTouched = + exact == null && (initial.amountRange.min != null || + initial.amountRange.max != null); + } + + @override + void dispose() { + _queryController.dispose(); + _labelSearchController.dispose(); + _exactAmountController.dispose(); + super.dispose(); + } + + static String _formatAmountInput(double value) { + if (value == value.roundToDouble()) { + return value.toStringAsFixed(0); + } + return value.toStringAsFixed(2); + } + + double? _parseExactAmount() { + final raw = _exactAmountController.text.trim().replaceAll(',', '.'); + if (raw.isEmpty) return null; + return double.tryParse(raw); + } + + void _toggle(String id, bool selected) { + setState(() { + if (selected) { + _selected.add(id); + } else { + _selected.remove(id); + } + }); + } + + TxAmountRange get _amountResult { + final exact = _parseExactAmount(); + if (exact != null && exact >= 0) { + return TxAmountRange(exact: exact); + } + if (!_rangeTouched) return const TxAmountRange(); + final start = _amountRange.start <= 0.009 ? null : _amountRange.start; + final end = + _amountRange.end >= _amountCeil - 0.009 ? null : _amountRange.end; + return TxAmountRange(min: start, max: end); + } + + void _apply() { + Navigator.pop( + context, + TransactionFilterSelection( + labelIds: Set.of(_selected), + flow: _flow, + amountRange: _amountResult, + query: _queryController.text.trim(), + monthOnly: _monthOnly, + ), + ); + } + + TextStyle? _sectionStyle(ThemeData theme, ColorScheme colors) { + return theme.textTheme.titleSmall?.copyWith( + color: colors.onSurface, + fontWeight: FontWeight.w600, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final meta = context.watch(); + final size = MediaQuery.sizeOf(context); + final dialogWidth = + (size.width - 48).clamp(280.0, 560.0).toDouble(); + final needle = _labelSearchController.text.trim().toLowerCase(); + final entries = _filterLabelEntries( + meta: meta, + available: widget.available, + needle: needle, + ); + final showUncategorized = needle.isEmpty || + kUncategorizedFilterLabel.name.toLowerCase().contains(needle); + final sectionStyle = _sectionStyle(theme, colors); + + return AlertDialog( + title: const Text('Filter transactions'), + contentPadding: const EdgeInsets.fromLTRB(24, 8, 24, 0), + content: SizedBox( + width: dialogWidth, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Search, period, flow, amount, and labels.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 14), + Text('Search text', style: sectionStyle), + const SizedBox(height: 8), + TextField( + controller: _queryController, + textInputAction: TextInputAction.search, + decoration: const InputDecoration( + hintText: 'Description, notes, amount…', + prefixIcon: Icon(Icons.search), + ), + ), + const SizedBox(height: 14), + Text('Period', style: sectionStyle), + const SizedBox(height: 8), + _RectToggleBar( + value: _monthOnly, + onChanged: (value) => setState(() => _monthOnly = value), + options: const [ + _RectToggleOption( + value: false, + label: 'All', + ), + _RectToggleOption( + value: true, + label: 'Month', + icon: Icons.calendar_month, + ), + ], + ), + if (_monthOnly) ...[ + const SizedBox(height: 8), + Text( + 'Uses ${formatMonthYear(widget.currentMonth)}. ' + 'Change month on the transactions list.', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + const SizedBox(height: 14), + Text('Income / expense', style: sectionStyle), + const SizedBox(height: 8), + _RectToggleBar( + value: _flow, + onChanged: (value) => setState(() => _flow = value), + options: const [ + _RectToggleOption( + value: TxFlowFilter.all, + label: 'All', + ), + _RectToggleOption( + value: TxFlowFilter.income, + label: 'In', + icon: Icons.south_west, + ), + _RectToggleOption( + value: TxFlowFilter.expense, + label: 'Out', + icon: Icons.north_east, + ), + ], + ), + const SizedBox(height: 14), + Text('Amount', style: sectionStyle), + const SizedBox(height: 8), + Text( + 'Exact', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + TextField( + controller: _exactAmountController, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: InputDecoration( + isDense: true, + hintText: 'e.g. 12.50', + prefixIcon: const Icon(Icons.pin, size: 20), + suffixIcon: _exactAmountController.text.trim().isEmpty + ? null + : IconButton( + tooltip: 'Clear exact', + onPressed: () { + _exactAmountController.clear(); + setState(() {}); + }, + icon: const Icon(Icons.clear, size: 18), + ), + ), + onChanged: (_) { + setState(() { + // Exact takes precedence; clear range filter intent. + _rangeTouched = false; + _amountRange = RangeValues(0, _amountCeil); + }); + }, + ), + const SizedBox(height: 12), + Text( + 'Range', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + _rangeTouched + ? '${_amountRange.start.toStringAsFixed(0)} – ${_amountRange.end.toStringAsFixed(0)}' + : 'Any amount', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + RangeSlider( + values: _amountRange, + min: 0, + max: _amountCeil, + labels: RangeLabels( + _amountRange.start.toStringAsFixed(0), + _amountRange.end.toStringAsFixed(0), + ), + onChanged: (values) { + setState(() { + _exactAmountController.clear(); + _rangeTouched = true; + _amountRange = values; + }); + }, + ), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: !_rangeTouched + ? null + : () { + setState(() { + _rangeTouched = false; + _amountRange = RangeValues(0, _amountCeil); + }); + }, + child: const Text('Reset range'), + ), + ), + const SizedBox(height: 8), + Text('Labels', style: sectionStyle), + const SizedBox(height: 8), + TextField( + controller: _labelSearchController, + decoration: InputDecoration( + isDense: true, + hintText: 'Filter labels', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: needle.isEmpty + ? null + : IconButton( + onPressed: () { + _labelSearchController.clear(); + setState(() {}); + }, + icon: const Icon(Icons.clear, size: 18), + ), + ), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 8), + SizedBox( + height: 220, + width: dialogWidth, + child: _LabelChecklist( + entries: entries, + showUncategorized: showUncategorized, + selected: _selected, + onToggle: _toggle, + allowMove: false, + ), + ), + ], + ), + ), + ), + actionsAlignment: MainAxisAlignment.spaceBetween, + actionsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + actions: [ + Wrap( + spacing: 4, + children: [ + TextButton( + onPressed: () { + setState(() { + _selected + ..clear() + ..addAll(entries.map((e) => e.label.id)); + if (showUncategorized) { + _selected.add(kUncategorizedFilterId); + } + }); + }, + child: const Text('All labels'), + ), + TextButton( + onPressed: () => setState(() => _selected.clear()), + child: const Text('No labels'), + ), + ], + ), + Wrap( + spacing: 8, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _apply, + child: const Text('Apply'), + ), + ], + ), + ], + ); + } +} + +List _filterLabelEntries({ + required TxMetaController meta, + required List available, + required String needle, +}) { + final full = meta.treeOrderFor(available); + if (needle.isEmpty) return full; + final keep = {}; + for (final label in available) { + if (!label.name.toLowerCase().contains(needle)) continue; + keep.add(label.id); + keep.addAll(meta.ancestorIdsOf(label.id)); + keep.addAll(meta.descendantIdsOf(label.id)); + } + return [ + for (final entry in full) + if (keep.contains(entry.label.id)) entry, + ]; +} + +class _LabelChecklist extends StatelessWidget { + const _LabelChecklist({ + required this.entries, + required this.showUncategorized, + required this.selected, + required this.onToggle, + required this.allowMove, + }); + + final List entries; + final bool showUncategorized; + final Set selected; + final void Function(String id, bool selected) onToggle; + final bool allowMove; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + if (entries.isEmpty && !showUncategorized) { + return DecoratedBox( + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: colors.outlineVariant.withValues(alpha: 0.8), + ), + ), + child: Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'No labels match.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + ), + ); + } + + return DecoratedBox( + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: colors.outlineVariant.withValues(alpha: 0.8), + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: entries.length + (showUncategorized ? 1 : 0), + separatorBuilder: (_, _) => Divider( + height: 1, + thickness: 1, + indent: 40, + color: colors.outlineVariant.withValues(alpha: 0.45), + ), + itemBuilder: (context, index) { + if (showUncategorized && index == 0) { + return _FilterRow( + depth: 0, + icon: kUncategorizedFilterLabel.icon, + color: kUncategorizedFilterLabel.color, + name: kUncategorizedFilterLabel.name, + checked: selected.contains(kUncategorizedFilterId), + onToggle: (value) => + onToggle(kUncategorizedFilterId, value), + ); + } + final entry = + entries[showUncategorized ? index - 1 : index]; + final label = entry.label; + 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 + ? () async { + await showMoveLabelDialog(context, label: label); + } + : null, + ); + }, + ), + ), + ); + } +} + +class _RectToggleOption { + const _RectToggleOption({ + required this.value, + required this.label, + this.icon, + }); + + final T value; + final String label; + final IconData? icon; +} + +class _RectToggleBar extends StatelessWidget { + const _RectToggleBar({ + required this.options, + required this.value, + required this.onChanged, + }); + + final List<_RectToggleOption> options; + final T value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + + return SizedBox( + width: double.infinity, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colors.outlineVariant), + ), + child: Row( + children: [ + for (var i = 0; i < options.length; i++) ...[ + if (i > 0) + Container( + width: 1, + height: 36, + color: colors.outlineVariant, + ), + Expanded( + child: Material( + color: options[i].value == value + ? colors.secondaryContainer + : colors.surface, + child: InkWell( + onTap: () => onChanged(options[i].value), + child: SizedBox( + height: 36, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (options[i].icon != null) ...[ + Icon( + options[i].icon, + size: 15, + color: options[i].value == value + ? colors.onSecondaryContainer + : colors.onSurfaceVariant, + ), + const SizedBox(width: 4), + ], + Flexible( + child: Text( + options[i].label, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelLarge?.copyWith( + color: options[i].value == value + ? colors.onSecondaryContainer + : colors.onSurface, + fontWeight: options[i].value == value + ? FontWeight.w600 + : FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ], + ], + ), + ), + ); + } +} + class _FilterRow extends StatelessWidget { const _FilterRow({ required this.depth, @@ -260,7 +985,9 @@ class _FilterRow extends StatelessWidget { Expanded( child: Text( name, - style: theme.textTheme.bodyMedium, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurface, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), diff --git a/lib/widgets/similar_match_dialog.dart b/lib/widgets/similar_match_dialog.dart new file mode 100644 index 0000000..4277903 --- /dev/null +++ b/lib/widgets/similar_match_dialog.dart @@ -0,0 +1,287 @@ +/// Dialog to refine “apply to similar” matching with a live match list. +library; + +import 'package:flutter/material.dart'; + +import '../models/bank.dart'; +import '../models/transaction_meta.dart'; +import '../utils/helpers.dart'; + +class _RegexExample { + const _RegexExample({required this.label, required this.pattern}); + final String label; + final String pattern; +} + +List<_RegexExample> _regexExamplesFor(BankTransaction tx) { + final stem = defaultSimilarPattern(tx.description); + final escaped = RegExp.escape(stem); + return [ + _RegexExample( + label: 'Contains stem (flexible spaces)', + pattern: '/${escaped.replaceAll(r'\ ', r'\s+')}/', + ), + _RegexExample( + label: 'Starts with stem', + pattern: '/^$escaped/', + ), + _RegexExample( + label: 'Whole description is stem', + pattern: '/^$escaped\$/', + ), + const _RegexExample( + label: 'Digits / reference', + pattern: r'/\d{4,}/', + ), + _RegexExample( + label: 'Stem as whole word', + pattern: '/\\b$escaped\\b/', + ), + ]; +} + +Future showSimilarMatchDialog( + BuildContext context, { + required BankTransaction prototype, + required List all, + required SimilarMatchCriteria initial, +}) { + return showDialog( + context: context, + builder: (dialogContext) { + return _SimilarMatchDialog( + prototype: prototype, + all: all, + initial: initial, + ); + }, + ); +} + +class _SimilarMatchDialog extends StatefulWidget { + const _SimilarMatchDialog({ + required this.prototype, + required this.all, + required this.initial, + }); + + final BankTransaction prototype; + final List all; + final SimilarMatchCriteria initial; + + @override + State<_SimilarMatchDialog> createState() => _SimilarMatchDialogState(); +} + +class _SimilarMatchDialogState extends State<_SimilarMatchDialog> { + late final TextEditingController _containsController; + late TxFlowFilter _flow; + + @override + void initState() { + super.initState(); + _containsController = TextEditingController(text: widget.initial.pattern); + _flow = widget.initial.flow; + } + + @override + void dispose() { + _containsController.dispose(); + super.dispose(); + } + + SimilarMatchCriteria get _criteria => SimilarMatchCriteria( + pattern: _containsController.text.trim(), + flow: _flow, + ); + + List get _matches { + final criteria = _criteria; + if (criteria.pattern.isEmpty) return const []; + return [ + for (final tx in widget.all) + if (txMatchesSimilarCriteria(tx, criteria)) tx, + ]; + } + + void _showRegexExamples() { + final examples = _regexExamplesFor(widget.prototype); + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) { + return SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text( + 'Regex examples', + style: Theme.of(sheetContext).textTheme.titleMedium, + ), + ), + for (final example in examples) + ListTile( + title: Text(example.label), + subtitle: Text( + example.pattern, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + onTap: () { + Navigator.pop(sheetContext); + setState(() => _containsController.text = example.pattern); + }, + ), + ], + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final matches = _matches; + final maxListHeight = MediaQuery.sizeOf(context).height * 0.35; + + return AlertDialog( + title: const Text('Match similar'), + content: SizedBox( + width: double.maxFinite, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _containsController, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: 'Name contains', + hintText: 'plain text or /regex/', + helperText: 'Plain text = contains. Wrap with /…/ for regex.', + helperMaxLines: 2, + suffixIcon: IconButton( + tooltip: 'Regex examples', + onPressed: _showRegexExamples, + icon: const Icon(Icons.data_object_outlined), + ), + ), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 12), + Text( + 'Income / expense', + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment( + value: TxFlowFilter.all, + label: Text('All'), + ), + ButtonSegment( + value: TxFlowFilter.income, + label: Text('In'), + icon: Icon(Icons.south_west, size: 16), + ), + ButtonSegment( + value: TxFlowFilter.expense, + label: Text('Out'), + icon: Icon(Icons.north_east, size: 16), + ), + ], + selected: {_flow}, + onSelectionChanged: (selected) { + setState(() => _flow = selected.first); + }, + ), + const SizedBox(height: 12), + Text( + matches.isEmpty + ? 'No matches' + : '${matches.length} matching transaction' + '${matches.length == 1 ? '' : 's'}', + style: theme.textTheme.titleSmall?.copyWith( + color: colors.onSurface, + ), + ), + const SizedBox(height: 6), + 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: matches.isEmpty + ? Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Adjust the pattern to see matches.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ) + : ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: matches.length, + separatorBuilder: (_, _) => Divider( + height: 1, + indent: 12, + color: + colors.outlineVariant.withValues(alpha: 0.45), + ), + itemBuilder: (context, index) { + final tx = matches[index]; + final isPrototype = tx.id == widget.prototype.id; + return ListTile( + dense: true, + title: Text( + tx.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text(formatIsoDate(tx.date)), + trailing: Text( + formatMoney(tx.amount, tx.currency), + style: theme.textTheme.bodyMedium?.copyWith( + color: tx.amount >= 0 + ? colors.primary + : colors.error, + fontWeight: + isPrototype ? FontWeight.w700 : null, + ), + ), + ); + }, + ), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _criteria.pattern.isEmpty + ? null + : () => Navigator.pop(context, _criteria), + child: const Text('Apply'), + ), + ], + ); + } +}