diff --git a/lib/main.dart b/lib/main.dart index 8794e4d..44803f3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,6 +14,7 @@ import 'screens/splash_screen.dart'; import 'services/storage_service.dart'; import 'utils/app_theme.dart'; import 'utils/constants.dart'; +import 'widgets/skip_build_when_covered.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -101,6 +102,7 @@ class _MaterialRoot extends StatelessWidget { theme: AppTheme.light(), darkTheme: AppTheme.dark(), themeMode: themeMode, + navigatorObservers: [appRouteObserver], home: const SplashScreen(), ); } diff --git a/lib/models/transaction_meta.dart b/lib/models/transaction_meta.dart index f4d4110..949922a 100644 --- a/lib/models/transaction_meta.dart +++ b/lib/models/transaction_meta.dart @@ -11,6 +11,7 @@ class TxLabel { required this.color, this.icon = Icons.label_outline, this.custom = false, + this.parentId, }); final String id; @@ -19,25 +20,127 @@ class TxLabel { final IconData icon; final bool custom; + /// Optional parent label id for nesting (sub-labels). + final String? parentId; + + bool get isRoot => parentId == null || parentId!.isEmpty; + + TxLabel copyWith({ + String? name, + Color? color, + IconData? icon, + String? parentId, + bool clearParent = false, + }) { + return TxLabel( + id: id, + name: name ?? this.name, + color: color ?? this.color, + icon: icon ?? this.icon, + custom: custom, + parentId: clearParent ? null : (parentId ?? this.parentId), + ); + } + Map toJson() => { 'id': id, 'name': name, // ignore: deprecated_member_use — wide compatibility across Flutter versions 'color': color.value, 'custom': custom, + if (parentId != null && parentId!.isNotEmpty) 'parentId': parentId, }; factory TxLabel.fromJson(Map json) { + final parent = json['parentId'] as String?; return TxLabel( id: json['id'] as String? ?? 'custom', name: json['name'] as String? ?? 'Label', color: Color(json['color'] as int? ?? 0xFF64748B), custom: json['custom'] as bool? ?? true, + parentId: (parent != null && parent.isNotEmpty) ? parent : null, ); } } -/// Built-in presets (no "uncategorized" / "other"). +/// One row in a depth-first label tree walk. +class LabelTreeEntry { + const LabelTreeEntry({required this.label, required this.depth}); + + final TxLabel label; + final int depth; +} + +/// Max nesting depth (root = 0). +const int kMaxLabelDepth = 3; + +/// Depth-first tree order using each label's [TxLabel.parentId]. +List flattenLabelTree( + Iterable labels, { + String? Function(String id)? parentOf, +}) { + final byId = { + for (final label in labels) label.id: label, + }; + String? resolveParent(String id) { + if (parentOf != null) return parentOf(id); + return byId[id]?.parentId; + } + + final children = >{}; + for (final label in byId.values) { + final parent = resolveParent(label.id); + final key = (parent != null && byId.containsKey(parent)) ? parent : null; + (children[key] ??= []).add(label); + } + for (final list in children.values) { + list.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + } + + final out = []; + void walk(String? parentId, int depth) { + final kids = children[parentId]; + if (kids == null) return; + for (final label in kids) { + out.add(LabelTreeEntry(label: label, depth: depth)); + if (depth < kMaxLabelDepth) { + walk(label.id, depth + 1); + } + } + } + + walk(null, 0); + return out; +} + +/// Visible indent depth among [visibleIds] only (skip missing ancestors). +int labelVisibleDepth( + String labelId, + Set visibleIds, + String? Function(String id) parentOf, +) { + var depth = 0; + var parent = parentOf(labelId); + final seen = {labelId}; + while (parent != null && parent.isNotEmpty && seen.add(parent)) { + if (visibleIds.contains(parent)) depth++; + parent = parentOf(parent); + } + return depth; +} + +/// Filter-only id for txs with no labels (not an assignable category). +const String kUncategorizedFilterId = '__uncategorized__'; + +/// Chip presentation for [kUncategorizedFilterId] refine filters. +const TxLabel kUncategorizedFilterLabel = TxLabel( + id: kUncategorizedFilterId, + name: 'Uncategorized', + color: Color(0xFF64748B), + icon: Icons.label_off_outlined, +); + +/// Built-in presets (no "uncategorized" / "other" as assignable labels). const List kBuiltinLabels = [ TxLabel( id: 'groceries', @@ -205,7 +308,10 @@ class TxExactTemplate { } } -/// Strip bank noise so "AH Campus … Transactie:123" matches other AH Campus txs. +/// Strip bank noise so supermarket / POS lines group by merchant stem. +/// +/// Example: `BCK*AH CAMPUS DIEMEN ZUID NLD Pasvolgnr:…` → `ah campus` +/// Example: `Albert Heijn 1562 Utrecht` → `albert heijn` String merchantKeyFromDescription(String description) { var s = description .trim() @@ -240,8 +346,58 @@ String merchantKeyFromDescription(String description) { } s = s - .replaceAll(RegExp(r'\s+nld\s*$'), '') .replaceAll(RegExp(r'^bck\*'), '') + .replaceAll(RegExp(r'\s+'), ' ') .trim(); - return s; + + // Drop trailing country / city noise and store / terminal numbers. + s = s.replaceAll( + RegExp( + r'\s+(nld|nl|nederland|netherlands|bel|belgium|deu|germany|de)\s*$', + ), + '', + ); + // Remove digit runs (store ids, amounts embedded in text, etc.). + s = s.replaceAll(RegExp(r'\b\d+\b'), ' '); + s = s.replaceAll(RegExp(r'\s+'), ' ').trim(); + + // Keep a short stem so "ah campus diemen zuid" ≈ "ah campus". + final words = [ + for (final w in s.split(' ')) + if (w.length > 1) w, + ]; + if (words.isEmpty) return s; + if (words.length <= 2) return words.join(' '); + // Prefer first two tokens for long POS strings; three when the 2nd is tiny + // ("de", "van") so "albert heijn" / "jumbo city" stay intact. + if (words[1].length <= 2 && words.length >= 3) { + return words.take(3).join(' '); + } + return words.take(2).join(' '); } + +/// Default “apply to similar” pattern — usually the merchant stem. +String defaultSimilarPattern(String description) => + merchantKeyFromDescription(description); + +/// Match [description] against a simple contains needle or `/regex/`. +bool descriptionMatchesPattern(String description, String pattern) { + final raw = pattern.trim(); + if (raw.isEmpty) return false; + final hay = description.toLowerCase(); + + if (raw.length >= 3 && raw.startsWith('/') && raw.endsWith('/')) { + final body = raw.substring(1, raw.length - 1); + if (body.isEmpty) return false; + try { + return RegExp(body, caseSensitive: false).hasMatch(description); + } catch (_) { + return hay.contains(raw.toLowerCase()); + } + } + + final needle = raw.toLowerCase(); + if (needle.length < 2) return false; + return hay.contains(needle); +} + diff --git a/lib/providers/bank_provider.dart b/lib/providers/bank_provider.dart index 1500546..26a8243 100644 --- a/lib/providers/bank_provider.dart +++ b/lib/providers/bank_provider.dart @@ -30,6 +30,7 @@ class BankController extends ChangeNotifier { _remoteTransactions = cached.transactions; } _importedTransactions = _storage?.loadImportedTransactions() ?? const []; + _invalidateMerged(); } final IngtrakApi _api; @@ -39,6 +40,7 @@ class BankController extends ChangeNotifier { BankBalance? _balance; List _remoteTransactions = const []; List _importedTransactions = const []; + List? _mergedTransactions; bool _isLoading = false; bool _isConnecting = false; @@ -50,8 +52,16 @@ class BankController extends ChangeNotifier { BankBalance? get balance => _balance; /// Remote API txs merged with locally imported CSV txs (deduped). + /// + /// Cached — [mergeTransactions] is O(n·m) and was re-running on every + /// `bank.transactions` read during rebuilds. List get transactions => - mergeTransactions(_remoteTransactions, _importedTransactions); + _mergedTransactions ??= + mergeTransactions(_remoteTransactions, _importedTransactions); + + void _invalidateMerged() { + _mergedTransactions = null; + } int get importedCount => _importedTransactions.length; bool get isLoading => _isLoading; @@ -121,6 +131,7 @@ class BankController extends ChangeNotifier { _remoteTransactions, _importedTransactions, ); + _invalidateMerged(); await _persistImported(); } return result; @@ -139,6 +150,7 @@ class BankController extends ChangeNotifier { Future clearImportedTransactions() async { _importedTransactions = const []; + _invalidateMerged(); await _storage?.clearImportedTransactions(); notifyListeners(); } @@ -210,6 +222,7 @@ class BankController extends ChangeNotifier { _remoteTransactions, _importedTransactions, ); + _invalidateMerged(); await _persistImported(); _error = null; } on IngtrakApiException catch (error) { @@ -218,6 +231,7 @@ class BankController extends ChangeNotifier { _status = ConnectStatus.disconnected; _balance = null; _remoteTransactions = const []; + _invalidateMerged(); _error = error.message; await _persistCache(); } else if (!hasData) { diff --git a/lib/providers/tx_meta_provider.dart b/lib/providers/tx_meta_provider.dart index 365b7ba..b71a642 100644 --- a/lib/providers/tx_meta_provider.dart +++ b/lib/providers/tx_meta_provider.dart @@ -1,6 +1,8 @@ /// Local labels / notes / recurring annotations for bank transactions. library; +import 'dart:async'; + import 'package:flutter/material.dart'; import '../models/bank.dart'; @@ -18,6 +20,7 @@ class TxMetaController extends ChangeNotifier { _notesByMerchant = Map.of(loaded.notesByMerchant); _exactTemplates = Map.of(loaded.exactTemplates); _customLabels = List.of(loaded.customLabels); + _labelParents = Map.of(loaded.labelParents); } final StorageService _storage; @@ -27,39 +30,246 @@ class TxMetaController extends ChangeNotifier { Map _notesByMerchant = {}; Map _exactTemplates = {}; List _customLabels = []; + Map _labelParents = {}; /// Built-in + custom labels available for picking. List get allLabels => [...kBuiltinLabels, ..._customLabels]; + Map? _labelByIdCache; + Map>? _labelIdsByTxCache; + + Map get _labelById { + return _labelByIdCache ??= { + for (final label in allLabels) label.id: label, + }; + } + + void _invalidateLabelCache() { + _labelByIdCache = null; + _labelIdsByTxCache = null; + _labeledTxCount = null; + _labeledCountListIdentity = null; + } + + int? _labeledTxCount; + int? _labeledCountListIdentity; + + /// Cached count of txs that resolve to at least one label. + int labeledTransactionCount(List txs) { + final identity = identityHashCode(txs); + if (_labeledTxCount != null && _labeledCountListIdentity == identity) { + return _labeledTxCount!; + } + var n = 0; + for (final tx in txs) { + if (labelIdsFor(tx).isNotEmpty) n++; + } + _labeledTxCount = n; + _labeledCountListIdentity = identity; + return n; + } + List get customLabels => List.unmodifiable(_customLabels); + /// Effective parent id (hierarchy map wins over embedded [TxLabel.parentId]). + String? parentIdOf(String labelId) { + final mapped = _labelParents[labelId]; + if (mapped != null) { + return mapped.isEmpty ? null : mapped; + } + final embedded = _labelById[labelId]?.parentId; + if (embedded == null || embedded.isEmpty) return null; + return embedded; + } + + /// Labels in tree order with depth. + List labelsInTreeOrder([Iterable? subset]) { + final source = subset?.toList() ?? allLabels; + return flattenLabelTree(source, parentOf: parentIdOf); + } + + /// Tree order limited to [available], keeping relative indent. + List treeOrderFor(Iterable available) { + final availableList = available.toList(); + final ids = availableList.map((l) => l.id).toSet(); + final full = flattenLabelTree(allLabels, parentOf: parentIdOf); + return [ + for (final entry in full) + if (ids.contains(entry.label.id)) + LabelTreeEntry( + label: entry.label, + depth: labelVisibleDepth(entry.label.id, ids, parentIdOf), + ), + ]; + } + + int depthOf(String labelId) { + var depth = 0; + var parent = parentIdOf(labelId); + final seen = {labelId}; + while (parent != null && seen.add(parent)) { + depth++; + parent = parentIdOf(parent); + if (depth > kMaxLabelDepth) break; + } + return depth; + } + + /// Parent chain of [labelId], nearest parent first. + List ancestorIdsOf(String labelId) { + final out = []; + var parent = parentIdOf(labelId); + final seen = {labelId}; + while (parent != null && parent.isNotEmpty && seen.add(parent)) { + out.add(parent); + parent = parentIdOf(parent); + } + return out; + } + + /// All nested labels under [labelId]. + List descendantIdsOf(String labelId) { + final out = []; + void walk(String id) { + for (final label in allLabels) { + if (parentIdOf(label.id) == id) { + out.add(label.id); + walk(label.id); + } + } + } + + walk(labelId); + return out; + } + + /// Ensures every selected sub-label also includes its parents. + List expandWithAncestors(Iterable ids) { + final out = {}; + for (final id in ids) { + if (id.isEmpty) continue; + out.add(id); + out.addAll(ancestorIdsOf(id)); + } + return out.toList(growable: false); + } + + bool canSetParent({required String labelId, String? parentId}) { + if (parentId == null || parentId.isEmpty) return true; + if (parentId == labelId) return false; + if (_labelById[parentId] == null) return false; + if (depthOf(parentId) >= kMaxLabelDepth) return false; + // Prevent cycles: parent cannot be a descendant of label. + var walk = parentIdOf(parentId); + final seen = {parentId}; + while (walk != null && seen.add(walk)) { + if (walk == labelId) return false; + walk = parentIdOf(walk); + } + return true; + } + + /// Candidates that [labelId] can move under (excluding self/descendants). + List validParentsFor(String labelId) { + final banned = {labelId}; + void collectDescendants(String id) { + for (final label in allLabels) { + if (parentIdOf(label.id) == id && banned.add(label.id)) { + collectDescendants(label.id); + } + } + } + + collectDescendants(labelId); + return [ + for (final label in allLabels) + if (!banned.contains(label.id) && depthOf(label.id) < kMaxLabelDepth) + label, + ]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + } + + Future setLabelParent(String labelId, String? parentId) async { + final nextParent = + (parentId == null || parentId.isEmpty) ? null : parentId; + if (!canSetParent(labelId: labelId, parentId: nextParent)) { + return; + } + + final nextParents = Map.of(_labelParents); + if (nextParent == null) { + nextParents.remove(labelId); + } else { + nextParents[labelId] = nextParent; + } + _labelParents = nextParents; + + final customIndex = _customLabels.indexWhere((l) => l.id == labelId); + if (customIndex >= 0) { + final current = _customLabels[customIndex]; + final updated = nextParent == null + ? current.copyWith(clearParent: true) + : current.copyWith(parentId: nextParent); + _customLabels = [ + for (var i = 0; i < _customLabels.length; i++) + if (i == customIndex) updated else _customLabels[i], + ]; + } + _schedulePersist(); + } + TxLabel? labelById(String? id) { if (id == null || id.isEmpty) return null; - for (final label in allLabels) { - if (label.id == id) return label; - } - return null; + return _labelById[id]; } List labelsFor(TxMeta meta) { + final index = _labelById; return [ for (final id in meta.labelIds) - if (labelById(id) != null) labelById(id)!, + if (index[id] != null) index[id]!, ]; } + /// Labels to show on tiles: omit parents when a child is also selected. + List leafLabelsFor(TxMeta meta) { + final ids = meta.labelIds.toSet(); + final index = _labelById; + return [ + for (final id in ids) + if (index[id] != null && + !ids.any( + (other) => + other != id && ancestorIdsOf(other).contains(id), + )) + index[id]!, + ]; + } + + /// Label ids only — cheaper than [metaFor] for chart aggregation. + /// Always includes ancestor labels of any selected sub-label. + List labelIdsFor(BankTransaction tx) { + final cache = _labelIdsByTxCache ??= {}; + final cached = cache[tx.id]; + if (cached != null) return cached; + + final stored = _byId[tx.id]; + final raw = stored != null && stored.hasLabel + ? stored.labelIds + : (_resolveMerchantLabels(tx) ?? const []); + final ids = expandWithAncestors(raw); + final frozen = ids.isEmpty + ? const [] + : List.unmodifiable(ids); + cache[tx.id] = frozen; + return frozen; + } + /// Resolved meta: per-id overrides + inherited merchant rules. TxMeta metaFor(BankTransaction tx) { final stored = _byId[tx.id]; final template = _exactTemplates[tx.exactKey]; - final inheritedLabels = _labelsByMerchant[tx.merchantKey] ?? - _labelsByMerchant[tx.descriptionKey]; - final inheritedNotes = _notesByMerchant[tx.merchantKey] ?? - _notesByMerchant[tx.descriptionKey]; - - final labelIds = stored != null && stored.hasLabel - ? stored.labelIds - : (inheritedLabels ?? const []); + final labelIds = labelIdsFor(tx); + final inheritedNotes = _resolveMerchantNotes(tx); final notes = stored != null && stored.hasNotes ? stored.notes @@ -76,93 +286,165 @@ class TxMetaController extends ChangeNotifier { ); } + /// Exact merchant key first, then longest rule key contained in description. + List? _resolveMerchantLabels(BankTransaction tx) { + final direct = _labelsByMerchant[tx.merchantKey] ?? + _labelsByMerchant[tx.descriptionKey]; + if (direct != null) return direct; + return _bestMatchingRule(_labelsByMerchant, tx.description); + } + + String? _resolveMerchantNotes(BankTransaction tx) { + final direct = + _notesByMerchant[tx.merchantKey] ?? _notesByMerchant[tx.descriptionKey]; + if (direct != null) return direct; + return _bestMatchingRule(_notesByMerchant, tx.description); + } + + V? _bestMatchingRule(Map rules, String description) { + if (rules.isEmpty) return null; + final hay = description.toLowerCase(); + String? bestKey; + V? bestValue; + for (final entry in rules.entries) { + final key = entry.key; + if (key.length < 2) continue; + if (!hay.contains(key)) continue; + if (bestKey == null || key.length > bestKey.length) { + bestKey = key; + bestValue = entry.value; + } + } + return bestValue; + } + int countSimilar( BankTransaction prototype, - List all, - ) { - final key = prototype.merchantKey; - if (key.isEmpty) return 1; - return all.where((tx) => tx.merchantKey == key).length; + List all, { + String? pattern, + }) { + final needle = (pattern ?? prototype.merchantKey).trim(); + if (needle.isEmpty) return 1; + var count = 0; + for (final tx in all) { + if (descriptionMatchesPattern(tx.description, needle)) count++; + } + return count == 0 ? 1 : count; } List similarTo( BankTransaction prototype, - List all, - ) { - final key = prototype.merchantKey; - if (key.isEmpty) return [prototype]; - return [ + List all, { + String? pattern, + }) { + final needle = (pattern ?? prototype.merchantKey).trim(); + if (needle.isEmpty) return [prototype]; + final matches = [ for (final tx in all) - if (tx.merchantKey == key) tx, + if (descriptionMatchesPattern(tx.description, needle)) tx, ]; + return matches.isEmpty ? [prototype] : matches; } Future setLabels( BankTransaction tx, List labelIds, { List applyToSimilar = const [], + String? matchPattern, }) async { - final cleaned = [ + final expanded = expandWithAncestors([ for (final id in labelIds) if (id.isNotEmpty) id, - ]; + ]); // Deduplicate while preserving order. final unique = []; - for (final id in cleaned) { + for (final id in expanded) { if (!unique.contains(id)) unique.add(id); } - final scope = applyToSimilar.isEmpty ? [tx] : applyToSimilar; + final pattern = (matchPattern ?? tx.merchantKey).trim().toLowerCase(); + final applying = applyToSimilar.isNotEmpty; + final nextById = Map.of(_byId); - for (final other in scope) { - if (applyToSimilar.isNotEmpty && - other.merchantKey != tx.merchantKey) { - continue; - } - final current = metaFor(other); + void writeTx(BankTransaction other) { + final stored = nextById[other.id]; + final current = stored ?? metaFor(other); nextById[other.id] = unique.isEmpty ? current.copyWith(clearLabels: true) : current.copyWith(labelIds: unique); } - _byId = nextById; - final nextRules = Map>.of(_labelsByMerchant); - if (unique.isEmpty) { - nextRules.remove(tx.merchantKey); - nextRules.remove(tx.descriptionKey); - } else { - nextRules[tx.merchantKey] = unique; + writeTx(tx); + if (applying) { + // Only patch txs that already have per-id overrides; others inherit + // from the merchant rule so we avoid bloating SharedPreferences. + for (final other in applyToSimilar) { + if (other.id == tx.id) continue; + if (!nextById.containsKey(other.id)) continue; + writeTx(other); + } + + final nextRules = Map>.of(_labelsByMerchant); + if (unique.isEmpty) { + if (pattern.isNotEmpty) nextRules.remove(pattern); + nextRules.remove(tx.merchantKey); + nextRules.remove(tx.descriptionKey); + } else if (pattern.isNotEmpty) { + nextRules[pattern] = unique; + } + _labelsByMerchant = nextRules; } - _labelsByMerchant = nextRules; - await _persist(); + _byId = nextById; + _schedulePersist(); } Future toggleLabel( BankTransaction tx, String labelId, { List applyToSimilar = const [], + String? matchPattern, }) async { final current = List.of(metaFor(tx).labelIds); if (current.contains(labelId)) { - current.remove(labelId); + final remove = {labelId, ...descendantIdsOf(labelId)}; + current.removeWhere(remove.contains); } else { current.add(labelId); } - await setLabels(tx, current, applyToSimilar: applyToSimilar); + await setLabels( + tx, + current, + applyToSimilar: applyToSimilar, + matchPattern: matchPattern, + ); } Future clearLabels( BankTransaction tx, { List applyToSimilar = const [], + String? matchPattern, }) { - return setLabels(tx, const [], applyToSimilar: applyToSimilar); + return setLabels( + tx, + const [], + applyToSimilar: applyToSimilar, + matchPattern: matchPattern, + ); } - Future createLabel(String name) async { + Future createLabel(String name, {String? parentId}) async { final trimmed = name.trim(); if (trimmed.isEmpty) { throw ArgumentError('Label name required'); } + final parent = + (parentId == null || parentId.isEmpty) ? null : parentId; + if (parent != null && _labelById[parent] == null) { + throw ArgumentError('Unknown parent label'); + } + if (parent != null && depthOf(parent) >= kMaxLabelDepth) { + throw ArgumentError('Parent is already at max depth'); + } final color = kCustomLabelColors[_customLabels.length % kCustomLabelColors.length]; final label = TxLabel( @@ -171,15 +453,44 @@ class TxMetaController extends ChangeNotifier { color: color, icon: Icons.label_outline, custom: true, + parentId: parent, ); _customLabels = [..._customLabels, label]; - await _persist(); + if (parent != null) { + _labelParents = {..._labelParents, label.id: parent}; + } + _schedulePersist(); return label; } Future removeLabel(String labelId) async { + final oldParent = parentIdOf(labelId); _customLabels = _customLabels.where((label) => label.id != labelId).toList(); + + // Re-parent children to the removed label's parent (or root). + final nextParents = {}; + for (final entry in _labelParents.entries) { + if (entry.key == labelId) continue; + if (entry.value == labelId) { + if (oldParent != null) { + nextParents[entry.key] = oldParent; + } + } else { + nextParents[entry.key] = entry.value; + } + } + _labelParents = nextParents; + _customLabels = [ + for (final label in _customLabels) + if (label.parentId == labelId) + oldParent == null + ? label.copyWith(clearParent: true) + : label.copyWith(parentId: oldParent) + else + label, + ]; + final nextById = {}; for (final entry in _byId.entries) { if (entry.value.labelIds.contains(labelId)) { @@ -200,7 +511,7 @@ class TxMetaController extends ChangeNotifier { if (id != labelId) id, ], }; - await _persist(); + _schedulePersist(); } Future setRecurring(BankTransaction tx, bool recurring) async { @@ -217,28 +528,36 @@ class TxMetaController extends ChangeNotifier { recurring: recurring, ), }; - await _persist(); + _schedulePersist(); } Future setNotes( BankTransaction tx, String notes, { List applyToSimilar = const [], + String? matchPattern, }) async { final trimmed = notes.trim(); - final scope = applyToSimilar.isEmpty ? [tx] : applyToSimilar; + final pattern = (matchPattern ?? tx.merchantKey).trim().toLowerCase(); + final applying = applyToSimilar.isNotEmpty; + final nextById = Map.of(_byId); - for (final other in scope) { - if (applyToSimilar.isNotEmpty && - other.merchantKey != tx.merchantKey) { - continue; - } + void writeTx(BankTransaction other) { final current = metaFor(other); nextById[other.id] = current.copyWith(notes: trimmed); } + + writeTx(tx); + if (applying) { + for (final other in applyToSimilar) { + if (other.id == tx.id) continue; + if (!nextById.containsKey(other.id)) continue; + writeTx(other); + } + } _byId = nextById; - if (applyToSimilar.isEmpty) { + if (!applying) { final current = metaFor(tx); final existing = _exactTemplates[tx.exactKey] ?? const TxExactTemplate(); if (trimmed.isEmpty && !existing.recurring && !current.recurring) { @@ -255,21 +574,23 @@ class TxMetaController extends ChangeNotifier { if (trimmed.isEmpty) { _notesByMerchant = Map.of(_notesByMerchant) ..remove(tx.merchantKey) - ..remove(tx.descriptionKey); + ..remove(tx.descriptionKey) + ..remove(pattern); } } else { if (trimmed.isEmpty) { _notesByMerchant = Map.of(_notesByMerchant) ..remove(tx.merchantKey) - ..remove(tx.descriptionKey); - } else { + ..remove(tx.descriptionKey) + ..remove(pattern); + } else if (pattern.isNotEmpty) { _notesByMerchant = { ..._notesByMerchant, - tx.merchantKey: trimmed, + pattern: trimmed, }; } } - await _persist(); + _schedulePersist(); } Future toggleRecurring(BankTransaction tx) { @@ -287,19 +608,47 @@ class TxMetaController extends ChangeNotifier { final hasLabels = labelIds.isNotEmpty; if (!hasText && !hasLabels) return txs; - return txs.where((tx) { - final meta = metaFor(tx); - if (hasLabels && !meta.labelIds.any(labelIds.contains)) { - return false; - } - if (!hasText) return true; - final labelNames = labelsFor(meta).map((l) => l.name.toLowerCase()); - return tx.description.toLowerCase().contains(needle) || - meta.notes.toLowerCase().contains(needle) || - labelNames.any((name) => name.contains(needle)) || - tx.date.toLowerCase().contains(needle) || - tx.amount.toString().contains(needle); - }).toList(growable: false); + final labelIndex = _labelById; + return [ + for (final tx in txs) + if (_matchesSearch(tx, needle, hasText, labelIds, hasLabels, labelIndex)) + tx, + ]; + } + + bool _matchesSearch( + BankTransaction tx, + String needle, + bool hasText, + Set labelIds, + bool hasLabels, + Map labelIndex, + ) { + final meta = metaFor(tx); + if (hasLabels && !_matchesLabelFilter(meta, labelIds)) { + return false; + } + if (!hasText) return true; + if (tx.description.toLowerCase().contains(needle)) return true; + if (meta.notes.toLowerCase().contains(needle)) return true; + if (tx.date.toLowerCase().contains(needle)) return true; + if (tx.amount.toString().contains(needle)) return true; + for (final id in meta.labelIds) { + final name = labelIndex[id]?.name; + if (name != null && name.toLowerCase().contains(needle)) return true; + } + return false; + } + + /// OR filter: any selected label, and/or unlabeled when uncategorized is selected. + bool _matchesLabelFilter(TxMeta meta, Set labelIds) { + final wantUncategorized = labelIds.contains(kUncategorizedFilterId); + if (wantUncategorized && !meta.hasLabel) return true; + for (final id in labelIds) { + if (id == kUncategorizedFilterId) continue; + if (meta.labelIds.contains(id)) return true; + } + return false; } /// Labels currently used by [transactions]. @@ -316,17 +665,41 @@ class TxMetaController extends ChangeNotifier { return list; } - Future _persist() async { - notifyListeners(); - await _storage.saveTxMetaStore( - TxMetaStoreData( - byId: _byId, - labelsByMerchant: _labelsByMerchant, - notesByMerchant: _notesByMerchant, - exactTemplates: _exactTemplates, - customLabels: _customLabels, - ), + /// UI-first: notify on the next frame so callers can paint a busy state first, + /// then write SharedPreferences in the background. + void _schedulePersist() { + _invalidateLabelCache(); + final binding = WidgetsBinding.instance; + binding.addPostFrameCallback((_) { + notifyListeners(); + }); + // Ensure a frame is scheduled even if nothing else is dirty yet. + binding.scheduleFrame(); + unawaited(_writeStore()); + } + + Future _writeStore() async { + // Let the busy indicator / label checkbox paint before JSON work. + await Future.delayed(Duration.zero); + final snapshot = TxMetaStoreData( + byId: _byId, + labelsByMerchant: _labelsByMerchant, + notesByMerchant: _notesByMerchant, + exactTemplates: _exactTemplates, + customLabels: [ + for (final label in _customLabels) + label.copyWith( + parentId: parentIdOf(label.id), + clearParent: parentIdOf(label.id) == null, + ), + ], + labelParents: _labelParents, ); + try { + await _storage.saveTxMetaStore(snapshot); + } catch (error, stackTrace) { + debugPrint('TxMetaController persist failed: $error\n$stackTrace'); + } } /// Absolute spend (outflows) per label. Multi-label txs count fully in each. diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 3cdeec5..f32c311 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -12,6 +12,7 @@ import '../utils/navigation.dart'; import '../widgets/empty_state.dart'; import '../widgets/loading_view.dart'; import '../widgets/responsive_body.dart'; +import '../widgets/skip_build_when_covered.dart'; import 'transaction_detail_screen.dart'; import 'transactions_screen.dart'; @@ -54,6 +55,10 @@ class _HomeBody extends StatelessWidget { @override Widget build(BuildContext context) { + // Stay subscribed, but skip heavy scans while covered by another route + // (e.g. transaction detail applying labels to many similar txs). + context.watch(); + if (bank.isLoading && bank.balance == null && !bank.hasData) { return const LoadingView(message: 'Checking bank link…'); } @@ -89,16 +94,17 @@ class _HomeBody extends StatelessWidget { ); } - final balance = bank.balance; - final theme = Theme.of(context); - final colors = theme.colorScheme; - final meta = context.watch(); - final recent = bank.transactions.take(5).toList(growable: false); - final categorized = bank.transactions - .where((tx) => meta.metaFor(tx).hasLabel) - .length; + return SkipBuildWhenCovered( + builder: (context) { + final balance = bank.balance; + final theme = Theme.of(context); + final colors = theme.colorScheme; + final meta = context.watch(); + final recent = bank.transactions.take(5).toList(growable: false); + final categorized = + meta.labeledTransactionCount(bank.transactions); - return ListView( + return ListView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.only(top: 8, bottom: 32), children: [ @@ -209,7 +215,7 @@ class _HomeBody extends StatelessWidget { leading: Builder( builder: (context) { final labels = - meta.labelsFor(meta.metaFor(tx)); + meta.leafLabelsFor(meta.metaFor(tx)); final label = labels.isEmpty ? null : labels.first; return Icon( label?.icon ?? Icons.receipt_long_outlined, @@ -247,6 +253,8 @@ class _HomeBody extends StatelessWidget { ], ], ); + }, + ); } } diff --git a/lib/screens/label_transactions_screen.dart b/lib/screens/label_transactions_screen.dart index e47b924..039b0cf 100644 --- a/lib/screens/label_transactions_screen.dart +++ b/lib/screens/label_transactions_screen.dart @@ -9,6 +9,7 @@ import '../providers/bank_provider.dart'; import '../providers/tx_meta_provider.dart'; import '../utils/helpers.dart'; import '../widgets/responsive_body.dart'; +import '../widgets/skip_build_when_covered.dart'; import 'transaction_detail_screen.dart'; class LabelTransactionsScreen extends StatelessWidget { @@ -20,69 +21,77 @@ class LabelTransactionsScreen extends StatelessWidget { Widget build(BuildContext context) { final bank = context.watch(); final meta = context.watch(); - final txs = [ - for (final tx in bank.transactions) - if (meta.metaFor(tx).labelIds.contains(label.id)) tx, - ]; - final theme = Theme.of(context); - final colors = theme.colorScheme; - final currency = bank.balance?.currency ?? - (txs.isNotEmpty ? txs.first.currency : 'EUR'); - var totalOut = 0.0; - for (final tx in txs) { - if (tx.amount < 0) totalOut += tx.amount.abs(); - } - return Scaffold( appBar: AppBar( title: Text(label.name), ), body: ResponsiveBody( - child: ListView( - padding: const EdgeInsets.only(top: 8, bottom: 32), - children: [ - ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon(label.icon, color: label.color), - title: Text('${txs.length} transactions'), - subtitle: Text('Money out ${formatMoney(totalOut, currency)}'), - ), - const Divider(), - if (txs.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 32), - child: Text( - 'No transactions with this label.', - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium, - ), - ) - else - for (final tx in txs) - ListTile( - contentPadding: EdgeInsets.zero, - title: Text( - tx.description, - maxLines: 2, - overflow: TextOverflow.ellipsis, + child: SkipBuildWhenCovered( + builder: (context) { + final txs = [ + for (final tx in bank.transactions) + if (meta.labelIdsFor(tx).contains(label.id)) tx, + ]; + final theme = Theme.of(context); + final colors = theme.colorScheme; + final currency = bank.balance?.currency ?? + (txs.isNotEmpty ? txs.first.currency : 'EUR'); + var totalOut = 0.0; + for (final tx in txs) { + if (tx.amount < 0) totalOut += tx.amount.abs(); + } + + return ListView( + padding: const EdgeInsets.only(top: 8, bottom: 32), + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(label.icon, color: label.color), + title: Text('${txs.length} transactions'), + subtitle: + Text('Money out ${formatMoney(totalOut, currency)}'), ), - subtitle: Text(formatIsoDate(tx.date)), - trailing: Text( - formatMoney(tx.amount, tx.currency), - style: theme.textTheme.titleSmall?.copyWith( - color: tx.amount >= 0 ? colors.primary : colors.error, - ), - ), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => TransactionDetailScreen(tx: tx), + const Divider(), + if (txs.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Text( + 'No transactions with this label.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium, ), - ); - }, - ), - ], - ), + ) + else + for (final tx in txs) + ListTile( + contentPadding: EdgeInsets.zero, + title: Text( + tx.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text(formatIsoDate(tx.date)), + trailing: Text( + formatMoney(tx.amount, tx.currency), + style: theme.textTheme.titleSmall?.copyWith( + color: tx.amount >= 0 + ? colors.primary + : colors.error, + ), + ), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + TransactionDetailScreen(tx: tx), + ), + ); + }, + ), + ], + ); + }, + ), ), ); } diff --git a/lib/screens/labels_screen.dart b/lib/screens/labels_screen.dart new file mode 100644 index 0000000..541366c --- /dev/null +++ b/lib/screens/labels_screen.dart @@ -0,0 +1,201 @@ +/// Manage labels: create, nest, and delete. +library; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/transaction_meta.dart'; +import '../providers/tx_meta_provider.dart'; +import '../widgets/label_parent_picker.dart'; +import '../widgets/responsive_body.dart'; + +class LabelsScreen extends StatelessWidget { + const LabelsScreen({super.key}); + + Future _create( + BuildContext context, { + String? parentId, + }) async { + final meta = context.read(); + final result = await showCreateLabelDialog( + context, + parents: meta.allLabels, + initialParentId: parentId, + ); + if (result == null || !context.mounted) return; + await meta.createLabel(result.name, parentId: result.parentId); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Created “${result.name}”')), + ); + } + + Future _delete(BuildContext context, TxLabel label) async { + final ok = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text('Remove “${label.name}”?'), + content: const Text( + 'This deletes the label. Transactions keep any other labels. ' + 'Sub-labels move up one level.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('Remove'), + ), + ], + ); + }, + ); + if (ok != true || !context.mounted) return; + await context.read().removeLabel(label.id); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Removed “${label.name}”')), + ); + } + + @override + Widget build(BuildContext context) { + final meta = context.watch(); + final theme = Theme.of(context); + final colors = theme.colorScheme; + final entries = meta.labelsInTreeOrder(); + + return Scaffold( + appBar: AppBar( + title: const Text('Labels'), + actions: [ + IconButton( + tooltip: 'New label', + onPressed: () => _create(context), + icon: const Icon(Icons.add), + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _create(context), + icon: const Icon(Icons.add), + label: const Text('New label'), + ), + body: ResponsiveBody( + child: entries.isEmpty + ? ListView( + children: [ + const SizedBox(height: 48), + Icon( + Icons.label_outline, + size: 48, + color: colors.outline, + ), + const SizedBox(height: 12), + Text( + 'No labels yet', + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Create labels to categorize spending.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium, + ), + ], + ) + : ListView.separated( + padding: const EdgeInsets.fromLTRB(0, 8, 0, 88), + itemCount: entries.length + 1, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.fromLTRB(4, 4, 4, 12), + child: Text( + 'Create, nest under another label, or delete custom ' + 'ones. Built-in labels can be moved but not deleted.', + style: theme.textTheme.bodyMedium, + ), + ); + } + final entry = entries[index - 1]; + final label = entry.label; + final parentId = meta.parentIdOf(label.id); + final parent = parentId == null ? null : meta.labelById(parentId); + return ListTile( + contentPadding: EdgeInsets.fromLTRB( + 8 + entry.depth * 16.0, + 0, + 4, + 0, + ), + leading: Icon(label.icon, color: label.color), + title: Text(label.name), + subtitle: Text( + [ + if (label.custom) 'Custom' else 'Built-in', + if (parent != null) 'under ${parent.name}', + ].join(' · '), + ), + trailing: PopupMenuButton( + tooltip: 'Actions', + onSelected: (value) async { + switch (value) { + case 'sub': + await _create(context, parentId: label.id); + case 'move': + await showMoveLabelDialog(context, label: label); + case 'delete': + await _delete(context, label); + } + }, + itemBuilder: (context) => [ + if (meta.depthOf(label.id) < kMaxLabelDepth) + const PopupMenuItem( + value: 'sub', + child: ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.subdirectory_arrow_right), + title: Text('Add sub-label'), + ), + ), + const PopupMenuItem( + value: 'move', + child: ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.account_tree_outlined), + title: Text('Move under…'), + ), + ), + if (label.custom) + PopupMenuItem( + value: 'delete', + child: ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon( + Icons.delete_outline, + color: colors.error, + ), + title: Text( + 'Delete', + style: TextStyle(color: colors.error), + ), + ), + ), + ], + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/screens/overview_screen.dart b/lib/screens/overview_screen.dart index 4682469..cc5821c 100644 --- a/lib/screens/overview_screen.dart +++ b/lib/screens/overview_screen.dart @@ -1,4 +1,4 @@ -/// Overview: monthly income/expense chart, summary cards, and day history. +/// Overview: navigable income/expense or label charts, summary, month history. library; import 'package:fl_chart/fl_chart.dart'; @@ -6,15 +6,28 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/bank.dart'; +import '../models/transaction_meta.dart'; import '../providers/bank_provider.dart'; +import '../providers/tx_meta_provider.dart'; import '../utils/helpers.dart'; import '../utils/navigation.dart'; import '../utils/tx_calendar.dart'; import '../widgets/empty_state.dart'; +import '../widgets/label_filter_dialog.dart'; import '../widgets/loading_view.dart'; import '../widgets/responsive_body.dart'; +import '../widgets/skip_build_when_covered.dart'; +import 'label_transactions_screen.dart'; import 'transactions_screen.dart'; +enum _PeriodMode { monthly, yearly } + +enum _MetricMode { flow, labels } + +/// Distinct, intuitive colors (avoid two greens). +const _kIncomeColor = Color(0xFF2563EB); // blue +const _kExpenseColor = Color(0xFFDC2626); // red + class OverviewScreen extends StatefulWidget { const OverviewScreen({super.key}); @@ -23,11 +36,116 @@ class OverviewScreen extends StatefulWidget { } class _OverviewScreenState extends State { - static const _chartMonths = 6; + _PeriodMode _period = _PeriodMode.monthly; + _MetricMode _metric = _MetricMode.flow; + + /// Empty = include every label. Non-empty = only these ids on the Labels chart. + final Set _includedLabelIds = {}; + + /// Selected calendar year for Monthly mode (`DateTime(year)`). + late DateTime _cursor; + + @override + void initState() { + super.initState(); + _cursor = DateTime(DateTime.now().year); + } + + DateTime get _nowMonth => monthStart(DateTime.now()); + + /// Chart X buckets: 12 months of [_cursor] year, or one Jan-1 per data year. + List _chartPeriods(List txs) { + if (_period == _PeriodMode.monthly) { + final year = _cursor.year; + return [for (var m = 1; m <= 12; m++) DateTime(year, m)]; + } + final years = {_nowMonth.year}; + for (final tx in txs) { + final date = _txDate(tx); + if (date != null) years.add(date.year); + } + final sorted = years.toList()..sort(); + return [for (final year in sorted) DateTime(year)]; + } + + String get _periodTitle { + if (_period == _PeriodMode.monthly) return '${_cursor.year}'; + return 'All years'; + } + + bool get _canGoForward => + _period == _PeriodMode.monthly && _cursor.year < _nowMonth.year; + + bool get _canGoBack => + _period == _PeriodMode.monthly && _cursor.year > _nowMonth.year - 20; + + void _go(int delta) { + if (_period != _PeriodMode.monthly) return; + setState(() { + final next = DateTime(_cursor.year + delta); + if (next.year > _nowMonth.year) { + _cursor = DateTime(_nowMonth.year); + } else if (next.year < _nowMonth.year - 20) { + return; + } else { + _cursor = next; + } + }); + } + + void _setPeriod(_PeriodMode mode) { + setState(() { + _period = mode; + _cursor = DateTime(_nowMonth.year); + }); + } + + Future _pickChartLabels( + BuildContext context, + List available, + ) async { + if (available.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No labels in use for this period yet')), + ); + return; + } + + final initial = Set.of( + _includedLabelIds.isEmpty + ? available.map((l) => l.id) + : _includedLabelIds.where((id) => available.any((l) => l.id == id)), + ); + + final result = await showLabelFilterDialog( + context, + available: available, + initiallySelected: initial, + title: 'Labels on chart', + subtitle: + 'Choose which labels appear. Long-press to nest under another.', + allowMove: true, + ); + if (result == null || !mounted) return; + + setState(() { + final allIds = available.map((l) => l.id).toSet(); + if (result.isEmpty || + (result.length == allIds.length && result.containsAll(allIds))) { + _includedLabelIds.clear(); + } else { + _includedLabelIds + ..clear() + ..addAll(result); + } + _metric = _MetricMode.labels; + }); + } @override Widget build(BuildContext context) { final bank = context.watch(); + final meta = context.watch(); return Scaffold( appBar: AppBar( @@ -41,15 +159,20 @@ class _OverviewScreenState extends State { body: RefreshIndicator( onRefresh: bank.refresh, child: ResponsiveBody( - child: _buildBody(context, bank), + child: SkipBuildWhenCovered( + builder: (context) => _buildBody(context, bank, meta), + ), ), ), ); } - Widget _buildBody(BuildContext context, BankController bank) { + Widget _buildBody( + BuildContext context, + BankController bank, + TxMetaController meta, + ) { final theme = Theme.of(context); - final colors = theme.colorScheme; if (bank.isLoading && bank.transactions.isEmpty && bank.balance == null) { return const LoadingView(message: 'Loading overview…'); @@ -72,14 +195,35 @@ class _OverviewScreenState extends State { (bank.transactions.isNotEmpty ? bank.transactions.first.currency : 'EUR'); - final months = _lastMonths(_chartMonths); - final monthly = _monthlyTotals(bank.transactions, months); - final nowMonth = monthStart(DateTime.now()); - final current = monthly[nowMonth] ?? - const _MonthBucket(income: 0, expense: 0); - final history = _monthHistory(bank.transactions); - final incomeColor = colors.primary; - final expenseColor = const Color(0xFF6FCF97); + final txs = bank.transactions; + final periods = _chartPeriods(txs); + final historyPeriods = [...periods.reversed]; + + // Only aggregate what the active metric needs — toggling was scanning + // all txs 3–4 times and resolving full meta each time. + Map monthly = const {}; + var labelSpend = const <_LabelSpend>[]; + var availableLabels = const []; + Map> labelAmountByPeriod = const {}; + + if (_metric == _MetricMode.flow) { + monthly = _flowTotals(txs, periods, _period); + } else { + final labels = _labelAggregates( + txs, + meta, + periods, + _period, + includedLabelIds: _includedLabelIds, + ); + labelSpend = labels.spend; + availableLabels = [for (final row in labels.allSpend) row.label]; + labelAmountByPeriod = labels.amountByPeriod; + } + + final summaryFlow = _metric == _MetricMode.flow + ? _sumBuckets(monthly.values) + : const _MonthBucket(income: 0, expense: 0); return ListView( physics: const AlwaysScrollableScrollPhysics(), @@ -88,73 +232,201 @@ class _OverviewScreenState extends State { if (bank.error != null) ...[ Text( bank.error!, - style: theme.textTheme.bodyMedium?.copyWith(color: colors.error), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.error, + ), ), const SizedBox(height: 12), ], _ChartCard( - months: months, + period: _period, + metric: _metric, + periodTitle: _periodTitle, + canGoBack: _canGoBack, + canGoForward: _canGoForward, + onPeriodChanged: _setPeriod, + onMetricChanged: (value) => setState(() => _metric = value), + onBack: () => _go(-1), + onForward: () => _go(1), + months: periods, monthly: monthly, + labelSpend: labelSpend, + labelAmountByMonth: labelAmountByPeriod, currency: currency, - incomeColor: incomeColor, - expenseColor: expenseColor, + includedLabelCount: _includedLabelIds.isEmpty + ? null + : _includedLabelIds.length, + availableLabelCount: availableLabels.length, + onPickLabels: () => _pickChartLabels(context, availableLabels), + onLabelTap: (label) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => LabelTransactionsScreen(label: label), + ), + ); + }, + onMonthTap: (month) => _openPeriod(context, month), ), const SizedBox(height: 14), - Row( - children: [ - Expanded( - child: _SummaryCard( - icon: Icons.savings_outlined, - iconBackground: incomeColor.withValues(alpha: 0.14), - iconColor: incomeColor, - amount: formatMoney(current.income, currency), - label: 'Income', + if (_metric == _MetricMode.flow) ...[ + Row( + children: [ + Expanded( + child: _SummaryCard( + icon: Icons.south_west, + iconBackground: _kIncomeColor.withValues(alpha: 0.12), + iconColor: _kIncomeColor, + amount: formatMoney(summaryFlow.income, currency), + label: 'Income', + ), ), - ), - const SizedBox(width: 12), - Expanded( - child: _SummaryCard( - icon: Icons.receipt_long_outlined, - iconBackground: expenseColor.withValues(alpha: 0.18), - iconColor: const Color(0xFF2F9E62), - amount: formatMoney(current.expense, currency), - label: 'Expenses', + const SizedBox(width: 12), + Expanded( + child: _SummaryCard( + icon: Icons.north_east, + iconBackground: _kExpenseColor.withValues(alpha: 0.12), + iconColor: _kExpenseColor, + amount: formatMoney(summaryFlow.expense, currency), + label: 'Expenses', + ), ), + ], + ), + const SizedBox(height: 8), + Text( + _period == _PeriodMode.monthly + ? 'Totals for ${_cursor.year}' + : 'Totals across all years', + style: theme.textTheme.labelMedium, + ), + ] else ...[ + _SummaryCard( + icon: Icons.label_outline, + iconBackground: + theme.colorScheme.secondaryContainer.withValues(alpha: 0.6), + iconColor: theme.colorScheme.onSecondaryContainer, + amount: formatMoney( + labelSpend.fold(0, (s, e) => s + e.amount), + currency, ), - ], - ), - const SizedBox(height: 8), + label: 'Labeled spend · $_periodTitle', + ), + ], + const SizedBox(height: 20), Text( - formatMonthYear(nowMonth), + _metric == _MetricMode.flow + ? 'History · Income & expenses' + : 'History · Labels', + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 4), + Text( + _period == _PeriodMode.monthly + ? (_metric == _MetricMode.flow + ? 'Months in ${_cursor.year}' + : 'Per-label totals by month in ${_cursor.year}') + : (_metric == _MetricMode.flow + ? 'One row per year' + : 'Per-label totals by year'), style: theme.textTheme.labelMedium, ), - const SizedBox(height: 20), - Text('History', style: theme.textTheme.titleLarge), const SizedBox(height: 12), - if (history.isEmpty) + if (historyPeriods.isEmpty) Text( - 'No monthly activity to show.', + 'No activity to show.', style: theme.textTheme.bodyMedium, ) else - for (final month in history) ...[ - _HistoryCard( - month: month.month, - income: month.income, - expense: month.expense, - currency: currency, - onTap: () => _openMonth(context, month.month), - ), - const SizedBox(height: 10), - ], + ..._historyTiles( + historyPeriods: historyPeriods, + metric: _metric, + periodMode: _period, + monthly: monthly, + labelSpend: labelSpend, + labelAmountByPeriod: labelAmountByPeriod, + currency: currency, + onOpenPeriod: (period) => _openPeriod(context, period), + onOpenLabel: (period, label) => + _openPeriod(context, period, labelId: label.id), + ), ], ); } - void _openMonth(BuildContext context, DateTime month) { + List _historyTiles({ + required List historyPeriods, + required _MetricMode metric, + required _PeriodMode periodMode, + required Map monthly, + required List<_LabelSpend> labelSpend, + required Map> labelAmountByPeriod, + required String currency, + required void Function(DateTime period) onOpenPeriod, + required void Function(DateTime period, TxLabel label) onOpenLabel, + }) { + final out = []; + for (final period in historyPeriods) { + final key = _periodKey(period, periodMode); + final title = _periodAxisLabel(period, periodMode); + if (metric == _MetricMode.flow) { + out.add( + _HistoryCard( + month: period, + title: title, + income: monthly[key]?.income ?? 0, + expense: monthly[key]?.expense ?? 0, + currency: currency, + onTap: () => onOpenPeriod(period), + ), + ); + } else { + final bucket = labelAmountByPeriod[key] ?? const {}; + final rows = <_LabelSpend>[ + for (final row in labelSpend) + if ((bucket[row.label.id] ?? 0) > 0) + _LabelSpend( + label: row.label, + amount: bucket[row.label.id]!, + ), + ]; + if (rows.isEmpty) continue; + out.add( + _LabelHistoryCard( + month: period, + title: title, + rows: rows, + currency: currency, + onOpenPeriod: () => onOpenPeriod(period), + onOpenLabel: (label) => onOpenLabel(period, label), + ), + ); + } + out.add(const SizedBox(height: 10)); + } + return out; + } + + void _openPeriod( + BuildContext context, + DateTime period, { + String? labelId, + }) { + final yearly = _period == _PeriodMode.yearly; + final initial = yearly ? DateTime(period.year, 12) : period; + final labelIds = labelId != null + ? {labelId} + : { + // Open with the same labels currently shown on the chart. + for (final id in _includedLabelIds) id, + }; Navigator.of(context).push( MaterialPageRoute( - builder: (_) => TransactionsScreen(initialMonth: month), + builder: (_) => TransactionsScreen( + initialMonth: initial, + initialLabelIds: labelIds, + initialSearchInMonthOnly: !yearly && labelIds.isNotEmpty, + initialYear: yearly ? period.year : null, + ), ), ); } @@ -166,22 +438,20 @@ class _MonthBucket { final double expense; } -class _MonthHistoryRow { - const _MonthHistoryRow({ - required this.month, - required this.income, - required this.expense, - }); - final DateTime month; - final double income; - final double expense; +class _LabelSpend { + const _LabelSpend({required this.label, required this.amount}); + final TxLabel label; + final double amount; } -List _lastMonths(int count) { - final now = monthStart(DateTime.now()); - return [ - for (var i = count - 1; i >= 0; i--) addMonths(now, -i), - ]; +_MonthBucket _sumBuckets(Iterable<_MonthBucket> buckets) { + var income = 0.0; + var expense = 0.0; + for (final b in buckets) { + income += b.income; + expense += b.expense; + } + return _MonthBucket(income: income, expense: expense); } DateTime? _txDate(BankTransaction tx) { @@ -189,17 +459,29 @@ DateTime? _txDate(BankTransaction tx) { return parsed == null ? null : dateOnly(parsed); } -Map _monthlyTotals( +DateTime _periodKey(DateTime date, _PeriodMode mode) { + if (mode == _PeriodMode.yearly) return DateTime(date.year); + return monthStart(date); +} + +String _periodAxisLabel(DateTime period, _PeriodMode mode) { + if (mode == _PeriodMode.yearly) return '${period.year}'; + return _shortMonth(period); +} + +Map _flowTotals( List txs, - List months, + List periods, + _PeriodMode mode, ) { + final keys = {for (final p in periods) _periodKey(p, mode)}; final totals = { - for (final month in months) month: (income: 0.0, expense: 0.0), + for (final key in keys) key: (income: 0.0, expense: 0.0), }; for (final tx in txs) { final date = _txDate(tx); if (date == null) continue; - final key = monthStart(date); + final key = _periodKey(date, mode); final current = totals[key]; if (current == null) continue; if (tx.amount >= 0) { @@ -212,65 +494,169 @@ Map _monthlyTotals( } } return { - for (final e in totals.entries) - e.key: _MonthBucket(income: e.value.income, expense: e.value.expense), + for (final period in periods) + _periodKey(period, mode): _MonthBucket( + income: totals[_periodKey(period, mode)]!.income, + expense: totals[_periodKey(period, mode)]!.expense, + ), }; } -/// Months that have activity, newest first. -List<_MonthHistoryRow> _monthHistory(List txs) { - final map = {}; +class _LabelAggregates { + const _LabelAggregates({ + required this.spend, + required this.allSpend, + required this.byPeriod, + required this.amountByPeriod, + }); + + final List<_LabelSpend> spend; + final List<_LabelSpend> allSpend; + final Map byPeriod; + final Map> amountByPeriod; + + int get availableCount => allSpend.length; +} + +/// One pass over outflows: totals, per-period series, and history amounts. +_LabelAggregates _labelAggregates( + List txs, + TxMetaController meta, + List periods, + _PeriodMode mode, { + Set includedLabelIds = const {}, +}) { + final keys = {for (final p in periods) _periodKey(p, mode)}; + final allByLabel = {}; + final byPeriod = { + for (final key in keys) key: 0.0, + }; + final amountByPeriod = >{ + for (final key in keys) key: {}, + }; + final filtering = includedLabelIds.isNotEmpty; + for (final tx in txs) { + if (tx.amount >= 0) continue; final date = _txDate(tx); if (date == null) continue; - final key = monthStart(date); - final current = map[key] ?? (income: 0.0, expense: 0.0); - if (tx.amount >= 0) { - map[key] = (income: current.income + tx.amount, expense: current.expense); - } else { - map[key] = ( - income: current.income, - expense: current.expense + tx.amount.abs(), - ); + final key = _periodKey(date, mode); + if (!keys.contains(key)) continue; + + final ids = meta.labelIdsFor(tx); + if (ids.isEmpty) continue; + final amount = tx.amount.abs(); + + for (final id in ids) { + allByLabel[id] = (allByLabel[id] ?? 0) + amount; + } + + final matchesFilter = + !filtering || ids.any(includedLabelIds.contains); + if (!matchesFilter) continue; + + byPeriod[key] = byPeriod[key]! + amount; + final bucket = amountByPeriod[key]!; + for (final id in ids) { + if (filtering && !includedLabelIds.contains(id)) continue; + bucket[id] = (bucket[id] ?? 0) + amount; } } - final keys = map.keys.toList()..sort((a, b) => b.compareTo(a)); - return [ - for (final key in keys) - _MonthHistoryRow( - month: key, - income: map[key]!.income, - expense: map[key]!.expense, - ), + + final allSpend = <_LabelSpend>[ + for (final entry in allByLabel.entries) + if (meta.labelById(entry.key) != null) + _LabelSpend(label: meta.labelById(entry.key)!, amount: entry.value), + ]..sort((a, b) => b.amount.compareTo(a.amount)); + + final spend = !filtering + ? allSpend + : [ + for (final row in allSpend) + if (includedLabelIds.contains(row.label.id)) row, + ]; + + return _LabelAggregates( + spend: spend, + allSpend: allSpend, + byPeriod: byPeriod, + amountByPeriod: amountByPeriod, + ); +} + +String _shortMonth(DateTime month) { + const names = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', ]; + return names[month.month - 1]; +} + +String _compactMoney(double value) { + if (value >= 1000) { + final k = value / 1000; + return k >= 10 ? '${k.round()}k' : '${k.toStringAsFixed(1)}k'; + } + return value.round().toString(); } class _ChartCard extends StatelessWidget { const _ChartCard({ + required this.period, + required this.metric, + required this.periodTitle, + required this.canGoBack, + required this.canGoForward, + required this.onPeriodChanged, + required this.onMetricChanged, + required this.onBack, + required this.onForward, required this.months, required this.monthly, + required this.labelSpend, + required this.labelAmountByMonth, required this.currency, - required this.incomeColor, - required this.expenseColor, + required this.onLabelTap, + required this.onMonthTap, + required this.onPickLabels, + required this.includedLabelCount, + required this.availableLabelCount, }); + final _PeriodMode period; + final _MetricMode metric; + final String periodTitle; + final bool canGoBack; + final bool canGoForward; + final ValueChanged<_PeriodMode> onPeriodChanged; + final ValueChanged<_MetricMode> onMetricChanged; + final VoidCallback onBack; + final VoidCallback onForward; final List months; final Map monthly; + final List<_LabelSpend> labelSpend; + final Map> labelAmountByMonth; final String currency; - final Color incomeColor; - final Color expenseColor; + final void Function(TxLabel label) onLabelTap; + final void Function(DateTime month) onMonthTap; + final VoidCallback onPickLabels; + final int? includedLabelCount; + final int availableLabelCount; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; - var maxY = 0.0; - for (final month in months) { - final bucket = monthly[month]!; - if (bucket.income > maxY) maxY = bucket.income; - if (bucket.expense > maxY) maxY = bucket.expense; - } - final safeMax = maxY <= 0 ? 1.0 : maxY * 1.15; return Card( color: colors.surface, @@ -280,157 +666,220 @@ class _ChartCard extends StatelessWidget { side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.5)), ), child: Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 16), + padding: const EdgeInsets.fromLTRB(12, 12, 12, 16), child: Column( children: [ Row( children: [ - Container( - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: colors.surfaceContainerHighest.withValues(alpha: 0.55), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text('Monthly', style: theme.textTheme.labelLarge), - const SizedBox(width: 4), - Icon( - Icons.keyboard_arrow_down, - size: 18, - color: colors.onSurfaceVariant, + SegmentedButton<_PeriodMode>( + showSelectedIcon: false, + style: ButtonStyle( + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -3, + ), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 8), + ), + minimumSize: const WidgetStatePropertyAll(Size(0, 28)), + textStyle: WidgetStatePropertyAll( + theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w600, ), - ], + ), ), + segments: const [ + ButtonSegment( + value: _PeriodMode.monthly, + label: Text('Month'), + ), + ButtonSegment( + value: _PeriodMode.yearly, + label: Text('Year'), + ), + ], + selected: {period}, + onSelectionChanged: (selection) { + onPeriodChanged(selection.first); + }, ), + if (metric == _MetricMode.labels) ...[ + const SizedBox(width: 2), + IconButton( + tooltip: includedLabelCount == null + ? 'Filter labels ($availableLabelCount)' + : 'Filter labels ($includedLabelCount/$availableLabelCount)', + onPressed: onPickLabels, + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + constraints: const BoxConstraints( + minWidth: 28, + minHeight: 28, + ), + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: includedLabelCount == null + ? null + : colors.secondaryContainer, + ), + icon: Badge( + isLabelVisible: includedLabelCount != null, + smallSize: 6, + child: Icon( + Icons.filter_list, + size: 18, + color: includedLabelCount == null + ? colors.onSurfaceVariant + : colors.onSecondaryContainer, + ), + ), + ), + ], const Spacer(), - _LegendDot(color: incomeColor, label: 'Income'), - const SizedBox(width: 14), - _LegendDot(color: expenseColor, label: 'Expense'), + SegmentedButton<_MetricMode>( + showSelectedIcon: false, + style: ButtonStyle( + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -3, + ), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 8), + ), + minimumSize: const WidgetStatePropertyAll(Size(0, 28)), + textStyle: WidgetStatePropertyAll( + theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + segments: const [ + ButtonSegment( + value: _MetricMode.flow, + label: Text('Flow'), + ), + ButtonSegment( + value: _MetricMode.labels, + label: Text('Labels'), + ), + ], + selected: {metric}, + onSelectionChanged: (selection) { + onMetricChanged(selection.first); + }, + ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 6), + if (period == _PeriodMode.monthly) + Row( + children: [ + IconButton( + tooltip: 'Previous year', + onPressed: canGoBack ? onBack : null, + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + padding: EdgeInsets.zero, + icon: const Icon(Icons.chevron_left, size: 22), + ), + Expanded( + child: Text( + periodTitle, + textAlign: TextAlign.center, + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + tooltip: 'Next year', + onPressed: canGoForward ? onForward : null, + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + padding: EdgeInsets.zero, + icon: const Icon(Icons.chevron_right, size: 22), + ), + ], + ) + else + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + periodTitle, + textAlign: TextAlign.center, + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + if (metric == _MetricMode.flow) ...[ + const Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + _LegendDot(color: _kIncomeColor, label: 'Income'), + SizedBox(width: 14), + _LegendDot(color: _kExpenseColor, label: 'Expense'), + ], + ), + const SizedBox(height: 8), + ] else if (labelSpend.isNotEmpty) ...[ + Align( + alignment: Alignment.centerRight, + child: Wrap( + spacing: 12, + runSpacing: 4, + alignment: WrapAlignment.end, + children: [ + for (final row in labelSpend.take(5)) + GestureDetector( + onTap: () => onLabelTap(row.label), + child: _LegendDot( + color: row.label.color, + label: row.label.name, + ), + ), + if (labelSpend.length > 5) + _LegendDot( + color: colors.outline, + label: 'Other', + ), + ], + ), + ), + const SizedBox(height: 8), + ], SizedBox( height: 220, - child: months.every( - (m) => - monthly[m]!.income == 0 && monthly[m]!.expense == 0, - ) - ? Center( - child: Text( - 'No income or expenses in this period', - style: theme.textTheme.bodyMedium, - textAlign: TextAlign.center, - ), + child: metric == _MetricMode.flow + ? _FlowBars( + months: months, + monthly: monthly, + currency: currency, + period: period, ) - : BarChart( - BarChartData( - maxY: safeMax, - groupsSpace: 18, - barTouchData: BarTouchData( - touchTooltipData: BarTouchTooltipData( - getTooltipItem: (group, groupIndex, rod, rodIndex) { - final month = months[group.x.toInt()]; - final bucket = monthly[month]!; - final isIncome = rodIndex == 1; - final value = - isIncome ? bucket.income : bucket.expense; - return BarTooltipItem( - '${_shortMonth(month)}\n' - '${isIncome ? 'Income' : 'Expense'}\n' - '${formatMoney(value, currency)}', - TextStyle(color: colors.onInverseSurface), - ); - }, - ), - ), - titlesData: FlTitlesData( - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - rightTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 42, - interval: safeMax / 3, - getTitlesWidget: (value, meta) { - if (value <= 0) { - return Text( - '0', - style: theme.textTheme.labelSmall, - ); - } - if ((value - safeMax).abs() < 0.01) { - return const SizedBox.shrink(); - } - return Text( - _compactMoney(value), - style: theme.textTheme.labelSmall, - ); - }, - ), - ), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 28, - getTitlesWidget: (value, meta) { - final index = value.toInt(); - if (index < 0 || index >= months.length) { - return const SizedBox.shrink(); - } - return Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - _shortMonth(months[index]), - style: theme.textTheme.labelSmall, - ), - ); - }, - ), - ), - ), - gridData: FlGridData( - show: true, - drawVerticalLine: false, - horizontalInterval: safeMax / 3, - getDrawingHorizontalLine: (value) => FlLine( - color: colors.outlineVariant.withValues(alpha: 0.45), - strokeWidth: 1, - ), - ), - borderData: FlBorderData(show: false), - barGroups: [ - for (var i = 0; i < months.length; i++) - BarChartGroupData( - x: i, - barsSpace: 4, - barRods: [ - BarChartRodData( - toY: monthly[months[i]]!.expense, - width: 12, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(8), - ), - color: expenseColor, - ), - BarChartRodData( - toY: monthly[months[i]]!.income, - width: 12, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(8), - ), - color: incomeColor, - ), - ], - ), - ], - ), + : _LabelMonthBars( + months: months, + labelSpend: labelSpend, + amountByMonth: labelAmountByMonth, + currency: currency, + period: period, + onMonthTap: onMonthTap, ), ), ], @@ -438,31 +887,350 @@ class _ChartCard extends StatelessWidget { ), ); } +} - static String _shortMonth(DateTime month) { - const names = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ]; - return names[month.month - 1]; - } +class _FlowBars extends StatelessWidget { + const _FlowBars({ + required this.months, + required this.monthly, + required this.currency, + required this.period, + }); - static String _compactMoney(double value) { - if (value >= 1000) { - final k = value / 1000; - return k >= 10 ? '${k.round()}k' : '${k.toStringAsFixed(1)}k'; + final List months; + final Map monthly; + final String currency; + final _PeriodMode period; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + var maxY = 0.0; + for (final month in months) { + final bucket = monthly[_periodKey(month, period)]!; + if (bucket.income > maxY) maxY = bucket.income; + if (bucket.expense > maxY) maxY = bucket.expense; } - return value.round().toString(); + final safeMax = maxY <= 0 ? 1.0 : maxY * 1.15; + final empty = months.every((m) { + final b = monthly[_periodKey(m, period)]!; + return b.income == 0 && b.expense == 0; + }); + + if (empty) { + return Center( + child: Text( + period == _PeriodMode.yearly + ? 'No income or expenses in these years' + : 'No income or expenses in this year', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ); + } + + return BarChart( + BarChartData( + maxY: safeMax, + groupsSpace: period == _PeriodMode.yearly ? 16 : 10, + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipItem: (group, groupIndex, rod, rodIndex) { + final month = months[group.x.toInt()]; + final bucket = monthly[_periodKey(month, period)]!; + final isIncome = rodIndex == 1; + final value = isIncome ? bucket.income : bucket.expense; + return BarTooltipItem( + '${_periodAxisLabel(month, period)}\n' + '${isIncome ? 'Income' : 'Expense'}\n' + '${formatMoney(value, currency)}', + TextStyle(color: colors.onInverseSurface), + ); + }, + ), + ), + titlesData: FlTitlesData( + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 42, + interval: safeMax / 3, + getTitlesWidget: (value, meta) { + if (value <= 0) { + return Text('0', style: theme.textTheme.labelSmall); + } + if ((value - safeMax).abs() < 0.01) { + return const SizedBox.shrink(); + } + return Text( + _compactMoney(value), + style: theme.textTheme.labelSmall, + ); + }, + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + getTitlesWidget: (value, meta) { + final index = value.toInt(); + if (index < 0 || index >= months.length) { + return const SizedBox.shrink(); + } + if (period == _PeriodMode.monthly && + index % 2 == 1 && + months.length > 8) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _periodAxisLabel(months[index], period), + style: theme.textTheme.labelSmall, + ), + ); + }, + ), + ), + ), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: safeMax / 3, + getDrawingHorizontalLine: (value) => FlLine( + color: colors.outlineVariant.withValues(alpha: 0.45), + strokeWidth: 1, + ), + ), + borderData: FlBorderData(show: false), + barGroups: [ + for (var i = 0; i < months.length; i++) + BarChartGroupData( + x: i, + barsSpace: 3, + barRods: [ + BarChartRodData( + toY: monthly[_periodKey(months[i], period)]!.expense, + width: period == _PeriodMode.yearly ? 14 : 9, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(6)), + color: _kExpenseColor, + ), + BarChartRodData( + toY: monthly[_periodKey(months[i], period)]!.income, + width: period == _PeriodMode.yearly ? 14 : 9, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(6)), + color: _kIncomeColor, + ), + ], + ), + ], + ), + duration: Duration.zero, + ); + } +} + +class _LabelMonthBars extends StatelessWidget { + const _LabelMonthBars({ + required this.months, + required this.labelSpend, + required this.amountByMonth, + required this.currency, + required this.period, + required this.onMonthTap, + }); + + static const _maxSeriesLabels = 5; + + final List months; + final List<_LabelSpend> labelSpend; + final Map> amountByMonth; + final String currency; + final _PeriodMode period; + final void Function(DateTime month) onMonthTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + if (labelSpend.isEmpty) { + return Center( + child: Text( + 'No labeled expenses in this period.\nTag transactions to see them here.', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ); + } + + final topLabels = labelSpend.take(_maxSeriesLabels).toList(growable: false); + final topIds = {for (final row in topLabels) row.label.id}; + final otherColor = colors.outline; + + double otherFor(DateTime month) { + final bucket = + amountByMonth[_periodKey(month, period)] ?? const {}; + var other = 0.0; + for (final entry in bucket.entries) { + if (!topIds.contains(entry.key)) other += entry.value; + } + return other; + } + + final hasOther = months.any((m) => otherFor(m) > 0); + final seriesCount = topLabels.length + (hasOther ? 1 : 0); + + var maxY = 0.0; + for (final month in months) { + final bucket = + amountByMonth[_periodKey(month, period)] ?? const {}; + for (final row in topLabels) { + final value = bucket[row.label.id] ?? 0; + if (value > maxY) maxY = value; + } + final other = otherFor(month); + if (other > maxY) maxY = other; + } + final safeMax = maxY <= 0 ? 1.0 : maxY * 1.15; + if (maxY <= 0) { + return Center( + child: Text( + 'No labeled expenses in this period.\nTag transactions to see them here.', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ); + } + + final rodWidth = period == _PeriodMode.yearly + ? (seriesCount <= 3 ? 12.0 : 8.0) + : (seriesCount <= 3 + ? 7.0 + : seriesCount <= 5 + ? 5.0 + : 4.0); + + return BarChart( + BarChartData( + maxY: safeMax, + groupsSpace: period == _PeriodMode.yearly ? 16 : 10, + barTouchData: BarTouchData( + touchCallback: (event, response) { + if (event is! FlTapUpEvent) return; + final index = response?.spot?.touchedBarGroupIndex; + if (index == null || index < 0 || index >= months.length) return; + onMonthTap(months[index]); + }, + touchTooltipData: BarTouchTooltipData( + getTooltipItem: (group, groupIndex, rod, rodIndex) { + final month = months[group.x.toInt()]; + final name = rodIndex < topLabels.length + ? topLabels[rodIndex].label.name + : 'Other'; + return BarTooltipItem( + '${_periodAxisLabel(month, period)}\n' + '$name\n' + '${formatMoney(rod.toY, currency)}', + TextStyle(color: colors.onInverseSurface), + ); + }, + ), + ), + titlesData: FlTitlesData( + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 42, + interval: safeMax / 3, + getTitlesWidget: (value, meta) { + if (value <= 0) { + return Text('0', style: theme.textTheme.labelSmall); + } + if ((value - safeMax).abs() < 0.01) { + return const SizedBox.shrink(); + } + return Text( + _compactMoney(value), + style: theme.textTheme.labelSmall, + ); + }, + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + getTitlesWidget: (value, meta) { + final index = value.toInt(); + if (index < 0 || index >= months.length) { + return const SizedBox.shrink(); + } + if (period == _PeriodMode.monthly && + index % 2 == 1 && + months.length > 8) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _periodAxisLabel(months[index], period), + style: theme.textTheme.labelSmall, + ), + ); + }, + ), + ), + ), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: safeMax / 3, + getDrawingHorizontalLine: (value) => FlLine( + color: colors.outlineVariant.withValues(alpha: 0.45), + strokeWidth: 1, + ), + ), + borderData: FlBorderData(show: false), + barGroups: [ + for (var i = 0; i < months.length; i++) + BarChartGroupData( + x: i, + barsSpace: 2, + barRods: [ + for (final row in topLabels) + BarChartRodData( + toY: amountByMonth[_periodKey(months[i], period)] + ?[row.label.id] ?? + 0, + width: rodWidth, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(4)), + color: row.label.color, + ), + if (hasOther) + BarChartRodData( + toY: otherFor(months[i]), + width: rodWidth, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(4)), + color: otherColor, + ), + ], + ), + ], + ), + duration: Duration.zero, + ); } } @@ -556,6 +1324,7 @@ class _SummaryCard extends StatelessWidget { class _HistoryCard extends StatelessWidget { const _HistoryCard({ required this.month, + required this.title, required this.income, required this.expense, required this.currency, @@ -563,6 +1332,7 @@ class _HistoryCard extends StatelessWidget { }); final DateTime month; + final String title; final double income; final double expense; final String currency; @@ -591,10 +1361,15 @@ class _HistoryCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Month', style: theme.textTheme.labelMedium), + Text( + month.month == 1 && title.length == 4 ? 'Year' : 'Month', + style: theme.textTheme.labelMedium, + ), const SizedBox(height: 2), Text( - formatMonthYear(month), + title.length == 4 && int.tryParse(title) != null + ? title + : formatMonthYear(month), style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), @@ -608,7 +1383,7 @@ class _HistoryCard extends StatelessWidget { Text( formatMoney(income, currency), style: theme.textTheme.titleSmall?.copyWith( - color: const Color(0xFF2F9E62), + color: _kIncomeColor, fontWeight: FontWeight.w600, ), ), @@ -616,7 +1391,7 @@ class _HistoryCard extends StatelessWidget { Text( formatMoney(-expense, currency), style: theme.textTheme.titleSmall?.copyWith( - color: colors.error, + color: _kExpenseColor, fontWeight: FontWeight.w600, ), ), @@ -629,3 +1404,123 @@ class _HistoryCard extends StatelessWidget { ); } } + +class _LabelHistoryCard extends StatelessWidget { + const _LabelHistoryCard({ + required this.month, + required this.title, + required this.rows, + required this.currency, + required this.onOpenPeriod, + required this.onOpenLabel, + }); + + final DateTime month; + final String title; + final List<_LabelSpend> rows; + final String currency; + final VoidCallback onOpenPeriod; + final void Function(TxLabel label) onOpenLabel; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final isYear = title.length == 4 && int.tryParse(title) != null; + final periodLabel = isYear ? title : formatMonthYear(month); + final total = rows.fold(0, (sum, row) => sum + row.amount); + + return Card( + color: colors.surface, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.5)), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InkWell( + onTap: onOpenPeriod, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 10), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isYear ? 'Year' : 'Month', + style: theme.textTheme.labelMedium, + ), + const SizedBox(height: 2), + Text( + periodLabel, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + Text( + formatMoney(-total, currency), + style: theme.textTheme.titleSmall?.copyWith( + color: _kExpenseColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + Divider( + height: 1, + color: colors.outlineVariant.withValues(alpha: 0.45), + ), + for (var i = 0; i < rows.length; i++) ...[ + if (i > 0) + Divider( + height: 1, + indent: 52, + color: colors.outlineVariant.withValues(alpha: 0.35), + ), + InkWell( + onTap: () => onOpenLabel(rows[i].label), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), + child: Row( + children: [ + Icon( + rows[i].label.icon, + size: 20, + color: rows[i].label.color, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + rows[i].label.name, + style: theme.textTheme.bodyMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + formatMoney(-rows[i].amount, currency), + style: theme.textTheme.bodyMedium?.copyWith( + color: _kExpenseColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 2638d99..5a225fc 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -13,6 +13,7 @@ import '../utils/connect_bank.dart'; import '../utils/constants.dart'; import '../utils/helpers.dart'; import '../widgets/responsive_body.dart'; +import 'labels_screen.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -201,6 +202,24 @@ class SettingsScreen extends StatelessWidget { style: theme.textTheme.bodyMedium, ), const SizedBox(height: 28), + Text('Labels', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Card( + child: ListTile( + leading: const Icon(Icons.label_outline), + title: const Text('Manage labels'), + subtitle: const Text('Create, nest, move, or delete'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const LabelsScreen(), + ), + ); + }, + ), + ), + const SizedBox(height: 28), Text('CSV import', style: theme.textTheme.titleMedium), const SizedBox(height: 8), Card( diff --git a/lib/screens/transaction_detail_screen.dart b/lib/screens/transaction_detail_screen.dart index f717246..58e4036 100644 --- a/lib/screens/transaction_detail_screen.dart +++ b/lib/screens/transaction_detail_screen.dart @@ -9,6 +9,7 @@ import '../models/transaction_meta.dart'; import '../providers/bank_provider.dart'; import '../providers/tx_meta_provider.dart'; import '../utils/helpers.dart'; +import '../widgets/label_parent_picker.dart'; import '../widgets/responsive_body.dart'; class TransactionDetailScreen extends StatefulWidget { @@ -23,8 +24,11 @@ class TransactionDetailScreen extends StatefulWidget { class _TransactionDetailScreenState extends State { late final TextEditingController _notesController; + late final TextEditingController _patternController; var _dirty = false; late bool _applyToSimilar; + var _busy = false; + String? _busyMessage; @override void initState() { @@ -32,8 +36,14 @@ class _TransactionDetailScreenState extends State { final meta = context.read(); final notes = meta.metaFor(widget.tx).notes; _notesController = TextEditingController(text: notes); + final pattern = defaultSimilarPattern(widget.tx.description); + _patternController = TextEditingController(text: pattern); final all = context.read().transactions; - final similarCount = meta.countSimilar(widget.tx, all); + final similarCount = meta.countSimilar( + widget.tx, + all, + pattern: pattern, + ); // Default on when there are peers — otherwise users never notice the toggle. _applyToSimilar = similarCount > 1; } @@ -41,77 +51,165 @@ class _TransactionDetailScreenState extends State { @override void dispose() { _notesController.dispose(); + _patternController.dispose(); super.dispose(); } + String get _pattern => _patternController.text.trim(); + List _similarScope(TxMetaController meta) { final all = context.read().transactions; if (!_applyToSimilar) return const []; - return meta.similarTo(widget.tx, all); + return meta.similarTo(widget.tx, all, pattern: _pattern); + } + + Future _runBusy( + Future Function() action, { + String? message, + }) async { + if (_busy) return; + setState(() { + _busy = true; + _busyMessage = message; + }); + // Let the progress bar paint before heavy similar-matching / rebuilds. + await WidgetsBinding.instance.endOfFrame; + try { + await action(); + } finally { + if (mounted) { + setState(() { + _busy = false; + _busyMessage = null; + }); + } + } } Future _saveNotes() async { - final meta = context.read(); - final similar = _similarScope(meta); - await meta.setNotes( - widget.tx, - _notesController.text, - applyToSimilar: similar, - ); - if (!mounted) return; - setState(() => _dirty = false); - final count = similar.isEmpty ? 1 : similar.length; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - count == 1 - ? 'Notes saved' - : 'Notes saved on $count similar transactions', - ), - ), + await _runBusy( + () async { + final meta = context.read(); + final similar = _similarScope(meta); + final count = similar.isEmpty ? 1 : similar.length; + await meta.setNotes( + widget.tx, + _notesController.text, + applyToSimilar: similar, + matchPattern: _pattern, + ); + if (!mounted) return; + setState(() => _dirty = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + count == 1 + ? 'Notes saved' + : 'Notes saved on $count similar transactions', + ), + ), + ); + }, + message: _applyToSimilar + ? 'Saving notes on matching transactions…' + : 'Saving notes…', ); } Future _toggleLabel(String labelId) async { - final meta = context.read(); - final similar = _similarScope(meta); - await meta.toggleLabel( - widget.tx, - labelId, - applyToSimilar: similar, + await _runBusy( + () async { + final meta = context.read(); + final similar = _similarScope(meta); + final count = similar.isEmpty ? 1 : similar.length; + await meta.toggleLabel( + widget.tx, + labelId, + applyToSimilar: similar, + matchPattern: _pattern, + ); + if (!mounted) return; + if (count > 1) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Labels updated on $count similar transactions'), + ), + ); + } + }, + message: _applyToSimilar + ? 'Updating labels on matching transactions…' + : null, ); - if (!mounted) return; - final count = similar.isEmpty ? 1 : similar.length; - if (count > 1) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Labels updated on $count similar transactions'), - ), - ); - } } Future _clearLabels() async { - final meta = context.read(); - final similar = _similarScope(meta); - await meta.clearLabels( - widget.tx, - applyToSimilar: similar, + await _runBusy( + () { + final meta = context.read(); + return meta.clearLabels( + widget.tx, + applyToSimilar: _similarScope(meta), + matchPattern: _pattern, + ); + }, + message: _applyToSimilar + ? 'Clearing labels on matching transactions…' + : 'Clearing labels…', ); } Future _createLabel() async { - final name = await showDialog( - context: context, - builder: (context) => const _NewLabelDialog(), - ); - if (name == null || name.isEmpty || !mounted) return; final meta = context.read(); - final label = await meta.createLabel(name); + final result = await showCreateLabelDialog( + context, + parents: meta.allLabels, + ); + if (result == null || !mounted) return; + final label = await meta.createLabel( + result.name, + parentId: result.parentId, + ); if (!mounted) return; await _toggleLabel(label.id); } + Future _labelActions(TxLabel label) async { + final action = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.account_tree_outlined), + title: const Text('Move under…'), + onTap: () => Navigator.pop(sheetContext, 'move'), + ), + if (label.custom) + ListTile( + leading: Icon( + Icons.delete_outline, + color: Theme.of(sheetContext).colorScheme.error, + ), + title: const Text('Remove label'), + onTap: () => Navigator.pop(sheetContext, 'remove'), + ), + ], + ), + ); + }, + ); + if (!mounted || action == null) return; + if (action == 'move') { + await showMoveLabelDialog(context, label: label); + } else if (action == 'remove') { + await _confirmRemoveLabel(label); + } + } + Future _confirmRemoveLabel(TxLabel label) async { final ok = await showDialog( context: context, @@ -143,17 +241,18 @@ class _TransactionDetailScreenState extends State { final tx = widget.tx; final metaController = context.watch(); final meta = metaController.metaFor(tx); - final all = context.watch().transactions; - final similarCount = metaController.countSimilar(tx, all); + // Prefer read: bank data rarely changes while editing labels. + final all = context.read().transactions; + final similarCount = metaController.countSimilar( + tx, + all, + pattern: _pattern, + ); final theme = Theme.of(context); final colors = theme.colorScheme; final amountColor = tx.amount >= 0 ? colors.primary : colors.error; final selectedIds = meta.labelIds.toSet(); - final merchantPreview = tx.merchantKey.isEmpty - ? tx.description - : (tx.merchantKey.length > 48 - ? '${tx.merchantKey.substring(0, 47)}…' - : tx.merchantKey); + final labelEntries = metaController.labelsInTreeOrder(); return Scaffold( appBar: AppBar( @@ -161,7 +260,7 @@ class _TransactionDetailScreenState extends State { actions: [ if (_dirty) TextButton( - onPressed: _saveNotes, + onPressed: _busy ? null : _saveNotes, child: const Text('Save'), ), ], @@ -170,9 +269,29 @@ class _TransactionDetailScreenState extends State { child: ListView( padding: const EdgeInsets.only(top: 8, bottom: 32), children: [ + if (_busy) ...[ + const LinearProgressIndicator(minHeight: 2), + if (_busyMessage != null) + Padding( + padding: const EdgeInsets.fromLTRB(0, 8, 0, 4), + child: Text( + _busyMessage!, + style: theme.textTheme.labelMedium?.copyWith( + color: colors.primary, + ), + ), + ), + ], + AbsorbPointer( + absorbing: _busy, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ Text( formatMoney(tx.amount, tx.currency), - style: theme.textTheme.headlineMedium?.copyWith(color: amountColor), + style: theme.textTheme.headlineMedium?.copyWith( + color: amountColor, + ), ), const SizedBox(height: 8), Text(tx.description, style: theme.textTheme.titleMedium), @@ -184,19 +303,47 @@ class _TransactionDetailScreenState extends State { ].join(' · '), style: theme.textTheme.bodyMedium, ), - if (similarCount > 1) ...[ - const SizedBox(height: 16), - SwitchListTile( - contentPadding: EdgeInsets.zero, - secondary: const Icon(Icons.copy_all_outlined), - title: const Text('Apply to all similar'), - subtitle: Text( - '$similarCount matches · $merchantPreview', + 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), ), - value: _applyToSimilar, - onChanged: (value) => setState(() => _applyToSimilar = value), ), - ], + onChanged: (_) => setState(() {}), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon(Icons.copy_all_outlined), + title: const Text('Apply to all similar'), + subtitle: Text( + similarCount <= 1 + ? 'No other matches for this pattern' + : '$similarCount matching transactions', + ), + value: _applyToSimilar && similarCount > 1, + onChanged: _busy || similarCount <= 1 + ? null + : (value) => setState(() => _applyToSimilar = value), + ), const SizedBox(height: 8), SwitchListTile( contentPadding: EdgeInsets.zero, @@ -204,45 +351,104 @@ class _TransactionDetailScreenState extends State { title: const Text('Recurring'), subtitle: const Text('Salary, mortgage, subscriptions…'), value: meta.recurring, - onChanged: (value) { - context.read().setRecurring(tx, value); - }, + onChanged: _busy + ? null + : (value) { + context.read().setRecurring(tx, value); + }, ), const SizedBox(height: 16), Row( children: [ Text('Labels', style: theme.textTheme.titleSmall), const Spacer(), + TextButton( + onPressed: _busy ? null : _createLabel, + child: const Text('New'), + ), if (meta.hasLabel) TextButton( - onPressed: _clearLabels, + onPressed: _busy ? null : _clearLabels, child: const Text('Clear'), ), ], ), const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final label in metaController.allLabels) - GestureDetector( - onLongPress: label.custom - ? () => _confirmRemoveLabel(label) - : null, - child: FilterChip( - avatar: Icon(label.icon, size: 16, color: label.color), - label: Text(label.name), - selected: selectedIds.contains(label.id), - onSelected: (_) => _toggleLabel(label.id), - ), - ), - ActionChip( - avatar: const Icon(Icons.add, size: 16), - label: const Text('New label'), - onPressed: _createLabel, + DecoratedBox( + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colors.outlineVariant.withValues(alpha: 0.8), ), - ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Column( + children: [ + for (final entry in labelEntries) ...[ + if (entry != labelEntries.first) + Divider( + height: 1, + indent: 40, + color: colors.outlineVariant.withValues(alpha: 0.45), + ), + InkWell( + onTap: _busy ? null : () => _toggleLabel(entry.label.id), + onLongPress: + _busy ? null : () => _labelActions(entry.label), + child: Padding( + padding: EdgeInsets.fromLTRB( + 8 + entry.depth * 16.0, + 2, + 4, + 2, + ), + child: Row( + children: [ + if (entry.depth > 0) ...[ + Icon( + Icons.subdirectory_arrow_right, + size: 14, + color: colors.onSurfaceVariant, + ), + const SizedBox(width: 4), + ], + Icon( + entry.label.icon, + size: 18, + color: entry.label.color, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + entry.label.name, + style: theme.textTheme.bodyMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + SizedBox( + height: 32, + width: 32, + child: Checkbox( + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + value: selectedIds.contains(entry.label.id), + onChanged: _busy + ? null + : (_) => _toggleLabel(entry.label.id), + ), + ), + ], + ), + ), + ), + ], + ], + ), + ), ), const SizedBox(height: 24), Text('Notes', style: theme.textTheme.titleSmall), @@ -251,6 +457,7 @@ class _TransactionDetailScreenState extends State { controller: _notesController, minLines: 3, maxLines: 6, + enabled: !_busy, textInputAction: TextInputAction.newline, decoration: const InputDecoration( hintText: 'Add a private note…', @@ -261,7 +468,7 @@ class _TransactionDetailScreenState extends State { ), const SizedBox(height: 12), FilledButton( - onPressed: _dirty ? _saveNotes : null, + onPressed: _dirty && !_busy ? _saveNotes : null, child: Text( _applyToSimilar && similarCount > 1 ? 'Save notes for $similarCount' @@ -270,10 +477,12 @@ class _TransactionDetailScreenState extends State { ), const SizedBox(height: 16), Text( - 'Tap labels to add or remove several. Long-press a custom label ' - 'to delete it. Similar matches strip dates and ids so supermarket ' - 'purchases group together.', + 'Sub-labels also select their parents. Unchecking a parent ' + 'clears its sub-labels. Long-press a row to move or delete.', style: theme.textTheme.labelMedium, + ), + ], + ), ), ], ), @@ -281,54 +490,3 @@ class _TransactionDetailScreenState extends State { ); } } - -class _NewLabelDialog extends StatefulWidget { - const _NewLabelDialog(); - - @override - State<_NewLabelDialog> createState() => _NewLabelDialogState(); -} - -class _NewLabelDialogState extends State<_NewLabelDialog> { - late final TextEditingController _controller; - - @override - void initState() { - super.initState(); - _controller = TextEditingController(); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - void _submit() { - Navigator.pop(context, _controller.text.trim()); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('New label'), - content: TextField( - controller: _controller, - autofocus: true, - textCapitalization: TextCapitalization.sentences, - decoration: const InputDecoration(hintText: 'e.g. Supermarket'), - onSubmitted: (_) => _submit(), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: _submit, - child: const Text('Create'), - ), - ], - ); - } -} diff --git a/lib/screens/transactions_screen.dart b/lib/screens/transactions_screen.dart index 2c44920..4c0419a 100644 --- a/lib/screens/transactions_screen.dart +++ b/lib/screens/transactions_screen.dart @@ -1,6 +1,8 @@ /// Searchable transaction list with month heatmap and money in/out totals. library; +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -13,28 +15,53 @@ import '../utils/helpers.dart'; import '../utils/navigation.dart'; import '../utils/tx_calendar.dart'; import '../widgets/empty_state.dart'; +import '../widgets/label_filter_dialog.dart'; import '../widgets/loading_view.dart'; import '../widgets/responsive_body.dart'; +import '../widgets/skip_build_when_covered.dart'; import '../widgets/tx_heatmap.dart'; import 'transaction_detail_screen.dart'; class TransactionsScreen extends StatefulWidget { - const TransactionsScreen({super.key, this.initialMonth}); + const TransactionsScreen({ + super.key, + this.initialMonth, + this.initialLabelIds = const {}, + this.initialSearchInMonthOnly, + this.initialYear, + }); /// When set, opens on this calendar month instead of the current month. final DateTime? initialMonth; + /// Pre-select these label filters (e.g. from overview history). + final Set initialLabelIds; + + /// When set, overrides the default "search in month" checkbox. + final bool? initialSearchInMonthOnly; + + /// When set, label/search results are limited to this calendar year. + final int? initialYear; + @override State createState() => _TransactionsScreenState(); } class _TransactionsScreenState extends State { + static const _minLiveSearchChars = 3; + static const _searchDebounce = Duration(milliseconds: 280); + final _searchController = TextEditingController(); - String _query = ''; late DateTime _month; DateTime? _selectedDay; - var _searchInMonthOnly = false; - final Set _labelFilters = {}; + late bool _searchInMonthOnly; + late final Set _labelFilters; + int? _yearFilter; + var _showCalendar = false; + + /// Query actually used for filtering (Enter, or live once ≥3 chars). + String _activeQuery = ''; + Timer? _searchDebounceTimer; @override void initState() { @@ -47,16 +74,20 @@ class _TransactionsScreenState extends State { final start = monthStart(initial); _month = start.isAfter(now) ? now : start; } + _labelFilters = Set.of(widget.initialLabelIds); + _searchInMonthOnly = widget.initialSearchInMonthOnly ?? false; + _yearFilter = widget.initialYear; } @override void dispose() { + _searchDebounceTimer?.cancel(); _searchController.dispose(); super.dispose(); } bool get _isSearching => - _query.trim().isNotEmpty || _labelFilters.isNotEmpty; + _activeQuery.trim().isNotEmpty || _labelFilters.isNotEmpty; void _goMonth(int delta) { final next = addMonths(_month, delta); @@ -70,17 +101,44 @@ class _TransactionsScreenState extends State { DateTime? _txDate(BankTransaction tx) => DateTime.tryParse(tx.date); - bool _inCalendarScope(BankTransaction tx) { - final date = _txDate(tx); - if (date == null) return false; - if (_selectedDay != null) return isSameDay(date, _selectedDay!); - return isSameMonth(date, _month); + List _yearScoped(List all) { + final year = _yearFilter; + if (year == null) return all; + return [ + for (final tx in all) + if (_txDate(tx)?.year == year) tx, + ]; } - bool _inMonth(BankTransaction tx) { - final date = _txDate(tx); - if (date == null) return false; - return isSameMonth(date, _month); + void _onSearchChanged(String value) { + final trimmed = value.trim(); + _searchDebounceTimer?.cancel(); + + if (trimmed.length >= _minLiveSearchChars) { + // TextField updates via the controller; only rebuild the list after debounce. + _searchDebounceTimer = Timer(_searchDebounce, () { + if (!mounted) return; + if (_activeQuery == trimmed) return; + setState(() => _activeQuery = trimmed); + }); + return; + } + + // Below threshold: no live search. Clear active text filter if needed. + if (_activeQuery.isNotEmpty) { + setState(() => _activeQuery = ''); + } + } + + void _onSearchSubmitted(String value) { + _searchDebounceTimer?.cancel(); + setState(() => _activeQuery = value.trim()); + } + + void _clearSearch() { + _searchDebounceTimer?.cancel(); + _searchController.clear(); + setState(() => _activeQuery = ''); } Future _pickLabels(List available) async { @@ -91,70 +149,21 @@ class _TransactionsScreenState extends State { return; } - final selected = Set.of(_labelFilters); - await showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (sheetContext) { - return StatefulBuilder( - builder: (context, setSheetState) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'Filter by label', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final label in available) - FilterChip( - avatar: Icon( - label.icon, - size: 16, - color: label.color, - ), - label: Text(label.name), - selected: selected.contains(label.id), - onSelected: (value) { - setSheetState(() { - if (value) { - selected.add(label.id); - } else { - selected.remove(label.id); - } - }); - }, - ), - ], - ), - const SizedBox(height: 16), - FilledButton( - onPressed: () { - setState(() { - _labelFilters - ..clear() - ..addAll(selected); - }); - Navigator.pop(sheetContext); - }, - child: const Text('Apply'), - ), - ], - ), - ), - ); - }, - ); - }, + final result = await showLabelFilterDialog( + context, + available: available, + initiallySelected: Set.of(_labelFilters), + title: 'Filter transactions', + subtitle: 'Show transactions with any of these labels.', + includeUncategorized: true, ); + if (result == null || !mounted) return; + + setState(() { + _labelFilters + ..clear() + ..addAll(result); + }); } @override @@ -170,11 +179,21 @@ class _TransactionsScreenState extends State { refreshing: bank.isLoading, ), ), - body: RefreshIndicator( - onRefresh: bank.refresh, - child: ResponsiveBody( - child: _buildBody(context, bank), - ), + body: Column( + children: [ + if (bank.isLoading && bank.transactions.isNotEmpty) + const LinearProgressIndicator(minHeight: 2), + Expanded( + child: RefreshIndicator( + onRefresh: bank.refresh, + child: ResponsiveBody( + child: SkipBuildWhenCovered( + builder: (context) => _buildBody(context, bank), + ), + ), + ), + ), + ], ), ); } @@ -183,12 +202,13 @@ class _TransactionsScreenState extends State { final metaController = context.watch(); final theme = Theme.of(context); final colors = theme.colorScheme; + final txs = bank.transactions; - if (bank.isLoading && bank.transactions.isEmpty) { + if (bank.isLoading && txs.isEmpty) { return const LoadingView(message: 'Loading transactions…'); } - if (!bank.isLinked && bank.transactions.isEmpty) { + if (!bank.isLinked && txs.isEmpty) { return ListView( physics: const AlwaysScrollableScrollPhysics(), children: [ @@ -204,7 +224,7 @@ class _TransactionsScreenState extends State { ); } - if (bank.error != null && bank.transactions.isEmpty) { + if (bank.error != null && txs.isEmpty) { return ListView( physics: const AlwaysScrollableScrollPhysics(), children: [ @@ -219,7 +239,7 @@ class _TransactionsScreenState extends State { ); } - if (bank.transactions.isEmpty) { + if (txs.isEmpty) { return ListView( physics: const AlwaysScrollableScrollPhysics(), children: const [ @@ -233,33 +253,55 @@ class _TransactionsScreenState extends State { } final nowMonth = monthStart(DateTime.now()); - final monthDates = [ - for (final tx in bank.transactions) - if (_txDate(tx) != null && isSameMonth(_txDate(tx)!, _month)) - dateOnly(_txDate(tx)!), - ]; - final counts = txCountsByDay(monthDates); + final monthDates = []; + final calendarScoped = []; + final monthScoped = []; - final calendarScoped = [ - for (final tx in bank.transactions) - if (_inCalendarScope(tx)) tx, - ]; + for (final tx in txs) { + final date = _txDate(tx); + if (date == null) continue; + if (isSameMonth(date, _month)) { + monthDates.add(dateOnly(date)); + monthScoped.add(tx); + if (_selectedDay == null || isSameDay(date, _selectedDay!)) { + calendarScoped.add(tx); + } + } else if (_selectedDay != null && isSameDay(date, _selectedDay!)) { + calendarScoped.add(tx); + } + } + final counts = txCountsByDay(monthDates); final searchPool = !_isSearching ? calendarScoped : _searchInMonthOnly - ? [ - for (final tx in bank.transactions) - if (_inMonth(tx)) tx, - ] - : bank.transactions; + ? monthScoped + : _yearScoped(txs); + + // Text hits first (no label filter) so refine chips stay available while selecting. + final textMatched = _activeQuery.trim().isEmpty + ? const [] + : metaController.search(searchPool, _activeQuery); final filtered = metaController.search( searchPool, - _query, + _activeQuery, labelIds: _labelFilters, ); + final refineLabels = _activeQuery.trim().isEmpty + ? const [] + : metaController.usedLabels(textMatched); + final showUncategorizedRefine = _activeQuery.trim().isNotEmpty && + textMatched.any((tx) => !metaController.metaFor(tx).hasLabel); + final refineChoices = [ + if (showUncategorizedRefine && + !_labelFilters.contains(kUncategorizedFilterId)) + kUncategorizedFilterLabel, + for (final label in refineLabels) + if (!_labelFilters.contains(label.id)) label, + ]; + var moneyOut = 0.0; var moneyIn = 0.0; for (final tx in calendarScoped) { @@ -270,173 +312,371 @@ class _TransactionsScreenState extends State { } } final currency = bank.balance?.currency ?? - (bank.transactions.isNotEmpty - ? bank.transactions.first.currency - : 'EUR'); - final usedLabels = metaController.usedLabels(bank.transactions); + (txs.isNotEmpty ? txs.first.currency : 'EUR'); final labelById = { for (final label in metaController.allLabels) label.id: label, }; - return ListView( + return CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.only(bottom: 32), - children: [ - Padding( - padding: const EdgeInsets.only(top: 8, bottom: 4), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _searchController, - textInputAction: TextInputAction.search, - decoration: InputDecoration( - hintText: 'Search description, notes…', - prefixIcon: IconButton( - tooltip: 'Labels', - onPressed: () => _pickLabels(usedLabels), - icon: Icon( - Icons.search, - color: _labelFilters.isEmpty ? null : colors.primary, - ), - ), - suffixIcon: _query.isEmpty - ? null - : IconButton( - tooltip: 'Clear', - onPressed: () { - _searchController.clear(); - setState(() => _query = ''); - }, - icon: const Icon(Icons.clear), - ), + slivers: [ + if (bank.error != null && txs.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: Material( + color: colors.errorContainer, + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, ), - onChanged: (value) => setState(() => _query = value), - ), - ), - const SizedBox(width: 4), - Tooltip( - message: 'Search only in calendar month', - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Checkbox( - value: _searchInMonthOnly, - onChanged: (value) { - setState(() => _searchInMonthOnly = value ?? false); - }, + child: Text( + bank.error!, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onErrorContainer, ), - Text('Month', style: theme.textTheme.labelMedium), - ], + ), ), ), - ], + ), ), - ), - if (_labelFilters.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Wrap( - spacing: 8, - runSpacing: 8, + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (final id in _labelFilters) - if (labelById[id] != null) - InputChip( - avatar: Icon( - labelById[id]!.icon, - size: 16, - color: labelById[id]!.color, + Expanded( + child: ValueListenableBuilder( + valueListenable: _searchController, + builder: (context, value, _) { + final draft = value.text.trim(); + final showHint = draft.isNotEmpty && + draft.length < _minLiveSearchChars && + draft != _activeQuery.trim(); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _searchController, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: + 'Search (3+ chars, or press search)', + prefixIcon: const Icon(Icons.search), + suffixIcon: value.text.isNotEmpty + ? IconButton( + tooltip: 'Clear', + onPressed: _clearSearch, + icon: const Icon(Icons.clear), + ) + : null, + ), + onChanged: _onSearchChanged, + onSubmitted: _onSearchSubmitted, + ), + if (showHint) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + 'Press search to look up short terms', + style: + theme.textTheme.labelMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + ], + ); + }, + ), + ), + const SizedBox(width: 2), + IconButton( + tooltip: _labelFilters.isEmpty + ? 'Filter by label' + : 'Filter by label (${_labelFilters.length})', + onPressed: () => _pickLabels( + metaController.usedLabels(txs), + ), + style: IconButton.styleFrom( + backgroundColor: _labelFilters.isEmpty + ? null + : colors.secondaryContainer, + ), + icon: Badge( + isLabelVisible: _labelFilters.isNotEmpty, + smallSize: 6, + child: const Icon(Icons.filter_list), + ), + ), + IconButton( + tooltip: _showCalendar ? 'Hide calendar' : 'Show calendar', + onPressed: () { + setState(() { + _showCalendar = !_showCalendar; + if (!_showCalendar) _selectedDay = null; + }); + }, + style: IconButton.styleFrom( + backgroundColor: _showCalendar + ? colors.secondaryContainer + : null, + ), + icon: Icon( + _showCalendar + ? 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, + ); + }, ), - label: Text(labelById[id]!.name), - onDeleted: () { - setState(() => _labelFilters.remove(id)); - }, - ), + Text('Month', style: theme.textTheme.labelMedium), + ], + ), + ), ], ), ), - TxHeatmap( - month: _month, - counts: counts, - canGoForward: _month.isBefore(nowMonth), - selectedDay: _selectedDay, - onPreviousMonth: () => _goMonth(-1), - onNextMonth: () => _goMonth(1), - onSelectDay: (day) => setState(() => _selectedDay = day), ), - if (_selectedDay != null) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Align( - alignment: Alignment.centerLeft, - child: InputChip( - label: Text(formatDate(_selectedDay!)), - onDeleted: () => setState(() => _selectedDay = null), + if (_showCalendar) ...[ + SliverToBoxAdapter( + child: TxHeatmap( + month: _month, + counts: counts, + canGoForward: _month.isBefore(nowMonth), + selectedDay: _selectedDay, + onPreviousMonth: () => _goMonth(-1), + onNextMonth: () => _goMonth(1), + onSelectDay: (day) => setState(() => _selectedDay = day), + ), + ), + if (_selectedDay != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Align( + alignment: Alignment.centerLeft, + child: InputChip( + label: Text(formatDate(_selectedDay!)), + onDeleted: () => setState(() => _selectedDay = null), + ), + ), + ), + ), + ] else + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 4, 0, 8), + child: Row( + children: [ + IconButton( + tooltip: 'Previous month', + onPressed: () => _goMonth(-1), + icon: const Icon(Icons.chevron_left), + ), + Expanded( + child: Text( + formatMonthYear(_month), + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium, + ), + ), + IconButton( + tooltip: 'Next month', + onPressed: + _month.isBefore(nowMonth) ? () => _goMonth(1) : null, + icon: const Icon(Icons.chevron_right), + ), + ], ), ), ), - Padding( - padding: const EdgeInsets.fromLTRB(0, 4, 0, 12), - child: Row( - children: [ - Expanded( - child: _TotalsCard( - label: 'Money out', - value: formatMoney(moneyOut, currency), - color: colors.error, - icon: Icons.arrow_upward, + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 4, 0, 12), + child: Row( + children: [ + Expanded( + child: _TotalsCard( + label: 'Money out', + value: formatMoney(moneyOut, currency), + color: colors.error, + icon: Icons.arrow_upward, + ), ), - ), - const SizedBox(width: 10), - Expanded( - child: _TotalsCard( - label: 'Money in', - value: formatMoney(moneyIn, currency), - color: colors.primary, - icon: Icons.arrow_downward, + const SizedBox(width: 10), + Expanded( + child: _TotalsCard( + label: 'Money in', + value: formatMoney(moneyIn, currency), + color: colors.primary, + icon: Icons.arrow_downward, + ), ), - ), - ], + ], + ), ), ), - if (_isSearching) - Padding( + if (_isSearching || _labelFilters.isNotEmpty) + 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), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final id in _labelFilters) + if (id == kUncategorizedFilterId) + InputChip( + visualDensity: VisualDensity.compact, + avatar: Icon( + kUncategorizedFilterLabel.icon, + size: 14, + color: kUncategorizedFilterLabel.color, + ), + label: const Text('Uncategorized'), + onDeleted: () { + setState( + () => _labelFilters + .remove(kUncategorizedFilterId), + ); + }, + ) + else if (labelById[id] != null) + InputChip( + visualDensity: VisualDensity.compact, + avatar: Icon( + labelById[id]!.icon, + size: 14, + color: labelById[id]!.color, + ), + label: Text(labelById[id]!.name), + onDeleted: () { + setState(() => _labelFilters.remove(id)); + }, + ), + ], + ), + ], + if (refineChoices.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + 'Refine by label', + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final label in refineChoices) + FilterChip( + visualDensity: VisualDensity.compact, + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + avatar: Icon( + label.icon, + size: 14, + color: label.color, + ), + label: Text(label.name), + selected: false, + onSelected: (_) { + setState(() => _labelFilters.add(label.id)); + }, + ), + ], + ), + ], + ], + ), + ), + ), + SliverToBoxAdapter( + child: Padding( padding: const EdgeInsets.only(bottom: 8), child: Text( - _searchInMonthOnly - ? 'Searching in ${formatMonthYear(_month)}' - : 'Searching all transactions', - style: theme.textTheme.labelMedium, + filtered.length == 1 + ? '1 transaction' + : '${filtered.length} transactions', + style: theme.textTheme.titleSmall?.copyWith( + color: colors.onSurfaceVariant, + ), ), ), + ), if (filtered.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 24), - child: Text( - _isSearching - ? 'No transactions match this search.' - : _selectedDay != null - ? 'No transactions on this day.' - : 'No transactions this month.', - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium, + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + _isSearching + ? 'No transactions match this search.' + : _selectedDay != null + ? 'No transactions on this day.' + : 'No transactions this month.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium, + ), ), ) else - for (var i = 0; i < filtered.length; i++) ...[ - if (i > 0) - Divider( - height: 1, - color: colors.outlineVariant.withValues(alpha: 0.5), - ), - _CompactTransactionTile( - tx: filtered[i], - meta: metaController.metaFor(filtered[i]), - labels: metaController - .labelsFor(metaController.metaFor(filtered[i])), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final tx = filtered[index]; + final meta = metaController.metaFor(tx); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (index > 0) + Divider( + height: 1, + color: colors.outlineVariant.withValues(alpha: 0.5), + ), + _CompactTransactionTile( + tx: tx, + meta: meta, + labels: metaController.leafLabelsFor(meta), + ), + ], + ); + }, + childCount: filtered.length, + addAutomaticKeepAlives: false, ), - ], + ), + const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ); } diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 6c752dd..b08dd33 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -17,6 +17,7 @@ class TxMetaStoreData { this.notesByMerchant = const {}, this.exactTemplates = const {}, this.customLabels = const [], + this.labelParents = const {}, }); final Map byId; @@ -24,6 +25,9 @@ class TxMetaStoreData { final Map notesByMerchant; final Map exactTemplates; final List customLabels; + + /// childLabelId → parentLabelId (roots omitted). + final Map labelParents; } /// Cached bank payload so the app stays usable across restarts / brief outages. @@ -236,6 +240,7 @@ class StorageService { 'customLabels': [ for (final label in store.customLabels) label.toJson(), ], + 'labelParents': store.labelParents, }; return _prefs.setString(StorageKeys.txMeta, jsonEncode(encoded)); } @@ -323,12 +328,35 @@ class StorageService { } } + final labelParents = {}; + final parentsRaw = root['labelParents']; + if (parentsRaw is Map) { + parentsRaw.forEach((key, value) { + if (key is String && + value is String && + key.isNotEmpty && + value.isNotEmpty) { + labelParents[key] = value; + } + }); + } + // Prefer explicit map; fall back to parentId embedded on custom labels. + for (final label in customLabels) { + final parent = label.parentId; + if (parent != null && + parent.isNotEmpty && + !labelParents.containsKey(label.id)) { + labelParents[label.id] = parent; + } + } + return TxMetaStoreData( byId: byId, labelsByMerchant: labelsByMerchant, notesByMerchant: notesByMerchant, exactTemplates: exactTemplates, customLabels: customLabels, + labelParents: labelParents, ); } catch (_) { return const TxMetaStoreData(); diff --git a/lib/widgets/label_filter_dialog.dart b/lib/widgets/label_filter_dialog.dart new file mode 100644 index 0000000..cfe20b1 --- /dev/null +++ b/lib/widgets/label_filter_dialog.dart @@ -0,0 +1,283 @@ +/// Compact indented label multi-select for list/chart filters. +library; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/transaction_meta.dart'; +import '../providers/tx_meta_provider.dart'; +import 'label_parent_picker.dart'; + +/// Returns the selected label ids, or `null` if cancelled. +/// +/// When [includeUncategorized] is true, [kUncategorizedFilterId] may be +/// selected. Long-press still opens move-parent when [allowMove] is true. +Future?> showLabelFilterDialog( + BuildContext context, { + required List available, + required Set initiallySelected, + String title = 'Filter by label', + String subtitle = 'Choose which labels to include.', + bool includeUncategorized = false, + bool allowMove = false, +}) { + return showDialog>( + context: context, + builder: (dialogContext) { + return _LabelFilterDialog( + available: available, + initiallySelected: initiallySelected, + title: title, + subtitle: subtitle, + includeUncategorized: includeUncategorized, + allowMove: allowMove, + ); + }, + ); +} + +class _LabelFilterDialog extends StatefulWidget { + const _LabelFilterDialog({ + required this.available, + required this.initiallySelected, + required this.title, + required this.subtitle, + required this.includeUncategorized, + required this.allowMove, + }); + + final List available; + final Set initiallySelected; + final String title; + final String subtitle; + final bool includeUncategorized; + final bool allowMove; + + @override + State<_LabelFilterDialog> createState() => _LabelFilterDialogState(); +} + +class _LabelFilterDialogState extends State<_LabelFilterDialog> { + late final Set _selected; + + @override + void initState() { + super.initState(); + _selected = Set.of(widget.initiallySelected); + } + + void _toggle(String id, bool selected) { + setState(() { + if (selected) { + _selected.add(id); + } else { + _selected.remove(id); + } + }); + } + + void _selectAll() { + setState(() { + _selected + ..clear() + ..addAll(widget.available.map((l) => l.id)); + if (widget.includeUncategorized) { + _selected.add(kUncategorizedFilterId); + } + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final meta = context.watch(); + final entries = meta.treeOrderFor(widget.available); + final maxListHeight = MediaQuery.sizeOf(context).height * 0.45; + final uncategorizedChecked = + _selected.contains(kUncategorizedFilterId); + + return AlertDialog( + title: Text(widget.title), + content: SizedBox( + width: double.maxFinite, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(widget.subtitle, style: theme.textTheme.bodyMedium), + const SizedBox(height: 10), + ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxListHeight), + child: DecoratedBox( + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colors.outlineVariant.withValues(alpha: 0.8), + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: entries.length + + (widget.includeUncategorized ? 1 : 0), + separatorBuilder: (_, _) => Divider( + height: 1, + thickness: 1, + indent: 40, + color: + colors.outlineVariant.withValues(alpha: 0.45), + ), + itemBuilder: (context, index) { + if (widget.includeUncategorized && index == 0) { + return _FilterRow( + depth: 0, + icon: kUncategorizedFilterLabel.icon, + color: kUncategorizedFilterLabel.color, + name: kUncategorizedFilterLabel.name, + checked: uncategorizedChecked, + onToggle: (value) => + _toggle(kUncategorizedFilterId, value), + ); + } + final entry = entries[ + widget.includeUncategorized ? index - 1 : index]; + final label = entry.label; + final checked = _selected.contains(label.id); + return _FilterRow( + depth: entry.depth, + icon: label.icon, + color: label.color, + name: label.name, + checked: checked, + onToggle: (value) => _toggle(label.id, value), + onLongPress: widget.allowMove + ? () async { + await showMoveLabelDialog( + context, + label: label, + ); + if (mounted) setState(() {}); + } + : null, + ); + }, + ), + ), + ), + ), + ], + ), + ), + actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + actions: [ + SizedBox( + width: double.maxFinite, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: TextButton( + onPressed: _selectAll, + child: const Text('All'), + ), + ), + Expanded( + child: TextButton( + onPressed: () { + setState(() => _selected.clear()); + }, + child: const Text('None'), + ), + ), + Expanded( + child: TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ), + ], + ), + const SizedBox(height: 4), + FilledButton( + onPressed: () => + Navigator.pop(context, Set.of(_selected)), + child: const Text('Apply'), + ), + ], + ), + ), + ], + ); + } +} + +class _FilterRow extends StatelessWidget { + const _FilterRow({ + required this.depth, + required this.icon, + required this.color, + required this.name, + required this.checked, + required this.onToggle, + this.onLongPress, + }); + + final int depth; + final IconData icon; + final Color color; + final String name; + final bool checked; + final ValueChanged onToggle; + final VoidCallback? onLongPress; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return InkWell( + onTap: () => onToggle(!checked), + onLongPress: onLongPress, + child: Padding( + padding: EdgeInsets.fromLTRB(8 + depth * 16.0, 2, 4, 2), + child: Row( + children: [ + if (depth > 0) ...[ + Icon( + Icons.subdirectory_arrow_right, + size: 14, + color: colors.onSurfaceVariant, + ), + const SizedBox(width: 4), + ], + Icon(icon, size: 18, color: color), + const SizedBox(width: 10), + Expanded( + child: Text( + name, + style: theme.textTheme.bodyMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + SizedBox( + height: 32, + width: 32, + child: Checkbox( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + value: checked, + onChanged: (value) => onToggle(value ?? false), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/label_parent_picker.dart b/lib/widgets/label_parent_picker.dart new file mode 100644 index 0000000..f260e45 --- /dev/null +++ b/lib/widgets/label_parent_picker.dart @@ -0,0 +1,207 @@ +/// Dialogs to nest or create labels. +library; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/transaction_meta.dart'; +import '../providers/tx_meta_provider.dart'; + +Future showMoveLabelDialog( + BuildContext context, { + required TxLabel label, +}) async { + final meta = context.read(); + final parents = meta.validParentsFor(label.id); + final currentParent = meta.parentIdOf(label.id); + + final chosen = await showDialog( + context: context, + builder: (dialogContext) { + final theme = Theme.of(dialogContext); + final maxHeight = MediaQuery.sizeOf(dialogContext).height * 0.45; + return AlertDialog( + title: Text('Move “${label.name}”'), + titleTextStyle: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + contentPadding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + content: SizedBox( + width: double.maxFinite, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: ListView( + shrinkWrap: true, + padding: EdgeInsets.zero, + children: [ + ListTile( + dense: true, + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -3, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 4), + minLeadingWidth: 28, + leading: Icon( + currentParent == null + ? Icons.check_circle + : Icons.circle_outlined, + size: 18, + ), + title: Text( + 'Top level', + style: theme.textTheme.bodyMedium, + ), + onTap: () => Navigator.pop(dialogContext, ''), + ), + Divider( + height: 1, + color: theme.colorScheme.outlineVariant.withValues( + alpha: 0.5, + ), + ), + for (final parent in parents) + ListTile( + dense: true, + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -3, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 4), + minLeadingWidth: 28, + leading: Icon( + parent.id == currentParent + ? Icons.check_circle + : parent.icon, + size: 18, + color: parent.color, + ), + title: Text( + parent.name, + style: theme.textTheme.bodyMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () => Navigator.pop(dialogContext, parent.id), + ), + ], + ), + ), + ), + actionsPadding: const EdgeInsets.fromLTRB(12, 4, 12, 8), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + ], + ); + }, + ); + + if (chosen == null || !context.mounted) return; + await meta.setLabelParent(label.id, chosen.isEmpty ? null : chosen); +} + +/// Returns name + optional parent, or null if cancelled. +Future<({String name, String? parentId})?> showCreateLabelDialog( + BuildContext context, { + required List parents, + String? initialParentId, +}) { + return showDialog<({String name, String? parentId})>( + context: context, + builder: (context) => _CreateLabelDialog( + parents: parents, + initialParentId: initialParentId, + ), + ); +} + +class _CreateLabelDialog extends StatefulWidget { + const _CreateLabelDialog({ + required this.parents, + this.initialParentId, + }); + + final List parents; + final String? initialParentId; + + @override + State<_CreateLabelDialog> createState() => _CreateLabelDialogState(); +} + +class _CreateLabelDialogState extends State<_CreateLabelDialog> { + late final TextEditingController _controller; + String? _parentId; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + _parentId = widget.initialParentId; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + final name = _controller.text.trim(); + if (name.isEmpty) return; + Navigator.pop(context, (name: name, parentId: _parentId)); + } + + @override + Widget build(BuildContext context) { + final parentChoices = List.of(widget.parents) + ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + + return AlertDialog( + title: const Text('New label'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _controller, + autofocus: true, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration(hintText: 'e.g. Supermarket'), + onSubmitted: (_) => _submit(), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _parentId, + decoration: const InputDecoration( + labelText: 'Under label (optional)', + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('Top level'), + ), + for (final parent in parentChoices) + DropdownMenuItem( + value: parent.id, + child: Text(parent.name), + ), + ], + onChanged: (value) => setState(() => _parentId = value), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _submit, + child: const Text('Create'), + ), + ], + ); + } +} diff --git a/lib/widgets/skip_build_when_covered.dart b/lib/widgets/skip_build_when_covered.dart new file mode 100644 index 0000000..a130c3a --- /dev/null +++ b/lib/widgets/skip_build_when_covered.dart @@ -0,0 +1,59 @@ +/// Skips building while another route covers this one, then rebuilds on pop. +library; + +import 'package:flutter/material.dart'; + +/// Shared observer — register on [MaterialApp.navigatorObservers]. +final RouteObserver> appRouteObserver = + RouteObserver>(); + +/// Builds via [builder] only when this route is not covered by a pushed page. +class SkipBuildWhenCovered extends StatefulWidget { + const SkipBuildWhenCovered({ + super.key, + required this.builder, + this.placeholder = const SizedBox.expand(), + }); + + final WidgetBuilder builder; + final Widget placeholder; + + @override + State createState() => _SkipBuildWhenCoveredState(); +} + +class _SkipBuildWhenCoveredState extends State + with RouteAware { + var _covered = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final route = ModalRoute.of(context); + if (route is PageRoute) { + appRouteObserver.subscribe(this, route); + } + } + + @override + void dispose() { + appRouteObserver.unsubscribe(this); + super.dispose(); + } + + @override + void didPushNext() { + if (!_covered) setState(() => _covered = true); + } + + @override + void didPopNext() { + if (_covered) setState(() => _covered = false); + } + + @override + Widget build(BuildContext context) { + if (_covered) return widget.placeholder; + return widget.builder(context); + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 9463da2..5d16c69 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:myapp/main.dart'; import 'package:myapp/models/bank.dart'; @@ -51,13 +52,62 @@ void main() { 'pending': false, }); expect(tx.descriptionKey, contains('pasvolgnr')); - expect(tx.merchantKey, contains('ah campus diemen')); + expect(tx.merchantKey, 'ah campus'); expect(tx.merchantKey.contains('pasvolgnr'), isFalse); expect(tx.exactKey, contains('-3.5')); expect(formatMoney(tx.amount, tx.currency), contains('EUR')); expect(kBuiltinLabels.first.name, 'Groceries'); }); + test('merchant stems group Albert Heijn / AH across stores', () { + final a = merchantKeyFromDescription('Albert Heijn 1562 Utrecht NLD'); + final b = merchantKeyFromDescription('ALBERT HEIJN 1847 AMSTERDAM'); + final c = merchantKeyFromDescription( + 'BCK*AH CAMPUS DIEMEN ZUID NLD Pasvolgnr: 001 Transactie: X', + ); + final d = merchantKeyFromDescription('AH CAMPUS AMSTERDAM NLD'); + expect(a, 'albert heijn'); + expect(b, 'albert heijn'); + expect(c, 'ah campus'); + expect(d, 'ah campus'); + expect(descriptionMatchesPattern('Albert Heijn 1562 Utrecht', a), isTrue); + expect( + descriptionMatchesPattern('Jumbo City Centrum', a), + isFalse, + ); + expect( + descriptionMatchesPattern('AH CAMPUS DIEMEN', '/ah campus/'), + isTrue, + ); + }); + + test('flattenLabelTree indents children under parents', () { + const parent = TxLabel( + id: 'groceries', + name: 'Groceries', + color: Color(0xFF16A34A), + ); + const child = TxLabel( + id: 'custom_ah', + name: 'Albert Heijn', + color: Color(0xFF0F766E), + custom: true, + parentId: 'groceries', + ); + const other = TxLabel( + id: 'dining', + name: 'Dining', + color: Color(0xFFEA580C), + ); + final tree = flattenLabelTree([child, other, parent]); + expect(tree.map((e) => e.label.id).toList(), [ + 'dining', + 'groceries', + 'custom_ah', + ]); + expect(tree.map((e) => e.depth).toList(), [0, 0, 1]); + }); + test('look-ahead applies monthly recurring amounts', () { final items = [ const RecurringItem(