From 9a11e42571cdaf8441fdf5c200b63dd2e2f25f76 Mon Sep 17 00:00:00 2001 From: Julian Mutter Date: Fri, 6 Feb 2026 16:05:55 +0100 Subject: [PATCH] Implement annotation syncing to and from server --- .../services/annotation_sync_service.dart | 118 +++++++++++++++ lib/core/services/api_client.dart | 55 +++++++ lib/core/services/storage_service.dart | 135 +++++++++++++++++- .../sheet_viewer/sheet_viewer_page.dart | 49 ++++++- 4 files changed, 348 insertions(+), 9 deletions(-) create mode 100644 lib/core/services/annotation_sync_service.dart diff --git a/lib/core/services/annotation_sync_service.dart b/lib/core/services/annotation_sync_service.dart new file mode 100644 index 0000000..5f85439 --- /dev/null +++ b/lib/core/services/annotation_sync_service.dart @@ -0,0 +1,118 @@ +import 'package:logging/logging.dart'; + +import 'api_client.dart'; +import 'storage_service.dart'; + +/// Service for synchronizing annotations between local storage and server. +/// +/// Handles downloading annotations on sheet open and uploading on save, +/// comparing timestamps to determine which version is newer. +class AnnotationSyncService { + final _log = Logger('AnnotationSyncService'); + final ApiClient _apiClient; + final StorageService _storageService; + + AnnotationSyncService({ + required ApiClient apiClient, + required StorageService storageService, + }) : _apiClient = apiClient, + _storageService = storageService; + + /// Downloads annotations from server and merges with local storage. + /// + /// For each page, compares server's lastModified with local lastModified. + /// If server is newer, overwrites local. Local annotations that are newer + /// are preserved. + Future syncFromServer(String sheetUuid) async { + try { + _log.info('Syncing annotations from server for sheet $sheetUuid'); + + // Fetch all annotations from server + final serverAnnotations = await _apiClient.fetchAnnotations(sheetUuid); + + // Get all local annotations with metadata + final localAnnotations = await _storageService + .readAllAnnotationsWithMetadata(sheetUuid); + + int updatedCount = 0; + + // Process each server annotation + for (final serverAnnotation in serverAnnotations) { + final page = serverAnnotation.page; + final localAnnotation = localAnnotations[page]; + + bool shouldUpdate = false; + + if (localAnnotation == null) { + // No local annotation - use server version + shouldUpdate = true; + _log.fine('Page $page: No local annotation, using server version'); + } else if (serverAnnotation.lastModified.isAfter( + localAnnotation.lastModified, + )) { + // Server is newer - overwrite local + shouldUpdate = true; + _log.fine( + 'Page $page: Server is newer ' + '(server: ${serverAnnotation.lastModified}, ' + 'local: ${localAnnotation.lastModified})', + ); + } else { + _log.fine( + 'Page $page: Local is newer or same, keeping local version', + ); + } + + if (shouldUpdate) { + await _storageService.writeAnnotationsWithMetadata( + sheetUuid, + page, + serverAnnotation.annotationsJson, + serverAnnotation.lastModified, + ); + updatedCount++; + } + } + + _log.info( + 'Sync complete: $updatedCount pages updated from server ' + '(${serverAnnotations.length} total on server)', + ); + } on ApiException catch (e) { + _log.warning('Failed to sync annotations from server: $e'); + } catch (e) { + _log.warning('Unexpected error syncing annotations: $e'); + } + } + + /// Uploads a single page's annotation to the server. + /// + /// Called when annotations are saved (e.g., exiting paint mode). + /// Silently fails if upload fails (allows offline usage). + Future uploadAnnotation({ + required String sheetUuid, + required int page, + required String annotationsJson, + required DateTime lastModified, + }) async { + try { + _log.info('Uploading annotation for sheet $sheetUuid page $page'); + + await _apiClient.uploadAnnotation( + sheetUuid: sheetUuid, + page: page, + lastModified: lastModified, + annotationsJson: annotationsJson, + ); + + _log.info('Upload successful'); + return true; + } on ApiException catch (e) { + _log.warning('Failed to upload annotation: $e'); + return false; + } catch (e) { + _log.warning('Unexpected error uploading annotation: $e'); + return false; + } + } +} diff --git a/lib/core/services/api_client.dart b/lib/core/services/api_client.dart index e4697c9..8641f59 100644 --- a/lib/core/services/api_client.dart +++ b/lib/core/services/api_client.dart @@ -183,6 +183,61 @@ class ApiClient { _log.info('PDF cached at: ${cachedFile.path}'); return cachedFile; } + + // --------------------------------------------------------------------------- + // Annotation Operations + // --------------------------------------------------------------------------- + + /// Fetches all annotations for a sheet from the server. + /// + /// Returns a list of [ServerAnnotation] objects containing page number, + /// lastModified timestamp, and the annotations JSON string. + Future> fetchAnnotations(String sheetUuid) async { + final response = await get('/api/sheets/$sheetUuid/annotations'); + final data = jsonDecode(response.body) as List; + + return data + .map((item) => ServerAnnotation.fromJson(item as Map)) + .toList(); + } + + /// Uploads annotations for a specific page of a sheet. + /// + /// The [lastModified] should be the current time when the annotation was saved. + Future uploadAnnotation({ + required String sheetUuid, + required int page, + required DateTime lastModified, + required String annotationsJson, + }) async { + await post('/api/sheets/$sheetUuid/annotations', { + 'page': page, + 'lastModified': lastModified.toIso8601String(), + 'annotations': annotationsJson, + }); + _log.info('Annotation uploaded for sheet $sheetUuid page $page'); + } +} + +/// Represents an annotation from the server. +class ServerAnnotation { + final int page; + final DateTime lastModified; + final String annotationsJson; + + ServerAnnotation({ + required this.page, + required this.lastModified, + required this.annotationsJson, + }); + + factory ServerAnnotation.fromJson(Map json) { + return ServerAnnotation( + page: json['page'] as int, + lastModified: DateTime.parse(json['lastModified'] as String), + annotationsJson: json['annotations'] as String, + ); + } } /// Exception thrown when an API request fails. diff --git a/lib/core/services/storage_service.dart b/lib/core/services/storage_service.dart index ed9b282..2655d83 100644 --- a/lib/core/services/storage_service.dart +++ b/lib/core/services/storage_service.dart @@ -6,6 +6,29 @@ import 'package:sheetless/core/models/config.dart'; /// Keys for secure storage (credentials and tokens). enum SecureStorageKey { url, jwt, email } +/// Data class for storing annotations with metadata. +class StoredAnnotation { + final String annotationsJson; + final DateTime lastModified; + + StoredAnnotation({ + required this.annotationsJson, + required this.lastModified, + }); + + Map toMap() => { + 'annotationsJson': annotationsJson, + 'lastModified': lastModified.toIso8601String(), + }; + + factory StoredAnnotation.fromMap(Map map) { + return StoredAnnotation( + annotationsJson: map['annotationsJson'] as String, + lastModified: DateTime.parse(map['lastModified'] as String), + ); + } +} + /// Service for managing local storage operations. /// /// Uses [FlutterSecureStorage] for sensitive data (credentials, tokens) @@ -134,16 +157,70 @@ class StorageService { /// Returns the JSON string of annotations, or null if none exist. Future readAnnotations(String sheetUuid, int pageNumber) async { final box = await Hive.openBox(_annotationsBox); - return box.get(_annotationKey(sheetUuid, pageNumber)); + final value = box.get(_annotationKey(sheetUuid, pageNumber)); + + // Handle legacy format (plain string) and new format (map with metadata) + if (value == null) return null; + if (value is String) return value; + if (value is Map) { + final stored = StoredAnnotation.fromMap(value); + return stored.annotationsJson; + } + return null; + } + + /// Reads annotations with metadata for a specific sheet page. + /// + /// Returns [StoredAnnotation] with annotations and lastModified, or null if none exist. + Future readAnnotationsWithMetadata( + String sheetUuid, + int pageNumber, + ) async { + final box = await Hive.openBox(_annotationsBox); + final value = box.get(_annotationKey(sheetUuid, pageNumber)); + + if (value == null) return null; + + // Handle legacy format (plain string) - treat as very old + if (value is String) { + return StoredAnnotation( + annotationsJson: value, + lastModified: DateTime.fromMillisecondsSinceEpoch(0), + ); + } + + if (value is Map) { + return StoredAnnotation.fromMap(value); + } + + return null; } /// Writes annotations for a specific sheet page. /// /// Pass null or empty string to delete annotations for that page. + /// Automatically sets lastModified to current time. Future writeAnnotations( String sheetUuid, int pageNumber, String? annotationsJson, + ) async { + await writeAnnotationsWithMetadata( + sheetUuid, + pageNumber, + annotationsJson, + DateTime.now(), + ); + } + + /// Writes annotations with a specific lastModified timestamp. + /// + /// Used when syncing from server to preserve server's timestamp. + Future writeAnnotationsWithMetadata( + String sheetUuid, + int pageNumber, + String? annotationsJson, + DateTime lastModified, ) async { final box = await Hive.openBox(_annotationsBox); final key = _annotationKey(sheetUuid, pageNumber); @@ -151,7 +228,11 @@ class StorageService { if (annotationsJson == null || annotationsJson.isEmpty) { await box.delete(key); } else { - await box.put(key, annotationsJson); + final stored = StoredAnnotation( + annotationsJson: annotationsJson, + lastModified: lastModified, + ); + await box.put(key, stored.toMap()); } } @@ -169,8 +250,54 @@ class StorageService { final pageNumber = int.tryParse(pageStr); if (pageNumber != null) { final value = box.get(key); - if (value != null && value is String && value.isNotEmpty) { - result[pageNumber] = value; + if (value != null) { + // Handle legacy format (plain string) and new format (map) + if (value is String && value.isNotEmpty) { + result[pageNumber] = value; + } else if (value is Map) { + final stored = StoredAnnotation.fromMap(value); + if (stored.annotationsJson.isNotEmpty) { + result[pageNumber] = stored.annotationsJson; + } + } + } + } + } + } + + return result; + } + + /// Reads all annotations with metadata for a sheet (all pages). + /// + /// Returns a map of page number to [StoredAnnotation]. + Future> readAllAnnotationsWithMetadata( + String sheetUuid, + ) async { + final box = await Hive.openBox(_annotationsBox); + final prefix = '${sheetUuid}_page_'; + final result = {}; + + for (final key in box.keys) { + if (key is String && key.startsWith(prefix)) { + final pageStr = key.substring(prefix.length); + final pageNumber = int.tryParse(pageStr); + if (pageNumber != null) { + final value = box.get(key); + if (value != null) { + StoredAnnotation? stored; + // Handle legacy format (plain string) and new format (map) + if (value is String && value.isNotEmpty) { + stored = StoredAnnotation( + annotationsJson: value, + lastModified: DateTime.fromMillisecondsSinceEpoch(0), + ); + } else if (value is Map) { + stored = StoredAnnotation.fromMap(value); + } + if (stored != null && stored.annotationsJson.isNotEmpty) { + result[pageNumber] = stored; + } } } } diff --git a/lib/features/sheet_viewer/sheet_viewer_page.dart b/lib/features/sheet_viewer/sheet_viewer_page.dart index d253d57..d11d99a 100644 --- a/lib/features/sheet_viewer/sheet_viewer_page.dart +++ b/lib/features/sheet_viewer/sheet_viewer_page.dart @@ -6,6 +6,7 @@ import 'package:logging/logging.dart'; import 'package:pdfrx/pdfrx.dart'; import 'package:sheetless/core/models/config.dart'; import 'package:sheetless/core/models/sheet.dart'; +import 'package:sheetless/core/services/annotation_sync_service.dart'; import 'package:sheetless/core/services/api_client.dart'; import 'package:sheetless/core/services/storage_service.dart'; @@ -35,6 +36,7 @@ class _SheetViewerPageState extends State with FullScreenListener { final _log = Logger('SheetViewerPage'); final _storageService = StorageService(); + late final AnnotationSyncService _syncService; PdfDocument? _document; late Future _documentLoaded; @@ -52,6 +54,12 @@ class _SheetViewerPageState extends State void initState() { super.initState(); + // Initialize sync service + _syncService = AnnotationSyncService( + apiClient: widget.apiClient, + storageService: _storageService, + ); + // Initialize drawing controllers _leftDrawingController = DrawingController(maxHistorySteps: 50); _rightDrawingController = DrawingController(maxHistorySteps: 50); @@ -94,6 +102,9 @@ class _SheetViewerPageState extends State _totalPages = _document!.pages.length; }); + // Sync annotations from server (downloads newer versions) + await _syncService.syncAnnotationsFromServer(widget.sheet.uuid); + // Load annotations for current page(s) await _loadAnnotationsForCurrentPages(); @@ -131,24 +142,52 @@ class _SheetViewerPageState extends State } } - /// Saves the current page(s) annotations to storage. + /// Saves the current page(s) annotations to storage and uploads to server. Future _saveCurrentAnnotations() async { + final now = DateTime.now(); + // Save left page final leftJson = _leftDrawingController.toJsonString(); - await _storageService.writeAnnotations( + final leftHasContent = leftJson.isNotEmpty && leftJson != '[]'; + + await _storageService.writeAnnotationsWithMetadata( widget.sheet.uuid, _currentPage, - leftJson.isEmpty || leftJson == '[]' ? null : leftJson, + leftHasContent ? leftJson : null, + now, ); + // Upload left page to server + if (leftHasContent) { + _syncService.uploadAnnotation( + sheetUuid: widget.sheet.uuid, + page: _currentPage, + annotationsJson: leftJson, + lastModified: now, + ); + } + // Save right page (two-page mode) if (widget.config.twoPageMode && _currentPage < _totalPages) { final rightJson = _rightDrawingController.toJsonString(); - await _storageService.writeAnnotations( + final rightHasContent = rightJson.isNotEmpty && rightJson != '[]'; + + await _storageService.writeAnnotationsWithMetadata( widget.sheet.uuid, _currentPage + 1, - rightJson.isEmpty || rightJson == '[]' ? null : rightJson, + rightHasContent ? rightJson : null, + now, ); + + // Upload right page to server + if (rightHasContent) { + _syncService.uploadAnnotation( + sheetUuid: widget.sheet.uuid, + page: _currentPage + 1, + annotationsJson: rightJson, + lastModified: now, + ); + } } }