fix tx listing

This commit is contained in:
davmar
2026-09-09 11:40:05 +02:00
parent 2a741c7f2a
commit 903a2980de
8 changed files with 1638 additions and 281 deletions
+237 -95
View File
@@ -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,
),