fix tx listing
This commit is contained in:
@@ -42,6 +42,9 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
/// Empty = include every label. Non-empty = only these ids on the Labels chart.
|
||||
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)`).
|
||||
late DateTime _cursor;
|
||||
|
||||
@@ -100,13 +103,12 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickChartLabels(
|
||||
BuildContext context,
|
||||
List<TxLabel> available,
|
||||
) async {
|
||||
Future<void> _pickChartLabels(BuildContext context) async {
|
||||
final meta = context.read<TxMetaController>();
|
||||
final available = meta.allLabels;
|
||||
if (available.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No labels in use for this period yet')),
|
||||
const SnackBar(content: Text('No labels yet — create some first')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -125,18 +127,22 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
subtitle:
|
||||
'Choose which labels appear. Long-press to nest under another.',
|
||||
allowMove: true,
|
||||
includeFlow: true,
|
||||
initialFlow: _labelFlow,
|
||||
);
|
||||
if (result == null || !mounted) return;
|
||||
|
||||
setState(() {
|
||||
_labelFlow = result.flow;
|
||||
final allIds = available.map((l) => l.id).toSet();
|
||||
if (result.isEmpty ||
|
||||
(result.length == allIds.length && result.containsAll(allIds))) {
|
||||
if (result.labelIds.isEmpty ||
|
||||
(result.labelIds.length == allIds.length &&
|
||||
result.labelIds.containsAll(allIds))) {
|
||||
_includedLabelIds.clear();
|
||||
} else {
|
||||
_includedLabelIds
|
||||
..clear()
|
||||
..addAll(result);
|
||||
..addAll(result.labelIds);
|
||||
}
|
||||
_metric = _MetricMode.labels;
|
||||
});
|
||||
@@ -203,7 +209,6 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
// all txs 3–4 times and resolving full meta each time.
|
||||
Map<DateTime, _MonthBucket> monthly = const {};
|
||||
var labelSpend = const <_LabelSpend>[];
|
||||
var availableLabels = const <TxLabel>[];
|
||||
Map<DateTime, Map<String, double>> labelAmountByPeriod = const {};
|
||||
|
||||
if (_metric == _MetricMode.flow) {
|
||||
@@ -215,9 +220,9 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
periods,
|
||||
_period,
|
||||
includedLabelIds: _includedLabelIds,
|
||||
flow: _labelFlow,
|
||||
);
|
||||
labelSpend = labels.spend;
|
||||
availableLabels = [for (final row in labels.allSpend) row.label];
|
||||
labelAmountByPeriod = labels.amountByPeriod;
|
||||
}
|
||||
|
||||
@@ -253,11 +258,14 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
labelSpend: labelSpend,
|
||||
labelAmountByMonth: labelAmountByPeriod,
|
||||
currency: currency,
|
||||
includedLabelCount: _includedLabelIds.isEmpty
|
||||
includedLabelCount: _includedLabelIds.isEmpty &&
|
||||
_labelFlow == TxFlowFilter.expense
|
||||
? null
|
||||
: _includedLabelIds.length,
|
||||
availableLabelCount: availableLabels.length,
|
||||
onPickLabels: () => _pickChartLabels(context, availableLabels),
|
||||
: (_includedLabelIds.isEmpty
|
||||
? meta.allLabels.length
|
||||
: _includedLabelIds.length),
|
||||
availableLabelCount: meta.allLabels.length,
|
||||
onPickLabels: () => _pickChartLabels(context),
|
||||
onLabelTap: (label) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
@@ -309,7 +317,11 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
labelSpend.fold<double>(0, (s, e) => s + e.amount),
|
||||
currency,
|
||||
),
|
||||
label: 'Labeled spend · $_periodTitle',
|
||||
label: switch (_labelFlow) {
|
||||
TxFlowFilter.income => 'Labeled income · $_periodTitle',
|
||||
TxFlowFilter.expense => 'Labeled spend · $_periodTitle',
|
||||
TxFlowFilter.all => 'Labeled activity · $_periodTitle',
|
||||
},
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
@@ -518,13 +530,14 @@ class _LabelAggregates {
|
||||
int get availableCount => allSpend.length;
|
||||
}
|
||||
|
||||
/// One pass over outflows: totals, per-period series, and history amounts.
|
||||
/// One pass over labeled amounts: totals, per-period series, and history.
|
||||
_LabelAggregates _labelAggregates(
|
||||
List<BankTransaction> txs,
|
||||
TxMetaController meta,
|
||||
List<DateTime> periods,
|
||||
_PeriodMode mode, {
|
||||
Set<String> includedLabelIds = const {},
|
||||
TxFlowFilter flow = TxFlowFilter.expense,
|
||||
}) {
|
||||
final keys = {for (final p in periods) _periodKey(p, mode)};
|
||||
final allByLabel = <String, double>{};
|
||||
@@ -537,7 +550,7 @@ _LabelAggregates _labelAggregates(
|
||||
final filtering = includedLabelIds.isNotEmpty;
|
||||
|
||||
for (final tx in txs) {
|
||||
if (tx.amount >= 0) continue;
|
||||
if (!txMatchesFlow(tx, flow)) continue;
|
||||
final date = _txDate(tx);
|
||||
if (date == null) continue;
|
||||
final key = _periodKey(date, mode);
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../providers/tx_meta_provider.dart';
|
||||
import '../utils/helpers.dart';
|
||||
import '../widgets/label_parent_picker.dart';
|
||||
import '../widgets/responsive_body.dart';
|
||||
import '../widgets/similar_match_dialog.dart';
|
||||
|
||||
class TransactionDetailScreen extends StatefulWidget {
|
||||
const TransactionDetailScreen({super.key, required this.tx});
|
||||
@@ -24,8 +25,10 @@ class TransactionDetailScreen extends StatefulWidget {
|
||||
|
||||
class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
late final TextEditingController _notesController;
|
||||
late final TextEditingController _patternController;
|
||||
late SimilarMatchCriteria _matchCriteria;
|
||||
late Set<String> _draftLabelIds;
|
||||
var _dirty = false;
|
||||
var _labelsDirty = false;
|
||||
late bool _applyToSimilar;
|
||||
var _busy = false;
|
||||
String? _busyMessage;
|
||||
@@ -36,13 +39,21 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
final meta = context.read<TxMetaController>();
|
||||
final notes = meta.metaFor(widget.tx).notes;
|
||||
_notesController = TextEditingController(text: notes);
|
||||
final pattern = defaultSimilarPattern(widget.tx.description);
|
||||
_patternController = TextEditingController(text: pattern);
|
||||
final tx = widget.tx;
|
||||
_matchCriteria = SimilarMatchCriteria(
|
||||
pattern: defaultSimilarPattern(tx.description),
|
||||
flow: tx.amount < 0
|
||||
? TxFlowFilter.expense
|
||||
: tx.amount > 0
|
||||
? TxFlowFilter.income
|
||||
: TxFlowFilter.all,
|
||||
);
|
||||
_draftLabelIds = meta.metaFor(tx).labelIds.toSet();
|
||||
final all = context.read<BankController>().transactions;
|
||||
final similarCount = meta.countSimilar(
|
||||
widget.tx,
|
||||
tx,
|
||||
all,
|
||||
pattern: pattern,
|
||||
criteria: _matchCriteria,
|
||||
);
|
||||
// Default on when there are peers — otherwise users never notice the toggle.
|
||||
_applyToSimilar = similarCount > 1;
|
||||
@@ -51,16 +62,35 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_notesController.dispose();
|
||||
_patternController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String get _pattern => _patternController.text.trim();
|
||||
String get _pattern => _matchCriteria.pattern;
|
||||
|
||||
List<BankTransaction> _similarScope(TxMetaController meta) {
|
||||
final all = context.read<BankController>().transactions;
|
||||
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(
|
||||
@@ -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(
|
||||
() async {
|
||||
final meta = context.read<TxMetaController>();
|
||||
final similar = _similarScope(meta);
|
||||
final count = similar.isEmpty ? 1 : similar.length;
|
||||
await meta.toggleLabel(
|
||||
widget.tx,
|
||||
labelId,
|
||||
applyToSimilar: similar,
|
||||
matchPattern: _pattern,
|
||||
final similar = toMatched ? _similarScope(meta) : const <BankTransaction>[];
|
||||
final targets = similar.isEmpty ? [widget.tx] : similar;
|
||||
await meta.setLabelsForMany(
|
||||
targets,
|
||||
_draftLabelIds.toList(growable: false),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (count > 1) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Labels updated on $count similar transactions'),
|
||||
setState(() {
|
||||
_labelsDirty = false;
|
||||
_draftLabelIds = meta.metaFor(widget.tx).labelIds.toSet();
|
||||
});
|
||||
final count = targets.length;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
count == 1
|
||||
? 'Labels saved'
|
||||
: 'Labels updated on $count transactions',
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
},
|
||||
message: _applyToSimilar
|
||||
message: toMatched
|
||||
? 'Updating labels on matching transactions…'
|
||||
: null,
|
||||
: 'Saving labels…',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _clearLabels() async {
|
||||
await _runBusy(
|
||||
() {
|
||||
final meta = context.read<TxMetaController>();
|
||||
return meta.clearLabels(
|
||||
widget.tx,
|
||||
applyToSimilar: _similarScope(meta),
|
||||
matchPattern: _pattern,
|
||||
);
|
||||
},
|
||||
message: _applyToSimilar
|
||||
? 'Clearing labels on matching transactions…'
|
||||
: 'Clearing labels…',
|
||||
);
|
||||
Future<void> _clearDraftLabels() async {
|
||||
setState(() {
|
||||
_draftLabelIds.clear();
|
||||
_labelsDirty = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _createLabel() async {
|
||||
@@ -171,7 +211,7 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
parentId: result.parentId,
|
||||
);
|
||||
if (!mounted) return;
|
||||
await _toggleLabel(label.id);
|
||||
await _toggleDraftLabel(label.id);
|
||||
}
|
||||
|
||||
Future<void> _labelActions(TxLabel label) async {
|
||||
@@ -246,12 +286,11 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
final similarCount = metaController.countSimilar(
|
||||
tx,
|
||||
all,
|
||||
pattern: _pattern,
|
||||
criteria: _matchCriteria,
|
||||
);
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final amountColor = tx.amount >= 0 ? colors.primary : colors.error;
|
||||
final selectedIds = meta.labelIds.toSet();
|
||||
final labelEntries = metaController.labelsInTreeOrder();
|
||||
|
||||
return Scaffold(
|
||||
@@ -304,36 +343,28 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Match similar', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _patternController,
|
||||
textInputAction: TextInputAction.done,
|
||||
enabled: !_busy,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'e.g. albert heijn or /albert\\s*heijn/',
|
||||
helperText:
|
||||
'Plain text = contains. Wrap with /…/ for regex.',
|
||||
helperMaxLines: 2,
|
||||
suffixIcon: IconButton(
|
||||
tooltip: 'Reset default',
|
||||
onPressed: _busy
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
_patternController.text =
|
||||
defaultSimilarPattern(tx.description);
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
Row(
|
||||
children: [
|
||||
Text('Match similar', style: theme.textTheme.titleSmall),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: _busy ? null : _editMatchCriteria,
|
||||
icon: const Icon(Icons.tune, size: 18),
|
||||
label: const Text('Edit'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'“${_matchCriteria.pattern}”',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: const Icon(Icons.copy_all_outlined),
|
||||
title: const Text('Apply to all similar'),
|
||||
title: const Text('Include matched transactions'),
|
||||
subtitle: Text(
|
||||
similarCount <= 1
|
||||
? 'No other matches for this pattern'
|
||||
@@ -344,6 +375,14 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
? null
|
||||
: (value) => setState(() => _applyToSimilar = value),
|
||||
),
|
||||
if (_applyToSimilar && similarCount > 1)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: _busy ? null : _editMatchCriteria,
|
||||
child: Text('Review $similarCount matches'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
@@ -366,9 +405,9 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
onPressed: _busy ? null : _createLabel,
|
||||
child: const Text('New'),
|
||||
),
|
||||
if (meta.hasLabel)
|
||||
if (_draftLabelIds.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: _busy ? null : _clearLabels,
|
||||
onPressed: _busy ? null : _clearDraftLabels,
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
@@ -394,7 +433,8 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
color: colors.outlineVariant.withValues(alpha: 0.45),
|
||||
),
|
||||
InkWell(
|
||||
onTap: _busy ? null : () => _toggleLabel(entry.label.id),
|
||||
onTap:
|
||||
_busy ? null : () => _toggleDraftLabel(entry.label.id),
|
||||
onLongPress:
|
||||
_busy ? null : () => _labelActions(entry.label),
|
||||
child: Padding(
|
||||
@@ -435,10 +475,11 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
materialTapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
value: selectedIds.contains(entry.label.id),
|
||||
value: _draftLabelIds.contains(entry.label.id),
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (_) => _toggleLabel(entry.label.id),
|
||||
: (_) =>
|
||||
_toggleDraftLabel(entry.label.id),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -450,6 +491,35 @@ class _TransactionDetailScreenState extends State<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),
|
||||
Text('Notes', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
@@ -478,7 +548,8 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Sub-labels also select their parents. Unchecking a parent '
|
||||
'clears its sub-labels. Long-press a row to move or delete.',
|
||||
'clears its sub-labels. Long-press a row to move or delete. '
|
||||
'Use Apply to save label changes.',
|
||||
style: theme.textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -58,6 +58,8 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
late final Set<String> _labelFilters;
|
||||
int? _yearFilter;
|
||||
var _showCalendar = false;
|
||||
var _flowFilter = TxFlowFilter.all;
|
||||
var _amountRange = const TxAmountRange();
|
||||
|
||||
/// Query actually used for filtering (Enter, or live once ≥3 chars).
|
||||
String _activeQuery = '';
|
||||
@@ -75,7 +77,8 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
_month = start.isAfter(now) ? now : start;
|
||||
}
|
||||
_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;
|
||||
}
|
||||
|
||||
@@ -89,6 +92,15 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
bool get _isSearching =>
|
||||
_activeQuery.trim().isNotEmpty || _labelFilters.isNotEmpty;
|
||||
|
||||
bool get _hasListFilters =>
|
||||
_labelFilters.isNotEmpty ||
|
||||
_flowFilter != TxFlowFilter.all ||
|
||||
_amountRange.isActive;
|
||||
|
||||
bool _matchesExtraFilters(BankTransaction tx) =>
|
||||
txMatchesFlow(tx, _flowFilter) &&
|
||||
txMatchesAmountRange(tx, _amountRange);
|
||||
|
||||
void _goMonth(int delta) {
|
||||
final next = addMonths(_month, delta);
|
||||
final current = monthStart(DateTime.now());
|
||||
@@ -141,31 +153,72 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
setState(() => _activeQuery = '');
|
||||
}
|
||||
|
||||
Future<void> _pickLabels(List<TxLabel> available) async {
|
||||
if (available.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No labels in use yet')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await showLabelFilterDialog(
|
||||
Future<void> _pickFilters(List<TxLabel> available) async {
|
||||
final bank = context.read<BankController>();
|
||||
final result = await showTransactionFilterDialog(
|
||||
context,
|
||||
available: available,
|
||||
initiallySelected: Set<String>.of(_labelFilters),
|
||||
title: 'Filter transactions',
|
||||
subtitle: 'Show transactions with any of these labels.',
|
||||
includeUncategorized: true,
|
||||
transactions: bank.transactions,
|
||||
currentMonth: _month,
|
||||
initial: TransactionFilterSelection(
|
||||
labelIds: Set<String>.of(_labelFilters),
|
||||
flow: _flowFilter,
|
||||
amountRange: _amountRange,
|
||||
query: _activeQuery,
|
||||
monthOnly: _searchInMonthOnly,
|
||||
),
|
||||
);
|
||||
if (result == null || !mounted) return;
|
||||
|
||||
setState(() {
|
||||
_labelFilters
|
||||
..clear()
|
||||
..addAll(result);
|
||||
..addAll(result.labelIds);
|
||||
_flowFilter = result.flow;
|
||||
_amountRange = result.amountRange;
|
||||
_searchInMonthOnly = result.monthOnly;
|
||||
if (!_searchInMonthOnly) {
|
||||
_showCalendar = false;
|
||||
_selectedDay = null;
|
||||
}
|
||||
_activeQuery = result.query.trim();
|
||||
_searchController.text = _activeQuery;
|
||||
if (_activeQuery.isNotEmpty) {
|
||||
_searchDebounceTimer?.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<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
|
||||
Widget build(BuildContext context) {
|
||||
final bank = context.watch<BankController>();
|
||||
@@ -272,39 +325,34 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
}
|
||||
final counts = txCountsByDay(monthDates);
|
||||
|
||||
final searchPool = !_isSearching
|
||||
? calendarScoped
|
||||
: _searchInMonthOnly
|
||||
? monthScoped
|
||||
: _yearScoped(txs);
|
||||
// Period always drives the pool (not only when text/labels are active),
|
||||
// so Income/Expense with Period=All searches the full list.
|
||||
final searchPool = _searchInMonthOnly
|
||||
? (_selectedDay != null ? calendarScoped : monthScoped)
|
||||
: _yearScoped(txs);
|
||||
|
||||
// Text hits first (no label filter) so refine chips stay available while selecting.
|
||||
final textMatched = _activeQuery.trim().isEmpty
|
||||
? const <BankTransaction>[]
|
||||
: metaController.search(searchPool, _activeQuery);
|
||||
// Apply text + label filters, then flow / amount.
|
||||
final filtered = [
|
||||
for (final tx in metaController.search(
|
||||
searchPool,
|
||||
_activeQuery,
|
||||
labelIds: _labelFilters,
|
||||
))
|
||||
if (_matchesExtraFilters(tx)) tx,
|
||||
];
|
||||
|
||||
final filtered = metaController.search(
|
||||
searchPool,
|
||||
_activeQuery,
|
||||
labelIds: _labelFilters,
|
||||
);
|
||||
|
||||
final refineLabels = _activeQuery.trim().isEmpty
|
||||
? const <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)
|
||||
// Labels on the current result that aren't part of the active label filter.
|
||||
final otherLabels = <TxLabel>[
|
||||
for (final label in metaController.usedLabels(filtered))
|
||||
if (!_labelFilters.contains(label.id)) label,
|
||||
];
|
||||
final showUncategorizedOther = filtered.isNotEmpty &&
|
||||
filtered.any((tx) => !metaController.metaFor(tx).hasLabel) &&
|
||||
!_labelFilters.contains(kUncategorizedFilterId);
|
||||
|
||||
var moneyOut = 0.0;
|
||||
var moneyIn = 0.0;
|
||||
for (final tx in calendarScoped) {
|
||||
for (final tx in filtered) {
|
||||
if (tx.amount < 0) {
|
||||
moneyOut += tx.amount.abs();
|
||||
} else if (tx.amount > 0) {
|
||||
@@ -395,19 +443,19 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
IconButton(
|
||||
tooltip: _labelFilters.isEmpty
|
||||
? 'Filter by label'
|
||||
: 'Filter by label (${_labelFilters.length})',
|
||||
onPressed: () => _pickLabels(
|
||||
tooltip: _hasListFilters
|
||||
? 'Filters active'
|
||||
: 'Filter transactions',
|
||||
onPressed: () => _pickFilters(
|
||||
metaController.usedLabels(txs),
|
||||
),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: _labelFilters.isEmpty
|
||||
? null
|
||||
: colors.secondaryContainer,
|
||||
backgroundColor: _hasListFilters
|
||||
? colors.secondaryContainer
|
||||
: null,
|
||||
),
|
||||
icon: Badge(
|
||||
isLabelVisible: _labelFilters.isNotEmpty,
|
||||
isLabelVisible: _hasListFilters,
|
||||
smallSize: 6,
|
||||
child: const Icon(Icons.filter_list),
|
||||
),
|
||||
@@ -416,43 +464,61 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
tooltip: _showCalendar ? 'Hide calendar' : 'Show calendar',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showCalendar = !_showCalendar;
|
||||
if (!_showCalendar) _selectedDay = null;
|
||||
if (_showCalendar) {
|
||||
_showCalendar = false;
|
||||
_selectedDay = null;
|
||||
} else {
|
||||
_showCalendar = true;
|
||||
// Calendar navigation requires month scope.
|
||||
_searchInMonthOnly = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: _showCalendar
|
||||
backgroundColor: _showCalendar && _searchInMonthOnly
|
||||
? colors.secondaryContainer
|
||||
: null,
|
||||
),
|
||||
icon: Icon(
|
||||
_showCalendar
|
||||
_showCalendar && _searchInMonthOnly
|
||||
? Icons.calendar_month
|
||||
: Icons.calendar_month_outlined,
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: 'Search only in calendar month',
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _searchInMonthOnly,
|
||||
onChanged: (value) {
|
||||
setState(
|
||||
() => _searchInMonthOnly = value ?? false,
|
||||
);
|
||||
},
|
||||
ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: _searchController,
|
||||
builder: (context, value, _) {
|
||||
if (value.text.trim().isEmpty && !_showCalendar) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Tooltip(
|
||||
message: 'Limit results to calendar month',
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _searchInMonthOnly,
|
||||
onChanged: (checked) {
|
||||
setState(() {
|
||||
_searchInMonthOnly = checked ?? false;
|
||||
if (!_searchInMonthOnly) {
|
||||
_showCalendar = false;
|
||||
_selectedDay = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
Text('Month', style: theme.textTheme.labelMedium),
|
||||
],
|
||||
),
|
||||
Text('Month', style: theme.textTheme.labelMedium),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_showCalendar) ...[
|
||||
if (_searchInMonthOnly && _showCalendar) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: TxHeatmap(
|
||||
month: _month,
|
||||
@@ -477,7 +543,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
] else
|
||||
] else if (_searchInMonthOnly)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
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(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_isSearching)
|
||||
Text(
|
||||
_yearFilter != null
|
||||
? 'Filtered to $_yearFilter'
|
||||
: _searchInMonthOnly
|
||||
? 'Searching in ${formatMonthYear(_month)}'
|
||||
: 'Searching all transactions',
|
||||
style: theme.textTheme.labelMedium,
|
||||
),
|
||||
if (_labelFilters.isNotEmpty) ...[
|
||||
if (_isSearching) const SizedBox(height: 8),
|
||||
Text(
|
||||
_yearFilter != null
|
||||
? 'Filtered to $_yearFilter'
|
||||
: _searchInMonthOnly
|
||||
? 'Showing ${formatMonthYear(_month)}'
|
||||
: 'Showing all transactions',
|
||||
style: theme.textTheme.labelMedium,
|
||||
),
|
||||
if (_hasListFilters) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
if (_flowFilter == TxFlowFilter.income)
|
||||
InputChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
avatar: const Icon(Icons.south_west, size: 14),
|
||||
label: const Text('Income'),
|
||||
onDeleted: () {
|
||||
setState(() => _flowFilter = TxFlowFilter.all);
|
||||
},
|
||||
),
|
||||
if (_flowFilter == TxFlowFilter.expense)
|
||||
InputChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
avatar: const Icon(Icons.north_east, size: 14),
|
||||
label: const Text('Expense'),
|
||||
onDeleted: () {
|
||||
setState(() => _flowFilter = TxFlowFilter.all);
|
||||
},
|
||||
),
|
||||
if (_amountRange.isActive)
|
||||
InputChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text(
|
||||
_amountRange.exact != null
|
||||
? '= ${_amountRange.exact!.toStringAsFixed(
|
||||
_amountRange.exact! ==
|
||||
_amountRange.exact!
|
||||
.roundToDouble()
|
||||
? 0
|
||||
: 2,
|
||||
)}'
|
||||
: [
|
||||
if (_amountRange.min != null)
|
||||
'≥ ${_amountRange.min!.toStringAsFixed(0)}',
|
||||
if (_amountRange.max != null)
|
||||
'≤ ${_amountRange.max!.toStringAsFixed(0)}',
|
||||
].join(' '),
|
||||
),
|
||||
onDeleted: () {
|
||||
setState(
|
||||
() => _amountRange = const TxAmountRange(),
|
||||
);
|
||||
},
|
||||
),
|
||||
for (final id in _labelFilters)
|
||||
if (id == kUncategorizedFilterId)
|
||||
InputChip(
|
||||
@@ -586,10 +694,10 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
],
|
||||
),
|
||||
],
|
||||
if (refineChoices.isNotEmpty) ...[
|
||||
if (otherLabels.isNotEmpty || showUncategorizedOther) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Refine by label',
|
||||
'Other labels found',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
@@ -599,7 +707,26 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final label in refineChoices)
|
||||
if (showUncategorizedOther)
|
||||
FilterChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
avatar: Icon(
|
||||
kUncategorizedFilterLabel.icon,
|
||||
size: 14,
|
||||
color: kUncategorizedFilterLabel.color,
|
||||
),
|
||||
label: const Text('Uncategorized'),
|
||||
selected: false,
|
||||
onSelected: (_) {
|
||||
setState(
|
||||
() =>
|
||||
_labelFilters.add(kUncategorizedFilterId),
|
||||
);
|
||||
},
|
||||
),
|
||||
for (final label in otherLabels)
|
||||
FilterChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize:
|
||||
@@ -625,13 +752,26 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
filtered.length == 1
|
||||
? '1 transaction'
|
||||
: '${filtered.length} transactions',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
filtered.length == 1
|
||||
? '1 transaction'
|
||||
: '${filtered.length} transactions',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
if ((_hasListFilters || _activeQuery.trim().isNotEmpty) &&
|
||||
filtered.isNotEmpty)
|
||||
IconButton(
|
||||
tooltip: 'Edit labels for these transactions',
|
||||
onPressed: () => _editLabelsForSubset(filtered),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -640,11 +780,13 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text(
|
||||
_isSearching
|
||||
? 'No transactions match this search.'
|
||||
_isSearching || _hasListFilters
|
||||
? 'No transactions match these filters.'
|
||||
: _selectedDay != null
|
||||
? 'No transactions on this day.'
|
||||
: 'No transactions this month.',
|
||||
: _searchInMonthOnly
|
||||
? 'No transactions this month.'
|
||||
: 'No transactions yet.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user