Compare commits

..

13 Commits

12 changed files with 960 additions and 127 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(),
);
}
}
@@ -76,16 +91,15 @@ class ChangeQueue {
for (final change in _queue) {
final sheet = sheets.firstWhere(
(s) => s.uuid == change.sheetUuid,
orElse: () => throw StateError(
'Sheet with UUID ${change.sheetUuid} not found',
),
orElse: () =>
throw StateError('Sheet with UUID ${change.sheetUuid} not found'),
);
switch (change.type) {
case ChangeType.sheetNameChange:
sheet.name = change.value;
case ChangeType.composerNameChange:
sheet.composerName = change.value;
sheet.composer = change.value;
case ChangeType.addTagChange:
throw UnimplementedError('Tag support not yet implemented');
case ChangeType.removeTagChange:

View File

@@ -5,26 +5,22 @@
class Sheet {
final String uuid;
String name;
String composerUuid;
String composerName;
String composer;
DateTime updatedAt;
Sheet({
required this.uuid,
required this.name,
required this.composerUuid,
required this.composerName,
required this.composer,
required this.updatedAt,
});
/// Creates a [Sheet] from a JSON map returned by the API.
factory Sheet.fromJson(Map<String, dynamic> json) {
final composer = json['composer'] as Map<String, dynamic>?;
return Sheet(
uuid: json['uuid'].toString(),
uuid: json['uuid'],
name: json['title'],
composerUuid: json['composer_uuid']?.toString() ?? '',
composerName: composer?['name'] ?? 'Unknown',
composer: json['composer'],
updatedAt: DateTime.parse(json['updated_at']),
);
}
@@ -33,8 +29,7 @@ class Sheet {
Map<String, dynamic> toJson() => {
'uuid': uuid,
'title': name,
'composer_uuid': composerUuid,
'composer_name': composerName,
'composer': composer,
'updated_at': updatedAt.toIso8601String(),
};
}

View File

@@ -0,0 +1,149 @@
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).
/// If upload fails (e.g., offline), the annotation is queued for later sync.
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, queuing for later: $e');
await _queueForLaterUpload(
sheetUuid: sheetUuid,
page: page,
annotationsJson: annotationsJson,
lastModified: lastModified,
);
return false;
} catch (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.
@@ -70,13 +71,12 @@ class ApiClient {
}
/// Performs a GET request to the given endpoint.
Future<http.Response> get(
String endpoint, {
bool isBinary = false,
}) async {
Future<http.Response> get(String endpoint, {bool isBinary = false}) async {
final url = Uri.parse('$baseUrl$endpoint');
final response =
await http.get(url, headers: _buildHeaders(isBinary: isBinary));
final response = await http.get(
url,
headers: _buildHeaders(isBinary: isBinary),
);
if (response.statusCode != 200) {
_log.warning(
@@ -93,10 +93,7 @@ class ApiClient {
}
/// Performs a POST request with JSON body.
Future<http.Response> post(
String endpoint,
Map<String, dynamic> body,
) async {
Future<http.Response> post(String endpoint, Map<String, dynamic> body) async {
final url = Uri.parse('$baseUrl$endpoint');
final response = await http.post(
@@ -187,6 +184,101 @@ class ApiClient {
_log.info('PDF cached at: ${cachedFile.path}');
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');
}
// ---------------------------------------------------------------------------
// 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.
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.

View File

@@ -1,11 +1,73 @@
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 }
/// 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.
///
/// 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)
@@ -17,13 +79,13 @@ 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;
StorageService() {
_secureStorage = FlutterSecureStorage(
aOptions: const AndroidOptions(encryptedSharedPreferences: true),
);
_secureStorage = FlutterSecureStorage();
}
// ---------------------------------------------------------------------------
@@ -42,9 +104,32 @@ class StorageService {
return _secureStorage.write(key: key.name, value: value);
}
/// Clears the JWT token from secure storage.
Future<void> clearToken() {
return writeSecure(SecureStorageKey.jwt, null);
/// Clears all user data except URL and email.
///
/// Called on logout to ensure a clean state for the next user,
/// while preserving server URL and email for convenience.
Future<void> clearAllUserData() async {
// Clear JWT token
await writeSecure(SecureStorageKey.jwt, null);
// Clear all Hive boxes
final sheetAccessTimesBox = await Hive.openBox(_sheetAccessTimesBox);
await sheetAccessTimesBox.clear();
final configBox = await Hive.openBox(_configBox);
await configBox.clear();
final changeQueueBox = await Hive.openBox(_changeQueueBox);
await changeQueueBox.clear();
final annotationsBox = await Hive.openBox(_annotationsBox);
await annotationsBox.clear();
final sheetsBox = await Hive.openBox(_sheetsBox);
await sheetsBox.clear();
final pendingAnnotationsBox = await Hive.openBox(_pendingAnnotationsBox);
await pendingAnnotationsBox.clear();
}
// ---------------------------------------------------------------------------
@@ -77,8 +162,7 @@ class StorageService {
Future<Map<String, DateTime>> readSheetAccessTimes() async {
final box = await Hive.openBox(_sheetAccessTimesBox);
return box.toMap().map(
(key, value) =>
MapEntry(key as String, DateTime.parse(value as String)),
(key, value) => MapEntry(key as String, DateTime.parse(value as String)),
);
}
@@ -134,16 +218,26 @@ class StorageService {
/// Returns the JSON string of annotations, or null if none exist.
Future<String?> readAnnotations(String sheetUuid, int pageNumber) async {
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;
}
/// Writes annotations for a specific sheet page.
/// Writes annotations with a specific lastModified timestamp.
///
/// Pass null or empty string to delete annotations for that page.
Future<void> writeAnnotations(
/// Used when syncing from server to preserve server's timestamp.
Future<void> writeAnnotationsWithMetadata(
String sheetUuid,
int pageNumber,
String? annotationsJson,
DateTime lastModified,
) async {
final box = await Hive.openBox(_annotationsBox);
final key = _annotationKey(sheetUuid, pageNumber);
@@ -151,7 +245,11 @@ class StorageService {
if (annotationsJson == null || annotationsJson.isEmpty) {
await box.delete(key);
} else {
await box.put(key, annotationsJson);
final stored = StoredAnnotation(
annotationsJson: annotationsJson,
lastModified: lastModified,
);
await box.put(key, stored.toMap());
}
}
@@ -169,8 +267,54 @@ class StorageService {
final pageNumber = int.tryParse(pageStr);
if (pageNumber != null) {
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;
} 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;
}
}
}
}
@@ -183,11 +327,103 @@ class StorageService {
Future<void> deleteAllAnnotations(String sheetUuid) async {
final box = await Hive.openBox(_annotationsBox);
final prefix = '${sheetUuid}_page_';
final keysToDelete =
box.keys.where((key) => key is String && key.startsWith(prefix));
final keysToDelete = box.keys.where(
(key) => key is String && key.startsWith(prefix),
);
for (final key in keysToDelete.toList()) {
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;
}
}

View File

@@ -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<HomePage> with RouteAware {
final _storageService = StorageService();
ApiClient? _apiClient;
late Future<List<Sheet>> _sheetsFuture;
SyncService? _syncService;
late Future<SyncResult> _syncFuture;
List<Sheet> _sheets = [];
bool _isShuffling = false;
bool _isOnline = true;
String? _appName;
String? _appVersion;
@@ -52,7 +56,7 @@ class _HomePageState extends State<HomePage> with RouteAware {
});
_loadAppInfo();
_sheetsFuture = _loadSheets();
_syncFuture = _loadSheets();
}
@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 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<List<Sheet>> _sortSheetsByRecency(List<Sheet> sheets) async {
@@ -128,7 +142,7 @@ class _HomePageState extends State<HomePage> with RouteAware {
Future<void> _refreshSheets() async {
setState(() {
_sheetsFuture = _loadSheets();
_syncFuture = _loadSheets();
});
}
@@ -137,19 +151,17 @@ class _HomePageState extends State<HomePage> 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);
}
Future<void> _handleLogout() async {
await _storageService.clearToken();
await _storageService.clearAllUserData();
if (!mounted) return;
@@ -181,7 +193,19 @@ class _HomePageState extends State<HomePage> with RouteAware {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sheetless')),
appBar: AppBar(
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Sheetless'),
if (!_isOnline)
const Padding(
padding: EdgeInsets.only(left: 8),
child: Icon(Icons.cloud_off, color: Colors.orange, size: 20),
),
],
),
),
endDrawer: AppDrawer(
isShuffling: _isShuffling,
onShuffleChanged: _handleShuffleChanged,
@@ -194,8 +218,8 @@ class _HomePageState extends State<HomePage> with RouteAware {
}
Widget _buildBody() {
return FutureBuilder<List<Sheet>>(
future: _sheetsFuture,
return FutureBuilder<SyncResult>(
future: _syncFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
@@ -208,8 +232,9 @@ class _HomePageState extends State<HomePage> with RouteAware {
if (snapshot.hasData) {
return SheetsList(
sheets: snapshot.data!,
sheets: _sheets,
onSheetSelected: _openSheet,
syncService: _syncService!,
);
}

View File

@@ -21,7 +21,7 @@ class SheetListItem extends StatelessWidget {
Widget build(BuildContext context) {
return ListTile(
title: Text(sheet.name),
subtitle: Text(sheet.composerName),
subtitle: Text(sheet.composer),
onTap: onTap,
onLongPress: onLongPress,
);

View File

@@ -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<Sheet> sheets;
final ValueSetter<Sheet> 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<SheetsList> {
static const _searchDebounceMs = 500;
final _storageService = StorageService();
final _searchController = TextEditingController();
Timer? _debounceTimer;
late List<Sheet> _filteredSheets;
@@ -79,7 +80,7 @@ class _SheetsListState extends State<SheetsList> {
setState(() {
_filteredSheets = widget.sheets.where((sheet) {
final name = sheet.name.toLowerCase();
final composer = sheet.composerName.toLowerCase();
final composer = sheet.composer.toLowerCase();
// Each term must appear in either name or composer
return terms.every(
@@ -111,9 +112,9 @@ class _SheetsListState extends State<SheetsList> {
}
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,
@@ -121,8 +122,8 @@ class _SheetsListState extends State<SheetsList> {
),
);
}
if (newComposer != sheet.composerName) {
_storageService.writeChange(
if (newComposer != sheet.composer) {
widget.syncService.queueChange(
Change(
type: ChangeType.composerNameChange,
sheetUuid: sheet.uuid,
@@ -134,8 +135,11 @@ class _SheetsListState extends State<SheetsList> {
// Update local state
setState(() {
sheet.name = newName;
sheet.composerName = newComposer;
sheet.composer = newComposer;
});
// Update cached sheets
widget.syncService.updateCachedSheets(widget.sheets);
}
// ---------------------------------------------------------------------------

View File

@@ -51,6 +51,9 @@ class DrawingController extends ChangeNotifier {
/// Maximum number of history steps to keep
final int maxHistorySteps;
/// Whether there are unsaved changes since last load/clear
bool _hasUnsavedChanges = false;
DrawingController({this.maxHistorySteps = 50});
// ---------------------------------------------------------------------------
@@ -75,6 +78,9 @@ class DrawingController extends ChangeNotifier {
/// Whether redo is available
bool get canRedo => _redoStack.isNotEmpty;
/// Whether there are unsaved changes since last load/clear/markSaved
bool get hasUnsavedChanges => _hasUnsavedChanges;
// ---------------------------------------------------------------------------
// Drawing Operations
// ---------------------------------------------------------------------------
@@ -120,6 +126,7 @@ class DrawingController extends ChangeNotifier {
_redoStack.clear();
_trimHistory();
_currentErasedLines.clear();
_hasUnsavedChanges = true;
notifyListeners(); // Update UI to enable undo button
}
return;
@@ -133,6 +140,7 @@ class DrawingController extends ChangeNotifier {
_undoStack.add(AddLineAction(_currentLine!));
_redoStack.clear();
_trimHistory();
_hasUnsavedChanges = true;
}
_currentLine = null;
@@ -257,6 +265,7 @@ class DrawingController extends ChangeNotifier {
_redoStack.add(action);
}
_hasUnsavedChanges = true;
notifyListeners();
}
@@ -279,6 +288,7 @@ class DrawingController extends ChangeNotifier {
_undoStack.add(action);
}
_hasUnsavedChanges = true;
notifyListeners();
}
@@ -289,6 +299,7 @@ class DrawingController extends ChangeNotifier {
_redoStack.clear();
_currentLine = null;
_currentErasedLines.clear();
_hasUnsavedChanges = false;
notifyListeners();
}
@@ -324,6 +335,7 @@ class DrawingController extends ChangeNotifier {
_redoStack.clear();
_currentLine = null;
_currentErasedLines.clear();
_hasUnsavedChanges = false;
for (final json in jsonList) {
_lines.add(DrawingLine.fromJson(json));
@@ -354,6 +366,11 @@ class DrawingController extends ChangeNotifier {
notifyListeners();
}
/// Marks the current state as saved (resets unsaved changes flag).
void markSaved() {
_hasUnsavedChanges = false;
}
@override
void dispose() {
_lines.clear();

View File

@@ -6,6 +6,7 @@ import 'package:logging/logging.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:sheetless/core/models/config.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/storage_service.dart';
@@ -35,6 +36,7 @@ class _SheetViewerPageState extends State<SheetViewerPage>
with FullScreenListener {
final _log = Logger('SheetViewerPage');
final _storageService = StorageService();
late final AnnotationSyncService _syncService;
PdfDocument? _document;
late Future<bool> _documentLoaded;
@@ -52,6 +54,12 @@ class _SheetViewerPageState extends State<SheetViewerPage>
void initState() {
super.initState();
// Initialize sync service
_syncService = AnnotationSyncService(
apiClient: widget.apiClient,
storageService: _storageService,
);
// Initialize drawing controllers
_leftDrawingController = DrawingController(maxHistorySteps: 50);
_rightDrawingController = DrawingController(maxHistorySteps: 50);
@@ -63,9 +71,10 @@ class _SheetViewerPageState extends State<SheetViewerPage>
@override
void dispose() {
// Save current annotations synchronously before disposing
// Note: This is fire-and-forget, but Hive operations are fast enough
_saveCurrentAnnotationsSync();
// Make sure annotations are saved before exiting
if (_isPaintMode) {
_saveCurrentAnnotations();
}
_leftDrawingController.dispose();
_rightDrawingController.dispose();
@@ -74,27 +83,6 @@ class _SheetViewerPageState extends State<SheetViewerPage>
super.dispose();
}
/// Synchronous version that doesn't await - used in dispose
void _saveCurrentAnnotationsSync() {
// Save left page (always, since paint mode is single-page only)
final leftJson = _leftDrawingController.toJsonString();
_storageService.writeAnnotations(
widget.sheet.uuid,
_currentPage,
leftJson.isEmpty || leftJson == '[]' ? null : leftJson,
);
// Save right page if in two-page mode
if (widget.config.twoPageMode && _currentPage < _totalPages) {
final rightJson = _rightDrawingController.toJsonString();
_storageService.writeAnnotations(
widget.sheet.uuid,
_currentPage + 1,
rightJson.isEmpty || rightJson == '[]' ? null : rightJson,
);
}
}
// ---------------------------------------------------------------------------
// PDF Loading
// ---------------------------------------------------------------------------
@@ -114,6 +102,9 @@ class _SheetViewerPageState extends State<SheetViewerPage>
_totalPages = _document!.pages.length;
});
// Sync annotations from server (downloads newer versions)
await _syncService.syncFromServer(widget.sheet.uuid);
// Load annotations for current page(s)
await _loadAnnotationsForCurrentPages();
@@ -151,24 +142,62 @@ 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.
///
/// Only saves if there are actual changes to avoid unnecessary writes/uploads.
Future<void> _saveCurrentAnnotations() async {
// Save left page
final now = DateTime.now();
// Save left page only if changed
if (_leftDrawingController.hasUnsavedChanges) {
final leftJson = _leftDrawingController.toJsonString();
await _storageService.writeAnnotations(
final leftHasContent = leftJson.isNotEmpty && leftJson != '[]';
await _storageService.writeAnnotationsWithMetadata(
widget.sheet.uuid,
_currentPage,
leftJson.isEmpty || leftJson == '[]' ? null : leftJson,
leftHasContent ? leftJson : null,
now,
);
// Save right page (two-page mode)
if (widget.config.twoPageMode && _currentPage < _totalPages) {
// Upload left page to server
if (leftHasContent) {
_syncService.uploadAnnotation(
sheetUuid: widget.sheet.uuid,
page: _currentPage,
annotationsJson: leftJson,
lastModified: now,
);
}
_leftDrawingController.markSaved();
}
// Save right page (two-page mode) only if changed
if (widget.config.twoPageMode &&
_currentPage < _totalPages &&
_rightDrawingController.hasUnsavedChanges) {
final rightJson = _rightDrawingController.toJsonString();
await _storageService.writeAnnotations(
final rightHasContent = rightJson.isNotEmpty && rightJson != '[]';
await _storageService.writeAnnotationsWithMetadata(
widget.sheet.uuid,
_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,
);
}
_rightDrawingController.markSaved();
}
}
@@ -271,9 +300,8 @@ class _SheetViewerPageState extends State<SheetViewerPage>
icon: Icon(
widget.config.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
),
tooltip: widget.config.fullscreen
? 'Exit Fullscreen'
: 'Enter Fullscreen',
tooltip:
widget.config.fullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen',
onPressed: _toggleFullscreen,
),
IconButton(
@@ -285,9 +313,8 @@ class _SheetViewerPageState extends State<SheetViewerPage>
icon: Icon(
widget.config.twoPageMode ? Icons.filter_1 : Icons.filter_2,
),
tooltip: widget.config.twoPageMode
? 'Single Page Mode'
: 'Two Page Mode',
tooltip:
widget.config.twoPageMode ? 'Single Page Mode' : 'Two Page Mode',
onPressed: _toggleTwoPageMode,
),
],
@@ -319,9 +346,8 @@ class _SheetViewerPageState extends State<SheetViewerPage>
currentPageNumber: _currentPage,
config: widget.config,
leftDrawingController: _leftDrawingController,
rightDrawingController: widget.config.twoPageMode
? _rightDrawingController
: null,
rightDrawingController:
widget.config.twoPageMode ? _rightDrawingController : null,
drawingEnabled: _isPaintMode,
);

View File

@@ -29,9 +29,7 @@ class _EditSheetBottomSheetState extends State<EditSheetBottomSheet> {
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.sheet.name);
_composerController = TextEditingController(
text: widget.sheet.composerName,
);
_composerController = TextEditingController(text: widget.sheet.composer);
}
@override