look ahead

This commit is contained in:
davmar
2026-09-10 00:35:54 +02:00
parent 91d6dc9e54
commit 5b22fc8694
20 changed files with 2942 additions and 799 deletions
+5 -2
View File
@@ -158,7 +158,10 @@ class BankController extends ChangeNotifier {
/// Union cloud txs into the local bank cache without dropping either side.
///
/// Rows land in the API cache; CSV imports that match are deduped away.
Future<void> applySyncedTransactions(List<BankTransaction> fromCloud) async {
Future<void> applySyncedTransactions(
List<BankTransaction> fromCloud, {
bool notify = true,
}) async {
if (fromCloud.isEmpty) return;
_remoteTransactions = mergeTransactions(_remoteTransactions, fromCloud);
@@ -181,7 +184,7 @@ class BankController extends ChangeNotifier {
await _persistCache();
await _persistImported();
notifyListeners();
if (notify) notifyListeners();
}
/// Load status; if linked, also load balance + transactions.
+54 -9
View File
@@ -51,6 +51,8 @@ class SyncController extends ChangeNotifier {
bool _queuedFullSync = false;
Timer? _pushDebounce;
Timer? _progressHold;
Timer? _uiNotifyThrottle;
var _uiNotifyQueued = false;
String? _lastError;
DateTime? _lastSyncedAt;
String _lastSyncSummary = '';
@@ -64,6 +66,7 @@ class SyncController extends ChangeNotifier {
double _syncProgress = 0;
bool _showProgressRing = false;
bool _progressSuccess = false;
int _lastNotifiedProgressPct = -1;
bool get isSyncing => _syncing;
String? get lastError => _lastError;
@@ -86,6 +89,25 @@ class SyncController extends ChangeNotifier {
/// Ring turns green when this is true (sync finished successfully).
bool get progressSuccess => _progressSuccess;
void _notifyUiNow() {
_uiNotifyThrottle?.cancel();
_uiNotifyQueued = false;
_lastNotifiedProgressPct = (_syncProgress * 100).round();
notifyListeners();
}
/// Coalesce high-frequency progress/status into ~10 Hz UI updates.
void _notifyUiThrottled() {
if (_uiNotifyQueued) return;
_uiNotifyQueued = true;
_uiNotifyThrottle?.cancel();
_uiNotifyThrottle = Timer(const Duration(milliseconds: 100), () {
_uiNotifyQueued = false;
_lastNotifiedProgressPct = (_syncProgress * 100).round();
notifyListeners();
});
}
void _announce(String message, {bool isProgress = false, bool isError = false}) {
_feedbackEpoch++;
_feedback = SyncFeedback(
@@ -94,24 +116,38 @@ class SyncController extends ChangeNotifier {
isProgress: isProgress,
isError: isError,
);
notifyListeners();
_notifyUiNow();
}
void _setProgress(double value, {bool success = false}) {
_syncProgress = value.clamp(0.0, 1.0);
final next = value.clamp(0.0, 1.0);
final pct = (next * 100).round();
_syncProgress = next;
_showProgressRing = true;
_progressSuccess = success;
notifyListeners();
// Skip tiny progress moves — avatar only shows whole percents.
if (!success &&
pct == _lastNotifiedProgressPct &&
_showProgressRing) {
return;
}
if (success) {
_notifyUiNow();
} else {
_notifyUiThrottled();
}
}
void _setStatus(String message) {
if (_statusDetail == message) return;
_statusDetail = message;
notifyListeners();
_notifyUiThrottled();
}
void _finishProgress({required bool success}) {
_progressHold?.cancel();
_uiNotifyThrottle?.cancel();
_uiNotifyQueued = false;
_statusDetail = '';
if (success) {
_setProgress(1, success: true);
@@ -119,13 +155,15 @@ class SyncController extends ChangeNotifier {
_showProgressRing = false;
_progressSuccess = false;
_syncProgress = 0;
notifyListeners();
_lastNotifiedProgressPct = -1;
_notifyUiNow();
});
} else {
_showProgressRing = false;
_progressSuccess = false;
_syncProgress = 0;
notifyListeners();
_lastNotifiedProgressPct = -1;
_notifyUiNow();
}
}
@@ -138,12 +176,15 @@ class SyncController extends ChangeNotifier {
if (!loggedIn) {
_pushDebounce?.cancel();
_progressHold?.cancel();
_uiNotifyThrottle?.cancel();
_uiNotifyQueued = false;
_lastError = null;
_statusDetail = '';
_showProgressRing = false;
_progressSuccess = false;
_syncProgress = 0;
notifyListeners();
_lastNotifiedProgressPct = -1;
_notifyUiNow();
}
}
@@ -191,8 +232,11 @@ class SyncController extends ChangeNotifier {
_setProgress(0.5);
_setStatus('Merging…');
final mergedMeta = mergeTxMetaStores(_meta.exportStore(), remoteMeta);
await _meta.replaceStore(mergedMeta);
await _bank.applySyncedTransactions(remoteTxs);
// Apply without notifying — one rebuild after both stores are ready.
await _meta.replaceStore(mergedMeta, notify: false);
await _bank.applySyncedTransactions(remoteTxs, notify: false);
_meta.notifyListeners();
_bank.notifyListeners();
_setProgress(0.55);
_setStatus('Pushing…');
final metaSummary = await service.push(
@@ -283,6 +327,7 @@ class SyncController extends ChangeNotifier {
void dispose() {
_pushDebounce?.cancel();
_progressHold?.cancel();
_uiNotifyThrottle?.cancel();
_auth.removeListener(_onAuthChanged);
_meta.onPersisted = null;
super.dispose();
+152 -48
View File
@@ -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(