Compare commits

..

29 Commits

Author SHA1 Message Date
9261ec341e Add sync status to app drawer 2026-02-06 20:43:26 +01:00
7ec7b20e74 Update version 2026-02-06 20:19:32 +01:00
829bf7512a Only call setFullScreen if that changes fullscreen state 2026-02-06 20:16:00 +01:00
4fe8896f9e Also upload annotations if page was cleared 2026-02-06 20:11:54 +01:00
d3addc7973 Move to jdk 17 2026-02-06 19:34:20 +01:00
f1b1ccf6be Update flutter dependencies 2026-02-06 19:34:09 +01:00
99300478d5 Update agp version 2026-02-06 19:34:01 +01:00
f6585e9850 Force newest ndk version for all subprojects 2026-02-06 19:33:42 +01:00
16589be409 Allow composer name to be empty 2026-02-06 17:38:13 +01:00
6669e2446c Implement clearing user data on logout 2026-02-06 17:31:04 +01:00
b62ed98375 Fix offline symbol on AppBar 2026-02-06 17:30:46 +01:00
a70c634d35 Format code 2026-02-06 16:52:00 +01:00
0fdf56c084 Remove unneeded function 2026-02-06 16:51:54 +01:00
a57831f50b Finalize switch from composerName to composer in Sheet model 2026-02-06 16:51:29 +01:00
8f05e9244a Remove deprecated option from FlutterSecureStorage 2026-02-06 16:48:30 +01:00
d01e1384d4 Sheet model: adapt to reduced data model of server 2026-02-06 16:47:04 +01:00
d0fd96a2f5 Implement offline mode 2026-02-06 16:41:58 +01:00
58157a2e6e Only save annotations on changes 2026-02-06 16:09:52 +01:00
9a11e42571 Implement annotation syncing to and from server 2026-02-06 16:05:55 +01:00
e5c71c9261 Reformat code 2026-02-06 15:54:09 +01:00
4fd287181b Only save on exit if in paint mode 2026-02-06 15:15:59 +01:00
181e853790 Remove unneeded function 2026-02-06 15:14:43 +01:00
d94e9eeb3d Allow free zoom and pan in drawing mode 2026-02-05 18:47:28 +01:00
d1b5cb54f4 Add eraser 2026-02-05 18:41:43 +01:00
b36011d9e8 Add more tools to the drawing toolbar 2026-02-05 18:31:58 +01:00
421171f1a3 Remove unused class 2026-02-05 18:23:41 +01:00
3b12be497e Avoid too close points being added to a drawing_line 2026-02-05 18:23:28 +01:00
f615ed5654 Remove unneeded child 2026-02-05 17:59:25 +01:00
d4d6e41a9d Custom drawing implementation 2026-02-05 17:55:26 +01:00
27 changed files with 2455 additions and 228 deletions

View File

