ok for now

This commit is contained in:
davmar
2026-09-06 21:58:59 +02:00
parent 773187f0af
commit 87afe271b7
20 changed files with 2271 additions and 377 deletions
+185
View File
@@ -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';
}
}