800 lines
24 KiB
Dart
800 lines
24 KiB
Dart
/// Local labels / notes / recurring annotations for bank transactions.
|
|
library;
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:flutter/material.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);
|
|
_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);
|
|
_labelParents = Map.of(loaded.labelParents);
|
|
}
|
|
|
|
final StorageService _storage;
|
|
|
|
Map<String, TxMeta> _byId = {};
|
|
Map<String, List<String>> _labelsByMerchant = {};
|
|
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;
|
|
return _labelById[id];
|
|
}
|
|
|
|
List<TxLabel> labelsFor(TxMeta meta) {
|
|
final index = _labelById;
|
|
return [
|
|
for (final id in meta.labelIds)
|
|
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 labelIds = labelIdsFor(tx);
|
|
final inheritedNotes = _resolveMerchantNotes(tx);
|
|
|
|
final notes = stored != null && stored.hasNotes
|
|
? stored.notes
|
|
: (template != null && template.notes.trim().isNotEmpty
|
|
? template.notes
|
|
: (inheritedNotes ?? ''));
|
|
|
|
final recurring = stored?.recurring ?? template?.recurring ?? false;
|
|
|
|
return TxMeta(
|
|
labelIds: List<String>.of(labelIds),
|
|
notes: notes,
|
|
recurring: recurring,
|
|
);
|
|
}
|
|
|
|
/// 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, {
|
|
String? pattern,
|
|
SimilarMatchCriteria? criteria,
|
|
}) {
|
|
return similarTo(
|
|
prototype,
|
|
all,
|
|
pattern: pattern,
|
|
criteria: criteria,
|
|
).length;
|
|
}
|
|
|
|
List<BankTransaction> similarTo(
|
|
BankTransaction prototype,
|
|
List<BankTransaction> all, {
|
|
String? pattern,
|
|
SimilarMatchCriteria? criteria,
|
|
}) {
|
|
final query = criteria ??
|
|
SimilarMatchCriteria(
|
|
pattern: (pattern ?? prototype.merchantKey).trim(),
|
|
);
|
|
if (query.pattern.trim().isEmpty) return [prototype];
|
|
final matches = [
|
|
for (final tx in all)
|
|
if (txMatchesSimilarCriteria(tx, query)) tx,
|
|
];
|
|
return matches.isEmpty ? [prototype] : matches;
|
|
}
|
|
|
|
Future<void> setLabels(
|
|
BankTransaction tx,
|
|
List<String> labelIds, {
|
|
List<BankTransaction> applyToSimilar = const [],
|
|
String? matchPattern,
|
|
}) async {
|
|
final expanded = expandWithAncestors([
|
|
for (final id in labelIds)
|
|
if (id.isNotEmpty) id,
|
|
]);
|
|
// Deduplicate while preserving order.
|
|
final unique = <String>[];
|
|
for (final id in expanded) {
|
|
if (!unique.contains(id)) unique.add(id);
|
|
}
|
|
|
|
final pattern = (matchPattern ?? tx.merchantKey).trim().toLowerCase();
|
|
final applying = applyToSimilar.isNotEmpty;
|
|
|
|
final nextById = Map<String, TxMeta>.of(_byId);
|
|
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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
_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)) {
|
|
final remove = {labelId, ...descendantIdsOf(labelId)};
|
|
current.removeWhere(remove.contains);
|
|
} else {
|
|
current.add(labelId);
|
|
}
|
|
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,
|
|
matchPattern: matchPattern,
|
|
);
|
|
}
|
|
|
|
/// Replace labels on every transaction in [txs] (per-id overrides).
|
|
Future<void> setLabelsForMany(
|
|
List<BankTransaction> txs,
|
|
List<String> labelIds,
|
|
) async {
|
|
if (txs.isEmpty) return;
|
|
|
|
final expanded = expandWithAncestors([
|
|
for (final id in labelIds)
|
|
if (id.isNotEmpty && id != kUncategorizedFilterId) id,
|
|
]);
|
|
final unique = <String>[];
|
|
for (final id in expanded) {
|
|
if (!unique.contains(id)) unique.add(id);
|
|
}
|
|
|
|
final nextById = Map<String, TxMeta>.of(_byId);
|
|
for (final tx in txs) {
|
|
final stored = nextById[tx.id];
|
|
final current = stored ?? metaFor(tx);
|
|
nextById[tx.id] = unique.isEmpty
|
|
? current.copyWith(clearLabels: true)
|
|
: current.copyWith(labelIds: unique);
|
|
}
|
|
_byId = nextById;
|
|
_schedulePersist();
|
|
}
|
|
|
|
/// Label ids shared by every transaction in [txs].
|
|
Set<String> commonLabelIds(List<BankTransaction> txs) {
|
|
if (txs.isEmpty) return {};
|
|
Set<String>? common;
|
|
for (final tx in txs) {
|
|
final ids = metaFor(tx).labelIds.toSet();
|
|
common = common == null ? ids : common.intersection(ids);
|
|
if (common.isEmpty) break;
|
|
}
|
|
return common ?? {};
|
|
}
|
|
|
|
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(
|
|
id: 'custom_${DateTime.now().millisecondsSinceEpoch}',
|
|
name: trimmed,
|
|
color: color,
|
|
icon: Icons.label_outline,
|
|
custom: true,
|
|
parentId: parent,
|
|
);
|
|
_customLabels = [..._customLabels, label];
|
|
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)) {
|
|
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,
|
|
],
|
|
};
|
|
_schedulePersist();
|
|
}
|
|
|
|
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,
|
|
),
|
|
};
|
|
_schedulePersist();
|
|
}
|
|
|
|
Future<void> setNotes(
|
|
BankTransaction tx,
|
|
String notes, {
|
|
List<BankTransaction> applyToSimilar = const [],
|
|
String? matchPattern,
|
|
}) async {
|
|
final trimmed = notes.trim();
|
|
final pattern = (matchPattern ?? tx.merchantKey).trim().toLowerCase();
|
|
final applying = applyToSimilar.isNotEmpty;
|
|
|
|
final nextById = Map<String, TxMeta>.of(_byId);
|
|
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 (!applying) {
|
|
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)
|
|
..remove(pattern);
|
|
}
|
|
} else {
|
|
if (trimmed.isEmpty) {
|
|
_notesByMerchant = Map.of(_notesByMerchant)
|
|
..remove(tx.merchantKey)
|
|
..remove(tx.descriptionKey)
|
|
..remove(pattern);
|
|
} else if (pattern.isNotEmpty) {
|
|
_notesByMerchant = {
|
|
..._notesByMerchant,
|
|
pattern: trimmed,
|
|
};
|
|
}
|
|
}
|
|
_schedulePersist();
|
|
}
|
|
|
|
Future<void> toggleRecurring(BankTransaction tx) {
|
|
return setRecurring(tx, !metaFor(tx).recurring);
|
|
}
|
|
|
|
/// Search description, notes, label name, and date.
|
|
List<BankTransaction> search(
|
|
List<BankTransaction> txs,
|
|
String query, {
|
|
Set<String> labelIds = const {},
|
|
}) {
|
|
final needle = query.trim().toLowerCase();
|
|
final hasText = needle.isNotEmpty;
|
|
final hasLabels = labelIds.isNotEmpty;
|
|
if (!hasText && !hasLabels) return txs;
|
|
|
|
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].
|
|
List<TxLabel> usedLabels(List<BankTransaction> transactions) {
|
|
final found = <String>{};
|
|
for (final tx in transactions) {
|
|
found.addAll(metaFor(tx).labelIds);
|
|
}
|
|
final list = [
|
|
for (final label in allLabels)
|
|
if (found.contains(label.id)) label,
|
|
];
|
|
list.sort((a, b) => a.name.compareTo(b.name));
|
|
return list;
|
|
}
|
|
|
|
/// 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.
|
|
Map<String, double> spendByLabel(List<BankTransaction> txs) {
|
|
final totals = <String, double>{};
|
|
for (final tx in txs) {
|
|
if (tx.amount >= 0) continue;
|
|
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;
|
|
}
|
|
|
|
/// 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;
|
|
if (!metaFor(tx).hasLabel) continue;
|
|
total += tx.amount.abs();
|
|
}
|
|
return total;
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
}
|