@@ -11,12 +11,12 @@ android {
ndkVersion = "27.2.12479018"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {

View File

@@ -1,3 +1,13 @@
subprojects {
afterEvaluate {
val android = project.extensions.findByName("android")
if (android is com.android.build.gradle.BaseExtension) {
// Force the same sdk version everywhere
android.ndkVersion = "27.2.12479018"
}
}
}
allprojects {
repositories {
google()
@@ -19,3 +29,4 @@ subprojects {
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@@ -18,7 +18,7 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.7.3" apply false
id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}

View File

@@ -13,24 +13,36 @@ 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,
};
'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,36 +5,31 @@
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']),
);
}
/// Converts this sheet to a JSON map for API requests.
Map<String, dynamic> toJson() => {
'uuid': uuid,
'title': name,
'composer_uuid': composerUuid,
'composer_name': composerName,
'updated_at': updatedAt.toIso8601String(),
};
'uuid': uuid,
'title': name,
'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,27 +1,91 @@
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 [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)
/// and [Hive] for general app data (config, sheet access times, change queue,
/// and PDF annotations).
class StorageService {
// Hive box names
static const String _sheetAccessTimesBox = 'sheetAccessTimes';
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();
}
// ---------------------------------------------------------------------------
@@ -40,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();
}
// ---------------------------------------------------------------------------
@@ -116,4 +203,227 @@ class StorageService {
await box.deleteAt(0);
}
}
// ---------------------------------------------------------------------------
// Annotations (PDF Drawing Persistence)
// ---------------------------------------------------------------------------
/// Generates a storage key for a specific page's annotations.
String _annotationKey(String sheetUuid, int pageNumber) {
return '${sheetUuid}_page_$pageNumber';
}
/// Reads annotations for a specific sheet page.
///
/// 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);
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 with a specific lastModified timestamp.
///
/// Used when syncing from server to preserve server's timestamp.
Future<void> writeAnnotationsWithMetadata(
String sheetUuid,
int pageNumber,
String? annotationsJson,
DateTime lastModified,
) async {
final box = await Hive.openBox(_annotationsBox);
final key = _annotationKey(sheetUuid, pageNumber);
if (annotationsJson == null || annotationsJson.isEmpty) {
await box.delete(key);
} else {
final stored = StoredAnnotation(
annotationsJson: annotationsJson,
lastModified: lastModified,
);
await box.put(key, stored.toMap());
}
}
/// Reads all annotations for a sheet (all pages).
///
/// Returns a map of page number to JSON string.
Future<Map<int, String>> readAllAnnotations(String sheetUuid) async {
final box = await Hive.openBox(_annotationsBox);
final prefix = '${sheetUuid}_page_';
final result = <int, String>{};
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) {
// 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;
}
}
}
}
}
return result;
}
/// Deletes all annotations for a sheet.
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),
);
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,292 @@
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;
final int changesUnsynced;
final int annotationsUnsynced;
SyncResult({
required this.sheets,
required this.isOnline,
required this.changesSynced,
required this.annotationsSynced,
required this.changesUnsynced,
required this.annotationsUnsynced,
});
}
/// 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();
final remainingAnnotations = await _storageService
.readPendingAnnotationUploads();
// 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,
changesUnsynced: changeQueue.length,
annotationsUnsynced: remainingAnnotations.length,
);
}
/// 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');
}
}
final remainingAnnotations = await _storageService
.readPendingAnnotationUploads();
return SyncResult(
sheets: sheets,
isOnline: false,
changesSynced: 0,
annotationsSynced: 0,
changesUnsynced: changeQueue.length,
annotationsUnsynced: remainingAnnotations.length,
);
}
/// 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;
@@ -44,7 +48,9 @@ class _HomePageState extends State<HomePage> with RouteAware {
super.initState();
// Exit fullscreen when entering home page
FullScreen.setFullScreen(false);
if (FullScreen.isFullScreen) {
FullScreen.setFullScreen(false);
}
// Subscribe to route changes
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -52,7 +58,7 @@ class _HomePageState extends State<HomePage> with RouteAware {
});
_loadAppInfo();
_sheetsFuture = _loadSheets();
_syncFuture = _loadSheets();
}
@override
@@ -67,14 +73,19 @@ class _HomePageState extends State<HomePage> with RouteAware {
@override
void didPush() {
FullScreen.setFullScreen(false);
// Exit fullscreen when entering home page
if (FullScreen.isFullScreen) {
FullScreen.setFullScreen(false);
}
super.didPush();
}
@override
void didPopNext() {
// Exit fullscreen when returning to home page
FullScreen.setFullScreen(false);
if (FullScreen.isFullScreen) {
FullScreen.setFullScreen(false);
}
super.didPopNext();
}
@@ -90,19 +101,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 +149,7 @@ class _HomePageState extends State<HomePage> with RouteAware {
Future<void> _refreshSheets() async {
setState(() {
_sheetsFuture = _loadSheets();
_syncFuture = _loadSheets();
});
}
@@ -137,19 +158,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,21 +200,34 @@ 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,
onLogout: _handleLogout,
appName: _appName,
appVersion: _appVersion,
syncFuture: _syncFuture,
),
body: RefreshIndicator(onRefresh: _refreshSheets, child: _buildBody()),
);
}
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 +240,9 @@ class _HomePageState extends State<HomePage> with RouteAware {
if (snapshot.hasData) {
return SheetsList(
sheets: snapshot.data!,
sheets: _sheets,
onSheetSelected: _openSheet,
syncService: _syncService!,
);
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:sheetless/core/services/sync_service.dart';
/// Callback for shuffle state changes.
typedef ShuffleCallback = void Function(bool enabled);
@@ -12,12 +13,14 @@ class AppDrawer extends StatelessWidget {
final VoidCallback onLogout;
final String? appName;
final String? appVersion;
final Future<SyncResult> syncFuture;
const AppDrawer({
super.key,
required this.isShuffling,
required this.onShuffleChanged,
required this.onLogout,
required this.syncFuture,
this.appName,
this.appVersion,
});
@@ -32,7 +35,7 @@ class AppDrawer extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildActions(),
_buildAppInfo(),
Column(children: [_buildSyncStatus(), _buildAppInfo()]),
],
),
),
@@ -44,10 +47,7 @@ class AppDrawer extends StatelessWidget {
return Column(
children: [
ListTile(
leading: Icon(
Icons.shuffle,
color: isShuffling ? Colors.blue : null,
),
leading: Icon(Icons.shuffle, color: isShuffling ? Colors.blue : null),
title: const Text('Shuffle'),
onTap: () => onShuffleChanged(!isShuffling),
),
@@ -60,6 +60,47 @@ class AppDrawer extends StatelessWidget {
);
}
Widget _buildSyncStatus() {
return Center(
// padding: const EdgeInsets.all(5.0),
child: FutureBuilder<SyncResult>(
future: syncFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Text(
"Error: ${snapshot.error.toString()}",
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
);
}
if (snapshot.hasData) {
final changes = snapshot.data!.changesUnsynced;
final annotations = snapshot.data!.annotationsUnsynced;
if (changes == 0 && annotations == 0) {
return Text(
"All synced!",
style: const TextStyle(color: Colors.black),
textAlign: TextAlign.center,
);
}
return Text(
"$changes changes and $annotations annotations unsynchronized!",
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
);
}
return const Center(child: CircularProgressIndicator());
},
),
);
}
Widget _buildAppInfo() {
final versionText = appName != null && appVersion != null
? '$appName v$appVersion'
@@ -67,10 +108,7 @@ class AppDrawer extends StatelessWidget {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
versionText,
style: const TextStyle(color: Colors.grey),
),
child: Text(versionText, style: const TextStyle(color: Colors.grey)),
);
}
}

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

@@ -0,0 +1,12 @@
/// Custom drawing library for PDF annotations.
///
/// Provides scalable drawing with normalized coordinates (0-1 range)
/// that work correctly when the canvas size changes.
library;
export 'drawing_board.dart';
export 'drawing_canvas.dart';
export 'drawing_controller.dart';
export 'drawing_line.dart';
export 'drawing_toolbar.dart';
export 'paint_preset.dart';

View File

