184 lines
7.6 KiB
Dart
184 lines
7.6 KiB
Dart
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';
|
|
|
|
/// 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<String, SyncProvider> _providers = {};
|
|
String _activeProviderId = 'fake';
|
|
bool _isProcessing = false;
|
|
DateTime? Function() _nowProvider;
|
|
final AppDatabase? _db;
|
|
final AuthService? _authService;
|
|
|
|
SyncManager(this._outbox, {List<SyncProvider>? initialProviders, String? defaultProviderId, DateTime? Function()? nowProvider, this._db, this._authService}) : _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<SyncItem> get allItems => _outbox.getAllItems();
|
|
List<SyncItem> 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<SyncItem> enqueueRide(int localRideId, {String? targetProviderId, bool autoProcess = true}) async {
|
|
final providerId = targetProviderId ?? _activeProviderId;
|
|
if (providerId == 'firebase') {
|
|
final user = _authService?.currentUser;
|
|
if (user != null && !user.isAnonymous && _db != null) {
|
|
await _db.claimRideForCloudOwner(localRideId, user.uid);
|
|
}
|
|
}
|
|
final item = await _outbox.enqueue(localRideId, providerId, now: _now());
|
|
notifyListeners();
|
|
if (autoProcess) unawaited(processQueue());
|
|
return item;
|
|
}
|
|
|
|
Future<int> backupAllRideIds(Iterable<int> 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<void> 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<void> 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<void> 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<void> 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();
|
|
}
|
|
}
|