MotorSignal-App/lib/services/sync/sync_manager.dart

174 lines
7.2 KiB
Dart

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<String, SyncProvider> _providers = {};
String _activeProviderId = 'fake';
bool _isProcessing = false;
DateTime? Function() _nowProvider;
SyncManager(this._outbox, {List<SyncProvider>? 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<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;
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();
}
}