@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import 'drawing_canvas.dart';
import 'drawing_controller.dart';
/// A drawing board that overlays a child widget with a drawing canvas.
///
/// Supports:
/// - Drawing with normalized coordinates that scale correctly
/// - Zooming and panning via InteractiveViewer
/// - Toggle between view-only and drawing modes
class DrawingBoard extends StatefulWidget {
/// The widget to display behind the drawing (e.g., PDF page)
final Widget child;
/// Size of the drawing area (should match child size)
final Size boardSize;
/// Controller for managing drawing state
final DrawingController controller;
/// Whether drawing is enabled (false = view only)
final bool drawingEnabled;
/// Minimum zoom scale
final double minScale;
/// Maximum zoom scale
final double maxScale;
/// Alignment of the board within available space
final Alignment alignment;
const DrawingBoard({
super.key,
required this.child,
required this.boardSize,
required this.controller,
this.drawingEnabled = true,
this.minScale = 1.0,
this.maxScale = 3.0,
this.alignment = Alignment.topCenter,
});
@override
State<DrawingBoard> createState() => _DrawingBoardState();
}
class _DrawingBoardState extends State<DrawingBoard> {
final TransformationController _transformationController =
TransformationController();
/// Tracks whether we're currently in a drawing gesture
bool _isDrawing = false;
/// Tracks the number of active pointers for gesture disambiguation
int _pointerCount = 0;
@override
void dispose() {
_transformationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// The content that will be transformed (zoomed/panned)
final content = SizedBox(
width: widget.boardSize.width,
height: widget.boardSize.height,
child: Stack(
children: [
// Background child (e.g., PDF)
Positioned.fill(child: widget.child),
// Drawing overlay
Positioned.fill(
child: widget.drawingEnabled
? _buildDrawingLayer()
: DrawingOverlay(
controller: widget.controller,
canvasSize: widget.boardSize,
),
),
],
),
);
if (!widget.drawingEnabled) {
// View-only mode: just show the content (no zoom/pan here,
// let parent handle navigation)
return Align(
alignment: widget.alignment,
child: content,
);
}
// Drawing mode: wrap with InteractiveViewer for zoom/pan
// Use LayoutBuilder to get available size and center content manually
return LayoutBuilder(
builder: (context, constraints) {
// Calculate padding to center the content
final horizontalPadding =
(constraints.maxWidth - widget.boardSize.width) / 2;
final verticalPadding =
(constraints.maxHeight - widget.boardSize.height) / 2;
return InteractiveViewer(
transformationController: _transformationController,
minScale: widget.minScale,
maxScale: widget.maxScale,
boundaryMargin: const EdgeInsets.all(double.infinity),
constrained: false,
panEnabled: !_isDrawing,
scaleEnabled: !_isDrawing,
child: Padding(
padding: EdgeInsets.only(
left: horizontalPadding > 0 ? horizontalPadding : 0,
top: verticalPadding > 0 ? verticalPadding : 0,
),
child: content,
),
);
},
);
}
/// Builds the drawing layer with gesture handling.
///
/// Uses Listener for drawing and distinguishes between:
/// - Single finger: Draw
/// - Two+ fingers: Pan/Zoom (handled by InteractiveViewer)
Widget _buildDrawingLayer() {
return Listener(
onPointerDown: _onPointerDown,
onPointerMove: _onPointerMove,
onPointerUp: _onPointerUp,
onPointerCancel: _onPointerCancel,
behavior: HitTestBehavior.translucent,
child: DrawingOverlay(
controller: widget.controller,
canvasSize: widget.boardSize,
),
);
}
void _onPointerDown(PointerDownEvent event) {
_pointerCount++;
// Only start drawing with single finger touch
if (_pointerCount == 1) {
_isDrawing = true;
final normalized = _toNormalized(event.localPosition);
widget.controller.startLine(normalized);
setState(() {}); // Update to disable pan/scale
} else {
// Multiple fingers: cancel drawing, enable pan/zoom
if (_isDrawing) {
widget.controller.endLine();
_isDrawing = false;
setState(() {});
}
}
}
void _onPointerMove(PointerMoveEvent event) {
if (_isDrawing && _pointerCount == 1) {
final normalized = _toNormalized(event.localPosition);
widget.controller.addPoint(normalized);
}
}
void _onPointerUp(PointerUpEvent event) {
_pointerCount = (_pointerCount - 1).clamp(0, 10);
if (_isDrawing) {
widget.controller.endLine();
_isDrawing = false;
setState(() {}); // Re-enable pan/scale
}
}
void _onPointerCancel(PointerCancelEvent event) {
_pointerCount = (_pointerCount - 1).clamp(0, 10);
if (_isDrawing) {
widget.controller.endLine();
_isDrawing = false;
setState(() {});
}
}
/// Converts local position to normalized coordinates (0-1).
Offset _toNormalized(Offset localPosition) {
return Offset(
(localPosition.dx / widget.boardSize.width).clamp(0.0, 1.0),
(localPosition.dy / widget.boardSize.height).clamp(0.0, 1.0),
);
}
}

View File

@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'drawing_controller.dart';
import 'drawing_line.dart';
/// Custom painter that renders drawing lines on a canvas.
///
/// Converts normalized coordinates (0-1) to actual canvas coordinates
/// based on the provided canvas size.
class DrawingPainter extends CustomPainter {
final List<DrawingLine> lines;
final DrawingLine? currentLine;
final Size canvasSize;
DrawingPainter({
required this.lines,
required this.currentLine,
required this.canvasSize,
});
@override
void paint(Canvas canvas, Size size) {
// Draw all completed lines
for (final line in lines) {
_drawLine(canvas, line);
}
// Draw the current line being drawn
if (currentLine != null) {
_drawLine(canvas, currentLine!);
}
}
void _drawLine(Canvas canvas, DrawingLine line) {
if (line.points.length < 2) return;
final paint = Paint()
..color = line.color
..strokeWidth = line.strokeWidth * canvasSize.width
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.stroke
..isAntiAlias = true;
// Create path from normalized points
final path = Path();
final firstPoint = _toCanvasPoint(line.points.first);
path.moveTo(firstPoint.dx, firstPoint.dy);
// Use quadratic bezier curves for smooth lines
if (line.points.length == 2) {
final endPoint = _toCanvasPoint(line.points.last);
path.lineTo(endPoint.dx, endPoint.dy);
} else {
for (int i = 1; i < line.points.length - 1; i++) {
final p0 = _toCanvasPoint(line.points[i]);
final p1 = _toCanvasPoint(line.points[i + 1]);
final midPoint = Offset((p0.dx + p1.dx) / 2, (p0.dy + p1.dy) / 2);
path.quadraticBezierTo(p0.dx, p0.dy, midPoint.dx, midPoint.dy);
}
// Draw to the last point
final lastPoint = _toCanvasPoint(line.points.last);
path.lineTo(lastPoint.dx, lastPoint.dy);
}
canvas.drawPath(path, paint);
}
/// Converts a normalized point (0-1) to canvas coordinates.
Offset _toCanvasPoint(Offset normalizedPoint) {
return Offset(
normalizedPoint.dx * canvasSize.width,
normalizedPoint.dy * canvasSize.height,
);
}
@override
bool shouldRepaint(covariant DrawingPainter oldDelegate) {
return lines != oldDelegate.lines ||
currentLine != oldDelegate.currentLine ||
canvasSize != oldDelegate.canvasSize;
}
}
/// A widget that displays drawing lines on a transparent canvas.
///
/// This widget only shows the drawings, it doesn't handle input.
/// Use [DrawingCanvas] or [DrawingBoard] for input handling.
class DrawingOverlay extends StatelessWidget {
final DrawingController controller;
final Size canvasSize;
const DrawingOverlay({
super.key,
required this.controller,
required this.canvasSize,
});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
return CustomPaint(
size: canvasSize,
painter: DrawingPainter(
lines: controller.lines,
currentLine: controller.currentLine,
canvasSize: canvasSize,
),
);
},
);
}
}

View File

