look ahead
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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