/// Coordinates PocketBase ↔ local annotation + bank-transaction sync. library; 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'; import 'auth_provider.dart'; import 'bank_provider.dart'; import 'preferences_provider.dart'; import 'tx_meta_provider.dart'; /// User-visible sync toast state (watched by [SyncFeedbackListener]). class SyncFeedback { const SyncFeedback({ required this.epoch, required this.message, this.isProgress = false, this.isError = false, }); final int epoch; final String message; final bool isProgress; final bool isError; } class SyncController extends ChangeNotifier { SyncController({ required AuthController auth, required TxMetaController meta, required BankController bank, required PreferencesController prefs, }) : _auth = auth, _meta = meta, _bank = bank, _prefs = prefs { _wasLoggedIn = auth.isLoggedIn; _meta.onPersisted = _scheduleMetaPush; _prefs.onFavouritesPersisted = _scheduleFavouritesPush; _prefs.onHomeWidgetsPersisted = _scheduleHomeWidgetsPush; _prefs.onAppPrefsPersisted = _scheduleAppPrefsPush; _auth.addListener(_onAuthChanged); _bank.onAfterSuccessfulLoad = _onBankDataLoaded; _bank.onPendingRemoteDeletesChanged = _scheduleBankTxPush; if (_auth.isLoggedIn) { unawaited(syncNow()); } } final AuthController _auth; final TxMetaController _meta; final BankController _bank; final PreferencesController _prefs; bool _wasLoggedIn = false; bool _syncing = false; bool _pushQueued = false; bool _queuedFullSync = false; bool _pushMetaPending = false; bool _pushFavouritesPending = false; bool _pushHomeWidgetsPending = false; bool _pushAppPrefsPending = false; bool _pushBankPending = false; Timer? _pushDebounce; Timer? _progressHold; Timer? _uiNotifyThrottle; var _uiNotifyQueued = false; String? _lastError; DateTime? _lastSyncedAt; String _lastSyncSummary = ''; int _feedbackEpoch = 0; SyncFeedback? _feedback; /// Live detail for the avatar menu / tooltip (does not re-fire SnackBars). String _statusDetail = ''; /// 0–1 while the avatar ring is visible. double _syncProgress = 0; bool _showProgressRing = false; bool _progressSuccess = false; int _lastNotifiedProgressPct = -1; bool get isSyncing => _syncing; String? get lastError => _lastError; DateTime? get lastSyncedAt => _lastSyncedAt; bool get canSync => _auth.isLoggedIn; SyncFeedback? get feedback => _feedback; /// After a successful sync, e.g. annotation + bank tx counts. String get lastSyncSummary => _lastSyncSummary; /// Short live status, e.g. `Writing tx_meta (40/200)…`. String get statusDetail => _statusDetail; /// Determinate progress for the avatar ring (0–1). double get syncProgress => _syncProgress; /// Whether to paint the progress ring (includes brief success flash). bool get showProgressRing => _showProgressRing; /// 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( epoch: _feedbackEpoch, message: message, isProgress: isProgress, isError: isError, ); _notifyUiNow(); } void _setProgress(double value, {bool success = false}) { final next = value.clamp(0.0, 1.0); final pct = (next * 100).round(); _syncProgress = next; _showProgressRing = true; _progressSuccess = success; // 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; _notifyUiThrottled(); } void _finishProgress({required bool success}) { _progressHold?.cancel(); _uiNotifyThrottle?.cancel(); _uiNotifyQueued = false; _statusDetail = ''; if (success) { _setProgress(1, success: true); _progressHold = Timer(const Duration(milliseconds: 900), () { _showProgressRing = false; _progressSuccess = false; _syncProgress = 0; _lastNotifiedProgressPct = -1; _notifyUiNow(); }); } else { _showProgressRing = false; _progressSuccess = false; _syncProgress = 0; _lastNotifiedProgressPct = -1; _notifyUiNow(); } } void _onAuthChanged() { final loggedIn = _auth.isLoggedIn; if (loggedIn && !_wasLoggedIn) { unawaited(syncNow()); } _wasLoggedIn = loggedIn; if (!loggedIn) { _pushDebounce?.cancel(); _progressHold?.cancel(); _uiNotifyThrottle?.cancel(); _uiNotifyQueued = false; _pushQueued = false; _pushMetaPending = false; _pushFavouritesPending = false; _pushHomeWidgetsPending = false; _pushAppPrefsPending = false; _pushBankPending = false; _lastError = null; _statusDetail = ''; _showProgressRing = false; _progressSuccess = false; _syncProgress = 0; _lastNotifiedProgressPct = -1; _notifyUiNow(); } } /// Bank pull-to-refresh / reconnect finished — reconcile with PocketBase. /// /// Runs a full sync when idle so (1) new API txs are pushed and /// (2) any cloud-only history the bank window lacks is pulled back. /// /// If a sync is already in flight (common at cold start: constructor sync /// overlaps bank refresh), do **not** queue another full sync — that was /// causing a back-to-back double pull of 4k+ txs. void _onBankDataLoaded() { if (!_auth.isLoggedIn) return; if (_syncing) { debugPrint( 'SyncController: bank load finished during sync — skip queued full sync', ); return; } unawaited(syncNow()); } /// If the server has more bank txs than this device, sync now. Future syncIfBehindServer() async { if (!_auth.isLoggedIn || _syncing) return; try { final remoteCount = await PbSyncService(_auth.client).countTransactions(); if (remoteCount > _bank.transactions.length) { debugPrint( 'SyncController: behind server ' '(local=${_bank.transactions.length} remote=$remoteCount) — syncing', ); await syncNow(); } } catch (error, stackTrace) { debugPrint('SyncController.syncIfBehindServer failed: $error\n$stackTrace'); } } void _scheduleMetaPush() { _pushMetaPending = true; _armPushDebounce(); } void _scheduleFavouritesPush() { _pushFavouritesPending = true; _armPushDebounce(); } void _scheduleHomeWidgetsPush() { _pushHomeWidgetsPending = true; _armPushDebounce(); } void _scheduleAppPrefsPush() { _pushAppPrefsPending = true; _armPushDebounce(); } void _scheduleBankTxPush() { _pushBankPending = true; _armPushDebounce(); } /// Pull shared delete tombstones, then remove matching local bank rows. Future _pullAndApplyTxDeletes(PbSyncService service) async { try { final remote = await service.pullTxDeletes(onStatus: _setStatus); await _bank.applyRemoteDeletes(remote, notify: false); } catch (error) { debugPrint('SyncController: tx_deletes pull skipped ($error)'); } } /// Removes tombstoned bank txs from the transactions collection. /// /// Tombstones themselves stay local + synced via `tx_deletes` so other /// devices learn about the delete and do not push the row back. Future _flushPendingTxDeletes(PbSyncService service) async { final pending = List.of(_bank.pendingRemoteDeletes); if (pending.isEmpty) return; _setStatus('Removing deleted bank txs…'); try { await service.deleteTransactionsMatching(pending); // Keep tombstones — they must remain until every device has seen them. } catch (error) { debugPrint('SyncController: tx deletes failed ($error)'); } } Future _pushTxDeletes(PbSyncService service) async { final tombs = List.of(_bank.pendingRemoteDeletes); if (tombs.isEmpty) return; try { await service.pushTxDeletes(tombs, onStatus: _setStatus); } catch (error) { debugPrint('SyncController: tx_deletes push skipped ($error)'); } } void _armPushDebounce() { if (!_auth.isLoggedIn) return; if (_syncing) { _pushQueued = true; return; } _pushDebounce?.cancel(); _pushDebounce = Timer(const Duration(seconds: 2), () { unawaited(_pushOnly()); }); } /// Pull → merge with local (no data loss) → save → push. /// /// Includes annotations and the full bank transaction list. Future syncNow() async { if (!_auth.isLoggedIn) return; if (_syncing) { _queuedFullSync = true; return; } _progressHold?.cancel(); _syncing = true; _lastError = null; _progressSuccess = false; _statusDetail = 'Starting…'; _setProgress(0.02); _announce('Syncing…', isProgress: true); try { final service = PbSyncService(_auth.client); // Learn deletes from other devices before merging bank txs / pushing. await _pullAndApplyTxDeletes(service); await _flushPendingTxDeletes(service); final remoteMeta = await service.pull( onProgress: (p) { _setProgress(p * 0.35); }, onStatus: _setStatus, ); _setProgress(0.36); final remoteTxs = await service.pullTransactions( onProgress: (p) { _setProgress(0.36 + p * 0.12); }, onStatus: _setStatus, ); _setProgress(0.5); _setStatus('Merging…'); final mergedMeta = mergeTxMetaStores(_meta.exportStore(), remoteMeta); List remoteFavourites = const []; try { remoteFavourites = await service.pullOverviewFavourites( onStatus: _setStatus, ); } catch (error) { debugPrint( 'SyncController: overview_favourites pull skipped ($error)', ); } final mergedFavourites = mergeOverviewFavourites( _prefs.overviewFavouritesForSync, remoteFavourites, ); List remoteHomeWidgets = const []; try { remoteHomeWidgets = await service.pullHomeFilterWidgets( onStatus: _setStatus, ); } catch (error) { debugPrint( 'SyncController: home_filter_widgets pull skipped ($error)', ); } final mergedHomeWidgets = mergeHomeFilterWidgets( _prefs.homeFilterWidgetsForSync, remoteHomeWidgets, ); AppCloudPrefs? remoteAppPrefs; try { remoteAppPrefs = await service.pullAppPrefs(onStatus: _setStatus); } catch (error) { debugPrint('SyncController: app_prefs pull skipped ($error)'); } // Prefer local when this device has pending prefs edits; otherwise // take remote so a new install picks up cloud Home section toggles. final preferLocalAppPrefs = _pushAppPrefsPending; final mergedAppPrefs = mergeAppCloudPrefs( local: _prefs.cloudPrefs, remote: remoteAppPrefs, preferLocal: preferLocalAppPrefs, ); // Apply without notifying — one rebuild after both stores are ready. await _meta.replaceStore(mergedMeta, notify: false); await _bank.applySyncedTransactions(remoteTxs, notify: false); await _prefs.replaceOverviewFavourites( mergedFavourites, notify: false, ); await _prefs.replaceHomeFilterWidgets( mergedHomeWidgets, notify: false, ); await _prefs.replaceCloudPrefs(mergedAppPrefs, notify: false); _meta.notifyListeners(); _bank.notifyListeners(); _prefs.notifyListeners(); _setProgress(0.55); _setStatus('Pushing…'); final metaSummary = await service.push( mergedMeta, progressStart: 0.55, progressEnd: 0.75, onProgress: _setProgress, onStatus: _setStatus, ); try { await service.pushOverviewFavourites( mergedFavourites, onStatus: _setStatus, ); } catch (error) { debugPrint( 'SyncController: overview_favourites push skipped ($error)', ); } try { await service.pushHomeFilterWidgets( mergedHomeWidgets, onStatus: _setStatus, ); } catch (error) { debugPrint( 'SyncController: home_filter_widgets push skipped ($error)', ); } try { await service.pushAppPrefs( mergedAppPrefs, onStatus: _setStatus, ); _pushAppPrefsPending = false; } catch (error) { debugPrint('SyncController: app_prefs push skipped ($error)'); } // Retry deletes in case any were queued during the pull/merge window. await _flushPendingTxDeletes(service); await _pushTxDeletes(service); _pushBankPending = false; final tombs = List.of(_bank.pendingRemoteDeletes); final bankSummary = await service.pushTransactions( _bank.transactions, deleteTombs: tombs, progressStart: 0.82, progressEnd: 1.0, onProgress: _setProgress, onStatus: _setStatus, ); _lastSyncedAt = DateTime.now(); _lastError = null; _lastSyncSummary = '$metaSummary\n$bankSummary'; _announce('Sync complete'); _syncing = false; _finishProgress(success: true); } catch (error, stackTrace) { debugPrint('SyncController.syncNow failed: $error\n$stackTrace'); _lastError = error.toString().replaceFirst(RegExp(r'^Bad state:\s*'), ''); _announce('Sync failed', isError: true); _syncing = false; _finishProgress(success: false); } finally { if (_queuedFullSync) { _queuedFullSync = false; _pushQueued = false; unawaited(syncNow()); } else if (_pushQueued) { _pushQueued = false; unawaited(_pushOnly()); } } } /// Pushes only the stores that changed locally (debounced). /// /// Annotation edits push tx_meta; favourite / Home-widget edits do not /// re-upload the full annotation store or bank transactions. Future _pushOnly() async { if (!_auth.isLoggedIn) return; if (_syncing) { _pushQueued = true; return; } final pushMeta = _pushMetaPending; final pushFavourites = _pushFavouritesPending; final pushHomeWidgets = _pushHomeWidgetsPending; final pushAppPrefs = _pushAppPrefsPending; final pushBank = _pushBankPending || _bank.pendingRemoteDeletes.isNotEmpty; _pushMetaPending = false; _pushFavouritesPending = false; _pushHomeWidgetsPending = false; _pushAppPrefsPending = false; _pushBankPending = false; if (!pushMeta && !pushFavourites && !pushHomeWidgets && !pushAppPrefs && !pushBank) { return; } _progressHold?.cancel(); _syncing = true; _progressSuccess = false; final label = switch (( pushMeta, pushFavourites, pushHomeWidgets, pushAppPrefs, pushBank, )) { (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…'; _setProgress(0.05); _announce('Saving $label…', isProgress: true); try { final service = PbSyncService(_auth.client); final parts = []; var done = 0; final total = (pushMeta ? 1 : 0) + (pushFavourites ? 1 : 0) + (pushHomeWidgets ? 1 : 0) + (pushAppPrefs ? 1 : 0) + (pushBank ? 1 : 0); void bump() { done++; _setProgress(done / total); } if (pushMeta) { _setStatus('Pushing annotations…'); final summary = await service.push( _meta.exportStore(), progressStart: done / total, progressEnd: (done + 1) / total, onProgress: _setProgress, onStatus: _setStatus, ); parts.add(summary); bump(); } if (pushFavourites) { _setStatus('Pushing favourites…'); try { await service.pushOverviewFavourites( _prefs.overviewFavouritesForSync, onStatus: _setStatus, ); parts.add('Favourites updated'); } catch (error) { debugPrint( 'SyncController: overview_favourites push skipped ($error)', ); _pushFavouritesPending = true; } bump(); } if (pushHomeWidgets) { _setStatus('Pushing home widgets…'); try { await service.pushHomeFilterWidgets( _prefs.homeFilterWidgetsForSync, onStatus: _setStatus, ); parts.add('Home widgets updated'); } catch (error) { debugPrint( 'SyncController: home_filter_widgets push skipped ($error)', ); _pushHomeWidgetsPending = true; } bump(); } if (pushAppPrefs) { _setStatus('Pushing app prefs…'); try { await service.pushAppPrefs( _prefs.cloudPrefs, onStatus: _setStatus, ); parts.add('App prefs updated'); } catch (error) { debugPrint('SyncController: app_prefs push skipped ($error)'); // Re-queue so a missing collection doesn't drop the edit forever // until the next manual full sync after migrate. _pushAppPrefsPending = true; } bump(); } if (pushBank) { _setStatus('Updating bank txs…'); // Other devices may have deleted test txs — learn that before push. await _pullAndApplyTxDeletes(service); await _flushPendingTxDeletes(service); await _pushTxDeletes(service); try { final tombs = List.of(_bank.pendingRemoteDeletes); final summary = await service.pushTransactions( _bank.transactions, deleteTombs: tombs, progressStart: done / total, progressEnd: (done + 1) / total, onProgress: _setProgress, onStatus: _setStatus, ); parts.add(summary); } catch (error) { debugPrint('SyncController: bank tx push skipped ($error)'); _pushBankPending = true; } bump(); } _setProgress(1.0); _lastSyncedAt = DateTime.now(); _lastError = null; _lastSyncSummary = parts.isEmpty ? label : parts.join('\n'); _announce('Saved $label'); _syncing = false; _finishProgress(success: true); } catch (error, stackTrace) { debugPrint('SyncController.push failed: $error\n$stackTrace'); _lastError = error.toString().replaceFirst(RegExp(r'^Bad state:\s*'), ''); _announce('Sync failed', isError: true); _syncing = false; _finishProgress(success: false); } finally { if (_queuedFullSync) { _queuedFullSync = false; _pushQueued = false; unawaited(syncNow()); } else if (_pushQueued) { _pushQueued = false; unawaited(_pushOnly()); } } } @override void dispose() { _pushDebounce?.cancel(); _progressHold?.cancel(); _uiNotifyThrottle?.cancel(); _auth.removeListener(_onAuthChanged); _bank.onAfterSuccessfulLoad = null; _bank.onPendingRemoteDeletesChanged = null; _meta.onPersisted = null; _prefs.onFavouritesPersisted = null; _prefs.onHomeWidgetsPersisted = null; _prefs.onAppPrefsPersisted = null; super.dispose(); } }