attempt to fix test txs
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
/// [ChangeNotifier] for bank link status, balance, and transactions.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/bank.dart';
|
||||
@@ -36,6 +38,18 @@ class BankController extends ChangeNotifier {
|
||||
_remoteTransactions = [
|
||||
for (final tx in _remoteTransactions) _upgradeLegacyManual(tx),
|
||||
];
|
||||
_pendingRemoteDeletes = _storage?.loadPendingTxDeletes() ?? const [];
|
||||
// Drop anything still queued for remote delete from the local caches.
|
||||
if (_pendingRemoteDeletes.isNotEmpty) {
|
||||
_remoteTransactions = [
|
||||
for (final tx in _remoteTransactions)
|
||||
if (!_isTombstoned(tx)) tx,
|
||||
];
|
||||
_importedTransactions = [
|
||||
for (final tx in _importedTransactions)
|
||||
if (!_isTombstoned(tx)) tx,
|
||||
];
|
||||
}
|
||||
_invalidateMerged();
|
||||
}
|
||||
|
||||
@@ -55,29 +69,66 @@ class BankController extends ChangeNotifier {
|
||||
/// cannot leave the device behind the cloud (or wipe cloud-only history).
|
||||
VoidCallback? onAfterSuccessfulLoad;
|
||||
|
||||
/// Fired when local admin txs are removed/replaced and need a remote delete.
|
||||
VoidCallback? onPendingRemoteDeletesChanged;
|
||||
|
||||
ConnectStatus _status = ConnectStatus.disconnected;
|
||||
BankBalance? _balance;
|
||||
List<BankTransaction> _remoteTransactions = const [];
|
||||
List<BankTransaction> _importedTransactions = const [];
|
||||
List<String> _pendingRemoteDeletes = const [];
|
||||
List<BankTransaction> _pendingRemoteDeletes = const [];
|
||||
|
||||
/// Local tx_ids removed/replaced that should be deleted on the next PB push.
|
||||
List<String> takePendingRemoteDeletes() {
|
||||
final out = List<String>.of(_pendingRemoteDeletes);
|
||||
_pendingRemoteDeletes = const [];
|
||||
return out;
|
||||
}
|
||||
/// Tombstoned txs waiting to be removed from PocketBase (also persisted).
|
||||
List<BankTransaction> get pendingRemoteDeletes =>
|
||||
List<BankTransaction>.unmodifiable(_pendingRemoteDeletes);
|
||||
|
||||
void requeueRemoteDeletes(Iterable<String> txIds) {
|
||||
for (final id in txIds) {
|
||||
_queueRemoteDelete(id);
|
||||
bool get hasPendingRemoteDeletes => _pendingRemoteDeletes.isNotEmpty;
|
||||
|
||||
bool _isTombstoned(BankTransaction tx) {
|
||||
for (final tomb in _pendingRemoteDeletes) {
|
||||
if (tx.matchesForDelete(tomb)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _queueRemoteDelete(String txId) {
|
||||
if (txId.isEmpty) return;
|
||||
if (_pendingRemoteDeletes.contains(txId)) return;
|
||||
_pendingRemoteDeletes = [..._pendingRemoteDeletes, txId];
|
||||
/// Drop tombstones that match any of [txs] (fingerprint-aware).
|
||||
void clearRemoteDeletesMatching(Iterable<BankTransaction> txs) {
|
||||
final list = txs.toList(growable: false);
|
||||
if (list.isEmpty) return;
|
||||
final next = [
|
||||
for (final tomb in _pendingRemoteDeletes)
|
||||
if (!list.any(tomb.matchesForDelete)) tomb,
|
||||
];
|
||||
if (next.length == _pendingRemoteDeletes.length) return;
|
||||
_pendingRemoteDeletes = next;
|
||||
unawaited(_persistPendingDeletes());
|
||||
}
|
||||
|
||||
void clearAllPendingRemoteDeletes() {
|
||||
if (_pendingRemoteDeletes.isEmpty) return;
|
||||
_pendingRemoteDeletes = const [];
|
||||
unawaited(_persistPendingDeletes());
|
||||
}
|
||||
|
||||
void requeueRemoteDeletes(Iterable<BankTransaction> txs) {
|
||||
for (final tx in txs) {
|
||||
_queueRemoteDelete(tx, notify: false);
|
||||
}
|
||||
unawaited(_persistPendingDeletes());
|
||||
}
|
||||
|
||||
void _queueRemoteDelete(BankTransaction tx, {bool notify = true}) {
|
||||
if (tx.id.isEmpty) return;
|
||||
if (_isTombstoned(tx)) return;
|
||||
_pendingRemoteDeletes = [..._pendingRemoteDeletes, tx];
|
||||
unawaited(_persistPendingDeletes());
|
||||
if (notify) onPendingRemoteDeletesChanged?.call();
|
||||
}
|
||||
|
||||
Future<void> _persistPendingDeletes() async {
|
||||
final storage = _storage;
|
||||
if (storage == null) return;
|
||||
await storage.savePendingTxDeletes(_pendingRemoteDeletes);
|
||||
}
|
||||
|
||||
/// Keep [isManual] when a synced/API row replaces a matching imported test tx.
|
||||
@@ -300,7 +351,7 @@ class BankController extends ChangeNotifier {
|
||||
if (tx.id != previous.id) tx,
|
||||
];
|
||||
if (previous.id != updated.id) {
|
||||
_queueRemoteDelete(previous.id);
|
||||
_queueRemoteDelete(previous);
|
||||
}
|
||||
_importedTransactions = mergeTransactions(_importedTransactions, [updated]);
|
||||
_invalidateMerged();
|
||||
@@ -317,13 +368,13 @@ class BankController extends ChangeNotifier {
|
||||
}
|
||||
_importedTransactions = [
|
||||
for (final row in _importedTransactions)
|
||||
if (row.id != tx.id) row,
|
||||
if (!row.matchesForDelete(tx)) row,
|
||||
];
|
||||
_remoteTransactions = [
|
||||
for (final row in _remoteTransactions)
|
||||
if (row.id != tx.id) row,
|
||||
if (!row.matchesForDelete(tx)) row,
|
||||
];
|
||||
_queueRemoteDelete(tx.id);
|
||||
_queueRemoteDelete(tx);
|
||||
_invalidateMerged();
|
||||
await _persistImported();
|
||||
await _persistCache();
|
||||
@@ -337,17 +388,36 @@ class BankController extends ChangeNotifier {
|
||||
List<BankTransaction> fromCloud, {
|
||||
bool notify = true,
|
||||
}) async {
|
||||
if (fromCloud.isEmpty) return;
|
||||
if (fromCloud.isEmpty && _pendingRemoteDeletes.isEmpty) return;
|
||||
|
||||
final manuals = _collectManualIds(fromCloud.map(_upgradeLegacyManual));
|
||||
_remoteTransactions = mergeTransactions(
|
||||
_remoteTransactions,
|
||||
[for (final tx in fromCloud) _upgradeLegacyManual(tx)],
|
||||
);
|
||||
// Never resurrect rows the user already deleted locally.
|
||||
final incoming = [
|
||||
for (final tx in fromCloud)
|
||||
if (!_isTombstoned(tx)) _upgradeLegacyManual(tx),
|
||||
];
|
||||
if (incoming.isEmpty && _pendingRemoteDeletes.isEmpty) return;
|
||||
|
||||
final manuals = _collectManualIds(incoming);
|
||||
if (incoming.isNotEmpty) {
|
||||
_remoteTransactions = mergeTransactions(_remoteTransactions, incoming);
|
||||
}
|
||||
// Strip any local copies that match a pending delete (defensive).
|
||||
if (_pendingRemoteDeletes.isNotEmpty) {
|
||||
_remoteTransactions = [
|
||||
for (final tx in _remoteTransactions)
|
||||
if (!_isTombstoned(tx)) tx,
|
||||
];
|
||||
}
|
||||
_importedTransactions = filterNewTransactions(
|
||||
_remoteTransactions,
|
||||
_importedTransactions,
|
||||
);
|
||||
if (_pendingRemoteDeletes.isNotEmpty) {
|
||||
_importedTransactions = [
|
||||
for (final tx in _importedTransactions)
|
||||
if (!_isTombstoned(tx)) tx,
|
||||
];
|
||||
}
|
||||
_reapplyManualFlags(manuals);
|
||||
_invalidateMerged();
|
||||
|
||||
@@ -444,6 +514,16 @@ class BankController extends ChangeNotifier {
|
||||
_remoteTransactions,
|
||||
_importedTransactions,
|
||||
);
|
||||
if (_pendingRemoteDeletes.isNotEmpty) {
|
||||
_remoteTransactions = [
|
||||
for (final tx in _remoteTransactions)
|
||||
if (!_isTombstoned(tx)) tx,
|
||||
];
|
||||
_importedTransactions = [
|
||||
for (final tx in _importedTransactions)
|
||||
if (!_isTombstoned(tx)) tx,
|
||||
];
|
||||
}
|
||||
_reapplyManualFlags(manuals);
|
||||
_invalidateMerged();
|
||||
await _persistImported();
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/app_cloud_prefs.dart';
|
||||
import '../models/bank.dart';
|
||||
import '../models/home_filter_widget.dart';
|
||||
import '../models/overview_favourite.dart';
|
||||
import '../services/pb_sync_service.dart';
|
||||
@@ -46,6 +47,7 @@ class SyncController extends ChangeNotifier {
|
||||
_prefs.onAppPrefsPersisted = _scheduleAppPrefsPush;
|
||||
_auth.addListener(_onAuthChanged);
|
||||
_bank.onAfterSuccessfulLoad = _onBankDataLoaded;
|
||||
_bank.onPendingRemoteDeletesChanged = _scheduleBankTxPush;
|
||||
if (_auth.isLoggedIn) {
|
||||
unawaited(syncNow());
|
||||
}
|
||||
@@ -64,6 +66,7 @@ class SyncController extends ChangeNotifier {
|
||||
bool _pushFavouritesPending = false;
|
||||
bool _pushHomeWidgetsPending = false;
|
||||
bool _pushAppPrefsPending = false;
|
||||
bool _pushBankPending = false;
|
||||
Timer? _pushDebounce;
|
||||
Timer? _progressHold;
|
||||
Timer? _uiNotifyThrottle;
|
||||
@@ -198,6 +201,7 @@ class SyncController extends ChangeNotifier {
|
||||
_pushFavouritesPending = false;
|
||||
_pushHomeWidgetsPending = false;
|
||||
_pushAppPrefsPending = false;
|
||||
_pushBankPending = false;
|
||||
_lastError = null;
|
||||
_statusDetail = '';
|
||||
_showProgressRing = false;
|
||||
@@ -265,6 +269,28 @@ class SyncController extends ChangeNotifier {
|
||||
_armPushDebounce();
|
||||
}
|
||||
|
||||
void _scheduleBankTxPush() {
|
||||
_pushBankPending = true;
|
||||
_armPushDebounce();
|
||||
}
|
||||
|
||||
/// Removes tombstoned bank txs from PocketBase before pull/push can resurrect them.
|
||||
Future<void> _flushPendingTxDeletes(PbSyncService service) async {
|
||||
final pending = List<BankTransaction>.of(_bank.pendingRemoteDeletes);
|
||||
if (pending.isEmpty) return;
|
||||
_setStatus('Removing deleted bank txs…');
|
||||
try {
|
||||
final matched = await service.deleteTransactionsMatching(pending);
|
||||
if (matched.isNotEmpty) {
|
||||
_bank.clearRemoteDeletesMatching(matched);
|
||||
}
|
||||
// Do NOT clear unmatched tombstones — they still block pull resurrection
|
||||
// until pushTransactions GC removes the remote rows.
|
||||
} catch (error) {
|
||||
debugPrint('SyncController: tx deletes failed ($error)');
|
||||
}
|
||||
}
|
||||
|
||||
void _armPushDebounce() {
|
||||
if (!_auth.isLoggedIn) return;
|
||||
if (_syncing) {
|
||||
@@ -297,6 +323,8 @@ class SyncController extends ChangeNotifier {
|
||||
|
||||
try {
|
||||
final service = PbSyncService(_auth.client);
|
||||
// Delete first — otherwise pull re-merges the row and push recreates it.
|
||||
await _flushPendingTxDeletes(service);
|
||||
final remoteMeta = await service.pull(
|
||||
onProgress: (p) {
|
||||
_setProgress(p * 0.35);
|
||||
@@ -408,22 +436,20 @@ class SyncController extends ChangeNotifier {
|
||||
} catch (error) {
|
||||
debugPrint('SyncController: app_prefs push skipped ($error)');
|
||||
}
|
||||
final pendingDeletes = _bank.takePendingRemoteDeletes();
|
||||
if (pendingDeletes.isNotEmpty) {
|
||||
try {
|
||||
await service.deleteTransactionsByTxIds(pendingDeletes);
|
||||
} catch (error) {
|
||||
debugPrint('SyncController: tx deletes skipped ($error)');
|
||||
_bank.requeueRemoteDeletes(pendingDeletes);
|
||||
}
|
||||
}
|
||||
// Retry deletes in case any were queued during the pull/merge window.
|
||||
await _flushPendingTxDeletes(service);
|
||||
_pushBankPending = false;
|
||||
final tombs = List<BankTransaction>.of(_bank.pendingRemoteDeletes);
|
||||
final bankSummary = await service.pushTransactions(
|
||||
_bank.transactions,
|
||||
deleteTombs: tombs,
|
||||
progressStart: 0.82,
|
||||
progressEnd: 1.0,
|
||||
onProgress: _setProgress,
|
||||
onStatus: _setStatus,
|
||||
);
|
||||
// Push GC removes orphan admin test rows; tombstones are done.
|
||||
_bank.clearAllPendingRemoteDeletes();
|
||||
_lastSyncedAt = DateTime.now();
|
||||
_lastError = null;
|
||||
_lastSyncSummary = '$metaSummary\n$bankSummary';
|
||||
@@ -463,11 +489,17 @@ class SyncController extends ChangeNotifier {
|
||||
final pushFavourites = _pushFavouritesPending;
|
||||
final pushHomeWidgets = _pushHomeWidgetsPending;
|
||||
final pushAppPrefs = _pushAppPrefsPending;
|
||||
final pushBank = _pushBankPending || _bank.pendingRemoteDeletes.isNotEmpty;
|
||||
_pushMetaPending = false;
|
||||
_pushFavouritesPending = false;
|
||||
_pushHomeWidgetsPending = false;
|
||||
_pushAppPrefsPending = false;
|
||||
if (!pushMeta && !pushFavourites && !pushHomeWidgets && !pushAppPrefs) {
|
||||
_pushBankPending = false;
|
||||
if (!pushMeta &&
|
||||
!pushFavourites &&
|
||||
!pushHomeWidgets &&
|
||||
!pushAppPrefs &&
|
||||
!pushBank) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -480,13 +512,15 @@ class SyncController extends ChangeNotifier {
|
||||
pushFavourites,
|
||||
pushHomeWidgets,
|
||||
pushAppPrefs,
|
||||
pushBank,
|
||||
)) {
|
||||
(false, false, false, true) => 'App prefs',
|
||||
(false, false, true, false) => 'Home widgets',
|
||||
(false, false, true, true) => 'Home layout',
|
||||
(false, true, false, false) => 'Favourites',
|
||||
(false, true, true, _) => 'Home layout',
|
||||
(true, false, false, false) => 'Annotations',
|
||||
(false, false, false, false, true) => 'Bank txs',
|
||||
(false, false, false, true, false) => 'App prefs',
|
||||
(false, false, true, false, false) => 'Home widgets',
|
||||
(false, false, true, true, _) => 'Home layout',
|
||||
(false, true, false, false, false) => 'Favourites',
|
||||
(false, true, true, _, _) => 'Home layout',
|
||||
(true, false, false, false, false) => 'Annotations',
|
||||
_ => 'Changes',
|
||||
};
|
||||
_statusDetail = 'Pushing $label…';
|
||||
@@ -500,7 +534,8 @@ class SyncController extends ChangeNotifier {
|
||||
final total = (pushMeta ? 1 : 0) +
|
||||
(pushFavourites ? 1 : 0) +
|
||||
(pushHomeWidgets ? 1 : 0) +
|
||||
(pushAppPrefs ? 1 : 0);
|
||||
(pushAppPrefs ? 1 : 0) +
|
||||
(pushBank ? 1 : 0);
|
||||
void bump() {
|
||||
done++;
|
||||
_setProgress(done / total);
|
||||
@@ -564,6 +599,27 @@ class SyncController extends ChangeNotifier {
|
||||
}
|
||||
bump();
|
||||
}
|
||||
if (pushBank) {
|
||||
_setStatus('Updating bank txs…');
|
||||
await _flushPendingTxDeletes(service);
|
||||
try {
|
||||
final tombs = List<BankTransaction>.of(_bank.pendingRemoteDeletes);
|
||||
final summary = await service.pushTransactions(
|
||||
_bank.transactions,
|
||||
deleteTombs: tombs,
|
||||
progressStart: done / total,
|
||||
progressEnd: (done + 1) / total,
|
||||
onProgress: _setProgress,
|
||||
onStatus: _setStatus,
|
||||
);
|
||||
_bank.clearAllPendingRemoteDeletes();
|
||||
parts.add(summary);
|
||||
} catch (error) {
|
||||
debugPrint('SyncController: bank tx push skipped ($error)');
|
||||
_pushBankPending = true;
|
||||
}
|
||||
bump();
|
||||
}
|
||||
|
||||
_setProgress(1.0);
|
||||
_lastSyncedAt = DateTime.now();
|
||||
@@ -597,6 +653,7 @@ class SyncController extends ChangeNotifier {
|
||||
_uiNotifyThrottle?.cancel();
|
||||
_auth.removeListener(_onAuthChanged);
|
||||
_bank.onAfterSuccessfulLoad = null;
|
||||
_bank.onPendingRemoteDeletesChanged = null;
|
||||
_meta.onPersisted = null;
|
||||
_prefs.onFavouritesPersisted = null;
|
||||
_prefs.onHomeWidgetsPersisted = null;
|
||||
|
||||
Reference in New Issue
Block a user