Implement offline mode

This commit is contained in:
2026-02-06 16:41:58 +01:00
parent 58157a2e6e
commit d0fd96a2f5
7 changed files with 556 additions and 31 deletions

View File

@@ -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<String, dynamic> toMap() => {
'type': type.index,
'sheetUuid': sheetUuid,
'value': value,
'createdAt': createdAt.toIso8601String(),
};
/// Serializes this change to JSON for API requests.
Map<String, dynamic> 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(),
);
}
}

View File

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

View File

@@ -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<List<int>> uploadChanges(List<Change> 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<dynamic>).cast<int>();
_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<bool> 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.

View File

@@ -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<String, dynamic> toMap() => {
'sheetUuid': sheetUuid,
'page': page,
'annotationsJson': annotationsJson,
'lastModified': lastModified.toIso8601String(),
};
factory PendingAnnotationUpload.fromMap(Map<dynamic, dynamic> 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<List<Sheet>> readCachedSheets() async {
final box = await Hive.openBox(_sheetsBox);
final sheetsJson = box.get('sheets');
if (sheetsJson == null) return [];
final List<dynamic> sheetsList = jsonDecode(sheetsJson as String);
return sheetsList
.map((json) => Sheet.fromJson(json as Map<String, dynamic>))
.toList();
}
/// Caches the sheets list to local storage.
Future<void> writeCachedSheets(List<Sheet> 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<void> writePendingAnnotationUpload(
PendingAnnotationUpload upload,
) async {
final box = await Hive.openBox(_pendingAnnotationsBox);
await box.put(upload.key, upload.toMap());
}
/// Reads all pending annotation uploads.
Future<List<PendingAnnotationUpload>> readPendingAnnotationUploads() async {
final box = await Hive.openBox(_pendingAnnotationsBox);
final uploads = <PendingAnnotationUpload>[];
for (final value in box.values) {
uploads.add(PendingAnnotationUpload.fromMap(value as Map));
}
return uploads;
}
/// Removes a pending annotation upload after successful sync.
Future<void> deletePendingAnnotationUpload(String key) async {
final box = await Hive.openBox(_pendingAnnotationsBox);
await box.delete(key);
}
/// Checks if there are any pending annotation uploads.
Future<bool> hasPendingAnnotationUploads() async {
final box = await Hive.openBox(_pendingAnnotationsBox);
return box.isNotEmpty;
}
// ---------------------------------------------------------------------------
// Change Queue Enhancements
// ---------------------------------------------------------------------------
/// Returns the number of pending changes.
Future<int> 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<void> clearChangeQueue() async {
final box = await Hive.openBox(_changeQueueBox);
await box.clear();
}
/// Gets all changes as a list (for batch upload).
Future<List<Change>> readChangeList() async {
final box = await Hive.openBox(_changeQueueBox);
return box.values
.map((map) => Change.fromMap(map as Map<dynamic, dynamic>))
.toList();
}
}

View 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;
}
}