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