ok for now
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
/// 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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user