MotorSignal-App/lib/services/sync/sync_outbox.dart

169 lines
5.0 KiB
Dart

import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'sync_models.dart';
/// Persistent, crash-resilient local synchronization outbox.
/// Operates outside Drift Schema 5, ensuring zero database schema changes or migrations.
class SyncOutbox {
static const String _outboxStorageKey = 'motorsignal_sync_outbox_manifest_v1';
static const int maxOutboxCapacity = 500;
final SharedPreferences _prefs;
final Map<String, SyncItem> _items = {};
bool _isLoaded = false;
SyncOutbox(this._prefs) {
load();
}
bool get isLoaded => _isLoaded;
int get count => _items.length;
/// Loads and deserializes outbox items from persistent storage with corruption recovery
void load() {
_items.clear();
final rawJson = _prefs.getString(_outboxStorageKey);
if (rawJson == null || rawJson.trim().isEmpty) {
_isLoaded = true;
return;
}
try {
final decoded = jsonDecode(rawJson);
if (decoded is List) {
for (final entry in decoded) {
try {
if (entry is Map<String, dynamic>) {
final item = SyncItem.fromJson(entry);
_items[item.key] = item;
} else if (entry is Map) {
final item = SyncItem.fromJson(Map<String, dynamic>.from(entry));
_items[item.key] = item;
}
} catch (itemError) {
debugPrint('[SyncOutbox] Skipped malformed item in outbox: $itemError');
}
}
}
} catch (e) {
debugPrint('[SyncOutbox] Corruption detected in outbox manifest, resetting safely: $e');
_items.clear();
}
_isLoaded = true;
}
/// Atomically writes current outbox manifest to persistent storage
Future<bool> _save() async {
try {
// Bound outbox capacity to avoid unbounded memory / storage growth
final list = _items.values.toList();
if (list.length > maxOutboxCapacity) {
list.sort((a, b) => a.createdAt.compareTo(b.createdAt));
// Keep most recent items
final trimmed = list.sublist(list.length - maxOutboxCapacity);
_items.clear();
for (final item in trimmed) {
_items[item.key] = item;
}
}
final encoded = jsonEncode(_items.values.map((e) => e.toJson()).toList());
return await _prefs.setString(_outboxStorageKey, encoded);
} catch (e) {
debugPrint('[SyncOutbox] Error saving outbox manifest: $e');
return false;
}
}
/// Retrieves an item by its ride ID and provider ID
SyncItem? getItem(int localRideId, String providerId) {
final key = '${localRideId}_$providerId';
return _items[key];
}
/// Returns all currently tracked items
List<SyncItem> getAllItems() {
return _items.values.toList(growable: false);
}
/// Returns all items requiring action or waiting for retry
List<SyncItem> getPendingItems() {
return _items.values.where((item) => item.status.isPending).toList(growable: false);
}
/// Returns all items currently eligible for an immediate upload attempt
List<SyncItem> getEligibleItems(DateTime now) {
return _items.values
.where((item) => item.isEligibleForAttempt(now))
.toList(growable: false);
}
/// Enqueues a local ride for a target provider.
/// Deduplicates: if already queued or synced, returns existing item without duplicate insertion.
Future<SyncItem> enqueue(
int localRideId,
String providerId, {
DateTime? now,
}) async {
final key = '${localRideId}_$providerId';
final existing = _items[key];
if (existing != null) {
if (existing.status == SyncStatus.synced) {
return existing; // Already completed
}
if (existing.status == SyncStatus.queued || existing.status == SyncStatus.uploading) {
return existing; // Already active in queue
}
// Re-arm failed or waiting item
final rearmed = existing.copyWith(
status: SyncStatus.queued,
clearLastError: true,
clearNextRetry: true,
);
_items[key] = rearmed;
await _save();
return rearmed;
}
final newItem = SyncItem(
localRideId: localRideId,
providerId: providerId,
status: SyncStatus.queued,
createdAt: now ?? DateTime.now(),
);
_items[key] = newItem;
await _save();
return newItem;
}
/// Updates the state of an existing outbox item
Future<void> update(SyncItem item) async {
_items[item.key] = item;
await _save();
}
/// Removes an item from the outbox
Future<void> remove(int localRideId, String providerId) async {
final key = '${localRideId}_$providerId';
if (_items.remove(key) != null) {
await _save();
}
}
/// Clears the entire outbox
Future<void> clear() async {
_items.clear();
await _prefs.remove(_outboxStorageKey);
}
/// Checks if a ride is marked synced for the specified provider
bool isRideSynced(int localRideId, String providerId) {
final item = getItem(localRideId, providerId);
return item?.status == SyncStatus.synced;
}
}