@@ -0,0 +1,381 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'drawing_line.dart';
import 'paint_preset.dart';
/// Represents an undoable action.
sealed class DrawingAction {
const DrawingAction();
}
/// Action for adding a line.
class AddLineAction extends DrawingAction {
final DrawingLine line;
const AddLineAction(this.line);
}
/// Action for erasing lines.
class EraseAction extends DrawingAction {
final List<DrawingLine> erasedLines;
const EraseAction(this.erasedLines);
}
/// Controller for managing drawing state with undo/redo support.
///
/// Manages a stack of [DrawingLine] objects and provides methods for
/// drawing, undoing, redoing, and serializing the drawing state.
class DrawingController extends ChangeNotifier {
/// All completed lines in the drawing
final List<DrawingLine> _lines = [];
/// Stack of actions for undo functionality
final List<DrawingAction> _undoStack = [];
/// Stack of actions for redo functionality
final List<DrawingAction> _redoStack = [];
/// The line currently being drawn (null when not drawing)
DrawingLine? _currentLine;
/// Current paint preset being used
PaintPreset _currentPreset = PaintPreset.blackPen;
/// Whether eraser mode is active
bool _isEraserMode = false;
/// Lines erased in the current eraser stroke (for undo as single action)
final List<DrawingLine> _currentErasedLines = [];
/// 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});
// ---------------------------------------------------------------------------
// Getters
// ---------------------------------------------------------------------------
/// All completed lines (read-only)
List<DrawingLine> get lines => List.unmodifiable(_lines);
/// The line currently being drawn
DrawingLine? get currentLine => _currentLine;
/// Current paint preset
PaintPreset get currentPreset => _currentPreset;
/// Whether eraser mode is active
bool get isEraserMode => _isEraserMode;
/// Whether undo is available
bool get canUndo => _undoStack.isNotEmpty;
/// 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
// ---------------------------------------------------------------------------
/// Starts a new line at the given normalized position.
void startLine(Offset normalizedPoint) {
if (_isEraserMode) {
_currentErasedLines.clear();
_eraseAtPoint(normalizedPoint);
return;
}
_currentLine = DrawingLine(
points: [normalizedPoint],
color: _currentPreset.color,
strokeWidth: _currentPreset.strokeWidth,
);
notifyListeners();
}
/// Adds a point to the current line.
void addPoint(Offset normalizedPoint) {
if (_isEraserMode) {
_eraseAtPoint(normalizedPoint);
return;
}
if (_currentLine == null) return;
// Filter points that are too close to reduce memory usage
if (_currentLine!.isPointTooClose(normalizedPoint)) return;
_currentLine = _currentLine!.addPoint(normalizedPoint);
notifyListeners();
}
/// Completes the current line and adds it to the history.
void endLine() {
if (_isEraserMode) {
// If we erased lines in this stroke, record as single action
if (_currentErasedLines.isNotEmpty) {
_undoStack.add(EraseAction(List.from(_currentErasedLines)));
_redoStack.clear();
_trimHistory();
_currentErasedLines.clear();
_hasUnsavedChanges = true;
notifyListeners(); // Update UI to enable undo button
}
return;
}
if (_currentLine == null) return;
// Only add lines with at least 2 points
if (_currentLine!.points.length >= 2) {
_lines.add(_currentLine!);
_undoStack.add(AddLineAction(_currentLine!));
_redoStack.clear();
_trimHistory();
_hasUnsavedChanges = true;
}
_currentLine = null;
notifyListeners();
}
/// Trims history to maxHistorySteps to prevent memory growth.
void _trimHistory() {
while (_undoStack.length > maxHistorySteps) {
_undoStack.removeAt(0);
}
}
// ---------------------------------------------------------------------------
// Eraser Operations
// ---------------------------------------------------------------------------
/// Eraser hit radius in normalized coordinates
static const double _eraserRadius = 0.015;
/// Checks if a point is near a line and erases it if so.
void _eraseAtPoint(Offset point) {
// Find all lines that intersect with the eraser point
final linesToRemove = <DrawingLine>[];
for (final line in _lines) {
if (_lineIntersectsPoint(
line, point, _eraserRadius + line.strokeWidth / 2)) {
linesToRemove.add(line);
}
}
// Remove intersecting lines and track them for undo
for (final line in linesToRemove) {
_lines.remove(line);
_currentErasedLines.add(line);
}
if (linesToRemove.isNotEmpty) {
notifyListeners();
}
}
/// Checks if a line intersects with a circular point.
bool _lineIntersectsPoint(DrawingLine line, Offset point, double radius) {
// Check if any point is within radius
for (final linePoint in line.points) {
if ((linePoint - point).distance <= radius) {
return true;
}
}
// Check line segments
for (int i = 0; i < line.points.length - 1; i++) {
if (_pointToSegmentDistance(point, line.points[i], line.points[i + 1]) <=
radius) {
return true;
}
}
return false;
}
/// Calculates the distance from a point to a line segment.
double _pointToSegmentDistance(Offset point, Offset segStart, Offset segEnd) {
final dx = segEnd.dx - segStart.dx;
final dy = segEnd.dy - segStart.dy;
final lengthSquared = dx * dx + dy * dy;
if (lengthSquared == 0) {
// Segment is a point
return (point - segStart).distance;
}
// Project point onto the line, clamping to segment
var t = ((point.dx - segStart.dx) * dx + (point.dy - segStart.dy) * dy) /
lengthSquared;
t = t.clamp(0.0, 1.0);
final projection = Offset(
segStart.dx + t * dx,
segStart.dy + t * dy,
);
return (point - projection).distance;
}
// ---------------------------------------------------------------------------
// Eraser Mode
// ---------------------------------------------------------------------------
/// Toggles eraser mode.
void toggleEraserMode() {
_isEraserMode = !_isEraserMode;
notifyListeners();
}
/// Sets eraser mode.
void setEraserMode(bool enabled) {
if (_isEraserMode != enabled) {
_isEraserMode = enabled;
notifyListeners();
}
}
// ---------------------------------------------------------------------------
// Undo/Redo
// ---------------------------------------------------------------------------
/// Undoes the last action.
void undo() {
if (!canUndo) return;
final action = _undoStack.removeLast();
switch (action) {
case AddLineAction(:final line):
// Remove the line that was added
_lines.remove(line);
_redoStack.add(action);
case EraseAction(:final erasedLines):
// Restore the lines that were erased
_lines.addAll(erasedLines);
_redoStack.add(action);
}
_hasUnsavedChanges = true;
notifyListeners();
}
/// Redoes the last undone action.
void redo() {
if (!canRedo) return;
final action = _redoStack.removeLast();
switch (action) {
case AddLineAction(:final line):
// Re-add the line
_lines.add(line);
_undoStack.add(action);
case EraseAction(:final erasedLines):
// Re-erase the lines
for (final line in erasedLines) {
_lines.remove(line);
}
_undoStack.add(action);
}
_hasUnsavedChanges = true;
notifyListeners();
}
/// Clears all lines from the canvas.
void clear() {
_lines.clear();
_undoStack.clear();
_redoStack.clear();
_currentLine = null;
_currentErasedLines.clear();
_hasUnsavedChanges = false;
notifyListeners();
}
// ---------------------------------------------------------------------------
// Paint Preset
// ---------------------------------------------------------------------------
/// Sets the current paint preset and disables eraser mode.
void setPreset(PaintPreset preset) {
_currentPreset = preset;
_isEraserMode = false;
notifyListeners();
}
// ---------------------------------------------------------------------------
// Serialization
// ---------------------------------------------------------------------------
/// Exports all lines to a JSON-serializable list.
List<Map<String, dynamic>> toJsonList() {
return _lines.map((line) => line.toJson()).toList();
}
/// Exports all lines to a JSON string.
String toJsonString() {
return jsonEncode(toJsonList());
}
/// Imports lines from a JSON-serializable list.
void fromJsonList(List<Map<String, dynamic>> jsonList) {
_lines.clear();
_undoStack.clear();
_redoStack.clear();
_currentLine = null;
_currentErasedLines.clear();
_hasUnsavedChanges = false;
for (final json in jsonList) {
_lines.add(DrawingLine.fromJson(json));
}
notifyListeners();
}
/// Imports lines from a JSON string.
void fromJsonString(String jsonString) {
if (jsonString.isEmpty || jsonString == '[]') {
clear();
return;
}
final decoded = jsonDecode(jsonString) as List;
if (decoded.isEmpty) {
clear();
return;
}
fromJsonList(decoded.cast<Map<String, dynamic>>());
}
/// Adds existing lines without clearing (for merging annotations).
void addLines(List<DrawingLine> newLines) {
_lines.addAll(newLines);
_trimHistory();
notifyListeners();
}
/// Marks the current state as saved (resets unsaved changes flag).
void markSaved() {
_hasUnsavedChanges = false;
}
@override
void dispose() {
_lines.clear();
_undoStack.clear();
_redoStack.clear();
super.dispose();
}
}

