import 'package:logging/logging.dart'; import '../models/change.dart'; import '../models/sheet.dart'; import 'api_client.dart'; import 'storage_service.dart'; /// Result of a sync operation. class SyncResult { final List sheets; final bool isOnline; final int changesSynced; final int annotationsSynced; SyncResult({ required this.sheets, required this.isOnline, this.changesSynced = 0, this.annotationsSynced = 0, }); } /// Service for coordinating offline/online synchronization. /// /// Handles: /// - Fetching sheets with offline fallback to cached data /// - Uploading pending changes when connection is available /// - Uploading pending annotation uploads /// - Applying local changes to sheets list class SyncService { final _log = Logger('SyncService'); final ApiClient _apiClient; final StorageService _storageService; SyncService({ required ApiClient apiClient, required StorageService storageService, }) : _apiClient = apiClient, _storageService = storageService; /// Performs a full sync operation. /// /// 1. Checks if online /// 2. If online: fetches sheets, uploads pending changes, uploads pending annotations /// 3. If offline: loads cached sheets and applies pending changes locally /// /// Returns [SyncResult] with the sheets list and sync status. Future sync() async { final isOnline = await _apiClient.checkConnection(); if (isOnline) { return _syncOnline(); } else { return _syncOffline(); } } /// Online sync: fetch from server, upload pending data. Future _syncOnline() async { _log.info('Online sync starting...'); int changesSynced = 0; int annotationsSynced = 0; // 1. Fetch fresh sheets from server List sheets; try { sheets = await _apiClient.fetchSheets(); _log.info('Fetched ${sheets.length} sheets from server'); // Cache the fetched sheets await _storageService.writeCachedSheets(sheets); } catch (e) { _log.warning('Failed to fetch sheets, falling back to cache: $e'); return _syncOffline(); } // 2. Upload pending changes changesSynced = await _uploadPendingChanges(); // 3. Upload pending annotations annotationsSynced = await _uploadPendingAnnotations(); // 4. Apply any remaining local changes (in case some failed to upload) final changeQueue = await _storageService.readChangeQueue(); if (changeQueue.isNotEmpty) { try { changeQueue.applyToSheets(sheets); // Update cache with applied changes await _storageService.writeCachedSheets(sheets); } catch (e) { _log.warning('Failed to apply remaining changes: $e'); } } _log.info( 'Online sync complete: $changesSynced changes, $annotationsSynced annotations synced', ); return SyncResult( sheets: sheets, isOnline: true, changesSynced: changesSynced, annotationsSynced: annotationsSynced, ); } /// Offline sync: use cached data with local changes applied. Future _syncOffline() async { _log.info('Offline mode: loading cached data...'); // 1. Load cached sheets var sheets = await _storageService.readCachedSheets(); if (sheets.isEmpty) { _log.warning('No cached sheets available in offline mode'); } // 2. Apply pending changes locally final changeQueue = await _storageService.readChangeQueue(); if (changeQueue.isNotEmpty) { _log.info('Applying ${changeQueue.length} pending changes locally'); try { changeQueue.applyToSheets(sheets); } catch (e) { _log.warning('Failed to apply some changes: $e'); } } return SyncResult( sheets: sheets, isOnline: false, ); } /// Uploads all pending changes to the server. /// /// Returns the number of successfully synced changes. Future _uploadPendingChanges() async { final changes = await _storageService.readChangeList(); if (changes.isEmpty) return 0; _log.info('Uploading ${changes.length} pending changes...'); try { final appliedIndices = await _apiClient.uploadChanges(changes); // Delete successfully synced changes (in reverse order to maintain indices) for (int i = appliedIndices.length - 1; i >= 0; i--) { await _storageService.deleteOldestChange(); } _log.info('${appliedIndices.length} changes synced successfully'); return appliedIndices.length; } catch (e) { _log.warning('Failed to upload changes: $e'); return 0; } } /// Uploads all pending annotation uploads to the server. /// /// Returns the number of successfully synced annotations. Future _uploadPendingAnnotations() async { final pendingUploads = await _storageService.readPendingAnnotationUploads(); if (pendingUploads.isEmpty) return 0; _log.info('Uploading ${pendingUploads.length} pending annotations...'); int syncedCount = 0; for (final upload in pendingUploads) { try { await _apiClient.uploadAnnotation( sheetUuid: upload.sheetUuid, page: upload.page, lastModified: upload.lastModified, annotationsJson: upload.annotationsJson, ); // Delete from pending queue after successful upload await _storageService.deletePendingAnnotationUpload(upload.key); syncedCount++; } catch (e) { _log.warning( 'Failed to upload annotation for ${upload.sheetUuid} page ${upload.page}: $e', ); // Continue with other uploads } } _log.info('$syncedCount annotations synced successfully'); return syncedCount; } /// Queues a change for sync. /// /// If online, attempts immediate upload. Otherwise, stores locally. Future queueChange(Change change) async { // Always store locally first await _storageService.writeChange(change); // Try to upload immediately if online try { final isOnline = await _apiClient.checkConnection(); if (isOnline) { final changes = await _storageService.readChangeList(); final appliedIndices = await _apiClient.uploadChanges(changes); // Delete synced changes for (int i = 0; i < appliedIndices.length; i++) { await _storageService.deleteOldestChange(); } } } catch (e) { _log.fine('Immediate upload failed, change queued for later: $e'); } } /// Queues an annotation upload. /// /// If the upload fails (e.g., offline), it will be stored for later sync. Future uploadAnnotationWithFallback({ required String sheetUuid, required int page, required String annotationsJson, required DateTime lastModified, }) async { try { await _apiClient.uploadAnnotation( sheetUuid: sheetUuid, page: page, lastModified: lastModified, annotationsJson: annotationsJson, ); return true; } catch (e) { _log.fine('Annotation upload failed, queuing for later: $e'); // Store for later upload await _storageService.writePendingAnnotationUpload( PendingAnnotationUpload( sheetUuid: sheetUuid, page: page, annotationsJson: annotationsJson, lastModified: lastModified, ), ); return false; } } /// Updates the local cache after a sheet edit. /// /// Call this after applying changes to the sheets list locally. Future updateCachedSheets(List sheets) async { await _storageService.writeCachedSheets(sheets); } /// Gets the number of pending changes. Future getPendingChangesCount() async { return _storageService.getChangeQueueLength(); } /// Gets the number of pending annotation uploads. Future getPendingAnnotationsCount() async { final uploads = await _storageService.readPendingAnnotationUploads(); return uploads.length; } /// Checks if there is any pending data to sync. Future hasPendingData() async { final changesCount = await getPendingChangesCount(); final annotationsCount = await getPendingAnnotationsCount(); return changesCount > 0 || annotationsCount > 0; } }