Implement offline mode
This commit is contained in:
277
lib/core/services/sync_service.dart
Normal file
277
lib/core/services/sync_service.dart
Normal file
@@ -0,0 +1,277 @@
|
||||
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<Sheet> 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<SyncResult> sync() async {
|
||||
final isOnline = await _apiClient.checkConnection();
|
||||
|
||||
if (isOnline) {
|
||||
return _syncOnline();
|
||||
} else {
|
||||
return _syncOffline();
|
||||
}
|
||||
}
|
||||
|
||||
/// Online sync: fetch from server, upload pending data.
|
||||
Future<SyncResult> _syncOnline() async {
|
||||
_log.info('Online sync starting...');
|
||||
|
||||
int changesSynced = 0;
|
||||
int annotationsSynced = 0;
|
||||
|
||||
// 1. Fetch fresh sheets from server
|
||||
List<Sheet> 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<SyncResult> _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<int> _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<int> _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<void> 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<bool> 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<void> updateCachedSheets(List<Sheet> sheets) async {
|
||||
await _storageService.writeCachedSheets(sheets);
|
||||
}
|
||||
|
||||
/// Gets the number of pending changes.
|
||||
Future<int> getPendingChangesCount() async {
|
||||
return _storageService.getChangeQueueLength();
|
||||
}
|
||||
|
||||
/// Gets the number of pending annotation uploads.
|
||||
Future<int> getPendingAnnotationsCount() async {
|
||||
final uploads = await _storageService.readPendingAnnotationUploads();
|
||||
return uploads.length;
|
||||
}
|
||||
|
||||
/// Checks if there is any pending data to sync.
|
||||
Future<bool> hasPendingData() async {
|
||||
final changesCount = await getPendingChangesCount();
|
||||
final annotationsCount = await getPendingAnnotationsCount();
|
||||
return changesCount > 0 || annotationsCount > 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user