attempt to fix test txs

This commit is contained in:
davmar
2026-09-10 23:59:00 +02:00
parent f8e293f823
commit 00ab05eefc
6 changed files with 327 additions and 62 deletions
+104 -24
View File
@@ -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();
+74 -17
View File
@@ -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;