look ahead
This commit is contained in:
@@ -19,6 +19,7 @@ class TxMetaController extends ChangeNotifier {
|
||||
};
|
||||
_notesByMerchant = Map.of(loaded.notesByMerchant);
|
||||
_exactTemplates = Map.of(loaded.exactTemplates);
|
||||
_recurringTemplates = Map.of(loaded.recurringTemplates);
|
||||
_customLabels = List.of(loaded.customLabels);
|
||||
_labelParents = Map.of(loaded.labelParents);
|
||||
}
|
||||
@@ -32,6 +33,7 @@ class TxMetaController extends ChangeNotifier {
|
||||
Map<String, List<String>> _labelsByMerchant = {};
|
||||
Map<String, String> _notesByMerchant = {};
|
||||
Map<String, TxExactTemplate> _exactTemplates = {};
|
||||
Map<String, RecurringTemplate> _recurringTemplates = {};
|
||||
List<TxLabel> _customLabels = [];
|
||||
Map<String, String> _labelParents = {};
|
||||
|
||||
@@ -45,6 +47,7 @@ class TxMetaController extends ChangeNotifier {
|
||||
},
|
||||
notesByMerchant: Map.of(_notesByMerchant),
|
||||
exactTemplates: Map.of(_exactTemplates),
|
||||
recurringTemplates: Map.of(_recurringTemplates),
|
||||
customLabels: List.of(_customLabels),
|
||||
labelParents: Map.of(_labelParents),
|
||||
);
|
||||
@@ -75,7 +78,10 @@ class TxMetaController extends ChangeNotifier {
|
||||
int get customLabelCount => _customLabels.length;
|
||||
|
||||
/// Replace local store (e.g. after merge) and persist without re-triggering push.
|
||||
Future<void> replaceStore(TxMetaStoreData store) async {
|
||||
Future<void> replaceStore(
|
||||
TxMetaStoreData store, {
|
||||
bool notify = true,
|
||||
}) async {
|
||||
_byId = Map.of(store.byId);
|
||||
_labelsByMerchant = {
|
||||
for (final e in store.labelsByMerchant.entries)
|
||||
@@ -83,10 +89,12 @@ class TxMetaController extends ChangeNotifier {
|
||||
};
|
||||
_notesByMerchant = Map.of(store.notesByMerchant);
|
||||
_exactTemplates = Map.of(store.exactTemplates);
|
||||
_recurringTemplates = Map.of(store.recurringTemplates);
|
||||
_customLabels = List.of(store.customLabels);
|
||||
_labelParents = Map.of(store.labelParents);
|
||||
_invalidateLabelCache();
|
||||
notifyListeners();
|
||||
_storeRevision++;
|
||||
if (notify) notifyListeners();
|
||||
await _writeStore(notifySync: false);
|
||||
}
|
||||
|
||||
@@ -396,12 +404,16 @@ class TxMetaController extends ChangeNotifier {
|
||||
String? pattern,
|
||||
SimilarMatchCriteria? criteria,
|
||||
}) {
|
||||
return similarTo(
|
||||
prototype,
|
||||
all,
|
||||
pattern: pattern,
|
||||
criteria: criteria,
|
||||
).length;
|
||||
final query = criteria ??
|
||||
SimilarMatchCriteria(
|
||||
pattern: (pattern ?? prototype.merchantKey).trim(),
|
||||
);
|
||||
if (query.pattern.trim().isEmpty) return 1;
|
||||
var n = 0;
|
||||
for (final tx in all) {
|
||||
if (txMatchesSimilarCriteria(tx, query)) n++;
|
||||
}
|
||||
return n == 0 ? 1 : n;
|
||||
}
|
||||
|
||||
List<BankTransaction> similarTo(
|
||||
@@ -630,23 +642,113 @@ class TxMetaController extends ChangeNotifier {
|
||||
_schedulePersist();
|
||||
}
|
||||
|
||||
/// Recurring look-ahead templates (manual + auto from marked txs).
|
||||
List<RecurringTemplate> get recurringTemplates {
|
||||
final list = _recurringTemplates.values.toList(growable: false);
|
||||
list.sort((a, b) {
|
||||
final day = a.dayOfMonth.compareTo(b.dayOfMonth);
|
||||
if (day != 0) return day;
|
||||
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
RecurringTemplate? recurringTemplateById(String id) =>
|
||||
_recurringTemplates[id];
|
||||
|
||||
void upsertRecurringTemplate(RecurringTemplate template) {
|
||||
if (template.id.isEmpty) return;
|
||||
_recurringTemplates[template.id] = template;
|
||||
_schedulePersist(invalidateLabels: false);
|
||||
}
|
||||
|
||||
void removeRecurringTemplate(String id) {
|
||||
if (!_recurringTemplates.containsKey(id)) return;
|
||||
_recurringTemplates.remove(id);
|
||||
_schedulePersist(invalidateLabels: false);
|
||||
}
|
||||
|
||||
/// Backfill auto templates for txs already marked recurring (upgrade path).
|
||||
void ensureAutoRecurringTemplates(List<BankTransaction> transactions) {
|
||||
var changed = false;
|
||||
final recurringIds = <String>{};
|
||||
for (final tx in transactions) {
|
||||
if (!metaFor(tx).recurring) continue;
|
||||
recurringIds.add(tx.id);
|
||||
final id = RecurringTemplate.autoIdForTx(tx.id);
|
||||
if (_recurringTemplates.containsKey(id)) continue;
|
||||
_recurringTemplates[id] = RecurringTemplate(
|
||||
id: id,
|
||||
name: tx.description,
|
||||
dayOfMonth: (DateTime.tryParse(tx.date)?.day ?? 1).clamp(1, 28),
|
||||
currency: tx.currency,
|
||||
source: RecurringTemplateSource.transaction,
|
||||
amountRule: RecurringAmountRule.lastValue,
|
||||
merchantKey: tx.merchantKey,
|
||||
sourceTxId: tx.id,
|
||||
avgCount: 5,
|
||||
autoFromRecurring: true,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
// Drop orphaned auto templates whose source is no longer recurring.
|
||||
final orphanIds = [
|
||||
for (final e in _recurringTemplates.entries)
|
||||
if (e.value.autoFromRecurring &&
|
||||
(e.value.sourceTxId == null ||
|
||||
!recurringIds.contains(e.value.sourceTxId)))
|
||||
e.key,
|
||||
];
|
||||
for (final id in orphanIds) {
|
||||
_recurringTemplates.remove(id);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) _schedulePersist(invalidateLabels: false);
|
||||
}
|
||||
|
||||
Future<void> setRecurring(BankTransaction tx, bool recurring) async {
|
||||
final current = metaFor(tx);
|
||||
_byId = {
|
||||
..._byId,
|
||||
tx.id: current.copyWith(recurring: recurring),
|
||||
};
|
||||
_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,
|
||||
internalTransfer:
|
||||
existing.internalTransfer || current.internalTransfer,
|
||||
),
|
||||
};
|
||||
_schedulePersist();
|
||||
_exactTemplates[tx.exactKey] = TxExactTemplate(
|
||||
notes: existing.notes.isNotEmpty ? existing.notes : current.notes,
|
||||
recurring: recurring,
|
||||
internalTransfer: existing.internalTransfer || current.internalTransfer,
|
||||
);
|
||||
if (recurring) {
|
||||
final id = RecurringTemplate.autoIdForTx(tx.id);
|
||||
final prev = _recurringTemplates[id];
|
||||
_recurringTemplates[id] = RecurringTemplate(
|
||||
id: id,
|
||||
name: prev?.name ?? tx.description,
|
||||
dayOfMonth: prev?.dayOfMonth ??
|
||||
(DateTime.tryParse(tx.date)?.day ?? 1).clamp(1, 28),
|
||||
currency: prev?.currency ?? tx.currency,
|
||||
source: RecurringTemplateSource.transaction,
|
||||
amountRule: prev?.amountRule ?? RecurringAmountRule.lastValue,
|
||||
merchantKey: prev?.merchantKey.isNotEmpty == true
|
||||
? prev!.merchantKey
|
||||
: tx.merchantKey,
|
||||
sourceTxId: tx.id,
|
||||
labelId: prev?.labelId,
|
||||
avgCount: prev?.avgCount ?? 5,
|
||||
autoFromRecurring: true,
|
||||
);
|
||||
} else {
|
||||
final id = RecurringTemplate.autoIdForTx(tx.id);
|
||||
final existingTpl = _recurringTemplates[id];
|
||||
if (existingTpl != null && existingTpl.autoFromRecurring) {
|
||||
_recurringTemplates.remove(id);
|
||||
}
|
||||
// Also drop auto templates that still point at this tx under another id.
|
||||
_recurringTemplates.removeWhere(
|
||||
(_, tpl) =>
|
||||
tpl.autoFromRecurring &&
|
||||
tpl.sourceTxId == tx.id &&
|
||||
tpl.id != id,
|
||||
);
|
||||
}
|
||||
_schedulePersist(invalidateLabels: false);
|
||||
}
|
||||
|
||||
Future<void> setInternalTransfer(
|
||||
@@ -654,20 +756,14 @@ class TxMetaController extends ChangeNotifier {
|
||||
bool internalTransfer,
|
||||
) async {
|
||||
final current = metaFor(tx);
|
||||
_byId = {
|
||||
..._byId,
|
||||
tx.id: current.copyWith(internalTransfer: internalTransfer),
|
||||
};
|
||||
_byId[tx.id] = current.copyWith(internalTransfer: internalTransfer);
|
||||
final existing = _exactTemplates[tx.exactKey] ?? const TxExactTemplate();
|
||||
_exactTemplates = {
|
||||
..._exactTemplates,
|
||||
tx.exactKey: TxExactTemplate(
|
||||
notes: existing.notes.isNotEmpty ? existing.notes : current.notes,
|
||||
recurring: existing.recurring || current.recurring,
|
||||
internalTransfer: internalTransfer,
|
||||
),
|
||||
};
|
||||
_schedulePersist();
|
||||
_exactTemplates[tx.exactKey] = TxExactTemplate(
|
||||
notes: existing.notes.isNotEmpty ? existing.notes : current.notes,
|
||||
recurring: existing.recurring || current.recurring,
|
||||
internalTransfer: internalTransfer,
|
||||
);
|
||||
_schedulePersist(invalidateLabels: false);
|
||||
}
|
||||
|
||||
Future<void> setNotes(
|
||||
@@ -824,27 +920,35 @@ class TxMetaController extends ChangeNotifier {
|
||||
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());
|
||||
int _persistGeneration = 0;
|
||||
int _storeRevision = 0;
|
||||
|
||||
/// Bumps on every local mutation — cheap cache key for look-ahead etc.
|
||||
int get storeRevision => _storeRevision;
|
||||
|
||||
/// Notify immediately; persist after the frame so toggles paint first.
|
||||
void _schedulePersist({bool invalidateLabels = true}) {
|
||||
if (invalidateLabels) _invalidateLabelCache();
|
||||
_storeRevision++;
|
||||
notifyListeners();
|
||||
final gen = ++_persistGeneration;
|
||||
unawaited(_writeStoreDeferred(gen));
|
||||
}
|
||||
|
||||
Future<void> _writeStoreDeferred(int gen) async {
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
// Coalesce rapid edits — only the latest snapshot is written.
|
||||
if (gen != _persistGeneration) return;
|
||||
await _writeStore();
|
||||
}
|
||||
|
||||
Future<void> _writeStore({bool notifySync = true}) 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,
|
||||
recurringTemplates: _recurringTemplates,
|
||||
customLabels: [
|
||||
for (final label in _customLabels)
|
||||
label.copyWith(
|
||||
|
||||
Reference in New Issue
Block a user