Implement annotation syncing to and from server
This commit is contained in:
118
lib/core/services/annotation_sync_service.dart
Normal file
118
lib/core/services/annotation_sync_service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user