diff --git a/lib/core/models/change.dart b/lib/core/models/change.dart index 47b05b2..09a0a53 100644 --- a/lib/core/models/change.dart +++ b/lib/core/models/change.dart @@ -13,23 +13,35 @@ enum ChangeType { /// Represents a single pending change to be synced with the server. /// /// Changes are stored locally when offline and applied once -/// the device regains connectivity. +/// the device regains connectivity. Each change has a [createdAt] timestamp +/// that the server uses to resolve conflicts between devices. class Change { final ChangeType type; final String sheetUuid; final String value; + final DateTime createdAt; Change({ required this.type, required this.sheetUuid, required this.value, - }); + DateTime? createdAt, + }) : createdAt = createdAt ?? DateTime.now(); /// Serializes this change to a map for storage. Map toMap() => { 'type': type.index, 'sheetUuid': sheetUuid, 'value': value, + 'createdAt': createdAt.toIso8601String(), + }; + + /// Serializes this change to JSON for API requests. + Map toJson() => { + 'type': type.name, + 'sheetUuid': sheetUuid, + 'value': value, + 'createdAt': createdAt.toIso8601String(), }; /// Deserializes a change from a stored map. @@ -41,6 +53,9 @@ class Change { type: ChangeType.values[map['type']], sheetUuid: map['sheetUuid'], value: map['value'], + createdAt: map['createdAt'] != null + ? DateTime.parse(map['createdAt']) + : DateTime.now(), ); } } diff --git a/lib/core/services/annotation_sync_service.dart b/lib/core/services/annotation_sync_service.dart index 5f85439..54eb4e4 100644 --- a/lib/core/services/annotation_sync_service.dart +++ b/lib/core/services/annotation_sync_service.dart @@ -15,8 +15,8 @@ class AnnotationSyncService { AnnotationSyncService({ required ApiClient apiClient, required StorageService storageService, - }) : _apiClient = apiClient, - _storageService = storageService; + }) : _apiClient = apiClient, + _storageService = storageService; /// Downloads annotations from server and merges with local storage. /// @@ -31,8 +31,8 @@ class AnnotationSyncService { final serverAnnotations = await _apiClient.fetchAnnotations(sheetUuid); // Get all local annotations with metadata - final localAnnotations = await _storageService - .readAllAnnotationsWithMetadata(sheetUuid); + final localAnnotations = + await _storageService.readAllAnnotationsWithMetadata(sheetUuid); int updatedCount = 0; @@ -88,7 +88,7 @@ class AnnotationSyncService { /// 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). + /// If upload fails (e.g., offline), the annotation is queued for later sync. Future uploadAnnotation({ required String sheetUuid, required int page, @@ -108,11 +108,42 @@ class AnnotationSyncService { _log.info('Upload successful'); return true; } on ApiException catch (e) { - _log.warning('Failed to upload annotation: $e'); + _log.warning('Failed to upload annotation, queuing for later: $e'); + await _queueForLaterUpload( + sheetUuid: sheetUuid, + page: page, + annotationsJson: annotationsJson, + lastModified: lastModified, + ); return false; } catch (e) { - _log.warning('Unexpected error uploading annotation: $e'); + _log.warning( + 'Unexpected error uploading annotation, queuing for later: $e'); + await _queueForLaterUpload( + sheetUuid: sheetUuid, + page: page, + annotationsJson: annotationsJson, + lastModified: lastModified, + ); return false; } } + + /// Queues an annotation for later upload when connection is restored. + Future _queueForLaterUpload({ + required String sheetUuid, + required int page, + required String annotationsJson, + required DateTime lastModified, + }) async { + await _storageService.writePendingAnnotationUpload( + PendingAnnotationUpload( + sheetUuid: sheetUuid, + page: page, + annotationsJson: annotationsJson, + lastModified: lastModified, + ), + ); + _log.info('Annotation queued for later upload: $sheetUuid page $page'); + } } diff --git a/lib/core/services/api_client.dart b/lib/core/services/api_client.dart index 8641f59..215fdc5 100644 --- a/lib/core/services/api_client.dart +++ b/lib/core/services/api_client.dart @@ -6,6 +6,7 @@ import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; import 'package:path_provider/path_provider.dart'; +import '../models/change.dart'; import '../models/sheet.dart'; /// HTTP client for communicating with the Sheetless API server. @@ -217,6 +218,46 @@ class ApiClient { }); _log.info('Annotation uploaded for sheet $sheetUuid page $page'); } + + // --------------------------------------------------------------------------- + // Change Sync Operations + // --------------------------------------------------------------------------- + + /// Uploads a batch of changes to the server. + /// + /// The server will apply changes based on their [createdAt] timestamps, + /// using the newest change for each field when resolving conflicts. + /// + /// Returns the list of change indices that were successfully applied. + /// Throws [ApiException] if the request fails (e.g., offline). + Future> uploadChanges(List changes) async { + if (changes.isEmpty) return []; + + final response = await post('/api/changes/sync', { + 'changes': changes.map((c) => c.toJson()).toList(), + }); + + final data = jsonDecode(response.body); + final applied = (data['applied'] as List).cast(); + _log.info('Uploaded ${changes.length} changes, ${applied.length} applied'); + return applied; + } + + /// Checks if the server is reachable. + /// + /// Returns true if the server responds, false otherwise. + Future checkConnection() async { + try { + final url = Uri.parse('$baseUrl/api/health'); + final response = await http + .get(url, headers: _buildHeaders()) + .timeout(const Duration(seconds: 5)); + return response.statusCode == 200; + } catch (e) { + _log.fine('Connection check failed: $e'); + return false; + } + } } /// Represents an annotation from the server. diff --git a/lib/core/services/storage_service.dart b/lib/core/services/storage_service.dart index 2655d83..57585fa 100644 --- a/lib/core/services/storage_service.dart +++ b/lib/core/services/storage_service.dart @@ -1,7 +1,10 @@ +import 'dart:convert'; + import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:hive/hive.dart'; import 'package:sheetless/core/models/change.dart'; import 'package:sheetless/core/models/config.dart'; +import 'package:sheetless/core/models/sheet.dart'; /// Keys for secure storage (credentials and tokens). enum SecureStorageKey { url, jwt, email } @@ -29,6 +32,45 @@ class StoredAnnotation { } } +/// Service for managing local storage operations. +/// +/// Uses [FlutterSecureStorage] for sensitive data (credentials, tokens) +/// and [Hive] for general app data (config, sheet access times, change queue, +/// and PDF annotations). +/// Data class for a pending annotation upload. +class PendingAnnotationUpload { + final String sheetUuid; + final int page; + final String annotationsJson; + final DateTime lastModified; + + PendingAnnotationUpload({ + required this.sheetUuid, + required this.page, + required this.annotationsJson, + required this.lastModified, + }); + + Map toMap() => { + 'sheetUuid': sheetUuid, + 'page': page, + 'annotationsJson': annotationsJson, + 'lastModified': lastModified.toIso8601String(), + }; + + factory PendingAnnotationUpload.fromMap(Map map) { + return PendingAnnotationUpload( + sheetUuid: map['sheetUuid'] as String, + page: map['page'] as int, + annotationsJson: map['annotationsJson'] as String, + lastModified: DateTime.parse(map['lastModified'] as String), + ); + } + + /// Unique key for deduplication (newer uploads replace older ones). + String get key => '${sheetUuid}_page_$page'; +} + /// Service for managing local storage operations. /// /// Uses [FlutterSecureStorage] for sensitive data (credentials, tokens) @@ -40,6 +82,8 @@ class StorageService { static const String _configBox = 'config'; static const String _changeQueueBox = 'changeQueue'; static const String _annotationsBox = 'annotations'; + static const String _sheetsBox = 'sheets'; + static const String _pendingAnnotationsBox = 'pendingAnnotations'; late final FlutterSecureStorage _secureStorage; @@ -317,4 +361,95 @@ class StorageService { await box.delete(key); } } + + // --------------------------------------------------------------------------- + // Sheets Cache (Offline Support) + // --------------------------------------------------------------------------- + + /// Reads cached sheets from local storage. + /// + /// Returns an empty list if no cached sheets exist. + Future> readCachedSheets() async { + final box = await Hive.openBox(_sheetsBox); + final sheetsJson = box.get('sheets'); + + if (sheetsJson == null) return []; + + final List sheetsList = jsonDecode(sheetsJson as String); + return sheetsList + .map((json) => Sheet.fromJson(json as Map)) + .toList(); + } + + /// Caches the sheets list to local storage. + Future writeCachedSheets(List sheets) async { + final box = await Hive.openBox(_sheetsBox); + final sheetsJson = jsonEncode(sheets.map((s) => s.toJson()).toList()); + await box.put('sheets', sheetsJson); + } + + // --------------------------------------------------------------------------- + // Pending Annotation Uploads (Offline Support) + // --------------------------------------------------------------------------- + + /// Adds or updates a pending annotation upload. + /// + /// If an upload for the same sheet/page already exists, it will be replaced + /// with the newer version. + Future writePendingAnnotationUpload( + PendingAnnotationUpload upload, + ) async { + final box = await Hive.openBox(_pendingAnnotationsBox); + await box.put(upload.key, upload.toMap()); + } + + /// Reads all pending annotation uploads. + Future> readPendingAnnotationUploads() async { + final box = await Hive.openBox(_pendingAnnotationsBox); + final uploads = []; + + for (final value in box.values) { + uploads.add(PendingAnnotationUpload.fromMap(value as Map)); + } + + return uploads; + } + + /// Removes a pending annotation upload after successful sync. + Future deletePendingAnnotationUpload(String key) async { + final box = await Hive.openBox(_pendingAnnotationsBox); + await box.delete(key); + } + + /// Checks if there are any pending annotation uploads. + Future hasPendingAnnotationUploads() async { + final box = await Hive.openBox(_pendingAnnotationsBox); + return box.isNotEmpty; + } + + // --------------------------------------------------------------------------- + // Change Queue Enhancements + // --------------------------------------------------------------------------- + + /// Returns the number of pending changes. + Future getChangeQueueLength() async { + final box = await Hive.openBox(_changeQueueBox); + return box.length; + } + + /// Clears all pending changes. + /// + /// Use with caution - only call after all changes are synced. + Future clearChangeQueue() async { + final box = await Hive.openBox(_changeQueueBox); + await box.clear(); + } + + /// Gets all changes as a list (for batch upload). + Future> readChangeList() async { + final box = await Hive.openBox(_changeQueueBox); + return box.values + .map((map) => Change.fromMap(map as Map)) + .toList(); + } } diff --git a/lib/core/services/sync_service.dart b/lib/core/services/sync_service.dart new file mode 100644 index 0000000..47e40bd --- /dev/null +++ b/lib/core/services/sync_service.dart @@ -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 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; + } +} diff --git a/lib/features/home/home_page.dart b/lib/features/home/home_page.dart index 796d333..2dd9b74 100644 --- a/lib/features/home/home_page.dart +++ b/lib/features/home/home_page.dart @@ -6,6 +6,7 @@ import 'package:sheetless/core/models/config.dart'; import 'package:sheetless/core/models/sheet.dart'; import 'package:sheetless/core/services/api_client.dart'; import 'package:sheetless/core/services/storage_service.dart'; +import 'package:sheetless/core/services/sync_service.dart'; import '../../app.dart'; import '../auth/login_page.dart'; @@ -34,8 +35,11 @@ class _HomePageState extends State with RouteAware { final _storageService = StorageService(); ApiClient? _apiClient; - late Future> _sheetsFuture; + SyncService? _syncService; + late Future _syncFuture; + List _sheets = []; bool _isShuffling = false; + bool _isOnline = true; String? _appName; String? _appVersion; @@ -52,7 +56,7 @@ class _HomePageState extends State with RouteAware { }); _loadAppInfo(); - _sheetsFuture = _loadSheets(); + _syncFuture = _loadSheets(); } @override @@ -90,19 +94,29 @@ class _HomePageState extends State with RouteAware { }); } - Future> _loadSheets() async { + Future _loadSheets() async { final url = await _storageService.readSecure(SecureStorageKey.url); final jwt = await _storageService.readSecure(SecureStorageKey.jwt); _apiClient = ApiClient(baseUrl: url!, token: jwt); + _syncService = SyncService( + apiClient: _apiClient!, + storageService: _storageService, + ); - final sheets = await _apiClient!.fetchSheets(); - _log.info('${sheets.length} sheets fetched'); + // Perform sync (fetches sheets, uploads pending changes/annotations) + final result = await _syncService!.sync(); + _log.info( + '${result.sheets.length} sheets loaded (online: ${result.isOnline}, ' + 'changes synced: ${result.changesSynced}, ' + 'annotations synced: ${result.annotationsSynced})', + ); - final sortedSheets = await _sortSheetsByRecency(sheets); - _log.info('${sortedSheets.length} sheets sorted'); + // Sort and store sheets + _sheets = await _sortSheetsByRecency(result.sheets); + _isOnline = result.isOnline; - return sortedSheets; + return result; } Future> _sortSheetsByRecency(List sheets) async { @@ -128,7 +142,7 @@ class _HomePageState extends State with RouteAware { Future _refreshSheets() async { setState(() { - _sheetsFuture = _loadSheets(); + _syncFuture = _loadSheets(); }); } @@ -137,12 +151,10 @@ class _HomePageState extends State with RouteAware { // --------------------------------------------------------------------------- void _handleShuffleChanged(bool enabled) async { - final sheets = await _sheetsFuture; - if (enabled) { - sheets.shuffle(); + _sheets.shuffle(); } else { - await _sortSheetsByRecency(sheets); + await _sortSheetsByRecency(_sheets); } setState(() => _isShuffling = enabled); @@ -181,7 +193,16 @@ class _HomePageState extends State with RouteAware { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Sheetless')), + appBar: AppBar( + title: const Text('Sheetless'), + actions: [ + if (!_isOnline) + const Padding( + padding: EdgeInsets.only(right: 8), + child: Icon(Icons.cloud_off, color: Colors.orange), + ), + ], + ), endDrawer: AppDrawer( isShuffling: _isShuffling, onShuffleChanged: _handleShuffleChanged, @@ -194,8 +215,8 @@ class _HomePageState extends State with RouteAware { } Widget _buildBody() { - return FutureBuilder>( - future: _sheetsFuture, + return FutureBuilder( + future: _syncFuture, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); @@ -208,8 +229,9 @@ class _HomePageState extends State with RouteAware { if (snapshot.hasData) { return SheetsList( - sheets: snapshot.data!, + sheets: _sheets, onSheetSelected: _openSheet, + syncService: _syncService!, ); } diff --git a/lib/features/home/widgets/sheets_list.dart b/lib/features/home/widgets/sheets_list.dart index 8072163..5ce2d7a 100644 --- a/lib/features/home/widgets/sheets_list.dart +++ b/lib/features/home/widgets/sheets_list.dart @@ -4,7 +4,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:sheetless/core/models/change.dart'; import 'package:sheetless/core/models/sheet.dart'; -import 'package:sheetless/core/services/storage_service.dart'; +import 'package:sheetless/core/services/sync_service.dart'; import '../../../shared/widgets/edit_sheet_bottom_sheet.dart'; import 'sheet_list_item.dart'; @@ -19,11 +19,13 @@ import 'sheet_search_bar.dart'; class SheetsList extends StatefulWidget { final List sheets; final ValueSetter onSheetSelected; + final SyncService syncService; const SheetsList({ super.key, required this.sheets, required this.onSheetSelected, + required this.syncService, }); @override @@ -33,7 +35,6 @@ class SheetsList extends StatefulWidget { class _SheetsListState extends State { static const _searchDebounceMs = 500; - final _storageService = StorageService(); final _searchController = TextEditingController(); Timer? _debounceTimer; late List _filteredSheets; @@ -111,9 +112,9 @@ class _SheetsListState extends State { } void _handleSheetEdit(Sheet sheet, String newName, String newComposer) { - // Queue changes for server sync + // Queue changes for server sync (with timestamp for conflict resolution) if (newName != sheet.name) { - _storageService.writeChange( + widget.syncService.queueChange( Change( type: ChangeType.sheetNameChange, sheetUuid: sheet.uuid, @@ -122,7 +123,7 @@ class _SheetsListState extends State { ); } if (newComposer != sheet.composerName) { - _storageService.writeChange( + widget.syncService.queueChange( Change( type: ChangeType.composerNameChange, sheetUuid: sheet.uuid, @@ -136,6 +137,9 @@ class _SheetsListState extends State { sheet.name = newName; sheet.composerName = newComposer; }); + + // Update cached sheets + widget.syncService.updateCachedSheets(widget.sheets); } // ---------------------------------------------------------------------------