feat: automatically reconcile offline sync on startup and resume
This commit is contained in:
parent
d8abc78a40
commit
2a6e2541fa
|
|
@ -1,16 +1,22 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
import 'sync_models.dart';
|
import 'sync_models.dart';
|
||||||
import 'sync_outbox.dart';
|
import 'sync_outbox.dart';
|
||||||
import 'sync_provider.dart';
|
import 'sync_provider.dart';
|
||||||
|
|
||||||
/// Central provider-agnostic synchronization coordinator.
|
/// 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 SyncOutbox _outbox;
|
||||||
final Map<String, SyncProvider> _providers = {};
|
final Map<String, SyncProvider> _providers = {};
|
||||||
String _activeProviderId = 'fake';
|
String _activeProviderId = 'fake';
|
||||||
bool _isProcessing = false;
|
bool _isProcessing = false;
|
||||||
DateTime? Function() _nowProvider;
|
DateTime? Function() _nowProvider;
|
||||||
|
Timer? _foregroundRetryTimer;
|
||||||
|
|
||||||
SyncManager(this._outbox, {List<SyncProvider>? initialProviders, String? defaultProviderId, DateTime? Function()? nowProvider}) : _nowProvider = nowProvider ?? (() => DateTime.now()) {
|
SyncManager(this._outbox, {List<SyncProvider>? initialProviders, String? defaultProviderId, DateTime? Function()? nowProvider}) : _nowProvider = nowProvider ?? (() => DateTime.now()) {
|
||||||
if (initialProviders != null) {
|
if (initialProviders != null) {
|
||||||
|
|
@ -19,6 +25,9 @@ class SyncManager extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (defaultProviderId != null) _activeProviderId = defaultProviderId;
|
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;
|
void setNowProvider(DateTime? Function() provider) => _nowProvider = provider;
|
||||||
|
|
@ -63,8 +72,6 @@ class SyncManager extends ChangeNotifier {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enqueues every local ride for the selected cloud provider. Existing
|
|
||||||
/// synced/queued items remain idempotent and are not duplicated.
|
|
||||||
Future<int> backupAllRideIds(Iterable<int> rideIds, {String? targetProviderId}) async {
|
Future<int> backupAllRideIds(Iterable<int> rideIds, {String? targetProviderId}) async {
|
||||||
var enqueued = 0;
|
var enqueued = 0;
|
||||||
for (final rideId in rideIds) {
|
for (final rideId in rideIds) {
|
||||||
|
|
@ -75,7 +82,6 @@ class SyncManager extends ChangeNotifier {
|
||||||
return enqueued;
|
return enqueued;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rearms authentication-blocked work. Call this after a successful login/link.
|
|
||||||
Future<void> retryAuthRequired() async {
|
Future<void> retryAuthRequired() async {
|
||||||
for (final item in _outbox.getAllItems().where((i) => i.status == SyncStatus.authRequired)) {
|
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 _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<void> processQueue() async {
|
Future<void> processQueue() async {
|
||||||
if (_isProcessing) return;
|
if (_isProcessing) return;
|
||||||
_isProcessing = true;
|
_isProcessing = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
final now = _now();
|
final now = _now();
|
||||||
final eligibleItems = _outbox.getEligibleItems(now);
|
for (final item in _outbox.getEligibleItems(now)) {
|
||||||
for (final item in eligibleItems) {
|
|
||||||
final provider = _providers[item.providerId];
|
final provider = _providers[item.providerId];
|
||||||
if (provider == null) {
|
if (provider == null) {
|
||||||
await _outbox.update(item.copyWith(status: SyncStatus.failed, lastError: 'Provider "${item.providerId}" not registered', lastAttemptAt: now));
|
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));
|
await _outbox.update(item.copyWith(status: SyncStatus.authRequired, lastError: 'Authentication required for provider "${item.providerId}"', lastAttemptAt: now));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
final uploadingItem = item.copyWith(status: SyncStatus.uploading, lastAttemptAt: now);
|
final uploadingItem = item.copyWith(status: SyncStatus.uploading, lastAttemptAt: now);
|
||||||
await _outbox.update(uploadingItem);
|
await _outbox.update(uploadingItem);
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
SyncResult result;
|
SyncResult result;
|
||||||
try {
|
try {
|
||||||
result = await provider.uploadRide(uploadingItem);
|
result = await provider.uploadRide(uploadingItem);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
result = SyncResult.temporaryFailure('Unhandled provider exception: $e', timestamp: now);
|
result = SyncResult.temporaryFailure('Unhandled provider exception: $e', timestamp: now);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
await _outbox.update(uploadingItem.copyWith(status: SyncStatus.synced, remoteId: result.remoteId, uploadedAt: result.timestamp, clearLastError: true, clearNextRetry: true));
|
await _outbox.update(uploadingItem.copyWith(status: SyncStatus.synced, remoteId: result.remoteId, uploadedAt: result.timestamp, clearLastError: true, clearNextRetry: true));
|
||||||
} else if (result.requiresAuth) {
|
} 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<void> retryAllPending() async {
|
Future<void> retryAllPending() async {
|
||||||
final retryable = _outbox.getAllItems().where((i) => i.status.isPending || i.status == SyncStatus.failed).toList(growable: false);
|
final retryable = _outbox.getAllItems().where((i) => i.status.isPending || i.status == SyncStatus.failed).toList(growable: false);
|
||||||
for (final item in retryable) {
|
for (final item in retryable) {
|
||||||
|
|
@ -161,4 +167,11 @@ class SyncManager extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
await processQueue();
|
await processQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_foregroundRetryTimer?.cancel();
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue