diff --git a/lib/services/sync/sync_manager.dart b/lib/services/sync/sync_manager.dart index 2196920..1b9fd14 100644 --- a/lib/services/sync/sync_manager.dart +++ b/lib/services/sync/sync_manager.dart @@ -1,16 +1,22 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'sync_models.dart'; import 'sync_outbox.dart'; import 'sync_provider.dart'; /// Central provider-agnostic synchronization coordinator. -class SyncManager extends ChangeNotifier { +/// +/// Sync work is persisted in [SyncOutbox]. The manager also reconciles on +/// startup, app resume, and periodically while the app is foregrounded so a +/// ride recorded offline is automatically retried after connectivity returns. +class SyncManager extends ChangeNotifier with WidgetsBindingObserver { final SyncOutbox _outbox; final Map _providers = {}; String _activeProviderId = 'fake'; bool _isProcessing = false; DateTime? Function() _nowProvider; + Timer? _foregroundRetryTimer; SyncManager(this._outbox, {List? initialProviders, String? defaultProviderId, DateTime? Function()? nowProvider}) : _nowProvider = nowProvider ?? (() => DateTime.now()) { if (initialProviders != null) { @@ -19,6 +25,9 @@ class SyncManager extends ChangeNotifier { } } if (defaultProviderId != null) _activeProviderId = defaultProviderId; + WidgetsBinding.instance.addObserver(this); + _foregroundRetryTimer = Timer.periodic(const Duration(seconds: 30), (_) => unawaited(processQueue())); + unawaited(processQueue()); } void setNowProvider(DateTime? Function() provider) => _nowProvider = provider; @@ -63,8 +72,6 @@ class SyncManager extends ChangeNotifier { return item; } - /// Enqueues every local ride for the selected cloud provider. Existing - /// synced/queued items remain idempotent and are not duplicated. Future backupAllRideIds(Iterable rideIds, {String? targetProviderId}) async { var enqueued = 0; for (final rideId in rideIds) { @@ -75,7 +82,6 @@ class SyncManager extends ChangeNotifier { return enqueued; } - /// Rearms authentication-blocked work. Call this after a successful login/link. 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)); @@ -94,14 +100,20 @@ class SyncManager extends ChangeNotifier { } } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + unawaited(processQueue()); + } + } + Future processQueue() async { if (_isProcessing) return; _isProcessing = true; notifyListeners(); try { final now = _now(); - final eligibleItems = _outbox.getEligibleItems(now); - for (final item in eligibleItems) { + 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)); @@ -115,18 +127,14 @@ class SyncManager extends ChangeNotifier { 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); - notifyListeners(); - 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) { @@ -152,8 +160,6 @@ class SyncManager extends ChangeNotifier { } } - /// Re-arms retryable/pending work and terminal failures. Failed items are - /// intentionally retained until the retry is explicitly requested. Future retryAllPending() async { final retryable = _outbox.getAllItems().where((i) => i.status.isPending || i.status == SyncStatus.failed).toList(growable: false); for (final item in retryable) { @@ -161,4 +167,11 @@ class SyncManager extends ChangeNotifier { } await processQueue(); } + + @override + void dispose() { + _foregroundRetryTimer?.cancel(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } }