282 lines
8.7 KiB
Dart
282 lines
8.7 KiB
Dart
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<String, dynamic> 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<String, dynamic> 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,
|
|
);
|
|
}
|
|
}
|