/// Local labels / notes / recurring annotations for bank transactions. library; 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); } final StorageService _storage; Map _byId = {}; Map> _labelsByMerchant = {}; Map _notesByMerchant = {}; Map _exactTemplates = {}; List _customLabels = []; /// Built-in + custom labels available for picking. List get allLabels => [...kBuiltinLabels, ..._customLabels]; List get customLabels => List.unmodifiable(_customLabels); TxLabel? labelById(String? id) { if (id == null || id.isEmpty) return null; for (final label in allLabels) { if (label.id == id) return label; } return null; } List labelsFor(TxMeta meta) { return [ for (final id in meta.labelIds) if (labelById(id) != null) labelById(id)!, ]; } /// 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 notes = stored != null && stored.hasNotes ? stored.notes : (template != null && template.notes.trim().isNotEmpty ? template.notes : (inheritedNotes ?? '')); final recurring = stored?.recurring ?? template?.recurring ?? false; return TxMeta( labelIds: List.of(labelIds), notes: notes, recurring: recurring, ); } int countSimilar( BankTransaction prototype, List all, ) { final key = prototype.merchantKey; if (key.isEmpty) return 1; return all.where((tx) => tx.merchantKey == key).length; } List similarTo( BankTransaction prototype, List all, ) { final key = prototype.merchantKey; if (key.isEmpty) return [prototype]; return [ for (final tx in all) if (tx.merchantKey == key) tx, ]; } Future setLabels( BankTransaction tx, List labelIds, { List applyToSimilar = const [], }) async { final cleaned = [ for (final id in labelIds) if (id.isNotEmpty) id, ]; // Deduplicate while preserving order. final unique = []; for (final id in cleaned) { if (!unique.contains(id)) unique.add(id); } final scope = applyToSimilar.isEmpty ? [tx] : applyToSimilar; final nextById = Map.of(_byId); for (final other in scope) { if (applyToSimilar.isNotEmpty && other.merchantKey != tx.merchantKey) { continue; } final current = 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; } _labelsByMerchant = nextRules; await _persist(); } Future toggleLabel( BankTransaction tx, String labelId, { List applyToSimilar = const [], }) async { final current = List.of(metaFor(tx).labelIds); if (current.contains(labelId)) { current.remove(labelId); } else { current.add(labelId); } await setLabels(tx, current, applyToSimilar: applyToSimilar); } Future clearLabels( BankTransaction tx, { List applyToSimilar = const [], }) { return setLabels(tx, const [], applyToSimilar: applyToSimilar); } Future createLabel(String name) async { final trimmed = name.trim(); if (trimmed.isEmpty) { throw ArgumentError('Label name required'); } 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, ); _customLabels = [..._customLabels, label]; await _persist(); return label; } Future removeLabel(String labelId) async { _customLabels = _customLabels.where((label) => label.id != labelId).toList(); 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, ], }; await _persist(); } 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, ), }; await _persist(); } Future setNotes( BankTransaction tx, String notes, { List applyToSimilar = const [], }) async { final trimmed = notes.trim(); final scope = applyToSimilar.isEmpty ? [tx] : applyToSimilar; final nextById = Map.of(_byId); for (final other in scope) { if (applyToSimilar.isNotEmpty && other.merchantKey != tx.merchantKey) { continue; } final current = metaFor(other); nextById[other.id] = current.copyWith(notes: trimmed); } _byId = nextById; if (applyToSimilar.isEmpty) { final current = metaFor(tx); final existing = _exactTemplates[tx.exactKey] ?? const TxExactTemplate(); if (trimmed.isEmpty && !existing.recurring && !current.recurring) { _exactTemplates = Map.of(_exactTemplates)..remove(tx.exactKey); } else { _exactTemplates = { ..._exactTemplates, tx.exactKey: TxExactTemplate( notes: trimmed, recurring: existing.recurring || current.recurring, ), }; } if (trimmed.isEmpty) { _notesByMerchant = Map.of(_notesByMerchant) ..remove(tx.merchantKey) ..remove(tx.descriptionKey); } } else { if (trimmed.isEmpty) { _notesByMerchant = Map.of(_notesByMerchant) ..remove(tx.merchantKey) ..remove(tx.descriptionKey); } else { _notesByMerchant = { ..._notesByMerchant, tx.merchantKey: trimmed, }; } } await _persist(); } Future toggleRecurring(BankTransaction tx) { return setRecurring(tx, !metaFor(tx).recurring); } /// 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; 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); } /// 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; } Future _persist() async { notifyListeners(); await _storage.saveTxMetaStore( TxMetaStoreData( byId: _byId, labelsByMerchant: _labelsByMerchant, notesByMerchant: _notesByMerchant, exactTemplates: _exactTemplates, customLabels: _customLabels, ), ); } /// 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 ids = metaFor(tx).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; if (!metaFor(tx).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; 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; } }