nice overview working

This commit is contained in:
davmar
2026-09-08 23:52:54 +02:00
parent e7a7b1a357
commit 2a741c7f2a
16 changed files with 3512 additions and 810 deletions
+15 -1
View File
@@ -30,6 +30,7 @@ class BankController extends ChangeNotifier {
_remoteTransactions = cached.transactions;
}
_importedTransactions = _storage?.loadImportedTransactions() ?? const [];
_invalidateMerged();
}
final IngtrakApi _api;
@@ -39,6 +40,7 @@ class BankController extends ChangeNotifier {
BankBalance? _balance;
List<BankTransaction> _remoteTransactions = const [];
List<BankTransaction> _importedTransactions = const [];
List<BankTransaction>? _mergedTransactions;
bool _isLoading = false;
bool _isConnecting = false;
@@ -50,8 +52,16 @@ class BankController extends ChangeNotifier {
BankBalance? get balance => _balance;
/// Remote API txs merged with locally imported CSV txs (deduped).
///
/// Cached — [mergeTransactions] is O(n·m) and was re-running on every
/// `bank.transactions` read during rebuilds.
List<BankTransaction> get transactions =>
mergeTransactions(_remoteTransactions, _importedTransactions);
_mergedTransactions ??=
mergeTransactions(_remoteTransactions, _importedTransactions);
void _invalidateMerged() {
_mergedTransactions = null;
}
int get importedCount => _importedTransactions.length;
bool get isLoading => _isLoading;
@@ -121,6 +131,7 @@ class BankController extends ChangeNotifier {
_remoteTransactions,
_importedTransactions,
);
_invalidateMerged();
await _persistImported();
}
return result;
@@ -139,6 +150,7 @@ class BankController extends ChangeNotifier {
Future<void> clearImportedTransactions() async {
_importedTransactions = const [];
_invalidateMerged();
await _storage?.clearImportedTransactions();
notifyListeners();
}
@@ -210,6 +222,7 @@ class BankController extends ChangeNotifier {
_remoteTransactions,
_importedTransactions,
);
_invalidateMerged();
await _persistImported();
_error = null;
} on IngtrakApiException catch (error) {
@@ -218,6 +231,7 @@ class BankController extends ChangeNotifier {
_status = ConnectStatus.disconnected;
_balance = null;
_remoteTransactions = const [];
_invalidateMerged();
_error = error.message;
await _persistCache();
} else if (!hasData) {
+458 -85
View File
@@ -1,6 +1,8 @@
/// Local labels / notes / recurring annotations for bank transactions.
library;
import 'dart:async';
import 'package:flutter/material.dart';
import '../models/bank.dart';
@@ -18,6 +20,7 @@ class TxMetaController extends ChangeNotifier {
_notesByMerchant = Map.of(loaded.notesByMerchant);
_exactTemplates = Map.of(loaded.exactTemplates);
_customLabels = List.of(loaded.customLabels);
_labelParents = Map.of(loaded.labelParents);
}
final StorageService _storage;
@@ -27,39 +30,246 @@ class TxMetaController extends ChangeNotifier {
Map<String, String> _notesByMerchant = {};
Map<String, TxExactTemplate> _exactTemplates = {};
List<TxLabel> _customLabels = [];
Map<String, String> _labelParents = {};
/// Built-in + custom labels available for picking.
List<TxLabel> get allLabels => [...kBuiltinLabels, ..._customLabels];
Map<String, TxLabel>? _labelByIdCache;
Map<String, List<String>>? _labelIdsByTxCache;
Map<String, TxLabel> get _labelById {
return _labelByIdCache ??= {
for (final label in allLabels) label.id: label,
};
}
void _invalidateLabelCache() {
_labelByIdCache = null;
_labelIdsByTxCache = null;
_labeledTxCount = null;
_labeledCountListIdentity = null;
}
int? _labeledTxCount;
int? _labeledCountListIdentity;
/// Cached count of txs that resolve to at least one label.
int labeledTransactionCount(List<BankTransaction> txs) {
final identity = identityHashCode(txs);
if (_labeledTxCount != null && _labeledCountListIdentity == identity) {
return _labeledTxCount!;
}
var n = 0;
for (final tx in txs) {
if (labelIdsFor(tx).isNotEmpty) n++;
}
_labeledTxCount = n;
_labeledCountListIdentity = identity;
return n;
}
List<TxLabel> get customLabels => List.unmodifiable(_customLabels);
/// Effective parent id (hierarchy map wins over embedded [TxLabel.parentId]).
String? parentIdOf(String labelId) {
final mapped = _labelParents[labelId];
if (mapped != null) {
return mapped.isEmpty ? null : mapped;
}
final embedded = _labelById[labelId]?.parentId;
if (embedded == null || embedded.isEmpty) return null;
return embedded;
}
/// Labels in tree order with depth.
List<LabelTreeEntry> labelsInTreeOrder([Iterable<TxLabel>? subset]) {
final source = subset?.toList() ?? allLabels;
return flattenLabelTree(source, parentOf: parentIdOf);
}
/// Tree order limited to [available], keeping relative indent.
List<LabelTreeEntry> treeOrderFor(Iterable<TxLabel> available) {
final availableList = available.toList();
final ids = availableList.map((l) => l.id).toSet();
final full = flattenLabelTree(allLabels, parentOf: parentIdOf);
return [
for (final entry in full)
if (ids.contains(entry.label.id))
LabelTreeEntry(
label: entry.label,
depth: labelVisibleDepth(entry.label.id, ids, parentIdOf),
),
];
}
int depthOf(String labelId) {
var depth = 0;
var parent = parentIdOf(labelId);
final seen = <String>{labelId};
while (parent != null && seen.add(parent)) {
depth++;
parent = parentIdOf(parent);
if (depth > kMaxLabelDepth) break;
}
return depth;
}
/// Parent chain of [labelId], nearest parent first.
List<String> ancestorIdsOf(String labelId) {
final out = <String>[];
var parent = parentIdOf(labelId);
final seen = <String>{labelId};
while (parent != null && parent.isNotEmpty && seen.add(parent)) {
out.add(parent);
parent = parentIdOf(parent);
}
return out;
}
/// All nested labels under [labelId].
List<String> descendantIdsOf(String labelId) {
final out = <String>[];
void walk(String id) {
for (final label in allLabels) {
if (parentIdOf(label.id) == id) {
out.add(label.id);
walk(label.id);
}
}
}
walk(labelId);
return out;
}
/// Ensures every selected sub-label also includes its parents.
List<String> expandWithAncestors(Iterable<String> ids) {
final out = <String>{};
for (final id in ids) {
if (id.isEmpty) continue;
out.add(id);
out.addAll(ancestorIdsOf(id));
}
return out.toList(growable: false);
}
bool canSetParent({required String labelId, String? parentId}) {
if (parentId == null || parentId.isEmpty) return true;
if (parentId == labelId) return false;
if (_labelById[parentId] == null) return false;
if (depthOf(parentId) >= kMaxLabelDepth) return false;
// Prevent cycles: parent cannot be a descendant of label.
var walk = parentIdOf(parentId);
final seen = <String>{parentId};
while (walk != null && seen.add(walk)) {
if (walk == labelId) return false;
walk = parentIdOf(walk);
}
return true;
}
/// Candidates that [labelId] can move under (excluding self/descendants).
List<TxLabel> validParentsFor(String labelId) {
final banned = <String>{labelId};
void collectDescendants(String id) {
for (final label in allLabels) {
if (parentIdOf(label.id) == id && banned.add(label.id)) {
collectDescendants(label.id);
}
}
}
collectDescendants(labelId);
return [
for (final label in allLabels)
if (!banned.contains(label.id) && depthOf(label.id) < kMaxLabelDepth)
label,
]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
}
Future<void> setLabelParent(String labelId, String? parentId) async {
final nextParent =
(parentId == null || parentId.isEmpty) ? null : parentId;
if (!canSetParent(labelId: labelId, parentId: nextParent)) {
return;
}
final nextParents = Map<String, String>.of(_labelParents);
if (nextParent == null) {
nextParents.remove(labelId);
} else {
nextParents[labelId] = nextParent;
}
_labelParents = nextParents;
final customIndex = _customLabels.indexWhere((l) => l.id == labelId);
if (customIndex >= 0) {
final current = _customLabels[customIndex];
final updated = nextParent == null
? current.copyWith(clearParent: true)
: current.copyWith(parentId: nextParent);
_customLabels = [
for (var i = 0; i < _customLabels.length; i++)
if (i == customIndex) updated else _customLabels[i],
];
}
_schedulePersist();
}
TxLabel? labelById(String? id) {
if (id == null || id.isEmpty) return null;
for (final label in allLabels) {
if (label.id == id) return label;
}
return null;
return _labelById[id];
}
List<TxLabel> labelsFor(TxMeta meta) {
final index = _labelById;
return [
for (final id in meta.labelIds)
if (labelById(id) != null) labelById(id)!,
if (index[id] != null) index[id]!,
];
}
/// Labels to show on tiles: omit parents when a child is also selected.
List<TxLabel> leafLabelsFor(TxMeta meta) {
final ids = meta.labelIds.toSet();
final index = _labelById;
return [
for (final id in ids)
if (index[id] != null &&
!ids.any(
(other) =>
other != id && ancestorIdsOf(other).contains(id),
))
index[id]!,
];
}
/// Label ids only — cheaper than [metaFor] for chart aggregation.
/// Always includes ancestor labels of any selected sub-label.
List<String> labelIdsFor(BankTransaction tx) {
final cache = _labelIdsByTxCache ??= {};
final cached = cache[tx.id];
if (cached != null) return cached;
final stored = _byId[tx.id];
final raw = stored != null && stored.hasLabel
? stored.labelIds
: (_resolveMerchantLabels(tx) ?? const <String>[]);
final ids = expandWithAncestors(raw);
final frozen = ids.isEmpty
? const <String>[]
: List<String>.unmodifiable(ids);
cache[tx.id] = frozen;
return frozen;
}
/// Resolved meta: per-id overrides + inherited merchant rules.
TxMeta metaFor(BankTransaction tx) {
final stored = _byId[tx.id];
final template = _exactTemplates[tx.exactKey];
final inheritedLabels = _labelsByMerchant[tx.merchantKey] ??
_labelsByMerchant[tx.descriptionKey];
final inheritedNotes = _notesByMerchant[tx.merchantKey] ??
_notesByMerchant[tx.descriptionKey];
final labelIds = stored != null && stored.hasLabel
? stored.labelIds
: (inheritedLabels ?? const <String>[]);
final labelIds = labelIdsFor(tx);
final inheritedNotes = _resolveMerchantNotes(tx);
final notes = stored != null && stored.hasNotes
? stored.notes
@@ -76,93 +286,165 @@ class TxMetaController extends ChangeNotifier {
);
}
/// Exact merchant key first, then longest rule key contained in description.
List<String>? _resolveMerchantLabels(BankTransaction tx) {
final direct = _labelsByMerchant[tx.merchantKey] ??
_labelsByMerchant[tx.descriptionKey];
if (direct != null) return direct;
return _bestMatchingRule(_labelsByMerchant, tx.description);
}
String? _resolveMerchantNotes(BankTransaction tx) {
final direct =
_notesByMerchant[tx.merchantKey] ?? _notesByMerchant[tx.descriptionKey];
if (direct != null) return direct;
return _bestMatchingRule(_notesByMerchant, tx.description);
}
V? _bestMatchingRule<V>(Map<String, V> rules, String description) {
if (rules.isEmpty) return null;
final hay = description.toLowerCase();
String? bestKey;
V? bestValue;
for (final entry in rules.entries) {
final key = entry.key;
if (key.length < 2) continue;
if (!hay.contains(key)) continue;
if (bestKey == null || key.length > bestKey.length) {
bestKey = key;
bestValue = entry.value;
}
}
return bestValue;
}
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> all, {
String? pattern,
}) {
final needle = (pattern ?? prototype.merchantKey).trim();
if (needle.isEmpty) return 1;
var count = 0;
for (final tx in all) {
if (descriptionMatchesPattern(tx.description, needle)) count++;
}
return count == 0 ? 1 : count;
}
List<BankTransaction> similarTo(
BankTransaction prototype,
List<BankTransaction> all,
) {
final key = prototype.merchantKey;
if (key.isEmpty) return [prototype];
return [
List<BankTransaction> all, {
String? pattern,
}) {
final needle = (pattern ?? prototype.merchantKey).trim();
if (needle.isEmpty) return [prototype];
final matches = [
for (final tx in all)
if (tx.merchantKey == key) tx,
if (descriptionMatchesPattern(tx.description, needle)) tx,
];
return matches.isEmpty ? [prototype] : matches;
}
Future<void> setLabels(
BankTransaction tx,
List<String> labelIds, {
List<BankTransaction> applyToSimilar = const [],
String? matchPattern,
}) async {
final cleaned = <String>[
final expanded = expandWithAncestors([
for (final id in labelIds)
if (id.isNotEmpty) id,
];
]);
// Deduplicate while preserving order.
final unique = <String>[];
for (final id in cleaned) {
for (final id in expanded) {
if (!unique.contains(id)) unique.add(id);
}
final scope = applyToSimilar.isEmpty ? [tx] : applyToSimilar;
final pattern = (matchPattern ?? tx.merchantKey).trim().toLowerCase();
final applying = applyToSimilar.isNotEmpty;
final nextById = Map<String, TxMeta>.of(_byId);
for (final other in scope) {
if (applyToSimilar.isNotEmpty &&
other.merchantKey != tx.merchantKey) {
continue;
}
final current = metaFor(other);
void writeTx(BankTransaction other) {
final stored = nextById[other.id];
final current = stored ?? 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;
writeTx(tx);
if (applying) {
// Only patch txs that already have per-id overrides; others inherit
// from the merchant rule so we avoid bloating SharedPreferences.
for (final other in applyToSimilar) {
if (other.id == tx.id) continue;
if (!nextById.containsKey(other.id)) continue;
writeTx(other);
}
final nextRules = Map<String, List<String>>.of(_labelsByMerchant);
if (unique.isEmpty) {
if (pattern.isNotEmpty) nextRules.remove(pattern);
nextRules.remove(tx.merchantKey);
nextRules.remove(tx.descriptionKey);
} else if (pattern.isNotEmpty) {
nextRules[pattern] = unique;
}
_labelsByMerchant = nextRules;
}
_labelsByMerchant = nextRules;
await _persist();
_byId = nextById;
_schedulePersist();
}
Future<void> toggleLabel(
BankTransaction tx,
String labelId, {
List<BankTransaction> applyToSimilar = const [],
String? matchPattern,
}) async {
final current = List<String>.of(metaFor(tx).labelIds);
if (current.contains(labelId)) {
current.remove(labelId);
final remove = {labelId, ...descendantIdsOf(labelId)};
current.removeWhere(remove.contains);
} else {
current.add(labelId);
}
await setLabels(tx, current, applyToSimilar: applyToSimilar);
await setLabels(
tx,
current,
applyToSimilar: applyToSimilar,
matchPattern: matchPattern,
);
}
Future<void> clearLabels(
BankTransaction tx, {
List<BankTransaction> applyToSimilar = const [],
String? matchPattern,
}) {
return setLabels(tx, const [], applyToSimilar: applyToSimilar);
return setLabels(
tx,
const [],
applyToSimilar: applyToSimilar,
matchPattern: matchPattern,
);
}
Future<TxLabel> createLabel(String name) async {
Future<TxLabel> createLabel(String name, {String? parentId}) async {
final trimmed = name.trim();
if (trimmed.isEmpty) {
throw ArgumentError('Label name required');
}
final parent =
(parentId == null || parentId.isEmpty) ? null : parentId;
if (parent != null && _labelById[parent] == null) {
throw ArgumentError('Unknown parent label');
}
if (parent != null && depthOf(parent) >= kMaxLabelDepth) {
throw ArgumentError('Parent is already at max depth');
}
final color =
kCustomLabelColors[_customLabels.length % kCustomLabelColors.length];
final label = TxLabel(
@@ -171,15 +453,44 @@ class TxMetaController extends ChangeNotifier {
color: color,
icon: Icons.label_outline,
custom: true,
parentId: parent,
);
_customLabels = [..._customLabels, label];
await _persist();
if (parent != null) {
_labelParents = {..._labelParents, label.id: parent};
}
_schedulePersist();
return label;
}
Future<void> removeLabel(String labelId) async {
final oldParent = parentIdOf(labelId);
_customLabels =
_customLabels.where((label) => label.id != labelId).toList();
// Re-parent children to the removed label's parent (or root).
final nextParents = <String, String>{};
for (final entry in _labelParents.entries) {
if (entry.key == labelId) continue;
if (entry.value == labelId) {
if (oldParent != null) {
nextParents[entry.key] = oldParent;
}
} else {
nextParents[entry.key] = entry.value;
}
}
_labelParents = nextParents;
_customLabels = [
for (final label in _customLabels)
if (label.parentId == labelId)
oldParent == null
? label.copyWith(clearParent: true)
: label.copyWith(parentId: oldParent)
else
label,
];
final nextById = <String, TxMeta>{};
for (final entry in _byId.entries) {
if (entry.value.labelIds.contains(labelId)) {
@@ -200,7 +511,7 @@ class TxMetaController extends ChangeNotifier {
if (id != labelId) id,
],
};
await _persist();
_schedulePersist();
}
Future<void> setRecurring(BankTransaction tx, bool recurring) async {
@@ -217,28 +528,36 @@ class TxMetaController extends ChangeNotifier {
recurring: recurring,
),
};
await _persist();
_schedulePersist();
}
Future<void> setNotes(
BankTransaction tx,
String notes, {
List<BankTransaction> applyToSimilar = const [],
String? matchPattern,
}) async {
final trimmed = notes.trim();
final scope = applyToSimilar.isEmpty ? [tx] : applyToSimilar;
final pattern = (matchPattern ?? tx.merchantKey).trim().toLowerCase();
final applying = applyToSimilar.isNotEmpty;
final nextById = Map<String, TxMeta>.of(_byId);
for (final other in scope) {
if (applyToSimilar.isNotEmpty &&
other.merchantKey != tx.merchantKey) {
continue;
}
void writeTx(BankTransaction other) {
final current = metaFor(other);
nextById[other.id] = current.copyWith(notes: trimmed);
}
writeTx(tx);
if (applying) {
for (final other in applyToSimilar) {
if (other.id == tx.id) continue;
if (!nextById.containsKey(other.id)) continue;
writeTx(other);
}
}
_byId = nextById;
if (applyToSimilar.isEmpty) {
if (!applying) {
final current = metaFor(tx);
final existing = _exactTemplates[tx.exactKey] ?? const TxExactTemplate();
if (trimmed.isEmpty && !existing.recurring && !current.recurring) {
@@ -255,21 +574,23 @@ class TxMetaController extends ChangeNotifier {
if (trimmed.isEmpty) {
_notesByMerchant = Map.of(_notesByMerchant)
..remove(tx.merchantKey)
..remove(tx.descriptionKey);
..remove(tx.descriptionKey)
..remove(pattern);
}
} else {
if (trimmed.isEmpty) {
_notesByMerchant = Map.of(_notesByMerchant)
..remove(tx.merchantKey)
..remove(tx.descriptionKey);
} else {
..remove(tx.descriptionKey)
..remove(pattern);
} else if (pattern.isNotEmpty) {
_notesByMerchant = {
..._notesByMerchant,
tx.merchantKey: trimmed,
pattern: trimmed,
};
}
}
await _persist();
_schedulePersist();
}
Future<void> toggleRecurring(BankTransaction tx) {
@@ -287,19 +608,47 @@ class TxMetaController extends ChangeNotifier {
final hasLabels = labelIds.isNotEmpty;
if (!hasText && !hasLabels) return txs;
return txs.where((tx) {
final meta = metaFor(tx);
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) ||
labelNames.any((name) => name.contains(needle)) ||
tx.date.toLowerCase().contains(needle) ||
tx.amount.toString().contains(needle);
}).toList(growable: false);
final labelIndex = _labelById;
return [
for (final tx in txs)
if (_matchesSearch(tx, needle, hasText, labelIds, hasLabels, labelIndex))
tx,
];
}
bool _matchesSearch(
BankTransaction tx,
String needle,
bool hasText,
Set<String> labelIds,
bool hasLabels,
Map<String, TxLabel> labelIndex,
) {
final meta = metaFor(tx);
if (hasLabels && !_matchesLabelFilter(meta, labelIds)) {
return false;
}
if (!hasText) return true;
if (tx.description.toLowerCase().contains(needle)) return true;
if (meta.notes.toLowerCase().contains(needle)) return true;
if (tx.date.toLowerCase().contains(needle)) return true;
if (tx.amount.toString().contains(needle)) return true;
for (final id in meta.labelIds) {
final name = labelIndex[id]?.name;
if (name != null && name.toLowerCase().contains(needle)) return true;
}
return false;
}
/// OR filter: any selected label, and/or unlabeled when uncategorized is selected.
bool _matchesLabelFilter(TxMeta meta, Set<String> labelIds) {
final wantUncategorized = labelIds.contains(kUncategorizedFilterId);
if (wantUncategorized && !meta.hasLabel) return true;
for (final id in labelIds) {
if (id == kUncategorizedFilterId) continue;
if (meta.labelIds.contains(id)) return true;
}
return false;
}
/// Labels currently used by [transactions].
@@ -316,17 +665,41 @@ class TxMetaController extends ChangeNotifier {
return list;
}
Future<void> _persist() async {
notifyListeners();
await _storage.saveTxMetaStore(
TxMetaStoreData(
byId: _byId,
labelsByMerchant: _labelsByMerchant,
notesByMerchant: _notesByMerchant,
exactTemplates: _exactTemplates,
customLabels: _customLabels,
),
/// UI-first: notify on the next frame so callers can paint a busy state first,
/// then write SharedPreferences in the background.
void _schedulePersist() {
_invalidateLabelCache();
final binding = WidgetsBinding.instance;
binding.addPostFrameCallback((_) {
notifyListeners();
});
// Ensure a frame is scheduled even if nothing else is dirty yet.
binding.scheduleFrame();
unawaited(_writeStore());
}
Future<void> _writeStore() async {
// Let the busy indicator / label checkbox paint before JSON work.
await Future<void>.delayed(Duration.zero);
final snapshot = TxMetaStoreData(
byId: _byId,
labelsByMerchant: _labelsByMerchant,
notesByMerchant: _notesByMerchant,
exactTemplates: _exactTemplates,
customLabels: [
for (final label in _customLabels)
label.copyWith(
parentId: parentIdOf(label.id),
clearParent: parentIdOf(label.id) == null,
),
],
labelParents: _labelParents,
);
try {
await _storage.saveTxMetaStore(snapshot);
} catch (error, stackTrace) {
debugPrint('TxMetaController persist failed: $error\n$stackTrace');
}
}
/// Absolute spend (outflows) per label. Multi-label txs count fully in each.