View File

@@ -0,0 +1,99 @@
import 'dart:ui';
/// Represents a single stroke/line drawn on the canvas.
///
/// Points are stored in normalized coordinates (0.0 to 1.0) where:
/// - (0, 0) is the top-left corner of the drawing area
/// - (1, 1) is the bottom-right corner of the drawing area
///
/// This allows drawings to scale correctly when the canvas size changes.
class DrawingLine {
/// The minimal squared distance between to points which are normalized so that this point is allowed to be added to the line
static const minNormalizedPointDistanceSquared = 0.001 * 0.001;
/// Points in normalized coordinates (0.0 to 1.0)
final List<Offset> points;
/// Color of the line (stored as ARGB integer for JSON serialization)
final Color color;
/// Stroke width in normalized units (relative to canvas width)
/// A value of 0.01 means the stroke is 1% of the canvas width
final double strokeWidth;
const DrawingLine({
required this.points,
required this.color,
required this.strokeWidth,
});
/// Creates a DrawingLine from JSON data.
factory DrawingLine.fromJson(Map<String, dynamic> json) {
final pointsList = (json['points'] as List)
.map(
(p) => Offset((p['x'] as num).toDouble(), (p['y'] as num).toDouble()),
)
.toList();
return DrawingLine(
points: pointsList,
color: Color(json['color'] as int),
strokeWidth: (json['strokeWidth'] as num).toDouble(),
);
}
/// Converts this line to a JSON-serializable map.
Map<String, dynamic> toJson() {
return {
'points': points.map((p) => {'x': p.dx, 'y': p.dy}).toList(),
'color': color.toARGB32(),
'strokeWidth': strokeWidth,
};
}
/// Creates a copy of this line with an additional point.
DrawingLine addPoint(Offset normalizedPoint) {
return DrawingLine(
points: [...points, normalizedPoint],
color: color,
strokeWidth: strokeWidth,
);
}
bool isPointTooClose(Offset nextNormalizedPoint) {
if (points.isEmpty) {
return false;
}
return (points.last - nextNormalizedPoint).distanceSquared <
minNormalizedPointDistanceSquared;
}
/// Creates a copy with updated points.
DrawingLine copyWith({
List<Offset>? points,
Color? color,
double? strokeWidth,
}) {
return DrawingLine(
points: points ?? this.points,
color: color ?? this.color,
strokeWidth: strokeWidth ?? this.strokeWidth,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! DrawingLine) return false;
if (points.length != other.points.length) return false;
if (color != other.color) return false;
if (strokeWidth != other.strokeWidth) return false;
for (int i = 0; i < points.length; i++) {
if (points[i] != other.points[i]) return false;
}
return true;
}
@override
int get hashCode => Object.hash(Object.hashAll(points), color, strokeWidth);
}

View File

