import 'dart:async'; import 'package:flutter/widgets.dart'; import 'sync_models.dart'; import 'sync_outbox.dart'; import 'sync_provider.dart'; /// Central provider-agnostic synchronization coordinator. /// /// Sync work is persisted in [SyncOutbox]. The manager reconciles persisted /// work on startup and whenever the app returns to the foreground. Network /// failures remain queued instead of being discarded. class SyncManager extends ChangeNotifier with WidgetsBindingObserver { final SyncOutbox _outbox; final Map _providers = {}; String _activeProviderId = 'fake'; bool _isProcessing = false; DateTime? Function() _nowProvider; SyncManager(this._outbox, {List? initialProviders, String? defaultProviderId, DateTime? Function()? nowProvider}) : _nowProvider = nowProvider ?? (() => DateTime.now()) { if (initialProviders != null) { for (final p in initialProviders) { registerProvider(p); } } if (defaultProviderId != null) _activeProviderId = defaultProviderId; WidgetsBinding.instance.addObserver(this); unawaited(processQueue()); } void setNowProvider(DateTime? Function() provider) => _nowProvider = provider; DateTime _now() => _nowProvider() ?? DateTime.now(); String get activeProviderId => _activeProviderId; set activeProviderId(String id) { if (_activeProviderId != id) { _activeProviderId = id; notifyListeners(); } } bool get isProcessing => _isProcessing; int get outboxCount => _outbox.count; List get allItems => _outbox.getAllItems(); List get pendingItems => _outbox.getPendingItems(); SyncManagerState get currentState { int pending = 0, synced = 0, failed = 0; DateTime? lastSuccess; String? lastErr; for (final item in _outbox.getAllItems()) { if (item.status == SyncStatus.synced) { synced++; if (item.uploadedAt != null && (lastSuccess == null || item.uploadedAt!.isAfter(lastSuccess))) lastSuccess = item.uploadedAt; } else if (item.status == SyncStatus.failed || item.status == SyncStatus.cancelled) { failed++; if (item.lastError != null) lastErr = item.lastError; } else { pending++; } } return SyncManagerState(isProcessing: _isProcessing, pendingCount: pending, syncedCount: synced, failedCount: failed, lastSuccessfulSync: lastSuccess, lastError: lastErr, activeProviderId: _activeProviderId); } void registerProvider(SyncProvider provider) { _providers[provider.providerId] = provider; if (_activeProviderId == 'none' || _activeProviderId == 'fake') _activeProviderId = provider.providerId; notifyListeners(); } SyncProvider? getProvider(String providerId) => _providers[providerId]; Future enqueueRide(int localRideId, {String? targetProviderId, bool autoProcess = true}) async { final providerId = targetProviderId ?? _activeProviderId; final item = await _outbox.enqueue(localRideId, providerId, now: _now()); notifyListeners(); if (autoProcess) unawaited(processQueue()); return item; } Future backupAllRideIds(Iterable rideIds, {String? targetProviderId}) async { var enqueued = 0; for (final rideId in rideIds) { await enqueueRide(rideId, targetProviderId: targetProviderId, autoProcess: false); enqueued++; } if (enqueued > 0) unawaited(processQueue()); return enqueued; } Future retryAuthRequired() async { for (final item in _outbox.getAllItems().where((i) => i.status == SyncStatus.authRequired)) { await _outbox.update(item.copyWith(status: SyncStatus.queued, clearLastError: true, clearNextRetry: true)); } await processQueue(); } static Duration calculateRetryDelay(int attemptCount) { switch (attemptCount) { case 1: return const Duration(seconds: 5); case 2: return const Duration(seconds: 30); case 3: return const Duration(minutes: 2); case 4: return const Duration(minutes: 5); case 5: return const Duration(minutes: 15); default: return const Duration(minutes: 30); } } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) unawaited(processQueue()); } Future processQueue() async { if (_isProcessing) return; _isProcessing = true; notifyListeners(); try { final now = _now(); for (final item in _outbox.getEligibleItems(now)) { final provider = _providers[item.providerId]; if (provider == null) { await _outbox.update(item.copyWith(status: SyncStatus.failed, lastError: 'Provider "${item.providerId}" not registered', lastAttemptAt: now)); continue; } if (!await provider.isAvailable()) { await _outbox.update(item.copyWith(status: SyncStatus.waitingForNetwork, lastAttemptAt: now)); continue; } if (await provider.requiresAuthentication()) { await _outbox.update(item.copyWith(status: SyncStatus.authRequired, lastError: 'Authentication required for provider "${item.providerId}"', lastAttemptAt: now)); continue; } final uploadingItem = item.copyWith(status: SyncStatus.uploading, lastAttemptAt: now); await _outbox.update(uploadingItem); SyncResult result; try { result = await provider.uploadRide(uploadingItem); } catch (e) { result = SyncResult.temporaryFailure('Unhandled provider exception: $e', timestamp: now); } if (result.success) { await _outbox.update(uploadingItem.copyWith(status: SyncStatus.synced, remoteId: result.remoteId, uploadedAt: result.timestamp, clearLastError: true, clearNextRetry: true)); } else if (result.requiresAuth) { await _outbox.update(uploadingItem.copyWith(status: SyncStatus.authRequired, lastError: result.errorMessage)); } else if (result.isRetryable) { final attempts = uploadingItem.attemptCount + 1; await _outbox.update(uploadingItem.copyWith(status: SyncStatus.retryWait, attemptCount: attempts, nextRetryAt: now.add(calculateRetryDelay(attempts)), lastError: result.errorMessage)); } else { await _outbox.update(uploadingItem.copyWith(status: SyncStatus.failed, attemptCount: uploadingItem.attemptCount + 1, lastError: result.errorMessage, clearNextRetry: true)); } } } finally { _isProcessing = false; notifyListeners(); } } Future cancelSync(int localRideId, String providerId) async { final item = _outbox.getItem(localRideId, providerId); if (item != null && !item.status.isTerminal) { await _outbox.update(item.copyWith(status: SyncStatus.cancelled)); notifyListeners(); } } Future retryAllPending() async { final retryable = _outbox.getAllItems().where((i) => i.status.isPending || i.status == SyncStatus.failed).toList(growable: false); for (final item in retryable) { await _outbox.update(item.copyWith(status: SyncStatus.queued, clearLastError: true, clearNextRetry: true)); } await processQueue(); } bool _disposed = false; @override void dispose() { if (_disposed) return; _disposed = true; WidgetsBinding.instance.removeObserver(this); super.dispose(); } }