713 lines
23 KiB
Dart
713 lines
23 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';
|
|
|
|
/// 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<String> 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<String, SyncProvider> _providers = {};
|
|
String _activeProviderId = 'fake';
|
|
bool _isProcessing = false;
|
|
Future<CompletedRideReconciliationReport>? _activeReconciliation;
|
|
Future<void>? _currentProcessingFuture;
|
|
bool _needsAnotherPass = 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,
|
|
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<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' && _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<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> _startupRecoveryAndProcess() async {
|
|
await reconcileCompletedRides();
|
|
await processQueue();
|
|
}
|
|
|
|
Future<void> 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<CompletedRideReconciliationReport> reconcileCompletedRides({
|
|
String? targetProviderId,
|
|
}) async {
|
|
if (_activeReconciliation != null) {
|
|
return _activeReconciliation!;
|
|
}
|
|
final future = _performReconcileCompletedRides(
|
|
targetProviderId: targetProviderId,
|
|
);
|
|
_activeReconciliation = future;
|
|
try {
|
|
return await future;
|
|
} finally {
|
|
_activeReconciliation = null;
|
|
}
|
|
}
|
|
|
|
Future<CompletedRideReconciliationReport> _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<String> 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<void> processQueue() async {
|
|
if (_currentProcessingFuture != null) {
|
|
_needsAnotherPass = true;
|
|
return _currentProcessingFuture!;
|
|
}
|
|
final future = _performProcessQueue();
|
|
_currentProcessingFuture = future;
|
|
try {
|
|
await future;
|
|
} finally {
|
|
_currentProcessingFuture = null;
|
|
}
|
|
}
|
|
|
|
Future<void> _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<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 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();
|
|
}
|
|
}
|