nice overview working
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user