MotorSignal-App/lib/services/sync/sync_models.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,
);
}
}