import 'dart:async'; import 'package:flutter/widgets.dart'; import 'sync_models.dart'; import 'sync_outbox.dart'; import 'sync_provider.dart'; import '../../database/database.dart'; import '../auth/auth_service.dart'; /// Diagnostic summary returned by [SyncManager.reconcileCompletedRides]. class CompletedRideReconciliationReport { final int discoveredCompletedRides; final int alreadyTrackedCount; final int recoveredQueuedCount; final int recoveredAuthRequiredCount; final int recoveredAlreadySyncedCount; final int foreignOwnerSkippedCount; final int inconsistentSkippedCount; final int unverifiablePendingCount; final int errorCount; final List errors; const CompletedRideReconciliationReport({ this.discoveredCompletedRides = 0, this.alreadyTrackedCount = 0, this.recoveredQueuedCount = 0, this.recoveredAuthRequiredCount = 0, this.recoveredAlreadySyncedCount = 0, this.foreignOwnerSkippedCount = 0, this.inconsistentSkippedCount = 0, this.unverifiablePendingCount = 0, this.errorCount = 0, this.errors = const [], }); int get totalRecovered => recoveredQueuedCount + recoveredAuthRequiredCount + recoveredAlreadySyncedCount; @override String toString() => 'CompletedRideReconciliationReport(discovered: $discoveredCompletedRides, ' 'tracked: $alreadyTrackedCount, queued: $recoveredQueuedCount, ' 'authRequired: $recoveredAuthRequiredCount, alreadySynced: $recoveredAlreadySyncedCount, ' 'foreignSkipped: $foreignOwnerSkippedCount, inconsistent: $inconsistentSkippedCount, ' 'unverifiable: $unverifiablePendingCount, errors: $errorCount)'; } /// 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; Future? _activeReconciliation; Future? _currentProcessingFuture; bool _needsAnotherPass = false; DateTime? Function() _nowProvider; final AppDatabase? _db; final AuthService? _authService; SyncManager( this._outbox, { List? initialProviders, String? defaultProviderId, DateTime? Function()? nowProvider, this._db, this._authService, bool autoStart = true, }) : _nowProvider = nowProvider ?? (() => DateTime.now()) { if (defaultProviderId != null) _activeProviderId = defaultProviderId; if (initialProviders != null) { for (final p in initialProviders) { registerProvider(p); } } WidgetsBinding.instance.addObserver(this); if (autoStart) { unawaited(_startupRecoveryAndProcess()); } } 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; if (providerId == 'firebase' && _db != null) { final user = _authService?.currentUser; final isAuthenticated = user != null && !user.isAnonymous; final rideOwner = await _db.getCloudOwnerUid(localRideId); if (isAuthenticated && rideOwner == null) { await _db.claimRideForCloudOwner(localRideId, user.uid); } } 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 _startupRecoveryAndProcess() async { await reconcileCompletedRides(); await processQueue(); } Future retryAuthRequired() async { final user = _authService?.currentUser; final isAuthenticated = user != null && !user.isAnonymous; if (isAuthenticated && _db != null) { await reconcileCompletedRides(); } for (final item in _outbox.getAllItems().where( (i) => i.status == SyncStatus.authRequired, )) { if (item.providerId == 'firebase') { if (!isAuthenticated) continue; if (_db != null) { final rideOwner = await _db.getCloudOwnerUid(item.localRideId); if (rideOwner != null && rideOwner != user.uid) { await _outbox.update( item.copyWith( status: SyncStatus.cancelled, lastError: 'Ride belongs to another account and cannot be retried', clearNextRetry: true, ), ); continue; } if (rideOwner == null) { await _db.claimRideForCloudOwner(item.localRideId, user.uid); } } } await _outbox.update( item.copyWith( status: SyncStatus.queued, clearLastError: true, clearNextRetry: true, ), ); } await processQueue(); } /// Discovers completed rides in local SQLite that are missing from [SyncOutbox] /// or need cloud confirmation recovery. Future reconcileCompletedRides({ String? targetProviderId, }) async { if (_activeReconciliation != null) { return _activeReconciliation!; } final future = _performReconcileCompletedRides( targetProviderId: targetProviderId, ); _activeReconciliation = future; try { return await future; } finally { _activeReconciliation = null; } } Future _performReconcileCompletedRides({ String? targetProviderId, }) async { if (_db == null) { return const CompletedRideReconciliationReport(); } final providerId = targetProviderId ?? _activeProviderId; final provider = _providers[providerId]; final user = _authService?.currentUser; final isAuthenticated = user != null && !user.isAnonymous; int discovered = 0; int alreadyTracked = 0; int recoveredQueued = 0; int recoveredAuthRequired = 0; int recoveredAlreadySynced = 0; int foreignOwnerSkipped = 0; int inconsistentSkipped = 0; int unverifiablePending = 0; int errorCount = 0; final List errors = []; try { final completedRides = await (_db.select( _db.rides, )..where((r) => r.endTime.isNotNull())).get(); discovered = completedRides.length; for (final ride in completedRides) { try { // Check if already tracked in outbox for this provider final existingItem = _outbox.getItem(ride.id, providerId); if (existingItem != null) { alreadyTracked++; continue; } // Foreign account check: never claim or upload another user's ride if (ride.cloudOwnerUid != null && isAuthenticated && ride.cloudOwnerUid != user.uid) { foreignOwnerSkipped++; continue; } if (ride.cloudOwnerUid != null && !isAuthenticated) { foreignOwnerSkipped++; continue; } // Claim unclaimed ride if user is authenticated and target is firebase if (ride.cloudOwnerUid == null && isAuthenticated && providerId == 'firebase') { await _db.claimRideForCloudOwner(ride.id, user.uid); } // Stable cloud identity check final remoteRideId = await _db.ensureCloudRideId(ride.id); // Deep verification check in cloud backend final confirmation = provider != null ? await provider.verifyCloudConfirmation(remoteRideId) : const CloudConfirmationResult( status: CloudConfirmationStatus.missing, ); switch (confirmation.status) { case CloudConfirmationStatus.confirmed: final syncedItem = SyncItem( localRideId: ride.id, providerId: providerId, status: SyncStatus.synced, remoteId: remoteRideId, createdAt: ride.endTime ?? ride.startTime, uploadedAt: _now(), ); await _outbox.update(syncedItem); recoveredAlreadySynced++; break; case CloudConfirmationStatus.missing: final providerRequiresAuth = provider != null && await provider.requiresAuthentication(); if (providerRequiresAuth || (!isAuthenticated && providerId == 'firebase')) { final authReqItem = SyncItem( localRideId: ride.id, providerId: providerId, status: SyncStatus.authRequired, createdAt: ride.endTime ?? ride.startTime, lastError: 'Authentication required for provider "$providerId"', ); await _outbox.update(authReqItem); recoveredAuthRequired++; } else { final queuedItem = SyncItem( localRideId: ride.id, providerId: providerId, status: SyncStatus.queued, createdAt: ride.endTime ?? ride.startTime, ); await _outbox.update(queuedItem); recoveredQueued++; } break; case CloudConfirmationStatus.inconsistent: inconsistentSkipped++; errors.add( 'Ride #${ride.id} ($remoteRideId) inconsistent in cloud: ${confirmation.message}', ); final inconsistentItem = SyncItem( localRideId: ride.id, providerId: providerId, status: SyncStatus.failed, remoteId: remoteRideId, createdAt: ride.endTime ?? ride.startTime, lastError: 'Cloud data inconsistent: ${confirmation.message}', attemptCount: 1, ); await _outbox.update(inconsistentItem); break; case CloudConfirmationStatus.unauthorized: final providerRequiresAuth = provider != null && await provider.requiresAuthentication(); if (!isAuthenticated || providerRequiresAuth) { final authReqItem = SyncItem( localRideId: ride.id, providerId: providerId, status: SyncStatus.authRequired, createdAt: ride.endTime ?? ride.startTime, lastError: 'Authentication required for provider "$providerId"', ); await _outbox.update(authReqItem); recoveredAuthRequired++; } else { foreignOwnerSkipped++; errors.add( 'Ride #${ride.id} ($remoteRideId) unauthorized: ${confirmation.message}', ); } break; case CloudConfirmationStatus.temporarilyUnverifiable: unverifiablePending++; final retryItem = SyncItem( localRideId: ride.id, providerId: providerId, status: SyncStatus.retryWait, remoteId: remoteRideId, createdAt: ride.endTime ?? ride.startTime, nextRetryAt: _now().add(const Duration(minutes: 2)), lastError: 'Cloud temporarily unverifiable: ${confirmation.message}', ); await _outbox.update(retryItem); break; } } catch (itemErr) { errorCount++; errors.add('Ride #${ride.id}: $itemErr'); } } } catch (e) { errorCount++; errors.add('Reconciliation failed: $e'); } final report = CompletedRideReconciliationReport( discoveredCompletedRides: discovered, alreadyTrackedCount: alreadyTracked, recoveredQueuedCount: recoveredQueued, recoveredAuthRequiredCount: recoveredAuthRequired, recoveredAlreadySyncedCount: recoveredAlreadySynced, foreignOwnerSkippedCount: foreignOwnerSkipped, inconsistentSkippedCount: inconsistentSkipped, unverifiablePendingCount: unverifiablePending, errorCount: errorCount, errors: errors, ); if (report.totalRecovered > 0) { notifyListeners(); } return report; } 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(_startupRecoveryAndProcess()); } } Future processQueue() async { if (_currentProcessingFuture != null) { _needsAnotherPass = true; return _currentProcessingFuture!; } final future = _performProcessQueue(); _currentProcessingFuture = future; try { await future; } finally { _currentProcessingFuture = null; } } Future _performProcessQueue() async { do { _needsAnotherPass = false; _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; } // 1. Confirm local database record exists final ride = _db != null ? await (_db.select(_db.rides) ..where((r) => r.id.equals(item.localRideId))) .getSingleOrNull() : null; if (_db != null && ride == null) { await _outbox.update( item.copyWith( status: SyncStatus.failed, lastError: 'Local ride #${item.localRideId} not found in database', lastAttemptAt: now, clearNextRetry: true, ), ); continue; } // 2. Immediate pre-upload ownership and account checks // 2. Immediate pre-upload ownership and account checks if (item.providerId == 'firebase' && _authService != null) { final user = _authService.currentUser; final isAuthenticated = user != null && !user.isAnonymous; if (!isAuthenticated) { await _outbox.update( item.copyWith( status: SyncStatus.authRequired, lastError: 'Authentication required for provider "${item.providerId}"', lastAttemptAt: now, ), ); continue; } if (ride != null && ride.cloudOwnerUid != null && ride.cloudOwnerUid != user.uid) { await _outbox.update( item.copyWith( status: SyncStatus.cancelled, lastError: 'Ride belongs to another account and cannot be uploaded', lastAttemptAt: now, clearNextRetry: true, ), ); continue; } if (ride != null && ride.cloudOwnerUid == null && _db != null) { final claimed = await _db.claimRideForCloudOwner( ride.id, user.uid, ); if (!claimed) { await _outbox.update( item.copyWith( status: SyncStatus.cancelled, lastError: 'Failed to claim ride ownership for upload', lastAttemptAt: now, clearNextRetry: true, ), ); 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 uidBeforeUpload = _authService?.currentUser?.uid; 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, ); } // 3. Post-upload identity integrity check: verify user didn't logout/switch mid-flight final uidAfterUpload = _authService?.currentUser?.uid; if (item.providerId == 'firebase' && _authService != null && uidBeforeUpload != uidAfterUpload) { await _outbox.update( uploadingItem.copyWith( status: SyncStatus.authRequired, lastError: 'User changed or logged out during upload', clearNextRetry: true, ), ); continue; } 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(); } } while (_needsAnotherPass); } 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 user = _authService?.currentUser; final isAuthenticated = user != null && !user.isAnonymous; final database = _db; final retryable = _outbox .getAllItems() .where((i) => i.status.isPending || i.status == SyncStatus.failed) .toList(growable: false); for (final item in retryable) { if (item.providerId == 'firebase' && database != null && _authService != null) { final rideOwner = await database.getCloudOwnerUid(item.localRideId); if (rideOwner != null && isAuthenticated && rideOwner != user.uid) { await _outbox.update( item.copyWith( status: SyncStatus.cancelled, lastError: 'Ride belongs to another account and cannot be retried', clearNextRetry: true, ), ); continue; } } 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(); } }