import 'package:flutter/foundation.dart'; /// Strongly typed synchronization statuses with documented lifecycle transitions: /// /// [localOnly] -> Initial state of historical or un-queued local rides. /// [queued] -> Placed in the outbox queue, ready for processing. /// [waitingForNetwork] -> Temporarily blocked due to absence of internet connectivity. /// [uploading] -> Actively transferring payload to the remote provider. /// [synced] -> Successfully confirmed and uploaded to the remote provider. /// [failed] -> Permanent failure or maximum retries exceeded. /// [retryWait] -> Temporary failure waiting for exponential backoff window. /// [authRequired] -> Provider requires authentication credentials before proceeding. /// [cancelled] -> Explicitly cancelled by user or invalidated by session. enum SyncStatus { localOnly, queued, waitingForNetwork, uploading, synced, failed, retryWait, authRequired, cancelled; bool get isTerminal => this == synced || this == failed || this == cancelled; bool get isPending => this == queued || this == waitingForNetwork || this == retryWait || this == authRequired; bool get isInProgress => this == uploading; } /// Immutable outbox synchronization item referencing an authoritative local ride. /// Never stores raw sub-second telemetry points internally. @immutable class SyncItem { final int localRideId; final String providerId; final SyncStatus status; final DateTime createdAt; final int attemptCount; final DateTime? lastAttemptAt; final DateTime? nextRetryAt; final DateTime? uploadedAt; final String? remoteId; final String? lastError; final String? bundleLocalPath; const SyncItem({ required this.localRideId, required this.providerId, this.status = SyncStatus.queued, required this.createdAt, this.attemptCount = 0, this.lastAttemptAt, this.nextRetryAt, this.uploadedAt, this.remoteId, this.lastError, this.bundleLocalPath, }); /// Deterministic composite idempotency key preventing duplicate queue entries String get key => '${localRideId}_$providerId'; /// Whether this item is eligible for execution at the provided [now] timestamp bool isEligibleForAttempt(DateTime now) { if (status == SyncStatus.synced || status == SyncStatus.failed || status == SyncStatus.cancelled) { return false; } if (status == SyncStatus.uploading) { return false; } if (status == SyncStatus.retryWait && nextRetryAt != null) { return now.isAfter(nextRetryAt!) || now.isAtSameMomentAs(nextRetryAt!); } return true; } SyncItem copyWith({ int? localRideId, String? providerId, SyncStatus? status, DateTime? createdAt, int? attemptCount, DateTime? lastAttemptAt, DateTime? nextRetryAt, DateTime? uploadedAt, String? remoteId, String? lastError, String? bundleLocalPath, bool clearLastError = false, bool clearNextRetry = false, }) { return SyncItem( localRideId: localRideId ?? this.localRideId, providerId: providerId ?? this.providerId, status: status ?? this.status, createdAt: createdAt ?? this.createdAt, attemptCount: attemptCount ?? this.attemptCount, lastAttemptAt: lastAttemptAt ?? this.lastAttemptAt, nextRetryAt: clearNextRetry ? null : (nextRetryAt ?? this.nextRetryAt), uploadedAt: uploadedAt ?? this.uploadedAt, remoteId: remoteId ?? this.remoteId, lastError: clearLastError ? null : (lastError ?? this.lastError), bundleLocalPath: bundleLocalPath ?? this.bundleLocalPath, ); } Map toJson() => { 'localRideId': localRideId, 'providerId': providerId, 'status': status.name, 'createdAt': createdAt.toIso8601String(), 'attemptCount': attemptCount, 'lastAttemptAt': lastAttemptAt?.toIso8601String(), 'nextRetryAt': nextRetryAt?.toIso8601String(), 'uploadedAt': uploadedAt?.toIso8601String(), 'remoteId': remoteId, 'lastError': lastError, 'bundleLocalPath': bundleLocalPath, }; factory SyncItem.fromJson(Map json) => SyncItem( localRideId: json['localRideId'] as int, providerId: json['providerId'] as String, status: SyncStatus.values.firstWhere( (e) => e.name == json['status'], orElse: () => SyncStatus.queued, ), createdAt: DateTime.parse(json['createdAt'] as String), attemptCount: json['attemptCount'] as int? ?? 0, lastAttemptAt: json['lastAttemptAt'] != null ? DateTime.tryParse(json['lastAttemptAt'] as String) : null, nextRetryAt: json['nextRetryAt'] != null ? DateTime.tryParse(json['nextRetryAt'] as String) : null, uploadedAt: json['uploadedAt'] != null ? DateTime.tryParse(json['uploadedAt'] as String) : null, remoteId: json['remoteId'] as String?, lastError: json['lastError'] as String?, bundleLocalPath: json['bundleLocalPath'] as String?, ); @override bool operator ==(Object other) => identical(this, other) || other is SyncItem && runtimeType == other.runtimeType && localRideId == other.localRideId && providerId == other.providerId && status == other.status && attemptCount == other.attemptCount && nextRetryAt == other.nextRetryAt && remoteId == other.remoteId && lastError == other.lastError; @override int get hashCode => localRideId.hashCode ^ providerId.hashCode ^ status.hashCode ^ attemptCount.hashCode ^ nextRetryAt.hashCode ^ remoteId.hashCode ^ lastError.hashCode; @override String toString() => 'SyncItem(ride: $localRideId, provider: $providerId, status: ${status.name}, attempts: $attemptCount, nextRetry: $nextRetryAt)'; } /// Result returned from a SyncProvider upload operation @immutable class SyncResult { final bool success; final String? remoteId; final String? errorMessage; final bool isRetryable; final bool requiresAuth; final DateTime timestamp; SyncResult({ required this.success, this.remoteId, this.errorMessage, this.isRetryable = true, this.requiresAuth = false, DateTime? timestamp, }) : timestamp = timestamp ?? DateTime.now(); SyncResult.success({ required String remoteId, DateTime? timestamp, }) : this( success: true, remoteId: remoteId, isRetryable: false, requiresAuth: false, timestamp: timestamp, ); SyncResult.temporaryFailure( String message, { DateTime? timestamp, }) : this( success: false, errorMessage: message, isRetryable: true, requiresAuth: false, timestamp: timestamp, ); SyncResult.permanentFailure( String message, { DateTime? timestamp, }) : this( success: false, errorMessage: message, isRetryable: false, requiresAuth: false, timestamp: timestamp, ); SyncResult.authRequired({ String message = 'Authentication credentials required for sync', DateTime? timestamp, }) : this( success: false, errorMessage: message, isRetryable: true, requiresAuth: true, timestamp: timestamp, ); @override String toString() => 'SyncResult(success: $success, remoteId: $remoteId, error: $errorMessage, retryable: $isRetryable, auth: $requiresAuth)'; } /// Aggregated synchronization state exposed to Riverpod UI @immutable class SyncManagerState { final bool isProcessing; final int pendingCount; final int syncedCount; final int failedCount; final DateTime? lastSuccessfulSync; final String? lastError; final String activeProviderId; const SyncManagerState({ this.isProcessing = false, this.pendingCount = 0, this.syncedCount = 0, this.failedCount = 0, this.lastSuccessfulSync, this.lastError, this.activeProviderId = 'none', }); SyncManagerState copyWith({ bool? isProcessing, int? pendingCount, int? syncedCount, int? failedCount, DateTime? lastSuccessfulSync, String? lastError, String? activeProviderId, bool clearLastError = false, }) { return SyncManagerState( isProcessing: isProcessing ?? this.isProcessing, pendingCount: pendingCount ?? this.pendingCount, syncedCount: syncedCount ?? this.syncedCount, failedCount: failedCount ?? this.failedCount, lastSuccessfulSync: lastSuccessfulSync ?? this.lastSuccessfulSync, lastError: clearLastError ? null : (lastError ?? this.lastError), activeProviderId: activeProviderId ?? this.activeProviderId, ); } }