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 _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) { final item = SyncItem.fromJson(entry); _items[item.key] = item; } else if (entry is Map) { final item = SyncItem.fromJson(Map.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 _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 getAllItems() { return _items.values.toList(growable: false); } /// Returns all items requiring action or waiting for retry List getPendingItems() { return _items.values.where((item) => item.status.isPending).toList(growable: false); } /// Returns all items currently eligible for an immediate upload attempt List 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 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 update(SyncItem item) async { _items[item.key] = item; await _save(); } /// Removes an item from the outbox Future remove(int localRideId, String providerId) async { final key = '${localRideId}_$providerId'; if (_items.remove(key) != null) { await _save(); } } /// Clears the entire outbox Future 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; } }