@@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import 'drawing_controller.dart';
import 'paint_preset.dart';
/// A floating toolbar for drawing controls.
///
/// Provides quick access to:
/// - Paint presets (pens and markers)
/// - Undo/Redo buttons
class DrawingToolbar extends StatelessWidget {
final DrawingController controller;
final VoidCallback? onClose;
const DrawingToolbar({
super.key,
required this.controller,
this.onClose,
});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Paint presets
...PaintPreset.quickAccess.map((preset) => _buildPresetButton(
context,
preset,
isSelected: !controller.isEraserMode &&
controller.currentPreset == preset,
)),
const SizedBox(width: 8),
_buildDivider(context),
const SizedBox(width: 8),
// Eraser button
_buildEraserButton(
context,
isSelected: controller.isEraserMode,
),
const SizedBox(width: 8),
_buildDivider(context),
const SizedBox(width: 8),
// Undo button
_buildActionButton(
context,
icon: Icons.undo,
onPressed: controller.canUndo ? controller.undo : null,
tooltip: 'Undo',
),
// Redo button
_buildActionButton(
context,
icon: Icons.redo,
onPressed: controller.canRedo ? controller.redo : null,
tooltip: 'Redo',
),
if (onClose != null) ...[
const SizedBox(width: 8),
_buildDivider(context),
const SizedBox(width: 8),
_buildActionButton(
context,
icon: Icons.close,
onPressed: onClose,
tooltip: 'Exit Drawing Mode',
),
],
],
),
);
},
);
}
Widget _buildPresetButton(
BuildContext context,
PaintPreset preset, {
required bool isSelected,
}) {
final isMarker = preset.strokeWidth > 0.01;
final colorScheme = Theme.of(context).colorScheme;
return Tooltip(
message: preset.name,
child: InkWell(
onTap: () => controller.setPreset(preset),
borderRadius: BorderRadius.circular(20),
child: Container(
width: 36,
height: 36,
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: isSelected
? Border.all(color: colorScheme.primary, width: 2)
: null,
),
child: Center(
child: Container(
width: isMarker ? 24 : 18,
height: isMarker ? 12 : 18,
decoration: BoxDecoration(
color: preset.color,
borderRadius: isMarker
? BorderRadius.circular(2)
: BorderRadius.circular(9),
border: preset.color.a < 1
? Border.all(color: Colors.grey.shade400, width: 0.5)
: null,
),
),
),
),
),
);
}
Widget _buildEraserButton(
BuildContext context, {
required bool isSelected,
}) {
final colorScheme = Theme.of(context).colorScheme;
return Tooltip(
message: 'Eraser',
child: InkWell(
onTap: () => controller.setEraserMode(true),
borderRadius: BorderRadius.circular(20),
child: Container(
width: 36,
height: 36,
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: isSelected
? Border.all(color: colorScheme.primary, width: 2)
: null,
),
child: Icon(
Icons.auto_fix_high,
size: 20,
color: colorScheme.onSurface,
),
),
),
);
}
Widget _buildActionButton(
BuildContext context, {
required IconData icon,
required VoidCallback? onPressed,
required String tooltip,
}) {
final colorScheme = Theme.of(context).colorScheme;
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(20),
child: Container(
width: 36,
height: 36,
margin: const EdgeInsets.symmetric(horizontal: 2),
child: Icon(
icon,
size: 20,
color: onPressed != null
? colorScheme.onSurface
: colorScheme.onSurface.withValues(alpha: 0.3),
),
),
),
);
}
Widget _buildDivider(BuildContext context) {
return Container(
width: 1,
height: 24,
color: Theme.of(context).dividerColor,
);
}
}
/// A compact floating action button to toggle paint mode.
class DrawingModeButton extends StatelessWidget {
final bool isActive;
final VoidCallback onPressed;
const DrawingModeButton({
super.key,
required this.isActive,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return FloatingActionButton.small(
onPressed: onPressed,
backgroundColor: isActive
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surface,
child: Icon(
isActive ? Icons.brush : Icons.brush_outlined,
color: isActive
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSurface,
),
);
}
}

View File

@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
/// Predefined paint configurations for common annotation styles.
///
/// Each preset defines a color and stroke width for drawing.
/// Stroke width is normalized (relative to canvas width).
class PaintPreset {
/// Display name for the preset
final String name;
/// Color of the paint (including opacity)
final Color color;
/// Stroke width in normalized units (relative to canvas width)
/// A value of 0.005 means the stroke is 0.5% of the canvas width
final double strokeWidth;
/// Icon to display for this preset
final IconData icon;
const PaintPreset({
required this.name,
required this.color,
required this.strokeWidth,
required this.icon,
});
// ---------------------------------------------------------------------------
// Default Presets
// ---------------------------------------------------------------------------
/// Black pen for writing/notes
static const blackPen = PaintPreset(
name: 'Black Pen',
color: Colors.black,
strokeWidth: 0.003, // Thin line for writing
icon: Icons.edit,
);
/// Red pen for corrections/markings
static const redPen = PaintPreset(
name: 'Red Pen',
color: Colors.red,
strokeWidth: 0.003,
icon: Icons.edit,
);
/// Blue pen for annotations
static const bluePen = PaintPreset(
name: 'Blue Pen',
color: Colors.blue,
strokeWidth: 0.003,
icon: Icons.edit,
);
/// Yellow highlighter (semi-transparent, thicker)
static const yellowMarker = PaintPreset(
name: 'Yellow Marker',
color: Color(0x80FFEB3B), // Yellow with 50% opacity
strokeWidth: 0.02, // Thicker for highlighting
icon: Icons.highlight,
);
/// Green highlighter
static const greenMarker = PaintPreset(
name: 'Green Marker',
color: Color(0x804CAF50), // Green with 50% opacity
strokeWidth: 0.018,
icon: Icons.highlight,
);
/// Pink highlighter
static const pinkMarker = PaintPreset(
name: 'Pink Marker',
color: Color(0x80E91E63), // Pink with 50% opacity
strokeWidth: 0.015,
icon: Icons.highlight,
);
/// All available default presets
static const List<PaintPreset> defaults = [
blackPen,
redPen,
bluePen,
yellowMarker,
greenMarker,
pinkMarker,
];
/// Quick access presets (shown in main toolbar)
static const List<PaintPreset> quickAccess = [
blackPen,
redPen,
bluePen,
yellowMarker,
greenMarker,
pinkMarker,
];
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is PaintPreset &&
other.name == name &&
other.color == color &&
other.strokeWidth == strokeWidth;
}
@override
int get hashCode => Object.hash(name, color, strokeWidth);
}

View File

