import 'dart:async'; import 'package:flutter/foundation.dart'; import 'sync_models.dart'; import 'sync_outbox.dart'; import 'sync_provider.dart'; /// Central, provider-agnostic synchronization manager. /// Coordinates queueing, deduplication, retry exponential backoff, and provider dispatch. class SyncManager extends ChangeNotifier { 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; } } 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 { final items = _outbox.getAllItems(); int pending = 0; int synced = 0; int failed = 0; DateTime? lastSuccess; String? lastErr; for (final item in items) { if (item.status == SyncStatus.synced) { synced++; if (item.uploadedAt != null) { if (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, ); } /// Registers a synchronization provider implementation void registerProvider(SyncProvider provider) { _providers[provider.providerId] = provider; if (_activeProviderId == 'none' || _activeProviderId == 'fake') { _activeProviderId = provider.providerId; } notifyListeners(); } /// Retrieves a registered provider by ID SyncProvider? getProvider(String providerId) => _providers[providerId]; /// Enqueues a local completed ride for synchronization 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) { // Fire-and-forget processing on microtask queue unawaited(processQueue()); } return item; } /// Calculates exponential backoff duration based on attempt number: /// attempt 1 -> 5s /// attempt 2 -> 30s /// attempt 3 -> 2m (120s) /// attempt 4 -> 5m (300s) /// attempt 5 -> 15m (900s) /// attempt 6+ -> 30m (1800s) maximum 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); } } /// Processes eligible outbox items sequentially without blocking the caller or UI thread Future processQueue() async { if (_isProcessing) return; _isProcessing = true; notifyListeners(); try { final now = _now(); final eligibleItems = _outbox.getEligibleItems(now); for (final item in eligibleItems) { final provider = _providers[item.providerId]; if (provider == null) { final updated = item.copyWith( status: SyncStatus.failed, lastError: 'Provider "${item.providerId}" not registered', lastAttemptAt: now, ); await _outbox.update(updated); continue; } // Check provider availability / network final isAvailable = await provider.isAvailable(); if (!isAvailable) { final updated = item.copyWith( status: SyncStatus.waitingForNetwork, lastAttemptAt: now, ); await _outbox.update(updated); continue; } // Check authentication requirement final requiresAuth = await provider.requiresAuthentication(); if (requiresAuth) { final updated = item.copyWith( status: SyncStatus.authRequired, lastError: 'Authentication required for provider "${item.providerId}"', lastAttemptAt: now, ); await _outbox.update(updated); continue; } // Mark in-progress final uploadingItem = item.copyWith( status: SyncStatus.uploading, lastAttemptAt: now, ); await _outbox.update(uploadingItem); notifyListeners(); // Perform upload through provider SyncResult result; try { result = await provider.uploadRide(uploadingItem); } catch (e) { result = SyncResult.temporaryFailure('Unhandled provider exception: $e', timestamp: now); } if (result.success) { final syncedItem = uploadingItem.copyWith( status: SyncStatus.synced, remoteId: result.remoteId, uploadedAt: result.timestamp, clearLastError: true, clearNextRetry: true, ); await _outbox.update(syncedItem); } else if (result.requiresAuth) { final authItem = uploadingItem.copyWith( status: SyncStatus.authRequired, lastError: result.errorMessage, ); await _outbox.update(authItem); } else if (result.isRetryable) { final newAttempts = uploadingItem.attemptCount + 1; final delay = calculateRetryDelay(newAttempts); final nextRetry = now.add(delay); final retryItem = uploadingItem.copyWith( status: SyncStatus.retryWait, attemptCount: newAttempts, nextRetryAt: nextRetry, lastError: result.errorMessage, ); await _outbox.update(retryItem); } else { // Non-retryable permanent failure final failedItem = uploadingItem.copyWith( status: SyncStatus.failed, attemptCount: uploadingItem.attemptCount + 1, lastError: result.errorMessage, clearNextRetry: true, ); await _outbox.update(failedItem); } } } finally { _isProcessing = false; notifyListeners(); } } /// Cancels an active or pending item in the outbox Future cancelSync(int localRideId, String providerId) async { final item = _outbox.getItem(localRideId, providerId); if (item != null && item.status != SyncStatus.synced) { final cancelled = item.copyWith(status: SyncStatus.cancelled); await _outbox.update(cancelled); notifyListeners(); } } /// Manually retries all failed, waiting, or auth-required items immediately Future retryAllPending() async { for (final item in _outbox.getPendingItems()) { final rearmed = item.copyWith( status: SyncStatus.queued, clearLastError: true, clearNextRetry: true, ); await _outbox.update(rearmed); } await processQueue(); } }