Implement offline mode
This commit is contained in:
@@ -13,23 +13,35 @@ enum ChangeType {
|
|||||||
/// Represents a single pending change to be synced with the server.
|
/// Represents a single pending change to be synced with the server.
|
||||||
///
|
///
|
||||||
/// Changes are stored locally when offline and applied once
|
/// 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 {
|
class Change {
|
||||||
final ChangeType type;
|
final ChangeType type;
|
||||||
final String sheetUuid;
|
final String sheetUuid;
|
||||||
final String value;
|
final String value;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
Change({
|
Change({
|
||||||
required this.type,
|
required this.type,
|
||||||
required this.sheetUuid,
|
required this.sheetUuid,
|
||||||
required this.value,
|
required this.value,
|
||||||
});
|
DateTime? createdAt,
|
||||||
|
}) : createdAt = createdAt ?? DateTime.now();
|
||||||
|
|
||||||
/// Serializes this change to a map for storage.
|
/// Serializes this change to a map for storage.
|
||||||
Map<String, dynamic> toMap() => {
|
Map<String, dynamic> toMap() => {
|
||||||
'type': type.index,
|
'type': type.index,
|
||||||
'sheetUuid': sheetUuid,
|
'sheetUuid': sheetUuid,
|
||||||
'value': value,
|
'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.
|
/// Deserializes a change from a stored map.
|
||||||
@@ -41,6 +53,9 @@ class Change {
|
|||||||
type: ChangeType.values[map['type']],
|
type: ChangeType.values[map['type']],
|
||||||
sheetUuid: map['sheetUuid'],
|
sheetUuid: map['sheetUuid'],
|
||||||
value: map['value'],
|
value: map['value'],
|
||||||
|
createdAt: map['createdAt'] != null
|
||||||
|
? DateTime.parse(map['createdAt'])
|
||||||
|
: DateTime.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ class AnnotationSyncService {
|
|||||||
AnnotationSyncService({
|
AnnotationSyncService({
|
||||||
required ApiClient apiClient,
|
required ApiClient apiClient,
|
||||||
required StorageService storageService,
|
required StorageService storageService,
|
||||||
}) : _apiClient = apiClient,
|
}) : _apiClient = apiClient,
|
||||||
_storageService = storageService;
|
_storageService = storageService;
|
||||||
|
|
||||||
/// Downloads annotations from server and merges with local storage.
|
/// Downloads annotations from server and merges with local storage.
|
||||||
///
|
///
|
||||||
@@ -31,8 +31,8 @@ class AnnotationSyncService {
|
|||||||
final serverAnnotations = await _apiClient.fetchAnnotations(sheetUuid);
|
final serverAnnotations = await _apiClient.fetchAnnotations(sheetUuid);
|
||||||
|
|
||||||
// Get all local annotations with metadata
|
// Get all local annotations with metadata
|
||||||
final localAnnotations = await _storageService
|
final localAnnotations =
|
||||||
.readAllAnnotationsWithMetadata(sheetUuid);
|
await _storageService.readAllAnnotationsWithMetadata(sheetUuid);
|
||||||
|
|
||||||
int updatedCount = 0;
|
int updatedCount = 0;
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ class AnnotationSyncService {
|
|||||||
/// Uploads a single page's annotation to the server.
|
/// Uploads a single page's annotation to the server.
|
||||||
///
|
///
|
||||||
/// Called when annotations are saved (e.g., exiting paint mode).
|
/// 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({
|
Future<bool> uploadAnnotation({
|
||||||
required String sheetUuid,
|
required String sheetUuid,
|
||||||
required int page,
|
required int page,
|
||||||
@@ -108,11 +108,42 @@ class AnnotationSyncService {
|
|||||||
_log.info('Upload successful');
|
_log.info('Upload successful');
|
||||||
return true;
|
return true;
|
||||||
} on ApiException catch (e) {
|
} 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;
|
return false;
|
||||||
} catch (e) {
|
} 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;
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
|
|||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../models/change.dart';
|
||||||
import '../models/sheet.dart';
|
import '../models/sheet.dart';
|
||||||
|
|
||||||
/// HTTP client for communicating with the Sheetless API server.
|
/// HTTP client for communicating with the Sheetless API server.
|
||||||
@@ -217,6 +218,46 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
_log.info('Annotation uploaded for sheet $sheetUuid page $page');
|
_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.
|
/// Represents an annotation from the server.
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:hive/hive.dart';
|
import 'package:hive/hive.dart';
|
||||||
import 'package:sheetless/core/models/change.dart';
|
import 'package:sheetless/core/models/change.dart';
|
||||||
import 'package:sheetless/core/models/config.dart';
|
import 'package:sheetless/core/models/config.dart';
|
||||||
|
import 'package:sheetless/core/models/sheet.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 }
|
||||||
@@ -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.
|
/// Service for managing local storage operations.
|
||||||
///
|
///
|
||||||
/// Uses [FlutterSecureStorage] for sensitive data (credentials, tokens)
|
/// Uses [FlutterSecureStorage] for sensitive data (credentials, tokens)
|
||||||
@@ -40,6 +82,8 @@ class StorageService {
|
|||||||
static const String _configBox = 'config';
|
static const String _configBox = 'config';
|
||||||
static const String _changeQueueBox = 'changeQueue';
|
static const String _changeQueueBox = 'changeQueue';
|
||||||
static const String _annotationsBox = 'annotations';
|
static const String _annotationsBox = 'annotations';
|
||||||
|
static const String _sheetsBox = 'sheets';
|
||||||
|
static const String _pendingAnnotationsBox = 'pendingAnnotations';
|
||||||
|
|
||||||
late final FlutterSecureStorage _secureStorage;
|
late final FlutterSecureStorage _secureStorage;
|
||||||
|
|
||||||
@@ -317,4 +361,95 @@ class StorageService {
|
|||||||
await box.delete(key);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
277
lib/core/services/sync_service.dart
Normal file
277
lib/core/services/sync_service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ 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/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';
|
||||||
|
import 'package:sheetless/core/services/sync_service.dart';
|
||||||
|
|
||||||
import '../../app.dart';
|
import '../../app.dart';
|
||||||
import '../auth/login_page.dart';
|
import '../auth/login_page.dart';
|
||||||
@@ -34,8 +35,11 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
final _storageService = StorageService();
|
final _storageService = StorageService();
|
||||||
|
|
||||||
ApiClient? _apiClient;
|
ApiClient? _apiClient;
|
||||||
late Future<List<Sheet>> _sheetsFuture;
|
SyncService? _syncService;
|
||||||
|
late Future<SyncResult> _syncFuture;
|
||||||
|
List<Sheet> _sheets = [];
|
||||||
bool _isShuffling = false;
|
bool _isShuffling = false;
|
||||||
|
bool _isOnline = true;
|
||||||
String? _appName;
|
String? _appName;
|
||||||
String? _appVersion;
|
String? _appVersion;
|
||||||
|
|
||||||
@@ -52,7 +56,7 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
});
|
});
|
||||||
|
|
||||||
_loadAppInfo();
|
_loadAppInfo();
|
||||||
_sheetsFuture = _loadSheets();
|
_syncFuture = _loadSheets();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -90,19 +94,29 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Sheet>> _loadSheets() async {
|
Future<SyncResult> _loadSheets() async {
|
||||||
final url = await _storageService.readSecure(SecureStorageKey.url);
|
final url = await _storageService.readSecure(SecureStorageKey.url);
|
||||||
final jwt = await _storageService.readSecure(SecureStorageKey.jwt);
|
final jwt = await _storageService.readSecure(SecureStorageKey.jwt);
|
||||||
|
|
||||||
_apiClient = ApiClient(baseUrl: url!, token: jwt);
|
_apiClient = ApiClient(baseUrl: url!, token: jwt);
|
||||||
|
_syncService = SyncService(
|
||||||
|
apiClient: _apiClient!,
|
||||||
|
storageService: _storageService,
|
||||||
|
);
|
||||||
|
|
||||||
final sheets = await _apiClient!.fetchSheets();
|
// Perform sync (fetches sheets, uploads pending changes/annotations)
|
||||||
_log.info('${sheets.length} sheets fetched');
|
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);
|
// Sort and store sheets
|
||||||
_log.info('${sortedSheets.length} sheets sorted');
|
_sheets = await _sortSheetsByRecency(result.sheets);
|
||||||
|
_isOnline = result.isOnline;
|
||||||
|
|
||||||
return sortedSheets;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Sheet>> _sortSheetsByRecency(List<Sheet> sheets) async {
|
Future<List<Sheet>> _sortSheetsByRecency(List<Sheet> sheets) async {
|
||||||
@@ -128,7 +142,7 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
|
|
||||||
Future<void> _refreshSheets() async {
|
Future<void> _refreshSheets() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_sheetsFuture = _loadSheets();
|
_syncFuture = _loadSheets();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,12 +151,10 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
void _handleShuffleChanged(bool enabled) async {
|
void _handleShuffleChanged(bool enabled) async {
|
||||||
final sheets = await _sheetsFuture;
|
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
sheets.shuffle();
|
_sheets.shuffle();
|
||||||
} else {
|
} else {
|
||||||
await _sortSheetsByRecency(sheets);
|
await _sortSheetsByRecency(_sheets);
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _isShuffling = enabled);
|
setState(() => _isShuffling = enabled);
|
||||||
@@ -181,7 +193,16 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
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(
|
endDrawer: AppDrawer(
|
||||||
isShuffling: _isShuffling,
|
isShuffling: _isShuffling,
|
||||||
onShuffleChanged: _handleShuffleChanged,
|
onShuffleChanged: _handleShuffleChanged,
|
||||||
@@ -194,8 +215,8 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody() {
|
Widget _buildBody() {
|
||||||
return FutureBuilder<List<Sheet>>(
|
return FutureBuilder<SyncResult>(
|
||||||
future: _sheetsFuture,
|
future: _syncFuture,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState != ConnectionState.done) {
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
@@ -208,8 +229,9 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
|
|
||||||
if (snapshot.hasData) {
|
if (snapshot.hasData) {
|
||||||
return SheetsList(
|
return SheetsList(
|
||||||
sheets: snapshot.data!,
|
sheets: _sheets,
|
||||||
onSheetSelected: _openSheet,
|
onSheetSelected: _openSheet,
|
||||||
|
syncService: _syncService!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'dart:ui';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:sheetless/core/models/change.dart';
|
import 'package:sheetless/core/models/change.dart';
|
||||||
import 'package:sheetless/core/models/sheet.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 '../../../shared/widgets/edit_sheet_bottom_sheet.dart';
|
||||||
import 'sheet_list_item.dart';
|
import 'sheet_list_item.dart';
|
||||||
@@ -19,11 +19,13 @@ import 'sheet_search_bar.dart';
|
|||||||
class SheetsList extends StatefulWidget {
|
class SheetsList extends StatefulWidget {
|
||||||
final List<Sheet> sheets;
|
final List<Sheet> sheets;
|
||||||
final ValueSetter<Sheet> onSheetSelected;
|
final ValueSetter<Sheet> onSheetSelected;
|
||||||
|
final SyncService syncService;
|
||||||
|
|
||||||
const SheetsList({
|
const SheetsList({
|
||||||
super.key,
|
super.key,
|
||||||
required this.sheets,
|
required this.sheets,
|
||||||
required this.onSheetSelected,
|
required this.onSheetSelected,
|
||||||
|
required this.syncService,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -33,7 +35,6 @@ class SheetsList extends StatefulWidget {
|
|||||||
class _SheetsListState extends State<SheetsList> {
|
class _SheetsListState extends State<SheetsList> {
|
||||||
static const _searchDebounceMs = 500;
|
static const _searchDebounceMs = 500;
|
||||||
|
|
||||||
final _storageService = StorageService();
|
|
||||||
final _searchController = TextEditingController();
|
final _searchController = TextEditingController();
|
||||||
Timer? _debounceTimer;
|
Timer? _debounceTimer;
|
||||||
late List<Sheet> _filteredSheets;
|
late List<Sheet> _filteredSheets;
|
||||||
@@ -111,9 +112,9 @@ class _SheetsListState extends State<SheetsList> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleSheetEdit(Sheet sheet, String newName, String newComposer) {
|
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) {
|
if (newName != sheet.name) {
|
||||||
_storageService.writeChange(
|
widget.syncService.queueChange(
|
||||||
Change(
|
Change(
|
||||||
type: ChangeType.sheetNameChange,
|
type: ChangeType.sheetNameChange,
|
||||||
sheetUuid: sheet.uuid,
|
sheetUuid: sheet.uuid,
|
||||||
@@ -122,7 +123,7 @@ class _SheetsListState extends State<SheetsList> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (newComposer != sheet.composerName) {
|
if (newComposer != sheet.composerName) {
|
||||||
_storageService.writeChange(
|
widget.syncService.queueChange(
|
||||||
Change(
|
Change(
|
||||||
type: ChangeType.composerNameChange,
|
type: ChangeType.composerNameChange,
|
||||||
sheetUuid: sheet.uuid,
|
sheetUuid: sheet.uuid,
|
||||||
@@ -136,6 +137,9 @@ class _SheetsListState extends State<SheetsList> {
|
|||||||
sheet.name = newName;
|
sheet.name = newName;
|
||||||
sheet.composerName = newComposer;
|
sheet.composerName = newComposer;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update cached sheets
|
||||||
|
widget.syncService.updateCachedSheets(widget.sheets);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user