fix tx listing

This commit is contained in:
davmar
2026-09-09 11:40:05 +02:00
parent 2a741c7f2a
commit 903a2980de
8 changed files with 1638 additions and 281 deletions
+73
View File
@@ -3,6 +3,8 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'bank.dart';
/// A spend/income label shown on transactions. /// A spend/income label shown on transactions.
class TxLabel { class TxLabel {
const TxLabel({ const TxLabel({
@@ -380,6 +382,77 @@ String merchantKeyFromDescription(String description) {
String defaultSimilarPattern(String description) => String defaultSimilarPattern(String description) =>
merchantKeyFromDescription(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/`. /// Match [description] against a simple contains needle or `/regex/`.
bool descriptionMatchesPattern(String description, String pattern) { bool descriptionMatchesPattern(String description, String pattern) {
final raw = pattern.trim(); final raw = pattern.trim();
+54 -10
View File
@@ -322,26 +322,30 @@ class TxMetaController extends ChangeNotifier {
BankTransaction prototype, BankTransaction prototype,
List<BankTransaction> all, { List<BankTransaction> all, {
String? pattern, String? pattern,
SimilarMatchCriteria? criteria,
}) { }) {
final needle = (pattern ?? prototype.merchantKey).trim(); return similarTo(
if (needle.isEmpty) return 1; prototype,
var count = 0; all,
for (final tx in all) { pattern: pattern,
if (descriptionMatchesPattern(tx.description, needle)) count++; criteria: criteria,
} ).length;
return count == 0 ? 1 : count;
} }
List<BankTransaction> similarTo( List<BankTransaction> similarTo(
BankTransaction prototype, BankTransaction prototype,
List<BankTransaction> all, { List<BankTransaction> all, {
String? pattern, String? pattern,
SimilarMatchCriteria? criteria,
}) { }) {
final needle = (pattern ?? prototype.merchantKey).trim(); final query = criteria ??
if (needle.isEmpty) return [prototype]; SimilarMatchCriteria(
pattern: (pattern ?? prototype.merchantKey).trim(),
);
if (query.pattern.trim().isEmpty) return [prototype];
final matches = [ final matches = [
for (final tx in all) for (final tx in all)
if (descriptionMatchesPattern(tx.description, needle)) tx, if (txMatchesSimilarCriteria(tx, query)) tx,
]; ];
return matches.isEmpty ? [prototype] : matches; return matches.isEmpty ? [prototype] : matches;
} }
@@ -432,6 +436,46 @@ class TxMetaController extends ChangeNotifier {
); );
} }
/// Replace labels on every transaction in [txs] (per-id overrides).
Future<void> setLabelsForMany(
List<BankTransaction> txs,
List<String> labelIds,
) async {
if (txs.isEmpty) return;
final expanded = expandWithAncestors([
for (final id in labelIds)
if (id.isNotEmpty && id != kUncategorizedFilterId) id,
]);
final unique = <String>[];
for (final id in expanded) {
if (!unique.contains(id)) unique.add(id);
}
final nextById = Map<String, TxMeta>.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<String> commonLabelIds(List<BankTransaction> txs) {
if (txs.isEmpty) return {};
Set<String>? 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<TxLabel> createLabel(String name, {String? parentId}) async { Future<TxLabel> createLabel(String name, {String? parentId}) async {
final trimmed = name.trim(); final trimmed = name.trim();
if (trimmed.isEmpty) { if (trimmed.isEmpty) {
+30 -17
View File
@@ -42,6 +42,9 @@ class _OverviewScreenState extends State<OverviewScreen> {
/// Empty = include every label. Non-empty = only these ids on the Labels chart. /// Empty = include every label. Non-empty = only these ids on the Labels chart.
final Set<String> _includedLabelIds = {}; final Set<String> _includedLabelIds = {};
/// Labels chart flow filter (defaults to expenses / labeled spend).
var _labelFlow = TxFlowFilter.expense;
/// Selected calendar year for Monthly mode (`DateTime(year)`). /// Selected calendar year for Monthly mode (`DateTime(year)`).
late DateTime _cursor; late DateTime _cursor;
@@ -100,13 +103,12 @@ class _OverviewScreenState extends State<OverviewScreen> {
}); });
} }
Future<void> _pickChartLabels( Future<void> _pickChartLabels(BuildContext context) async {
BuildContext context, final meta = context.read<TxMetaController>();
List<TxLabel> available, final available = meta.allLabels;
) async {
if (available.isEmpty) { if (available.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( 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; return;
} }
@@ -125,18 +127,22 @@ class _OverviewScreenState extends State<OverviewScreen> {
subtitle: subtitle:
'Choose which labels appear. Long-press to nest under another.', 'Choose which labels appear. Long-press to nest under another.',
allowMove: true, allowMove: true,
includeFlow: true,
initialFlow: _labelFlow,
); );
if (result == null || !mounted) return; if (result == null || !mounted) return;
setState(() { setState(() {
_labelFlow = result.flow;
final allIds = available.map((l) => l.id).toSet(); final allIds = available.map((l) => l.id).toSet();
if (result.isEmpty || if (result.labelIds.isEmpty ||
(result.length == allIds.length && result.containsAll(allIds))) { (result.labelIds.length == allIds.length &&
result.labelIds.containsAll(allIds))) {
_includedLabelIds.clear(); _includedLabelIds.clear();
} else { } else {
_includedLabelIds _includedLabelIds
..clear() ..clear()
..addAll(result); ..addAll(result.labelIds);
} }
_metric = _MetricMode.labels; _metric = _MetricMode.labels;
}); });
@@ -203,7 +209,6 @@ class _OverviewScreenState extends State<OverviewScreen> {
// all txs 3–4 times and resolving full meta each time. // all txs 3–4 times and resolving full meta each time.
Map<DateTime, _MonthBucket> monthly = const {}; Map<DateTime, _MonthBucket> monthly = const {};
var labelSpend = const <_LabelSpend>[]; var labelSpend = const <_LabelSpend>[];
var availableLabels = const <TxLabel>[];
Map<DateTime, Map<String, double>> labelAmountByPeriod = const {}; Map<DateTime, Map<String, double>> labelAmountByPeriod = const {};
if (_metric == _MetricMode.flow) { if (_metric == _MetricMode.flow) {
@@ -215,9 +220,9 @@ class _OverviewScreenState extends State<OverviewScreen> {
periods, periods,
_period, _period,
includedLabelIds: _includedLabelIds, includedLabelIds: _includedLabelIds,
flow: _labelFlow,
); );
labelSpend = labels.spend; labelSpend = labels.spend;
availableLabels = [for (final row in labels.allSpend) row.label];
labelAmountByPeriod = labels.amountByPeriod; labelAmountByPeriod = labels.amountByPeriod;
} }
@@ -253,11 +258,14 @@ class _OverviewScreenState extends State<OverviewScreen> {
labelSpend: labelSpend, labelSpend: labelSpend,
labelAmountByMonth: labelAmountByPeriod, labelAmountByMonth: labelAmountByPeriod,
currency: currency, currency: currency,
includedLabelCount: _includedLabelIds.isEmpty includedLabelCount: _includedLabelIds.isEmpty &&
_labelFlow == TxFlowFilter.expense
? null ? null
: _includedLabelIds.length, : (_includedLabelIds.isEmpty
availableLabelCount: availableLabels.length, ? meta.allLabels.length
onPickLabels: () => _pickChartLabels(context, availableLabels), : _includedLabelIds.length),
availableLabelCount: meta.allLabels.length,
onPickLabels: () => _pickChartLabels(context),
onLabelTap: (label) { onLabelTap: (label) {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute<void>( MaterialPageRoute<void>(
@@ -309,7 +317,11 @@ class _OverviewScreenState extends State<OverviewScreen> {
labelSpend.fold<double>(0, (s, e) => s + e.amount), labelSpend.fold<double>(0, (s, e) => s + e.amount),
currency, 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), const SizedBox(height: 20),
@@ -518,13 +530,14 @@ class _LabelAggregates {
int get availableCount => allSpend.length; 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( _LabelAggregates _labelAggregates(
List<BankTransaction> txs, List<BankTransaction> txs,
TxMetaController meta, TxMetaController meta,
List<DateTime> periods, List<DateTime> periods,
_PeriodMode mode, { _PeriodMode mode, {
Set<String> includedLabelIds = const {}, Set<String> includedLabelIds = const {},
TxFlowFilter flow = TxFlowFilter.expense,
}) { }) {
final keys = {for (final p in periods) _periodKey(p, mode)}; final keys = {for (final p in periods) _periodKey(p, mode)};
final allByLabel = <String, double>{}; final allByLabel = <String, double>{};
@@ -537,7 +550,7 @@ _LabelAggregates _labelAggregates(
final filtering = includedLabelIds.isNotEmpty; final filtering = includedLabelIds.isNotEmpty;
for (final tx in txs) { for (final tx in txs) {
if (tx.amount >= 0) continue; if (!txMatchesFlow(tx, flow)) continue;
final date = _txDate(tx); final date = _txDate(tx);
if (date == null) continue; if (date == null) continue;
final key = _periodKey(date, mode); final key = _periodKey(date, mode);
+142 -71
View File
@@ -11,6 +11,7 @@ import '../providers/tx_meta_provider.dart';
import '../utils/helpers.dart'; import '../utils/helpers.dart';
import '../widgets/label_parent_picker.dart'; import '../widgets/label_parent_picker.dart';
import '../widgets/responsive_body.dart'; import '../widgets/responsive_body.dart';
import '../widgets/similar_match_dialog.dart';
class TransactionDetailScreen extends StatefulWidget { class TransactionDetailScreen extends StatefulWidget {
const TransactionDetailScreen({super.key, required this.tx}); const TransactionDetailScreen({super.key, required this.tx});
@@ -24,8 +25,10 @@ class TransactionDetailScreen extends StatefulWidget {
class _TransactionDetailScreenState extends State<TransactionDetailScreen> { class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
late final TextEditingController _notesController; late final TextEditingController _notesController;
late final TextEditingController _patternController; late SimilarMatchCriteria _matchCriteria;
late Set<String> _draftLabelIds;
var _dirty = false; var _dirty = false;
var _labelsDirty = false;
late bool _applyToSimilar; late bool _applyToSimilar;
var _busy = false; var _busy = false;
String? _busyMessage; String? _busyMessage;
@@ -36,13 +39,21 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
final meta = context.read<TxMetaController>(); final meta = context.read<TxMetaController>();
final notes = meta.metaFor(widget.tx).notes; final notes = meta.metaFor(widget.tx).notes;
_notesController = TextEditingController(text: notes); _notesController = TextEditingController(text: notes);
final pattern = defaultSimilarPattern(widget.tx.description); final tx = widget.tx;
_patternController = TextEditingController(text: pattern); _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<BankController>().transactions; final all = context.read<BankController>().transactions;
final similarCount = meta.countSimilar( final similarCount = meta.countSimilar(
widget.tx, tx,
all, all,
pattern: pattern, criteria: _matchCriteria,
); );
// Default on when there are peers — otherwise users never notice the toggle. // Default on when there are peers — otherwise users never notice the toggle.
_applyToSimilar = similarCount > 1; _applyToSimilar = similarCount > 1;
@@ -51,16 +62,35 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
@override @override
void dispose() { void dispose() {
_notesController.dispose(); _notesController.dispose();
_patternController.dispose();
super.dispose(); super.dispose();
} }
String get _pattern => _patternController.text.trim(); String get _pattern => _matchCriteria.pattern;
List<BankTransaction> _similarScope(TxMetaController meta) { List<BankTransaction> _similarScope(TxMetaController meta) {
final all = context.read<BankController>().transactions; final all = context.read<BankController>().transactions;
if (!_applyToSimilar) return const []; if (!_applyToSimilar) return const [];
return meta.similarTo(widget.tx, all, pattern: _pattern); return meta.similarTo(widget.tx, all, criteria: _matchCriteria);
}
Future<void> _editMatchCriteria() async {
final all = context.read<BankController>().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<TxMetaController>().countSimilar(
widget.tx,
all,
criteria: result,
);
if (count > 1) _applyToSimilar = true;
});
} }
Future<void> _runBusy( Future<void> _runBusy(
@@ -116,47 +146,57 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
); );
} }
Future<void> _toggleLabel(String labelId) async { Future<void> _toggleDraftLabel(String labelId) async {
final meta = context.read<TxMetaController>();
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<void> _applyLabels({required bool toMatched}) async {
await _runBusy( await _runBusy(
() async { () async {
final meta = context.read<TxMetaController>(); final meta = context.read<TxMetaController>();
final similar = _similarScope(meta); final similar = toMatched ? _similarScope(meta) : const <BankTransaction>[];
final count = similar.isEmpty ? 1 : similar.length; final targets = similar.isEmpty ? [widget.tx] : similar;
await meta.toggleLabel( await meta.setLabelsForMany(
widget.tx, targets,
labelId, _draftLabelIds.toList(growable: false),
applyToSimilar: similar,
matchPattern: _pattern,
); );
if (!mounted) return; if (!mounted) return;
if (count > 1) { setState(() {
ScaffoldMessenger.of(context).showSnackBar( _labelsDirty = false;
SnackBar( _draftLabelIds = meta.metaFor(widget.tx).labelIds.toSet();
content: Text('Labels updated on $count similar transactions'), });
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…' ? 'Updating labels on matching transactions…'
: null, : 'Saving labels…',
); );
} }
Future<void> _clearLabels() async { Future<void> _clearDraftLabels() async {
await _runBusy( setState(() {
() { _draftLabelIds.clear();
final meta = context.read<TxMetaController>(); _labelsDirty = true;
return meta.clearLabels( });
widget.tx,
applyToSimilar: _similarScope(meta),
matchPattern: _pattern,
);
},
message: _applyToSimilar
? 'Clearing labels on matching transactions…'
: 'Clearing labels…',
);
} }
Future<void> _createLabel() async { Future<void> _createLabel() async {
@@ -171,7 +211,7 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
parentId: result.parentId, parentId: result.parentId,
); );
if (!mounted) return; if (!mounted) return;
await _toggleLabel(label.id); await _toggleDraftLabel(label.id);
} }
Future<void> _labelActions(TxLabel label) async { Future<void> _labelActions(TxLabel label) async {
@@ -246,12 +286,11 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
final similarCount = metaController.countSimilar( final similarCount = metaController.countSimilar(
tx, tx,
all, all,
pattern: _pattern, criteria: _matchCriteria,
); );
final theme = Theme.of(context); final theme = Theme.of(context);
final colors = theme.colorScheme; final colors = theme.colorScheme;
final amountColor = tx.amount >= 0 ? colors.primary : colors.error; final amountColor = tx.amount >= 0 ? colors.primary : colors.error;
final selectedIds = meta.labelIds.toSet();
final labelEntries = metaController.labelsInTreeOrder(); final labelEntries = metaController.labelsInTreeOrder();
return Scaffold( return Scaffold(
@@ -304,36 +343,28 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
style: theme.textTheme.bodyMedium, style: theme.textTheme.bodyMedium,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('Match similar', style: theme.textTheme.titleSmall), Row(
const SizedBox(height: 8), children: [
TextField( Text('Match similar', style: theme.textTheme.titleSmall),
controller: _patternController, const Spacer(),
textInputAction: TextInputAction.done, TextButton.icon(
enabled: !_busy, onPressed: _busy ? null : _editMatchCriteria,
decoration: InputDecoration( icon: const Icon(Icons.tune, size: 18),
hintText: 'e.g. albert heijn or /albert\\s*heijn/', label: const Text('Edit'),
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),
), ),
],
),
const SizedBox(height: 4),
Text(
'“${_matchCriteria.pattern}”',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
), ),
onChanged: (_) => setState(() {}),
), ),
SwitchListTile( SwitchListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.copy_all_outlined), secondary: const Icon(Icons.copy_all_outlined),
title: const Text('Apply to all similar'), title: const Text('Include matched transactions'),
subtitle: Text( subtitle: Text(
similarCount <= 1 similarCount <= 1
? 'No other matches for this pattern' ? 'No other matches for this pattern'
@@ -344,6 +375,14 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
? null ? null
: (value) => setState(() => _applyToSimilar = value), : (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), const SizedBox(height: 8),
SwitchListTile( SwitchListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
@@ -366,9 +405,9 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
onPressed: _busy ? null : _createLabel, onPressed: _busy ? null : _createLabel,
child: const Text('New'), child: const Text('New'),
), ),
if (meta.hasLabel) if (_draftLabelIds.isNotEmpty)
TextButton( TextButton(
onPressed: _busy ? null : _clearLabels, onPressed: _busy ? null : _clearDraftLabels,
child: const Text('Clear'), child: const Text('Clear'),
), ),
], ],
@@ -394,7 +433,8 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
color: colors.outlineVariant.withValues(alpha: 0.45), color: colors.outlineVariant.withValues(alpha: 0.45),
), ),
InkWell( InkWell(
onTap: _busy ? null : () => _toggleLabel(entry.label.id), onTap:
_busy ? null : () => _toggleDraftLabel(entry.label.id),
onLongPress: onLongPress:
_busy ? null : () => _labelActions(entry.label), _busy ? null : () => _labelActions(entry.label),
child: Padding( child: Padding(
@@ -435,10 +475,11 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
materialTapTargetSize: materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap, MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
value: selectedIds.contains(entry.label.id), value: _draftLabelIds.contains(entry.label.id),
onChanged: _busy onChanged: _busy
? null ? null
: (_) => _toggleLabel(entry.label.id), : (_) =>
_toggleDraftLabel(entry.label.id),
), ),
), ),
], ],
@@ -450,6 +491,35 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
), ),
), ),
), ),
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), const SizedBox(height: 24),
Text('Notes', style: theme.textTheme.titleSmall), Text('Notes', style: theme.textTheme.titleSmall),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -478,7 +548,8 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Sub-labels also select their parents. Unchecking a parent ' '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, style: theme.textTheme.labelMedium,
), ),
], ],
+237 -95
View File
@@ -58,6 +58,8 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
late final Set<String> _labelFilters; late final Set<String> _labelFilters;
int? _yearFilter; int? _yearFilter;
var _showCalendar = false; var _showCalendar = false;
var _flowFilter = TxFlowFilter.all;
var _amountRange = const TxAmountRange();
/// Query actually used for filtering (Enter, or live once ≥3 chars). /// Query actually used for filtering (Enter, or live once ≥3 chars).
String _activeQuery = ''; String _activeQuery = '';
@@ -75,7 +77,8 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
_month = start.isAfter(now) ? now : start; _month = start.isAfter(now) ? now : start;
} }
_labelFilters = Set<String>.of(widget.initialLabelIds); _labelFilters = Set<String>.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; _yearFilter = widget.initialYear;
} }
@@ -89,6 +92,15 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
bool get _isSearching => bool get _isSearching =>
_activeQuery.trim().isNotEmpty || _labelFilters.isNotEmpty; _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) { void _goMonth(int delta) {
final next = addMonths(_month, delta); final next = addMonths(_month, delta);
final current = monthStart(DateTime.now()); final current = monthStart(DateTime.now());
@@ -141,31 +153,72 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
setState(() => _activeQuery = ''); setState(() => _activeQuery = '');
} }
Future<void> _pickLabels(List<TxLabel> available) async { Future<void> _pickFilters(List<TxLabel> available) async {
if (available.isEmpty) { final bank = context.read<BankController>();
ScaffoldMessenger.of(context).showSnackBar( final result = await showTransactionFilterDialog(
const SnackBar(content: Text('No labels in use yet')),
);
return;
}
final result = await showLabelFilterDialog(
context, context,
available: available, available: available,
initiallySelected: Set<String>.of(_labelFilters), transactions: bank.transactions,
title: 'Filter transactions', currentMonth: _month,
subtitle: 'Show transactions with any of these labels.', initial: TransactionFilterSelection(
includeUncategorized: true, labelIds: Set<String>.of(_labelFilters),
flow: _flowFilter,
amountRange: _amountRange,
query: _activeQuery,
monthOnly: _searchInMonthOnly,
),
); );
if (result == null || !mounted) return; if (result == null || !mounted) return;
setState(() { setState(() {
_labelFilters _labelFilters
..clear() ..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<void> _editLabelsForSubset(List<BankTransaction> subset) async {
if (subset.isEmpty) return;
final meta = context.read<TxMetaController>();
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bank = context.watch<BankController>(); final bank = context.watch<BankController>();
@@ -272,39 +325,34 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
} }
final counts = txCountsByDay(monthDates); final counts = txCountsByDay(monthDates);
final searchPool = !_isSearching // Period always drives the pool (not only when text/labels are active),
? calendarScoped // so Income/Expense with Period=All searches the full list.
: _searchInMonthOnly final searchPool = _searchInMonthOnly
? monthScoped ? (_selectedDay != null ? calendarScoped : monthScoped)
: _yearScoped(txs); : _yearScoped(txs);
// Text hits first (no label filter) so refine chips stay available while selecting. // Apply text + label filters, then flow / amount.
final textMatched = _activeQuery.trim().isEmpty final filtered = [
? const <BankTransaction>[] for (final tx in metaController.search(
: metaController.search(searchPool, _activeQuery); searchPool,
_activeQuery,
labelIds: _labelFilters,
))
if (_matchesExtraFilters(tx)) tx,
];
final filtered = metaController.search( // Labels on the current result that aren't part of the active label filter.
searchPool, final otherLabels = <TxLabel>[
_activeQuery, for (final label in metaController.usedLabels(filtered))
labelIds: _labelFilters,
);
final refineLabels = _activeQuery.trim().isEmpty
? const <TxLabel>[]
: metaController.usedLabels(textMatched);
final showUncategorizedRefine = _activeQuery.trim().isNotEmpty &&
textMatched.any((tx) => !metaController.metaFor(tx).hasLabel);
final refineChoices = <TxLabel>[
if (showUncategorizedRefine &&
!_labelFilters.contains(kUncategorizedFilterId))
kUncategorizedFilterLabel,
for (final label in refineLabels)
if (!_labelFilters.contains(label.id)) label, 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 moneyOut = 0.0;
var moneyIn = 0.0; var moneyIn = 0.0;
for (final tx in calendarScoped) { for (final tx in filtered) {
if (tx.amount < 0) { if (tx.amount < 0) {
moneyOut += tx.amount.abs(); moneyOut += tx.amount.abs();
} else if (tx.amount > 0) { } else if (tx.amount > 0) {
@@ -395,19 +443,19 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
), ),
const SizedBox(width: 2), const SizedBox(width: 2),
IconButton( IconButton(
tooltip: _labelFilters.isEmpty tooltip: _hasListFilters
? 'Filter by label' ? 'Filters active'
: 'Filter by label (${_labelFilters.length})', : 'Filter transactions',
onPressed: () => _pickLabels( onPressed: () => _pickFilters(
metaController.usedLabels(txs), metaController.usedLabels(txs),
), ),
style: IconButton.styleFrom( style: IconButton.styleFrom(
backgroundColor: _labelFilters.isEmpty backgroundColor: _hasListFilters
? null ? colors.secondaryContainer
: colors.secondaryContainer, : null,
), ),
icon: Badge( icon: Badge(
isLabelVisible: _labelFilters.isNotEmpty, isLabelVisible: _hasListFilters,
smallSize: 6, smallSize: 6,
child: const Icon(Icons.filter_list), child: const Icon(Icons.filter_list),
), ),
@@ -416,43 +464,61 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
tooltip: _showCalendar ? 'Hide calendar' : 'Show calendar', tooltip: _showCalendar ? 'Hide calendar' : 'Show calendar',
onPressed: () { onPressed: () {
setState(() { setState(() {
_showCalendar = !_showCalendar; if (_showCalendar) {
if (!_showCalendar) _selectedDay = null; _showCalendar = false;
_selectedDay = null;
} else {
_showCalendar = true;
// Calendar navigation requires month scope.
_searchInMonthOnly = true;
}
}); });
}, },
style: IconButton.styleFrom( style: IconButton.styleFrom(
backgroundColor: _showCalendar backgroundColor: _showCalendar && _searchInMonthOnly
? colors.secondaryContainer ? colors.secondaryContainer
: null, : null,
), ),
icon: Icon( icon: Icon(
_showCalendar _showCalendar && _searchInMonthOnly
? Icons.calendar_month ? Icons.calendar_month
: Icons.calendar_month_outlined, : Icons.calendar_month_outlined,
), ),
), ),
Tooltip( ValueListenableBuilder<TextEditingValue>(
message: 'Search only in calendar month', valueListenable: _searchController,
child: Row( builder: (context, value, _) {
mainAxisSize: MainAxisSize.min, if (value.text.trim().isEmpty && !_showCalendar) {
children: [ return const SizedBox.shrink();
Checkbox( }
value: _searchInMonthOnly, return Tooltip(
onChanged: (value) { message: 'Limit results to calendar month',
setState( child: Row(
() => _searchInMonthOnly = value ?? false, 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( SliverToBoxAdapter(
child: TxHeatmap( child: TxHeatmap(
month: _month, month: _month,
@@ -477,7 +543,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
), ),
), ),
), ),
] else ] else if (_searchInMonthOnly)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(0, 4, 0, 8), padding: const EdgeInsets.fromLTRB(0, 4, 0, 8),
@@ -531,28 +597,70 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
), ),
), ),
), ),
if (_isSearching || _labelFilters.isNotEmpty) if (_isSearching || _hasListFilters)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.only(bottom: 8),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (_isSearching) Text(
Text( _yearFilter != null
_yearFilter != null ? 'Filtered to $_yearFilter'
? 'Filtered to $_yearFilter' : _searchInMonthOnly
: _searchInMonthOnly ? 'Showing ${formatMonthYear(_month)}'
? 'Searching in ${formatMonthYear(_month)}' : 'Showing all transactions',
: 'Searching all transactions', style: theme.textTheme.labelMedium,
style: theme.textTheme.labelMedium, ),
), if (_hasListFilters) ...[
if (_labelFilters.isNotEmpty) ...[ const SizedBox(height: 8),
if (_isSearching) const SizedBox(height: 8),
Wrap( Wrap(
spacing: 6, spacing: 6,
runSpacing: 6, runSpacing: 6,
children: [ 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) for (final id in _labelFilters)
if (id == kUncategorizedFilterId) if (id == kUncategorizedFilterId)
InputChip( InputChip(
@@ -586,10 +694,10 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
], ],
), ),
], ],
if (refineChoices.isNotEmpty) ...[ if (otherLabels.isNotEmpty || showUncategorizedOther) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Refine by label', 'Other labels found',
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
color: colors.onSurfaceVariant, color: colors.onSurfaceVariant,
), ),
@@ -599,7 +707,26 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
spacing: 6, spacing: 6,
runSpacing: 6, runSpacing: 6,
children: [ 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( FilterChip(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
materialTapTargetSize: materialTapTargetSize:
@@ -625,13 +752,26 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.only(bottom: 8),
child: Text( child: Row(
filtered.length == 1 children: [
? '1 transaction' Expanded(
: '${filtered.length} transactions', child: Text(
style: theme.textTheme.titleSmall?.copyWith( filtered.length == 1
color: colors.onSurfaceVariant, ? '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<TransactionsScreen> {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24), padding: const EdgeInsets.symmetric(vertical: 24),
child: Text( child: Text(
_isSearching _isSearching || _hasListFilters
? 'No transactions match this search.' ? 'No transactions match these filters.'
: _selectedDay != null : _selectedDay != null
? 'No transactions on this day.' ? 'No transactions on this day.'
: 'No transactions this month.', : _searchInMonthOnly
? 'No transactions this month.'
: 'No transactions yet.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium, style: theme.textTheme.bodyMedium,
), ),
+1 -1
View File
@@ -137,7 +137,7 @@ class IngtrakApi {
return BankBalance.fromJson(await _decodeObject(response)); return BankBalance.fromJson(await _decodeObject(response));
} }
Future<List<BankTransaction>> fetchTransactions({int days = 90}) async { Future<List<BankTransaction>> fetchTransactions({int days = 89}) async {
final response = await _client.get( final response = await _client.get(
_uri('/api/transactions', {'days': '$days'}), _uri('/api/transactions', {'days': '$days'}),
headers: const {'Accept': 'application/json'}, headers: const {'Accept': 'application/json'},
File diff suppressed because it is too large Load Diff
+287
View File
@@ -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<SimilarMatchCriteria?> showSimilarMatchDialog(
BuildContext context, {
required BankTransaction prototype,
required List<BankTransaction> all,
required SimilarMatchCriteria initial,
}) {
return showDialog<SimilarMatchCriteria>(
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<BankTransaction> 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<BankTransaction> 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<void>(
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<TxFlowFilter>(
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'),
),
],
);
}
}