/// Local category / notes / recurring annotations for bank transactions. library; import 'package:flutter/foundation.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); _categoryByDescription = Map.of(loaded.categoryByDescription); _exactTemplates = Map.of(loaded.exactTemplates); } final StorageService _storage; Map _byId = {}; Map _categoryByDescription = {}; Map _exactTemplates = {}; /// Resolved meta: per-id overrides + inherited category / notes rules. TxMeta metaFor(BankTransaction tx) { final stored = _byId[tx.id]; final template = _exactTemplates[tx.exactKey]; final inheritedCategory = _categoryByDescription[tx.descriptionKey]; final category = stored != null && stored.hasCategory ? stored.category : (inheritedCategory ?? TxCategory.uncategorized); final notes = stored != null && stored.hasNotes ? stored.notes : (template?.notes ?? ''); final recurring = stored?.recurring ?? template?.recurring ?? false; return TxMeta( category: category, notes: notes, recurring: recurring, ); } Future setCategory(BankTransaction tx, TxCategory category) async { final current = metaFor(tx); _byId = { ..._byId, tx.id: current.copyWith(category: category), }; if (category == TxCategory.uncategorized) { _categoryByDescription = Map.of(_categoryByDescription) ..remove(tx.descriptionKey); } else { _categoryByDescription = { ..._categoryByDescription, tx.descriptionKey: category, }; } 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) async { final trimmed = notes.trim(); final current = metaFor(tx); _byId = { ..._byId, tx.id: current.copyWith(notes: trimmed), }; 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, ), }; } await _persist(); } Future toggleRecurring(BankTransaction tx) { return setRecurring(tx, !metaFor(tx).recurring); } /// Search description, notes, category label, and date. /// Optional [categories] require a matching category when non-empty. List search( List txs, String query, { Set categories = const {}, }) { final needle = query.trim().toLowerCase(); final hasText = needle.isNotEmpty; final hasCats = categories.isNotEmpty; if (!hasText && !hasCats) return txs; return txs.where((tx) { final meta = metaFor(tx); if (hasCats && !categories.contains(meta.category)) return false; if (!hasText) return true; return tx.description.toLowerCase().contains(needle) || meta.notes.toLowerCase().contains(needle) || meta.category.label.toLowerCase().contains(needle) || tx.date.toLowerCase().contains(needle) || tx.amount.toString().contains(needle); }).toList(growable: false); } /// Categories currently used by [transactions] (excluding uncategorized). List usedCategories(List transactions) { final found = {}; for (final tx in transactions) { final category = metaFor(tx).category; if (category != TxCategory.uncategorized) found.add(category); } final list = found.toList() ..sort((a, b) => a.label.compareTo(b.label)); return list; } Future _persist() async { notifyListeners(); await _storage.saveTxMetaStore( TxMetaStoreData( byId: _byId, categoryByDescription: _categoryByDescription, exactTemplates: _exactTemplates, ), ); } /// Absolute spend (outflows only) grouped by category. Map spendByCategory(List txs) { final totals = {}; for (final tx in txs) { if (tx.amount >= 0) continue; final category = metaFor(tx).category; totals[category] = (totals[category] ?? 0) + tx.amount.abs(); } return totals; } /// Absolute spend grouped by calendar month key `yyyy-MM`. Map spendByMonth(List txs) { final totals = {}; for (final tx in txs) { if (tx.amount >= 0) continue; final key = _monthKey(tx.date); if (key == null) continue; totals[key] = (totals[key] ?? 0) + tx.amount.abs(); } final keys = totals.keys.toList()..sort(); return {for (final key in keys) key: totals[key]!}; } String? _monthKey(String raw) { final parsed = DateTime.tryParse(raw); if (parsed == null) return null; final m = parsed.month.toString().padLeft(2, '0'); return '${parsed.year}-$m'; } }