/// Local labels / notes / recurring annotations for bank transactions. library; import 'dart:async'; import 'package:flutter/material.dart'; import '../models/bank.dart'; import '../models/transaction_meta.dart'; import '../services/storage_service.dart'; class TxMetaController extends ChangeNotifier { TxMetaController(this._storage) { final loaded = _storage.loadTxMetaStore(); _byId = Map.of(loaded.byId); _labelsByMerchant = { for (final e in loaded.labelsByMerchant.entries) e.key: List.of(e.value), }; _notesByMerchant = Map.of(loaded.notesByMerchant); _exactTemplates = Map.of(loaded.exactTemplates); _customLabels = List.of(loaded.customLabels); _labelParents = Map.of(loaded.labelParents); } final StorageService _storage; Map _byId = {}; Map> _labelsByMerchant = {}; 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; return _labelById[id]; } List labelsFor(TxMeta meta) { final index = _labelById; return [ for (final id in meta.labelIds) 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 labelIds = labelIdsFor(tx); final inheritedNotes = _resolveMerchantNotes(tx); final notes = stored != null && stored.hasNotes ? stored.notes : (template != null && template.notes.trim().isNotEmpty ? template.notes : (inheritedNotes ?? '')); final recurring = stored?.recurring ?? template?.recurring ?? false; final internalTransfer = stored?.internalTransfer ?? template?.internalTransfer ?? false; return TxMeta( labelIds: List.of(labelIds), notes: notes, recurring: recurring, internalTransfer: internalTransfer, ); } /// 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, { String? pattern, SimilarMatchCriteria? criteria, }) { return similarTo( prototype, all, pattern: pattern, criteria: criteria, ).length; } List similarTo( BankTransaction prototype, List all, { String? pattern, SimilarMatchCriteria? criteria, }) { final query = criteria ?? SimilarMatchCriteria( pattern: (pattern ?? prototype.merchantKey).trim(), ); if (query.pattern.trim().isEmpty) return [prototype]; final matches = [ for (final tx in all) if (txMatchesSimilarCriteria(tx, query)) tx, ]; return matches.isEmpty ? [prototype] : matches; } Future setLabels( BankTransaction tx, List labelIds, { List applyToSimilar = const [], String? matchPattern, }) async { final expanded = expandWithAncestors([ for (final id in labelIds) if (id.isNotEmpty) id, ]); // Deduplicate while preserving order. final unique = []; for (final id in expanded) { if (!unique.contains(id)) unique.add(id); } final pattern = (matchPattern ?? tx.merchantKey).trim().toLowerCase(); final applying = applyToSimilar.isNotEmpty; final nextById = Map.of(_byId); 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); } 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; } _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)) { final remove = {labelId, ...descendantIdsOf(labelId)}; current.removeWhere(remove.contains); } else { current.add(labelId); } await setLabels( tx, current, applyToSimilar: applyToSimilar, matchPattern: matchPattern, ); } Future clearLabels( BankTransaction tx, { List applyToSimilar = const [], String? matchPattern, }) { return setLabels( tx, const [], applyToSimilar: applyToSimilar, matchPattern: matchPattern, ); } /// Replace labels on every transaction in [txs] (per-id overrides). Future setLabelsForMany( List txs, List labelIds, ) async { if (txs.isEmpty) return; final expanded = expandWithAncestors([ for (final id in labelIds) if (id.isNotEmpty && id != kUncategorizedFilterId) id, ]); final unique = []; for (final id in expanded) { if (!unique.contains(id)) unique.add(id); } final nextById = Map.of(_byId); for (final tx in txs) { final stored = nextById[tx.id]; final current = stored ?? metaFor(tx); nextById[tx.id] = unique.isEmpty ? current.copyWith(clearLabels: true) : current.copyWith(labelIds: unique); } _byId = nextById; _schedulePersist(); } /// Label ids shared by every transaction in [txs]. Set commonLabelIds(List txs) { if (txs.isEmpty) return {}; Set? common; for (final tx in txs) { final ids = metaFor(tx).labelIds.toSet(); common = common == null ? ids : common.intersection(ids); if (common.isEmpty) break; } return common ?? {}; } Future 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( id: 'custom_${DateTime.now().millisecondsSinceEpoch}', name: trimmed, color: color, icon: Icons.label_outline, custom: true, parentId: parent, ); _customLabels = [..._customLabels, label]; 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)) { final next = entry.value.labelIds.where((id) => id != labelId).toList(); nextById[entry.key] = next.isEmpty ? entry.value.copyWith(clearLabels: true) : entry.value.copyWith(labelIds: next); } else { nextById[entry.key] = entry.value; } } _byId = nextById; _labelsByMerchant = { for (final entry in _labelsByMerchant.entries) if (entry.value.any((id) => id != labelId)) entry.key: [ for (final id in entry.value) if (id != labelId) id, ], }; _schedulePersist(); } Future setRecurring(BankTransaction tx, bool recurring) async { final current = metaFor(tx); _byId = { ..._byId, tx.id: current.copyWith(recurring: recurring), }; final existing = _exactTemplates[tx.exactKey] ?? const TxExactTemplate(); _exactTemplates = { ..._exactTemplates, tx.exactKey: TxExactTemplate( notes: existing.notes.isNotEmpty ? existing.notes : current.notes, recurring: recurring, internalTransfer: existing.internalTransfer || current.internalTransfer, ), }; _schedulePersist(); } Future setInternalTransfer( BankTransaction tx, bool internalTransfer, ) async { final current = metaFor(tx); _byId = { ..._byId, tx.id: current.copyWith(internalTransfer: internalTransfer), }; final existing = _exactTemplates[tx.exactKey] ?? const TxExactTemplate(); _exactTemplates = { ..._exactTemplates, tx.exactKey: TxExactTemplate( notes: existing.notes.isNotEmpty ? existing.notes : current.notes, recurring: existing.recurring || current.recurring, internalTransfer: internalTransfer, ), }; _schedulePersist(); } Future setNotes( BankTransaction tx, String notes, { List applyToSimilar = const [], String? matchPattern, }) async { final trimmed = notes.trim(); final pattern = (matchPattern ?? tx.merchantKey).trim().toLowerCase(); final applying = applyToSimilar.isNotEmpty; final nextById = Map.of(_byId); 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 (!applying) { final current = metaFor(tx); final existing = _exactTemplates[tx.exactKey] ?? const TxExactTemplate(); final keepTemplate = trimmed.isNotEmpty || existing.recurring || current.recurring || existing.internalTransfer || current.internalTransfer; if (!keepTemplate) { _exactTemplates = Map.of(_exactTemplates)..remove(tx.exactKey); } else { _exactTemplates = { ..._exactTemplates, tx.exactKey: TxExactTemplate( notes: trimmed, recurring: existing.recurring || current.recurring, internalTransfer: existing.internalTransfer || current.internalTransfer, ), }; } if (trimmed.isEmpty) { _notesByMerchant = Map.of(_notesByMerchant) ..remove(tx.merchantKey) ..remove(tx.descriptionKey) ..remove(pattern); } } else { if (trimmed.isEmpty) { _notesByMerchant = Map.of(_notesByMerchant) ..remove(tx.merchantKey) ..remove(tx.descriptionKey) ..remove(pattern); } else if (pattern.isNotEmpty) { _notesByMerchant = { ..._notesByMerchant, pattern: trimmed, }; } } _schedulePersist(); } Future toggleRecurring(BankTransaction tx) { return setRecurring(tx, !metaFor(tx).recurring); } Future toggleInternalTransfer(BankTransaction tx) { return setInternalTransfer(tx, !metaFor(tx).internalTransfer); } /// Search description, notes, label name, and date. List search( List txs, String query, { Set labelIds = const {}, }) { final needle = query.trim().toLowerCase(); final hasText = needle.isNotEmpty; final hasLabels = labelIds.isNotEmpty; if (!hasText && !hasLabels) return txs; 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]. List usedLabels(List transactions) { final found = {}; for (final tx in transactions) { found.addAll(metaFor(tx).labelIds); } final list = [ for (final label in allLabels) if (found.contains(label.id)) label, ]; list.sort((a, b) => a.name.compareTo(b.name)); return list; } /// 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. Map spendByLabel(List txs) { final totals = {}; for (final tx in txs) { if (tx.amount >= 0) continue; final meta = metaFor(tx); if (meta.internalTransfer) continue; final ids = meta.labelIds; if (ids.isEmpty) continue; final amount = tx.amount.abs(); for (final id in ids) { totals[id] = (totals[id] ?? 0) + amount; } } return totals; } /// Unique labeled outflow total (no double-count for multi-label txs). double labeledSpendTotal(List txs) { var total = 0.0; for (final tx in txs) { if (tx.amount >= 0) continue; final meta = metaFor(tx); if (meta.internalTransfer || !meta.hasLabel) continue; total += tx.amount.abs(); } return total; } /// Absolute spend for each day in [month] (`yyyy-MM-dd` → amount). Map spendByDayInMonth( List txs, DateTime month, ) { final start = DateTime(month.year, month.month, 1); final end = DateTime(month.year, month.month + 1, 0); final totals = {}; for (var day = 1; day <= end.day; day++) { final key = '${start.year}-${start.month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}'; totals[key] = 0; } for (final tx in txs) { if (tx.amount >= 0) continue; if (metaFor(tx).internalTransfer) continue; final parsed = DateTime.tryParse(tx.date); if (parsed == null) continue; if (parsed.year != month.year || parsed.month != month.month) continue; final key = '${parsed.year}-${parsed.month.toString().padLeft(2, '0')}-${parsed.day.toString().padLeft(2, '0')}'; totals[key] = (totals[key] ?? 0) + tx.amount.abs(); } return totals; } }