ing connected, makign some features

This commit is contained in:
davmar
2026-09-07 00:20:58 +02:00
parent 87afe271b7
commit 21df7b0085
15 changed files with 1197 additions and 322 deletions
+271 -74
View File
@@ -1,7 +1,7 @@
/// Local category / notes / recurring annotations for bank transactions.
/// Local labels / notes / recurring annotations for bank transactions.
library;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../models/bank.dart';
import '../models/transaction_meta.dart';
@@ -11,54 +11,195 @@ class TxMetaController extends ChangeNotifier {
TxMetaController(this._storage) {
final loaded = _storage.loadTxMetaStore();
_byId = Map.of(loaded.byId);
_categoryByDescription = Map.of(loaded.categoryByDescription);
_labelsByMerchant = {
for (final e in loaded.labelsByMerchant.entries)
e.key: List<String>.of(e.value),
};
_notesByMerchant = Map.of(loaded.notesByMerchant);
_exactTemplates = Map.of(loaded.exactTemplates);
_customLabels = List.of(loaded.customLabels);
}
final StorageService _storage;
Map<String, TxMeta> _byId = {};
Map<String, TxCategory> _categoryByDescription = {};
Map<String, List<String>> _labelsByMerchant = {};
Map<String, String> _notesByMerchant = {};
Map<String, TxExactTemplate> _exactTemplates = {};
List<TxLabel> _customLabels = [];
/// Resolved meta: per-id overrides + inherited category / notes rules.
/// Built-in + custom labels available for picking.
List<TxLabel> get allLabels => [...kBuiltinLabels, ..._customLabels];
List<TxLabel> 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<TxLabel> 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 inheritedCategory = _categoryByDescription[tx.descriptionKey];
final inheritedLabels = _labelsByMerchant[tx.merchantKey] ??
_labelsByMerchant[tx.descriptionKey];
final inheritedNotes = _notesByMerchant[tx.merchantKey] ??
_notesByMerchant[tx.descriptionKey];
final category = stored != null && stored.hasCategory
? stored.category
: (inheritedCategory ?? TxCategory.uncategorized);
final labelIds = stored != null && stored.hasLabel
? stored.labelIds
: (inheritedLabels ?? const <String>[]);
final notes = stored != null && stored.hasNotes
? stored.notes
: (template?.notes ?? '');
: (template != null && template.notes.trim().isNotEmpty
? template.notes
: (inheritedNotes ?? ''));
final recurring = stored?.recurring ?? template?.recurring ?? false;
return TxMeta(
category: category,
labelIds: List<String>.of(labelIds),
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,
};
int countSimilar(
BankTransaction prototype,
List<BankTransaction> all,
) {
final key = prototype.merchantKey;
if (key.isEmpty) return 1;
return all.where((tx) => tx.merchantKey == key).length;
}
List<BankTransaction> similarTo(
BankTransaction prototype,
List<BankTransaction> all,
) {
final key = prototype.merchantKey;
if (key.isEmpty) return [prototype];
return [
for (final tx in all)
if (tx.merchantKey == key) tx,
];
}
Future<void> setLabels(
BankTransaction tx,
List<String> labelIds, {
List<BankTransaction> applyToSimilar = const [],
}) async {
final cleaned = <String>[
for (final id in labelIds)
if (id.isNotEmpty) id,
];
// Deduplicate while preserving order.
final unique = <String>[];
for (final id in cleaned) {
if (!unique.contains(id)) unique.add(id);
}
final scope = applyToSimilar.isEmpty ? [tx] : applyToSimilar;
final nextById = Map<String, TxMeta>.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<String, List<String>>.of(_labelsByMerchant);
if (unique.isEmpty) {
nextRules.remove(tx.merchantKey);
nextRules.remove(tx.descriptionKey);
} else {
nextRules[tx.merchantKey] = unique;
}
_labelsByMerchant = nextRules;
await _persist();
}
Future<void> toggleLabel(
BankTransaction tx,
String labelId, {
List<BankTransaction> applyToSimilar = const [],
}) async {
final current = List<String>.of(metaFor(tx).labelIds);
if (current.contains(labelId)) {
current.remove(labelId);
} else {
current.add(labelId);
}
await setLabels(tx, current, applyToSimilar: applyToSimilar);
}
Future<void> clearLabels(
BankTransaction tx, {
List<BankTransaction> applyToSimilar = const [],
}) {
return setLabels(tx, const [], applyToSimilar: applyToSimilar);
}
Future<TxLabel> 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<void> removeLabel(String labelId) async {
_customLabels =
_customLabels.where((label) => label.id != labelId).toList();
final nextById = <String, TxMeta>{};
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();
}
@@ -79,24 +220,54 @@ class TxMetaController extends ChangeNotifier {
await _persist();
}
Future<void> setNotes(BankTransaction tx, String notes) async {
Future<void> setNotes(
BankTransaction tx,
String notes, {
List<BankTransaction> applyToSimilar = const [],
}) 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);
final scope = applyToSimilar.isEmpty ? [tx] : applyToSimilar;
final nextById = Map<String, TxMeta>.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 {
_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 {
_notesByMerchant = {
..._notesByMerchant,
tx.merchantKey: trimmed,
};
}
}
await _persist();
}
@@ -105,39 +276,43 @@ class TxMetaController extends ChangeNotifier {
return setRecurring(tx, !metaFor(tx).recurring);
}
/// Search description, notes, category label, and date.
/// Optional [categories] require a matching category when non-empty.
/// Search description, notes, label name, and date.
List<BankTransaction> search(
List<BankTransaction> txs,
String query, {
Set<TxCategory> categories = const {},
Set<String> labelIds = const {},
}) {
final needle = query.trim().toLowerCase();
final hasText = needle.isNotEmpty;
final hasCats = categories.isNotEmpty;
if (!hasText && !hasCats) return txs;
final hasLabels = labelIds.isNotEmpty;
if (!hasText && !hasLabels) return txs;
return txs.where((tx) {
final meta = metaFor(tx);
if (hasCats && !categories.contains(meta.category)) return false;
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) ||
meta.category.label.toLowerCase().contains(needle) ||
labelNames.any((name) => name.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>{};
/// Labels currently used by [transactions].
List<TxLabel> usedLabels(List<BankTransaction> transactions) {
final found = <String>{};
for (final tx in transactions) {
final category = metaFor(tx).category;
if (category != TxCategory.uncategorized) found.add(category);
found.addAll(metaFor(tx).labelIds);
}
final list = found.toList()
..sort((a, b) => a.label.compareTo(b.label));
final list = [
for (final label in allLabels)
if (found.contains(label.id)) label,
];
list.sort((a, b) => a.name.compareTo(b.name));
return list;
}
@@ -146,40 +321,62 @@ class TxMetaController extends ChangeNotifier {
await _storage.saveTxMetaStore(
TxMetaStoreData(
byId: _byId,
categoryByDescription: _categoryByDescription,
labelsByMerchant: _labelsByMerchant,
notesByMerchant: _notesByMerchant,
exactTemplates: _exactTemplates,
customLabels: _customLabels,
),
);
}
/// Absolute spend (outflows only) grouped by category.
Map<TxCategory, double> spendByCategory(List<BankTransaction> txs) {
final totals = <TxCategory, double>{};
/// Absolute spend (outflows) per label. Multi-label txs count fully in each.
Map<String, double> spendByLabel(List<BankTransaction> txs) {
final totals = <String, double>{};
for (final tx in txs) {
if (tx.amount >= 0) continue;
final category = metaFor(tx).category;
totals[category] = (totals[category] ?? 0) + tx.amount.abs();
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;
}
/// Absolute spend grouped by calendar month key `yyyy-MM`.
Map<String, double> spendByMonth(List<BankTransaction> txs) {
final totals = <String, double>{};
/// Unique labeled outflow total (no double-count for multi-label txs).
double labeledSpendTotal(List<BankTransaction> txs) {
var total = 0.0;
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();
if (!metaFor(tx).hasLabel) continue;
total += tx.amount.abs();
}
final keys = totals.keys.toList()..sort();
return {for (final key in keys) key: totals[key]!};
return total;
}
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';
/// Absolute spend for each day in [month] (`yyyy-MM-dd` → amount).
Map<String, double> spendByDayInMonth(
List<BankTransaction> txs,
DateTime month,
) {
final start = DateTime(month.year, month.month, 1);
final end = DateTime(month.year, month.month + 1, 0);
final totals = <String, double>{};
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;
}
}