186 lines
5.8 KiB
Dart
186 lines
5.8 KiB
Dart
/// 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<String, TxMeta> _byId = {};
|
|
Map<String, TxCategory> _categoryByDescription = {};
|
|
Map<String, TxExactTemplate> _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<void> 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<void> 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<void> 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<void> 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<BankTransaction> search(
|
|
List<BankTransaction> txs,
|
|
String query, {
|
|
Set<TxCategory> 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<TxCategory> usedCategories(List<BankTransaction> transactions) {
|
|
final found = <TxCategory>{};
|
|
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<void> _persist() async {
|
|
notifyListeners();
|
|
await _storage.saveTxMetaStore(
|
|
TxMetaStoreData(
|
|
byId: _byId,
|
|
categoryByDescription: _categoryByDescription,
|
|
exactTemplates: _exactTemplates,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Absolute spend (outflows only) grouped by category.
|
|
Map<TxCategory, double> spendByCategory(List<BankTransaction> txs) {
|
|
final totals = <TxCategory, double>{};
|
|
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<String, double> spendByMonth(List<BankTransaction> txs) {
|
|
final totals = <String, double>{};
|
|
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';
|
|
}
|
|
}
|