Implement annotation syncing to and from server

This commit is contained in:
2026-02-06 16:05:55 +01:00
parent e5c71c9261
commit 9a11e42571
4 changed files with 348 additions and 9 deletions

View File

@@ -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<void> 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<bool> 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;
}
}
}

View File

@@ -183,6 +183,61 @@ class ApiClient {
_log.info('PDF cached at: ${cachedFile.path}'); _log.info('PDF cached at: ${cachedFile.path}');
return cachedFile; 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<List<ServerAnnotation>> fetchAnnotations(String sheetUuid) async {
final response = await get('/api/sheets/$sheetUuid/annotations');
final data = jsonDecode(response.body) as List<dynamic>;
return data
.map((item) => ServerAnnotation.fromJson(item as Map<String, dynamic>))
.toList();
}
/// Uploads annotations for a specific page of a sheet.
///
/// The [lastModified] should be the current time when the annotation was saved.
Future<void> 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<String, dynamic> 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. /// Exception thrown when an API request fails.

View File

@@ -6,6 +6,29 @@ import 'package:sheetless/core/models/config.dart';
/// Keys for secure storage (credentials and tokens). /// Keys for secure storage (credentials and tokens).
enum SecureStorageKey { url, jwt, email } 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<String, dynamic> toMap() => {
'annotationsJson': annotationsJson,
'lastModified': lastModified.toIso8601String(),
};
factory StoredAnnotation.fromMap(Map<dynamic, dynamic> map) {
return StoredAnnotation(
annotationsJson: map['annotationsJson'] as String,
lastModified: DateTime.parse(map['lastModified'] as String),
);
}
}
/// Service for managing local storage operations. /// Service for managing local storage operations.
/// ///
/// Uses [FlutterSecureStorage] for sensitive data (credentials, tokens) /// Uses [FlutterSecureStorage] for sensitive data (credentials, tokens)
@@ -134,16 +157,70 @@ class StorageService {
/// Returns the JSON string of annotations, or null if none exist. /// Returns the JSON string of annotations, or null if none exist.
Future<String?> readAnnotations(String sheetUuid, int pageNumber) async { Future<String?> readAnnotations(String sheetUuid, int pageNumber) async {
final box = await Hive.openBox(_annotationsBox); 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<StoredAnnotation?> 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. /// Writes annotations for a specific sheet page.
/// ///
/// Pass null or empty string to delete annotations for that page. /// Pass null or empty string to delete annotations for that page.
/// Automatically sets lastModified to current time.
Future<void> writeAnnotations( Future<void> writeAnnotations(
String sheetUuid, String sheetUuid,
int pageNumber, int pageNumber,
String? annotationsJson, 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<void> writeAnnotationsWithMetadata(
String sheetUuid,
int pageNumber,
String? annotationsJson,
DateTime lastModified,
) async { ) async {
final box = await Hive.openBox(_annotationsBox); final box = await Hive.openBox(_annotationsBox);
final key = _annotationKey(sheetUuid, pageNumber); final key = _annotationKey(sheetUuid, pageNumber);
@@ -151,7 +228,11 @@ class StorageService {
if (annotationsJson == null || annotationsJson.isEmpty) { if (annotationsJson == null || annotationsJson.isEmpty) {
await box.delete(key); await box.delete(key);
} else { } 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); final pageNumber = int.tryParse(pageStr);
if (pageNumber != null) { if (pageNumber != null) {
final value = box.get(key); final value = box.get(key);
if (value != null && value is String && value.isNotEmpty) { if (value != null) {
// Handle legacy format (plain string) and new format (map)
if (value is String && value.isNotEmpty) {
result[pageNumber] = value; 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<Map<int, StoredAnnotation>> readAllAnnotationsWithMetadata(
String sheetUuid,
) async {
final box = await Hive.openBox(_annotationsBox);
final prefix = '${sheetUuid}_page_';
final result = <int, StoredAnnotation>{};
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;
}
} }
} }
} }

View File

@@ -6,6 +6,7 @@ import 'package:logging/logging.dart';
import 'package:pdfrx/pdfrx.dart'; import 'package:pdfrx/pdfrx.dart';
import 'package:sheetless/core/models/config.dart'; import 'package:sheetless/core/models/config.dart';
import 'package:sheetless/core/models/sheet.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/api_client.dart';
import 'package:sheetless/core/services/storage_service.dart'; import 'package:sheetless/core/services/storage_service.dart';
@@ -35,6 +36,7 @@ class _SheetViewerPageState extends State<SheetViewerPage>
with FullScreenListener { with FullScreenListener {
final _log = Logger('SheetViewerPage'); final _log = Logger('SheetViewerPage');
final _storageService = StorageService(); final _storageService = StorageService();
late final AnnotationSyncService _syncService;
PdfDocument? _document; PdfDocument? _document;
late Future<bool> _documentLoaded; late Future<bool> _documentLoaded;
@@ -52,6 +54,12 @@ class _SheetViewerPageState extends State<SheetViewerPage>
void initState() { void initState() {
super.initState(); super.initState();
// Initialize sync service
_syncService = AnnotationSyncService(
apiClient: widget.apiClient,
storageService: _storageService,
);
// Initialize drawing controllers // Initialize drawing controllers
_leftDrawingController = DrawingController(maxHistorySteps: 50); _leftDrawingController = DrawingController(maxHistorySteps: 50);
_rightDrawingController = DrawingController(maxHistorySteps: 50); _rightDrawingController = DrawingController(maxHistorySteps: 50);
@@ -94,6 +102,9 @@ class _SheetViewerPageState extends State<SheetViewerPage>
_totalPages = _document!.pages.length; _totalPages = _document!.pages.length;
}); });
// Sync annotations from server (downloads newer versions)
await _syncService.syncAnnotationsFromServer(widget.sheet.uuid);
// Load annotations for current page(s) // Load annotations for current page(s)
await _loadAnnotationsForCurrentPages(); await _loadAnnotationsForCurrentPages();
@@ -131,24 +142,52 @@ class _SheetViewerPageState extends State<SheetViewerPage>
} }
} }
/// Saves the current page(s) annotations to storage. /// Saves the current page(s) annotations to storage and uploads to server.
Future<void> _saveCurrentAnnotations() async { Future<void> _saveCurrentAnnotations() async {
final now = DateTime.now();
// Save left page // Save left page
final leftJson = _leftDrawingController.toJsonString(); final leftJson = _leftDrawingController.toJsonString();
await _storageService.writeAnnotations( final leftHasContent = leftJson.isNotEmpty && leftJson != '[]';
await _storageService.writeAnnotationsWithMetadata(
widget.sheet.uuid, widget.sheet.uuid,
_currentPage, _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) // Save right page (two-page mode)
if (widget.config.twoPageMode && _currentPage < _totalPages) { if (widget.config.twoPageMode && _currentPage < _totalPages) {
final rightJson = _rightDrawingController.toJsonString(); final rightJson = _rightDrawingController.toJsonString();
await _storageService.writeAnnotations( final rightHasContent = rightJson.isNotEmpty && rightJson != '[]';
await _storageService.writeAnnotationsWithMetadata(
widget.sheet.uuid, widget.sheet.uuid,
_currentPage + 1, _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,
);
}
} }
} }