/// Coordinates PocketBase ↔ local annotation + bank-transaction sync. library; import 'dart:async'; import 'package:flutter/foundation.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 = _schedulePush; _prefs.onFavouritesPersisted = _schedulePush; _prefs.onHomeWidgetsPersisted = _schedulePush; _auth.addListener(_onAuthChanged); _bank.onAfterSuccessfulLoad = _onBankDataLoaded; 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; 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; _lastError = null; _statusDetail = ''; _showProgressRing = false; _progressSuccess = false; _syncProgress = 0; _lastNotifiedProgressPct = -1; _notifyUiNow(); } } /// Bank pull-to-refresh / reconnect finished — reconcile with PocketBase. /// /// Always runs a full sync when logged in so (1) new API txs are pushed and /// (2) any cloud-only history the bank window lacks is pulled back. void _onBankDataLoaded() { if (!_auth.isLoggedIn) 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 _schedulePush() { if (!_auth.isLoggedIn) 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); 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.overviewFavourites, 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.homeFilterWidgets, remoteHomeWidgets, ); // 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, ); _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)', ); } final bankSummary = await service.pushTransactions( _bank.transactions, 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()); } } } /// Annotation-only push after local meta edits (avoids re-uploading 4k+ txs). Future _pushOnly() async { if (!_auth.isLoggedIn || _syncing) { if (_syncing) _pushQueued = true; return; } _progressHold?.cancel(); _syncing = true; _progressSuccess = false; _statusDetail = 'Pushing…'; _setProgress(0.05); _announce('Syncing…', isProgress: true); try { final service = PbSyncService(_auth.client); final summary = await service.push( _meta.exportStore(), progressStart: 0.05, progressEnd: 0.9, onProgress: _setProgress, onStatus: _setStatus, ); try { await service.pushOverviewFavourites( _prefs.overviewFavourites, onStatus: _setStatus, ); } catch (error) { debugPrint( 'SyncController: overview_favourites push skipped ($error)', ); } try { await service.pushHomeFilterWidgets( _prefs.homeFilterWidgets, onStatus: _setStatus, ); } catch (error) { debugPrint( 'SyncController: home_filter_widgets push skipped ($error)', ); } _setProgress(1.0); _lastSyncedAt = DateTime.now(); _lastError = null; _lastSyncSummary = summary; _announce('Sync complete'); _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; _meta.onPersisted = null; _prefs.onFavouritesPersisted = null; _prefs.onHomeWidgetsPersisted = null; super.dispose(); } }