@@ -1,18 +1,17 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_drawing_board/flutter_drawing_board.dart';
import 'package:flutter_drawing_board/paint_contents.dart';
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
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';
import '../../shared/input/pedal_shortcuts.dart';
import 'widgets/paint_mode_layer.dart';
import 'drawing/drawing.dart';
import 'widgets/pdf_page_display.dart';
import 'widgets/touch_navigation_layer.dart';
@@ -37,34 +36,50 @@ class _SheetViewerPageState extends State<SheetViewerPage>
with FullScreenListener {
final _log = Logger('SheetViewerPage');
final _storageService = StorageService();
late final AnnotationSyncService _syncService;
PdfDocument? _document;
late Future<bool> _documentLoaded;
int _currentPage = 1;
int _totalPages = 1;
bool _isPaintMode = false;
late DrawingController _drawingController;
/// Drawing controller for the current left page
late DrawingController _leftDrawingController;
/// Drawing controller for the right page (two-page mode)
late DrawingController _rightDrawingController;
@override
void initState() {
super.initState();
// Initialize drawing controller with default configuration
_drawingController = DrawingController(
config: DrawConfig(
contentType: SimpleLine,
strokeWidth: 4.0,
color: Colors.black,
),
maxHistorySteps: 100, // Limit undo/redo history (default: 100)
// Initialize sync service
_syncService = AnnotationSyncService(
apiClient: widget.apiClient,
storageService: _storageService,
);
// Initialize drawing controllers
_leftDrawingController = DrawingController(maxHistorySteps: 50);
_rightDrawingController = DrawingController(maxHistorySteps: 50);
FullScreen.addListener(this);
FullScreen.setFullScreen(widget.config.fullscreen);
if (FullScreen.isFullScreen != widget.config.fullscreen) {
FullScreen.setFullScreen(widget.config.fullscreen);
}
_documentLoaded = _loadPdf();
}
@override
void dispose() {
_drawingController.dispose();
// Make sure annotations are saved before exiting
if (_isPaintMode) {
_saveCurrentAnnotations();
}
_leftDrawingController.dispose();
_rightDrawingController.dispose();
FullScreen.removeListener(this);
_document?.dispose();
super.dispose();
@@ -89,9 +104,101 @@ 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();
return true;
}
// ---------------------------------------------------------------------------
// Annotation Persistence
// ---------------------------------------------------------------------------
/// Loads annotations for the current page(s) from storage.
Future<void> _loadAnnotationsForCurrentPages() async {
// Load left page annotations
final leftJson = await _storageService.readAnnotations(
widget.sheet.uuid,
_currentPage,
);
if (leftJson != null && leftJson.isNotEmpty) {
_leftDrawingController.fromJsonString(leftJson);
} else {
_leftDrawingController.clear();
}
// Load right page annotations (two-page mode)
if (widget.config.twoPageMode && _currentPage < _totalPages) {
final rightJson = await _storageService.readAnnotations(
widget.sheet.uuid,
_currentPage + 1,
);
if (rightJson != null && rightJson.isNotEmpty) {
_rightDrawingController.fromJsonString(rightJson);
} else {
_rightDrawingController.clear();
}
}
}
/// 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 {
final now = DateTime.now();
// Save left page only if changed
if (_leftDrawingController.hasUnsavedChanges) {
final leftJson = _leftDrawingController.toJsonString();
final leftHasContent = leftJson.isNotEmpty && leftJson != '[]';
await _storageService.writeAnnotationsWithMetadata(
widget.sheet.uuid,
_currentPage,
leftHasContent ? leftJson : null,
now,
);
// Upload left page to server
_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();
final rightHasContent = rightJson.isNotEmpty && rightJson != '[]';
await _storageService.writeAnnotationsWithMetadata(
widget.sheet.uuid,
_currentPage + 1,
rightHasContent ? rightJson : null,
now,
);
// Upload right page to server
_syncService.uploadAnnotation(
sheetUuid: widget.sheet.uuid,
page: _currentPage + 1,
annotationsJson: rightJson,
lastModified: now,
);
_rightDrawingController.markSaved();
}
}
// ---------------------------------------------------------------------------
// Fullscreen
// ---------------------------------------------------------------------------
@@ -105,21 +212,26 @@ class _SheetViewerPageState extends State<SheetViewerPage>
}
void _toggleFullscreen() {
if (_isPaintMode) {
_showSnackBar('Cannot enter fullscreen while in paint mode');
return;
}
FullScreen.setFullScreen(!widget.config.fullscreen);
FullScreen.setFullScreen(!FullScreen.isFullScreen);
}
// ---------------------------------------------------------------------------
// Navigation
// ---------------------------------------------------------------------------
void _turnPage(int delta) {
setState(() {
_currentPage = (_currentPage + delta).clamp(1, _totalPages);
});
Future<void> _turnPage(int delta) async {
// Save current annotations before turning
await _saveCurrentAnnotations();
// Calculate new page
final newPage = (_currentPage + delta).clamp(1, _totalPages);
// Load annotations for new page(s) BEFORE updating state
_currentPage = newPage;
await _loadAnnotationsForCurrentPages();
// Now update UI
setState(() {});
}
// ---------------------------------------------------------------------------
@@ -132,6 +244,11 @@ class _SheetViewerPageState extends State<SheetViewerPage>
return;
}
if (_isPaintMode) {
// Exiting paint mode - save annotations
_saveCurrentAnnotations();
}
setState(() => _isPaintMode = !_isPaintMode);
}
@@ -145,6 +262,9 @@ class _SheetViewerPageState extends State<SheetViewerPage>
widget.config.twoPageMode = !widget.config.twoPageMode;
_storageService.writeConfig(widget.config);
});
// Reload annotations for new mode
_loadAnnotationsForCurrentPages();
}
// ---------------------------------------------------------------------------
@@ -156,7 +276,12 @@ class _SheetViewerPageState extends State<SheetViewerPage>
return PedalShortcuts(
onPageForward: () => _turnPage(1),
onPageBackward: () => _turnPage(-1),
child: Scaffold(appBar: _buildAppBar(), body: _buildBody()),
child: Scaffold(
appBar: _buildAppBar(),
body: _buildBody(),
floatingActionButton: _isPaintMode ? _buildDrawingToolbar() : null,
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
),
);
}
@@ -180,7 +305,7 @@ class _SheetViewerPageState extends State<SheetViewerPage>
),
IconButton(
icon: Icon(_isPaintMode ? Icons.brush : Icons.brush_outlined),
tooltip: 'Toggle Paint Mode',
tooltip: _isPaintMode ? 'Exit Paint Mode' : 'Enter Paint Mode',
onPressed: _togglePaintMode,
),
IconButton(
@@ -220,30 +345,37 @@ class _SheetViewerPageState extends State<SheetViewerPage>
numPages: _totalPages,
currentPageNumber: _currentPage,
config: widget.config,
leftDrawingController: _leftDrawingController,
rightDrawingController: widget.config.twoPageMode
? _rightDrawingController
: null,
drawingEnabled: _isPaintMode,
);
return Stack(
children: [
// Show touch navigation when not in paint mode
Visibility(
visible: !_isPaintMode,
child: TouchNavigationLayer(
pageDisplay: pageDisplay,
config: widget.config,
onToggleFullscreen: _toggleFullscreen,
onExit: () => Navigator.pop(context),
onPageTurn: _turnPage,
),
// When in paint mode, show the page display directly (DrawingBoard handles zoom/pan)
if (_isPaintMode) {
return pageDisplay;
}
// When not in paint mode, wrap with touch navigation
return TouchNavigationLayer(
pageDisplay: pageDisplay,
config: widget.config,
onToggleFullscreen: _toggleFullscreen,
onExit: () => Navigator.pop(context),
onPageTurn: _turnPage,
);
}
Widget _buildDrawingToolbar() {
return SafeArea(
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: DrawingToolbar(
controller: _leftDrawingController,
onClose: _togglePaintMode,
),
// Show paint mode layer when active
Visibility(
visible: _isPaintMode,
child: PaintModeLayer(
pageDisplay: pageDisplay,
drawingController: _drawingController,
),
),
],
),
);
}
@@ -264,7 +396,7 @@ class _SheetViewerPageState extends State<SheetViewerPage>
void _showSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), duration: Duration(seconds: 2)),
SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
);
}
}

View File

@@ -1,45 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_drawing_board/flutter_drawing_board.dart';
import 'pdf_page_display.dart';
/// Drawing overlay for annotating PDF pages.
///
/// Uses flutter_drawing_board to provide a paint canvas over the PDF.
/// Only working in single-page mode.
class PaintModeLayer extends StatelessWidget {
final PdfPageDisplay pageDisplay;
final DrawingController drawingController;
const PaintModeLayer({
super.key,
required this.pageDisplay,
required this.drawingController,
});
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: LayoutBuilder(
builder: (context, constraints) {
final maxSize = Size(constraints.maxWidth, constraints.maxHeight);
final (pageSize, _) = pageDisplay.calculateScaledPageSizes(maxSize);
return DrawingBoard(
controller: drawingController,
background: SizedBox(
width: pageSize.width,
height: pageSize.height,
child: pageDisplay,
),
boardConstrained: true,
minScale: 1,
maxScale: 3,
alignment: Alignment.topRight,
boardBoundaryMargin: EdgeInsets.zero,
);
},
),
);
}
}

View File

@@ -4,20 +4,33 @@ import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import '../../../core/models/config.dart';
import '../drawing/drawing.dart';
/// Displays PDF pages with optional two-page mode.
/// Displays PDF pages with optional two-page mode and drawing overlay.
class PdfPageDisplay extends StatelessWidget {
final PdfDocument document;
final int numPages;
final int currentPageNumber;
final Config config;
/// Controller for the left/main page drawing
final DrawingController? leftDrawingController;
/// Controller for the right page drawing (two-page mode only)
final DrawingController? rightDrawingController;
/// Whether drawing is enabled
final bool drawingEnabled;
const PdfPageDisplay({
super.key,
required this.document,
required this.numPages,
required this.currentPageNumber,
required this.config,
this.leftDrawingController,
this.rightDrawingController,
this.drawingEnabled = false,
});
/// Whether two-page mode is active and we have enough pages.
@@ -25,55 +38,102 @@ class PdfPageDisplay extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [_buildLeftPage(), if (_showTwoPages) _buildRightPage()],
);
}
return LayoutBuilder(
builder: (context, constraints) {
final maxSize = Size(constraints.maxWidth, constraints.maxHeight);
final (leftSize, rightSize) = calculateScaledPageSizes(maxSize);
Widget _buildLeftPage() {
return Expanded(
child: Stack(
children: [
PdfPageView(
key: ValueKey(currentPageNumber),
document: document,
if (_showTwoPages) {
// Two-page mode: pages touch each other and are centered together
return Center(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildPage(
pageNumber: currentPageNumber,
pageSize: leftSize,
controller: leftDrawingController,
),
// Only show right page if there is one
if (currentPageNumber < numPages)
_buildPage(
pageNumber: currentPageNumber + 1,
pageSize: rightSize!,
controller: rightDrawingController,
)
else
// Empty space to keep left page position consistent on last page
SizedBox(width: rightSize!.width, height: rightSize.height),
],
),
);
}
// Single page mode
return Center(
child: _buildPage(
pageNumber: currentPageNumber,
maximumDpi: 300,
alignment: _showTwoPages ? Alignment.centerRight : Alignment.center,
pageSize: leftSize,
controller: leftDrawingController,
),
_buildPageIndicator(currentPageNumber),
],
),
);
},
);
}
Widget _buildRightPage() {
final rightPageNumber = currentPageNumber + 1;
return Expanded(
Widget _buildPage({
required int pageNumber,
required Size pageSize,
required DrawingController? controller,
}) {
final pdfPage = SizedBox(
width: pageSize.width,
height: pageSize.height,
child: Stack(
children: [
PdfPageView(
key: ValueKey(rightPageNumber),
key: ValueKey(pageNumber),
document: document,
pageNumber: rightPageNumber,
pageNumber: pageNumber,
maximumDpi: 300,
alignment: Alignment.centerLeft,
alignment: Alignment.center,
),
_buildPageIndicator(rightPageNumber),
_buildPageIndicator(pageNumber, pageSize),
],
),
);
// If no controller, just show the PDF
if (controller == null) {
return pdfPage;
}
// Wrap with DrawingBoard
return DrawingBoard(
boardSize: pageSize,
controller: controller,
drawingEnabled: drawingEnabled,
minScale: 1.0,
maxScale: 3.0,
alignment: Alignment.center,
child: pdfPage,
);
}
Widget _buildPageIndicator(int pageNumber) {
return Positioned.fill(
child: Container(
alignment: Alignment.bottomCenter,
padding: const EdgeInsets.only(bottom: 5),
child: Text('$pageNumber / $numPages'),
Widget _buildPageIndicator(int pageNumber, Size pageSize) {
return Positioned(
bottom: 5,
left: 0,
right: 0,
child: Center(
child: Text(
'$pageNumber / $numPages',
style: const TextStyle(
fontSize: 12,
color: Colors.black54,
),
),
),
);
}

View File

@@ -4,7 +4,7 @@ import '../../../core/models/config.dart';
import 'pdf_page_display.dart';
/// Callback for page turn events.
typedef PageTurnCallback = void Function(int delta);
typedef PageTurnCallback = dynamic Function(int delta);
/// Gesture layer for touch-based navigation over PDF pages.
///

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
@@ -88,7 +86,6 @@ class _EditSheetBottomSheetState extends State<EditSheetBottomSheet> {
const SizedBox(height: 12),
TextFormField(
controller: _composerController,
validator: _validateNotEmpty,
decoration: const InputDecoration(
labelText: 'Composer',
border: OutlineInputBorder(),

View File

@@ -142,14 +142,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_drawing_board:
dependency: "direct main"
description:
name: flutter_drawing_board
sha256: "0fc6b73ac6a54f23d0357ff3f3a804156315f43212a417406062462fe2e3ca7b"
url: "https://pub.dev"
source: hosted
version: "1.0.1+2"
flutter_fullscreen:
dependency: "direct main"
description:
@@ -372,10 +364,10 @@ packages:
dependency: transitive
description:
name: objective_c
sha256: "983c7fa1501f6dcc0cb7af4e42072e9993cb28d73604d25ebf4dab08165d997e"
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.2.5"
version: "9.3.0"
package_info_plus:
dependency: "direct main"
description:
@@ -625,10 +617,10 @@ packages:
dependency: transitive
description:
name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.1"
version: "1.10.2"
sqflite:
dependency: transitive
description:

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.1.2
version: 0.2.0
environment:
sdk: ^3.0.0
@@ -45,7 +45,6 @@ dependencies:
jwt_decoder: ^2.0.1
pdfrx: ^2.0.4
logging: ^1.3.0
flutter_drawing_board: ^1.0.1+2
flutter_launcher_icons: ^0.14.4
hive: ^2.2.3
flutter_fullscreen: ^1.2.0