Compare commits
28 Commits
d4d6e41a9d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 9261ec341e | |||
| 7ec7b20e74 | |||
| 829bf7512a | |||
| 4fe8896f9e | |||
| d3addc7973 | |||
| f1b1ccf6be | |||
| 99300478d5 | |||
| f6585e9850 | |||
| 16589be409 | |||
| 6669e2446c | |||
| b62ed98375 | |||
| a70c634d35 | |||
| 0fdf56c084 | |||
| a57831f50b | |||
| 8f05e9244a | |||
| d01e1384d4 | |||
| d0fd96a2f5 | |||
| 58157a2e6e | |||
| 9a11e42571 | |||
| e5c71c9261 | |||
| 4fd287181b | |||
| 181e853790 | |||
| d94e9eeb3d | |||
| d1b5cb54f4 | |||
| b36011d9e8 | |||
| 421171f1a3 | |||
| 3b12be497e | |||
| f615ed5654 |
@@ -11,12 +11,12 @@ android {
|
|||||||
ndkVersion = "27.2.12479018"
|
ndkVersion = "27.2.12479018"
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
|
|||||||
@@ -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 {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
@@ -19,3 +29,4 @@ subprojects {
|
|||||||
tasks.register<Delete>("clean") {
|
tasks.register<Delete>("clean") {
|
||||||
delete(rootProject.layout.buildDirectory)
|
delete(rootProject.layout.buildDirectory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
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
|
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,24 +13,36 @@ enum ChangeType {
|
|||||||
/// Represents a single pending change to be synced with the server.
|
/// Represents a single pending change to be synced with the server.
|
||||||
///
|
///
|
||||||
/// Changes are stored locally when offline and applied once
|
/// Changes are stored locally when offline and applied once
|
||||||
/// the device regains connectivity.
|
/// the device regains connectivity. Each change has a [createdAt] timestamp
|
||||||
|
/// that the server uses to resolve conflicts between devices.
|
||||||
class Change {
|
class Change {
|
||||||
final ChangeType type;
|
final ChangeType type;
|
||||||
final String sheetUuid;
|
final String sheetUuid;
|
||||||
final String value;
|
final String value;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
Change({
|
Change({
|
||||||
required this.type,
|
required this.type,
|
||||||
required this.sheetUuid,
|
required this.sheetUuid,
|
||||||
required this.value,
|
required this.value,
|
||||||
});
|
DateTime? createdAt,
|
||||||
|
}) : createdAt = createdAt ?? DateTime.now();
|
||||||
|
|
||||||
/// Serializes this change to a map for storage.
|
/// Serializes this change to a map for storage.
|
||||||
Map<String, dynamic> toMap() => {
|
Map<String, dynamic> toMap() => {
|
||||||
'type': type.index,
|
'type': type.index,
|
||||||
'sheetUuid': sheetUuid,
|
'sheetUuid': sheetUuid,
|
||||||
'value': value,
|
'value': value,
|
||||||
};
|
'createdAt': createdAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Serializes this change to JSON for API requests.
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'type': type.name,
|
||||||
|
'sheetUuid': sheetUuid,
|
||||||
|
'value': value,
|
||||||
|
'createdAt': createdAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
/// Deserializes a change from a stored map.
|
/// Deserializes a change from a stored map.
|
||||||
///
|
///
|
||||||
@@ -41,6 +53,9 @@ class Change {
|
|||||||
type: ChangeType.values[map['type']],
|
type: ChangeType.values[map['type']],
|
||||||
sheetUuid: map['sheetUuid'],
|
sheetUuid: map['sheetUuid'],
|
||||||
value: map['value'],
|
value: map['value'],
|
||||||
|
createdAt: map['createdAt'] != null
|
||||||
|
? DateTime.parse(map['createdAt'])
|
||||||
|
: DateTime.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,16 +91,15 @@ class ChangeQueue {
|
|||||||
for (final change in _queue) {
|
for (final change in _queue) {
|
||||||
final sheet = sheets.firstWhere(
|
final sheet = sheets.firstWhere(
|
||||||
(s) => s.uuid == change.sheetUuid,
|
(s) => s.uuid == change.sheetUuid,
|
||||||
orElse: () => throw StateError(
|
orElse: () =>
|
||||||
'Sheet with UUID ${change.sheetUuid} not found',
|
throw StateError('Sheet with UUID ${change.sheetUuid} not found'),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
switch (change.type) {
|
switch (change.type) {
|
||||||
case ChangeType.sheetNameChange:
|
case ChangeType.sheetNameChange:
|
||||||
sheet.name = change.value;
|
sheet.name = change.value;
|
||||||
case ChangeType.composerNameChange:
|
case ChangeType.composerNameChange:
|
||||||
sheet.composerName = change.value;
|
sheet.composer = change.value;
|
||||||
case ChangeType.addTagChange:
|
case ChangeType.addTagChange:
|
||||||
throw UnimplementedError('Tag support not yet implemented');
|
throw UnimplementedError('Tag support not yet implemented');
|
||||||
case ChangeType.removeTagChange:
|
case ChangeType.removeTagChange:
|
||||||
|
|||||||
@@ -5,36 +5,31 @@
|
|||||||
class Sheet {
|
class Sheet {
|
||||||
final String uuid;
|
final String uuid;
|
||||||
String name;
|
String name;
|
||||||
String composerUuid;
|
String composer;
|
||||||
String composerName;
|
|
||||||
DateTime updatedAt;
|
DateTime updatedAt;
|
||||||
|
|
||||||
Sheet({
|
Sheet({
|
||||||
required this.uuid,
|
required this.uuid,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.composerUuid,
|
required this.composer,
|
||||||
required this.composerName,
|
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Creates a [Sheet] from a JSON map returned by the API.
|
/// Creates a [Sheet] from a JSON map returned by the API.
|
||||||
factory Sheet.fromJson(Map<String, dynamic> json) {
|
factory Sheet.fromJson(Map<String, dynamic> json) {
|
||||||
final composer = json['composer'] as Map<String, dynamic>?;
|
|
||||||
return Sheet(
|
return Sheet(
|
||||||
uuid: json['uuid'].toString(),
|
uuid: json['uuid'],
|
||||||
name: json['title'],
|
name: json['title'],
|
||||||
composerUuid: json['composer_uuid']?.toString() ?? '',
|
composer: json['composer'],
|
||||||
composerName: composer?['name'] ?? 'Unknown',
|
|
||||||
updatedAt: DateTime.parse(json['updated_at']),
|
updatedAt: DateTime.parse(json['updated_at']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Converts this sheet to a JSON map for API requests.
|
/// Converts this sheet to a JSON map for API requests.
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'uuid': uuid,
|
'uuid': uuid,
|
||||||
'title': name,
|
'title': name,
|
||||||
'composer_uuid': composerUuid,
|
'composer': composer,
|
||||||
'composer_name': composerName,
|
'updated_at': updatedAt.toIso8601String(),
|
||||||
'updated_at': updatedAt.toIso8601String(),
|
};
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
149
lib/core/services/annotation_sync_service.dart
Normal file
149
lib/core/services/annotation_sync_service.dart
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
|
|||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../models/change.dart';
|
||||||
import '../models/sheet.dart';
|
import '../models/sheet.dart';
|
||||||
|
|
||||||
/// HTTP client for communicating with the Sheetless API server.
|
/// HTTP client for communicating with the Sheetless API server.
|
||||||
@@ -70,13 +71,12 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Performs a GET request to the given endpoint.
|
/// Performs a GET request to the given endpoint.
|
||||||
Future<http.Response> get(
|
Future<http.Response> get(String endpoint, {bool isBinary = false}) async {
|
||||||
String endpoint, {
|
|
||||||
bool isBinary = false,
|
|
||||||
}) async {
|
|
||||||
final url = Uri.parse('$baseUrl$endpoint');
|
final url = Uri.parse('$baseUrl$endpoint');
|
||||||
final response =
|
final response = await http.get(
|
||||||
await http.get(url, headers: _buildHeaders(isBinary: isBinary));
|
url,
|
||||||
|
headers: _buildHeaders(isBinary: isBinary),
|
||||||
|
);
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
_log.warning(
|
_log.warning(
|
||||||
@@ -93,10 +93,7 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Performs a POST request with JSON body.
|
/// Performs a POST request with JSON body.
|
||||||
Future<http.Response> post(
|
Future<http.Response> post(String endpoint, Map<String, dynamic> body) async {
|
||||||
String endpoint,
|
|
||||||
Map<String, dynamic> body,
|
|
||||||
) async {
|
|
||||||
final url = Uri.parse('$baseUrl$endpoint');
|
final url = Uri.parse('$baseUrl$endpoint');
|
||||||
|
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
@@ -187,6 +184,101 @@ class ApiClient {
|
|||||||
_log.info('PDF cached at: ${cachedFile.path}');
|
_log.info('PDF cached at: ${cachedFile.path}');
|
||||||
return cachedFile;
|
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.
|
/// Exception thrown when an API request fails.
|
||||||
|
|||||||
@@ -1,11 +1,73 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:hive/hive.dart';
|
import 'package:hive/hive.dart';
|
||||||
import 'package:sheetless/core/models/change.dart';
|
import 'package:sheetless/core/models/change.dart';
|
||||||
import 'package:sheetless/core/models/config.dart';
|
import 'package:sheetless/core/models/config.dart';
|
||||||
|
import 'package:sheetless/core/models/sheet.dart';
|
||||||
|
|
||||||
/// Keys for secure storage (credentials and tokens).
|
/// Keys for secure storage (credentials and tokens).
|
||||||
enum SecureStorageKey { url, jwt, email }
|
enum SecureStorageKey { url, jwt, email }
|
||||||
|
|
||||||
|
/// 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.
|
/// Service for managing local storage operations.
|
||||||
///
|
///
|
||||||
/// Uses [FlutterSecureStorage] for sensitive data (credentials, tokens)
|
/// Uses [FlutterSecureStorage] for sensitive data (credentials, tokens)
|
||||||
@@ -17,13 +79,13 @@ class StorageService {
|
|||||||
static const String _configBox = 'config';
|
static const String _configBox = 'config';
|
||||||
static const String _changeQueueBox = 'changeQueue';
|
static const String _changeQueueBox = 'changeQueue';
|
||||||
static const String _annotationsBox = 'annotations';
|
static const String _annotationsBox = 'annotations';
|
||||||
|
static const String _sheetsBox = 'sheets';
|
||||||
|
static const String _pendingAnnotationsBox = 'pendingAnnotations';
|
||||||
|
|
||||||
late final FlutterSecureStorage _secureStorage;
|
late final FlutterSecureStorage _secureStorage;
|
||||||
|
|
||||||
StorageService() {
|
StorageService() {
|
||||||
_secureStorage = FlutterSecureStorage(
|
_secureStorage = FlutterSecureStorage();
|
||||||
aOptions: const AndroidOptions(encryptedSharedPreferences: true),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -42,9 +104,32 @@ class StorageService {
|
|||||||
return _secureStorage.write(key: key.name, value: value);
|
return _secureStorage.write(key: key.name, value: value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clears the JWT token from secure storage.
|
/// Clears all user data except URL and email.
|
||||||
Future<void> clearToken() {
|
///
|
||||||
return writeSecure(SecureStorageKey.jwt, null);
|
/// 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,9 +162,8 @@ class StorageService {
|
|||||||
Future<Map<String, DateTime>> readSheetAccessTimes() async {
|
Future<Map<String, DateTime>> readSheetAccessTimes() async {
|
||||||
final box = await Hive.openBox(_sheetAccessTimesBox);
|
final box = await Hive.openBox(_sheetAccessTimesBox);
|
||||||
return box.toMap().map(
|
return box.toMap().map(
|
||||||
(key, value) =>
|
(key, value) => MapEntry(key as String, DateTime.parse(value as String)),
|
||||||
MapEntry(key as String, DateTime.parse(value as String)),
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Records when a sheet was last accessed.
|
/// Records when a sheet was last accessed.
|
||||||
@@ -134,16 +218,26 @@ class StorageService {
|
|||||||
/// Returns the JSON string of annotations, or null if none exist.
|
/// Returns the JSON string of annotations, or null if none exist.
|
||||||
Future<String?> readAnnotations(String sheetUuid, int pageNumber) async {
|
Future<String?> readAnnotations(String sheetUuid, int pageNumber) async {
|
||||||
final box = await Hive.openBox(_annotationsBox);
|
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.
|
/// Used when syncing from server to preserve server's timestamp.
|
||||||
Future<void> writeAnnotations(
|
Future<void> writeAnnotationsWithMetadata(
|
||||||
String sheetUuid,
|
String sheetUuid,
|
||||||
int pageNumber,
|
int pageNumber,
|
||||||
String? annotationsJson,
|
String? annotationsJson,
|
||||||
|
DateTime lastModified,
|
||||||
) async {
|
) async {
|
||||||
final box = await Hive.openBox(_annotationsBox);
|
final box = await Hive.openBox(_annotationsBox);
|
||||||
final key = _annotationKey(sheetUuid, pageNumber);
|
final key = _annotationKey(sheetUuid, pageNumber);
|
||||||
@@ -151,7 +245,11 @@ class StorageService {
|
|||||||
if (annotationsJson == null || annotationsJson.isEmpty) {
|
if (annotationsJson == null || annotationsJson.isEmpty) {
|
||||||
await box.delete(key);
|
await box.delete(key);
|
||||||
} else {
|
} 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);
|
final pageNumber = int.tryParse(pageStr);
|
||||||
if (pageNumber != null) {
|
if (pageNumber != null) {
|
||||||
final value = box.get(key);
|
final value = box.get(key);
|
||||||
if (value != null && value is String && value.isNotEmpty) {
|
if (value != null) {
|
||||||
result[pageNumber] = value;
|
// 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 {
|
Future<void> deleteAllAnnotations(String sheetUuid) async {
|
||||||
final box = await Hive.openBox(_annotationsBox);
|
final box = await Hive.openBox(_annotationsBox);
|
||||||
final prefix = '${sheetUuid}_page_';
|
final prefix = '${sheetUuid}_page_';
|
||||||
final keysToDelete =
|
final keysToDelete = box.keys.where(
|
||||||
box.keys.where((key) => key is String && key.startsWith(prefix));
|
(key) => key is String && key.startsWith(prefix),
|
||||||
|
);
|
||||||
|
|
||||||
for (final key in keysToDelete.toList()) {
|
for (final key in keysToDelete.toList()) {
|
||||||
await box.delete(key);
|
await box.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sheets Cache (Offline Support)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Reads cached sheets from local storage.
|
||||||
|
///
|
||||||
|
/// Returns an empty list if no cached sheets exist.
|
||||||
|
Future<List<Sheet>> readCachedSheets() async {
|
||||||
|
final box = await Hive.openBox(_sheetsBox);
|
||||||
|
final sheetsJson = box.get('sheets');
|
||||||
|
|
||||||
|
if (sheetsJson == null) return [];
|
||||||
|
|
||||||
|
final List<dynamic> sheetsList = jsonDecode(sheetsJson as String);
|
||||||
|
return sheetsList
|
||||||
|
.map((json) => Sheet.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Caches the sheets list to local storage.
|
||||||
|
Future<void> writeCachedSheets(List<Sheet> sheets) async {
|
||||||
|
final box = await Hive.openBox(_sheetsBox);
|
||||||
|
final sheetsJson = jsonEncode(sheets.map((s) => s.toJson()).toList());
|
||||||
|
await box.put('sheets', sheetsJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pending Annotation Uploads (Offline Support)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Adds or updates a pending annotation upload.
|
||||||
|
///
|
||||||
|
/// If an upload for the same sheet/page already exists, it will be replaced
|
||||||
|
/// with the newer version.
|
||||||
|
Future<void> writePendingAnnotationUpload(
|
||||||
|
PendingAnnotationUpload upload,
|
||||||
|
) async {
|
||||||
|
final box = await Hive.openBox(_pendingAnnotationsBox);
|
||||||
|
await box.put(upload.key, upload.toMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads all pending annotation uploads.
|
||||||
|
Future<List<PendingAnnotationUpload>> readPendingAnnotationUploads() async {
|
||||||
|
final box = await Hive.openBox(_pendingAnnotationsBox);
|
||||||
|
final uploads = <PendingAnnotationUpload>[];
|
||||||
|
|
||||||
|
for (final value in box.values) {
|
||||||
|
uploads.add(PendingAnnotationUpload.fromMap(value as Map));
|
||||||
|
}
|
||||||
|
|
||||||
|
return uploads;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a pending annotation upload after successful sync.
|
||||||
|
Future<void> deletePendingAnnotationUpload(String key) async {
|
||||||
|
final box = await Hive.openBox(_pendingAnnotationsBox);
|
||||||
|
await box.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if there are any pending annotation uploads.
|
||||||
|
Future<bool> hasPendingAnnotationUploads() async {
|
||||||
|
final box = await Hive.openBox(_pendingAnnotationsBox);
|
||||||
|
return box.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Change Queue Enhancements
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Returns the number of pending changes.
|
||||||
|
Future<int> getChangeQueueLength() async {
|
||||||
|
final box = await Hive.openBox(_changeQueueBox);
|
||||||
|
return box.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears all pending changes.
|
||||||
|
///
|
||||||
|
/// Use with caution - only call after all changes are synced.
|
||||||
|
Future<void> clearChangeQueue() async {
|
||||||
|
final box = await Hive.openBox(_changeQueueBox);
|
||||||
|
await box.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets all changes as a list (for batch upload).
|
||||||
|
Future<List<Change>> readChangeList() async {
|
||||||
|
final box = await Hive.openBox(_changeQueueBox);
|
||||||
|
return box.values
|
||||||
|
.map((map) => Change.fromMap(map as Map<dynamic, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
292
lib/core/services/sync_service.dart
Normal file
292
lib/core/services/sync_service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import 'package:sheetless/core/models/config.dart';
|
|||||||
import 'package:sheetless/core/models/sheet.dart';
|
import 'package:sheetless/core/models/sheet.dart';
|
||||||
import 'package:sheetless/core/services/api_client.dart';
|
import 'package:sheetless/core/services/api_client.dart';
|
||||||
import 'package:sheetless/core/services/storage_service.dart';
|
import 'package:sheetless/core/services/storage_service.dart';
|
||||||
|
import 'package:sheetless/core/services/sync_service.dart';
|
||||||
|
|
||||||
import '../../app.dart';
|
import '../../app.dart';
|
||||||
import '../auth/login_page.dart';
|
import '../auth/login_page.dart';
|
||||||
@@ -34,8 +35,11 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
final _storageService = StorageService();
|
final _storageService = StorageService();
|
||||||
|
|
||||||
ApiClient? _apiClient;
|
ApiClient? _apiClient;
|
||||||
late Future<List<Sheet>> _sheetsFuture;
|
SyncService? _syncService;
|
||||||
|
late Future<SyncResult> _syncFuture;
|
||||||
|
List<Sheet> _sheets = [];
|
||||||
bool _isShuffling = false;
|
bool _isShuffling = false;
|
||||||
|
bool _isOnline = true;
|
||||||
String? _appName;
|
String? _appName;
|
||||||
String? _appVersion;
|
String? _appVersion;
|
||||||
|
|
||||||
@@ -44,7 +48,9 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
// Exit fullscreen when entering home page
|
// Exit fullscreen when entering home page
|
||||||
FullScreen.setFullScreen(false);
|
if (FullScreen.isFullScreen) {
|
||||||
|
FullScreen.setFullScreen(false);
|
||||||
|
}
|
||||||
|
|
||||||
// Subscribe to route changes
|
// Subscribe to route changes
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -52,7 +58,7 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
});
|
});
|
||||||
|
|
||||||
_loadAppInfo();
|
_loadAppInfo();
|
||||||
_sheetsFuture = _loadSheets();
|
_syncFuture = _loadSheets();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -67,14 +73,19 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void didPush() {
|
void didPush() {
|
||||||
FullScreen.setFullScreen(false);
|
// Exit fullscreen when entering home page
|
||||||
|
if (FullScreen.isFullScreen) {
|
||||||
|
FullScreen.setFullScreen(false);
|
||||||
|
}
|
||||||
super.didPush();
|
super.didPush();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didPopNext() {
|
void didPopNext() {
|
||||||
// Exit fullscreen when returning to home page
|
// Exit fullscreen when returning to home page
|
||||||
FullScreen.setFullScreen(false);
|
if (FullScreen.isFullScreen) {
|
||||||
|
FullScreen.setFullScreen(false);
|
||||||
|
}
|
||||||
super.didPopNext();
|
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 url = await _storageService.readSecure(SecureStorageKey.url);
|
||||||
final jwt = await _storageService.readSecure(SecureStorageKey.jwt);
|
final jwt = await _storageService.readSecure(SecureStorageKey.jwt);
|
||||||
|
|
||||||
_apiClient = ApiClient(baseUrl: url!, token: jwt);
|
_apiClient = ApiClient(baseUrl: url!, token: jwt);
|
||||||
|
_syncService = SyncService(
|
||||||
|
apiClient: _apiClient!,
|
||||||
|
storageService: _storageService,
|
||||||
|
);
|
||||||
|
|
||||||
final sheets = await _apiClient!.fetchSheets();
|
// Perform sync (fetches sheets, uploads pending changes/annotations)
|
||||||
_log.info('${sheets.length} sheets fetched');
|
final result = await _syncService!.sync();
|
||||||
|
_log.info(
|
||||||
|
'${result.sheets.length} sheets loaded (online: ${result.isOnline}, '
|
||||||
|
'changes synced: ${result.changesSynced}, '
|
||||||
|
'annotations synced: ${result.annotationsSynced})',
|
||||||
|
);
|
||||||
|
|
||||||
final sortedSheets = await _sortSheetsByRecency(sheets);
|
// Sort and store sheets
|
||||||
_log.info('${sortedSheets.length} sheets sorted');
|
_sheets = await _sortSheetsByRecency(result.sheets);
|
||||||
|
_isOnline = result.isOnline;
|
||||||
|
|
||||||
return sortedSheets;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Sheet>> _sortSheetsByRecency(List<Sheet> sheets) async {
|
Future<List<Sheet>> _sortSheetsByRecency(List<Sheet> sheets) async {
|
||||||
@@ -128,7 +149,7 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
|
|
||||||
Future<void> _refreshSheets() async {
|
Future<void> _refreshSheets() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_sheetsFuture = _loadSheets();
|
_syncFuture = _loadSheets();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,19 +158,17 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
void _handleShuffleChanged(bool enabled) async {
|
void _handleShuffleChanged(bool enabled) async {
|
||||||
final sheets = await _sheetsFuture;
|
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
sheets.shuffle();
|
_sheets.shuffle();
|
||||||
} else {
|
} else {
|
||||||
await _sortSheetsByRecency(sheets);
|
await _sortSheetsByRecency(_sheets);
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _isShuffling = enabled);
|
setState(() => _isShuffling = enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleLogout() async {
|
Future<void> _handleLogout() async {
|
||||||
await _storageService.clearToken();
|
await _storageService.clearAllUserData();
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
@@ -181,21 +200,34 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Sheetless')),
|
appBar: AppBar(
|
||||||
|
title: 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(
|
endDrawer: AppDrawer(
|
||||||
isShuffling: _isShuffling,
|
isShuffling: _isShuffling,
|
||||||
onShuffleChanged: _handleShuffleChanged,
|
onShuffleChanged: _handleShuffleChanged,
|
||||||
onLogout: _handleLogout,
|
onLogout: _handleLogout,
|
||||||
appName: _appName,
|
appName: _appName,
|
||||||
appVersion: _appVersion,
|
appVersion: _appVersion,
|
||||||
|
syncFuture: _syncFuture,
|
||||||
),
|
),
|
||||||
body: RefreshIndicator(onRefresh: _refreshSheets, child: _buildBody()),
|
body: RefreshIndicator(onRefresh: _refreshSheets, child: _buildBody()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody() {
|
Widget _buildBody() {
|
||||||
return FutureBuilder<List<Sheet>>(
|
return FutureBuilder<SyncResult>(
|
||||||
future: _sheetsFuture,
|
future: _syncFuture,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState != ConnectionState.done) {
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
@@ -208,8 +240,9 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
|
|
||||||
if (snapshot.hasData) {
|
if (snapshot.hasData) {
|
||||||
return SheetsList(
|
return SheetsList(
|
||||||
sheets: snapshot.data!,
|
sheets: _sheets,
|
||||||
onSheetSelected: _openSheet,
|
onSheetSelected: _openSheet,
|
||||||
|
syncService: _syncService!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sheetless/core/services/sync_service.dart';
|
||||||
|
|
||||||
/// Callback for shuffle state changes.
|
/// Callback for shuffle state changes.
|
||||||
typedef ShuffleCallback = void Function(bool enabled);
|
typedef ShuffleCallback = void Function(bool enabled);
|
||||||
@@ -12,12 +13,14 @@ class AppDrawer extends StatelessWidget {
|
|||||||
final VoidCallback onLogout;
|
final VoidCallback onLogout;
|
||||||
final String? appName;
|
final String? appName;
|
||||||
final String? appVersion;
|
final String? appVersion;
|
||||||
|
final Future<SyncResult> syncFuture;
|
||||||
|
|
||||||
const AppDrawer({
|
const AppDrawer({
|
||||||
super.key,
|
super.key,
|
||||||
required this.isShuffling,
|
required this.isShuffling,
|
||||||
required this.onShuffleChanged,
|
required this.onShuffleChanged,
|
||||||
required this.onLogout,
|
required this.onLogout,
|
||||||
|
required this.syncFuture,
|
||||||
this.appName,
|
this.appName,
|
||||||
this.appVersion,
|
this.appVersion,
|
||||||
});
|
});
|
||||||
@@ -32,7 +35,7 @@ class AppDrawer extends StatelessWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
_buildActions(),
|
_buildActions(),
|
||||||
_buildAppInfo(),
|
Column(children: [_buildSyncStatus(), _buildAppInfo()]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -44,10 +47,7 @@ class AppDrawer extends StatelessWidget {
|
|||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(
|
leading: Icon(Icons.shuffle, color: isShuffling ? Colors.blue : null),
|
||||||
Icons.shuffle,
|
|
||||||
color: isShuffling ? Colors.blue : null,
|
|
||||||
),
|
|
||||||
title: const Text('Shuffle'),
|
title: const Text('Shuffle'),
|
||||||
onTap: () => onShuffleChanged(!isShuffling),
|
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() {
|
Widget _buildAppInfo() {
|
||||||
final versionText = appName != null && appVersion != null
|
final versionText = appName != null && appVersion != null
|
||||||
? '$appName v$appVersion'
|
? '$appName v$appVersion'
|
||||||
@@ -67,10 +108,7 @@ class AppDrawer extends StatelessWidget {
|
|||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Text(
|
child: Text(versionText, style: const TextStyle(color: Colors.grey)),
|
||||||
versionText,
|
|
||||||
style: const TextStyle(color: Colors.grey),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class SheetListItem extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(sheet.name),
|
title: Text(sheet.name),
|
||||||
subtitle: Text(sheet.composerName),
|
subtitle: Text(sheet.composer),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
onLongPress: onLongPress,
|
onLongPress: onLongPress,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'dart:ui';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:sheetless/core/models/change.dart';
|
import 'package:sheetless/core/models/change.dart';
|
||||||
import 'package:sheetless/core/models/sheet.dart';
|
import 'package:sheetless/core/models/sheet.dart';
|
||||||
import 'package:sheetless/core/services/storage_service.dart';
|
import 'package:sheetless/core/services/sync_service.dart';
|
||||||
|
|
||||||
import '../../../shared/widgets/edit_sheet_bottom_sheet.dart';
|
import '../../../shared/widgets/edit_sheet_bottom_sheet.dart';
|
||||||
import 'sheet_list_item.dart';
|
import 'sheet_list_item.dart';
|
||||||
@@ -19,11 +19,13 @@ import 'sheet_search_bar.dart';
|
|||||||
class SheetsList extends StatefulWidget {
|
class SheetsList extends StatefulWidget {
|
||||||
final List<Sheet> sheets;
|
final List<Sheet> sheets;
|
||||||
final ValueSetter<Sheet> onSheetSelected;
|
final ValueSetter<Sheet> onSheetSelected;
|
||||||
|
final SyncService syncService;
|
||||||
|
|
||||||
const SheetsList({
|
const SheetsList({
|
||||||
super.key,
|
super.key,
|
||||||
required this.sheets,
|
required this.sheets,
|
||||||
required this.onSheetSelected,
|
required this.onSheetSelected,
|
||||||
|
required this.syncService,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -33,7 +35,6 @@ class SheetsList extends StatefulWidget {
|
|||||||
class _SheetsListState extends State<SheetsList> {
|
class _SheetsListState extends State<SheetsList> {
|
||||||
static const _searchDebounceMs = 500;
|
static const _searchDebounceMs = 500;
|
||||||
|
|
||||||
final _storageService = StorageService();
|
|
||||||
final _searchController = TextEditingController();
|
final _searchController = TextEditingController();
|
||||||
Timer? _debounceTimer;
|
Timer? _debounceTimer;
|
||||||
late List<Sheet> _filteredSheets;
|
late List<Sheet> _filteredSheets;
|
||||||
@@ -79,7 +80,7 @@ class _SheetsListState extends State<SheetsList> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_filteredSheets = widget.sheets.where((sheet) {
|
_filteredSheets = widget.sheets.where((sheet) {
|
||||||
final name = sheet.name.toLowerCase();
|
final name = sheet.name.toLowerCase();
|
||||||
final composer = sheet.composerName.toLowerCase();
|
final composer = sheet.composer.toLowerCase();
|
||||||
|
|
||||||
// Each term must appear in either name or composer
|
// Each term must appear in either name or composer
|
||||||
return terms.every(
|
return terms.every(
|
||||||
@@ -111,9 +112,9 @@ class _SheetsListState extends State<SheetsList> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleSheetEdit(Sheet sheet, String newName, String newComposer) {
|
void _handleSheetEdit(Sheet sheet, String newName, String newComposer) {
|
||||||
// Queue changes for server sync
|
// Queue changes for server sync (with timestamp for conflict resolution)
|
||||||
if (newName != sheet.name) {
|
if (newName != sheet.name) {
|
||||||
_storageService.writeChange(
|
widget.syncService.queueChange(
|
||||||
Change(
|
Change(
|
||||||
type: ChangeType.sheetNameChange,
|
type: ChangeType.sheetNameChange,
|
||||||
sheetUuid: sheet.uuid,
|
sheetUuid: sheet.uuid,
|
||||||
@@ -121,8 +122,8 @@ class _SheetsListState extends State<SheetsList> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (newComposer != sheet.composerName) {
|
if (newComposer != sheet.composer) {
|
||||||
_storageService.writeChange(
|
widget.syncService.queueChange(
|
||||||
Change(
|
Change(
|
||||||
type: ChangeType.composerNameChange,
|
type: ChangeType.composerNameChange,
|
||||||
sheetUuid: sheet.uuid,
|
sheetUuid: sheet.uuid,
|
||||||
@@ -134,8 +135,11 @@ class _SheetsListState extends State<SheetsList> {
|
|||||||
// Update local state
|
// Update local state
|
||||||
setState(() {
|
setState(() {
|
||||||
sheet.name = newName;
|
sheet.name = newName;
|
||||||
sheet.composerName = newComposer;
|
sheet.composer = newComposer;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update cached sheets
|
||||||
|
widget.syncService.updateCachedSheets(widget.sheets);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -96,18 +96,32 @@ class _DrawingBoardState extends State<DrawingBoard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Drawing mode: wrap with InteractiveViewer for zoom/pan
|
// Drawing mode: wrap with InteractiveViewer for zoom/pan
|
||||||
return Align(
|
// Use LayoutBuilder to get available size and center content manually
|
||||||
alignment: widget.alignment,
|
return LayoutBuilder(
|
||||||
child: InteractiveViewer(
|
builder: (context, constraints) {
|
||||||
transformationController: _transformationController,
|
// Calculate padding to center the content
|
||||||
minScale: widget.minScale,
|
final horizontalPadding =
|
||||||
maxScale: widget.maxScale,
|
(constraints.maxWidth - widget.boardSize.width) / 2;
|
||||||
boundaryMargin: EdgeInsets.zero,
|
final verticalPadding =
|
||||||
constrained: true,
|
(constraints.maxHeight - widget.boardSize.height) / 2;
|
||||||
panEnabled: !_isDrawing,
|
|
||||||
scaleEnabled: !_isDrawing,
|
return InteractiveViewer(
|
||||||
child: content,
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,10 +55,7 @@ class DrawingPainter extends CustomPainter {
|
|||||||
for (int i = 1; i < line.points.length - 1; i++) {
|
for (int i = 1; i < line.points.length - 1; i++) {
|
||||||
final p0 = _toCanvasPoint(line.points[i]);
|
final p0 = _toCanvasPoint(line.points[i]);
|
||||||
final p1 = _toCanvasPoint(line.points[i + 1]);
|
final p1 = _toCanvasPoint(line.points[i + 1]);
|
||||||
final midPoint = Offset(
|
final midPoint = Offset((p0.dx + p1.dx) / 2, (p0.dy + p1.dy) / 2);
|
||||||
(p0.dx + p1.dx) / 2,
|
|
||||||
(p0.dy + p1.dy) / 2,
|
|
||||||
);
|
|
||||||
path.quadraticBezierTo(p0.dx, p0.dy, midPoint.dx, midPoint.dy);
|
path.quadraticBezierTo(p0.dx, p0.dy, midPoint.dx, midPoint.dy);
|
||||||
}
|
}
|
||||||
// Draw to the last point
|
// Draw to the last point
|
||||||
@@ -116,61 +113,3 @@ class DrawingOverlay extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A widget that handles drawing input and renders lines.
|
|
||||||
///
|
|
||||||
/// Converts touch/pointer events to normalized coordinates and
|
|
||||||
/// passes them to the [DrawingController].
|
|
||||||
class DrawingCanvas extends StatelessWidget {
|
|
||||||
final DrawingController controller;
|
|
||||||
final Size canvasSize;
|
|
||||||
final bool enabled;
|
|
||||||
|
|
||||||
const DrawingCanvas({
|
|
||||||
super.key,
|
|
||||||
required this.controller,
|
|
||||||
required this.canvasSize,
|
|
||||||
this.enabled = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Listener(
|
|
||||||
onPointerDown: enabled ? _onPointerDown : null,
|
|
||||||
onPointerMove: enabled ? _onPointerMove : null,
|
|
||||||
onPointerUp: enabled ? _onPointerUp : null,
|
|
||||||
onPointerCancel: enabled ? _onPointerCancel : null,
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
child: DrawingOverlay(
|
|
||||||
controller: controller,
|
|
||||||
canvasSize: canvasSize,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onPointerDown(PointerDownEvent event) {
|
|
||||||
final normalized = _toNormalized(event.localPosition);
|
|
||||||
controller.startLine(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onPointerMove(PointerMoveEvent event) {
|
|
||||||
final normalized = _toNormalized(event.localPosition);
|
|
||||||
controller.addPoint(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onPointerUp(PointerUpEvent event) {
|
|
||||||
controller.endLine();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onPointerCancel(PointerCancelEvent event) {
|
|
||||||
controller.endLine();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Converts canvas coordinates to normalized coordinates (0-1).
|
|
||||||
Offset _toNormalized(Offset canvasPoint) {
|
|
||||||
return Offset(
|
|
||||||
(canvasPoint.dx / canvasSize.width).clamp(0.0, 1.0),
|
|
||||||
(canvasPoint.dy / canvasSize.height).clamp(0.0, 1.0),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,6 +5,23 @@ import 'package:flutter/material.dart';
|
|||||||
import 'drawing_line.dart';
|
import 'drawing_line.dart';
|
||||||
import 'paint_preset.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.
|
/// Controller for managing drawing state with undo/redo support.
|
||||||
///
|
///
|
||||||
/// Manages a stack of [DrawingLine] objects and provides methods for
|
/// Manages a stack of [DrawingLine] objects and provides methods for
|
||||||
@@ -13,8 +30,11 @@ class DrawingController extends ChangeNotifier {
|
|||||||
/// All completed lines in the drawing
|
/// All completed lines in the drawing
|
||||||
final List<DrawingLine> _lines = [];
|
final List<DrawingLine> _lines = [];
|
||||||
|
|
||||||
/// Lines that have been undone (for redo functionality)
|
/// Stack of actions for undo functionality
|
||||||
final List<DrawingLine> _undoneLines = [];
|
final List<DrawingAction> _undoStack = [];
|
||||||
|
|
||||||
|
/// Stack of actions for redo functionality
|
||||||
|
final List<DrawingAction> _redoStack = [];
|
||||||
|
|
||||||
/// The line currently being drawn (null when not drawing)
|
/// The line currently being drawn (null when not drawing)
|
||||||
DrawingLine? _currentLine;
|
DrawingLine? _currentLine;
|
||||||
@@ -22,9 +42,18 @@ class DrawingController extends ChangeNotifier {
|
|||||||
/// Current paint preset being used
|
/// Current paint preset being used
|
||||||
PaintPreset _currentPreset = PaintPreset.blackPen;
|
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
|
/// Maximum number of history steps to keep
|
||||||
final int maxHistorySteps;
|
final int maxHistorySteps;
|
||||||
|
|
||||||
|
/// Whether there are unsaved changes since last load/clear
|
||||||
|
bool _hasUnsavedChanges = false;
|
||||||
|
|
||||||
DrawingController({this.maxHistorySteps = 50});
|
DrawingController({this.maxHistorySteps = 50});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -40,11 +69,17 @@ class DrawingController extends ChangeNotifier {
|
|||||||
/// Current paint preset
|
/// Current paint preset
|
||||||
PaintPreset get currentPreset => _currentPreset;
|
PaintPreset get currentPreset => _currentPreset;
|
||||||
|
|
||||||
|
/// Whether eraser mode is active
|
||||||
|
bool get isEraserMode => _isEraserMode;
|
||||||
|
|
||||||
/// Whether undo is available
|
/// Whether undo is available
|
||||||
bool get canUndo => _lines.isNotEmpty;
|
bool get canUndo => _undoStack.isNotEmpty;
|
||||||
|
|
||||||
/// Whether redo is available
|
/// Whether redo is available
|
||||||
bool get canRedo => _undoneLines.isNotEmpty;
|
bool get canRedo => _redoStack.isNotEmpty;
|
||||||
|
|
||||||
|
/// Whether there are unsaved changes since last load/clear/markSaved
|
||||||
|
bool get hasUnsavedChanges => _hasUnsavedChanges;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Drawing Operations
|
// Drawing Operations
|
||||||
@@ -52,6 +87,12 @@ class DrawingController extends ChangeNotifier {
|
|||||||
|
|
||||||
/// Starts a new line at the given normalized position.
|
/// Starts a new line at the given normalized position.
|
||||||
void startLine(Offset normalizedPoint) {
|
void startLine(Offset normalizedPoint) {
|
||||||
|
if (_isEraserMode) {
|
||||||
|
_currentErasedLines.clear();
|
||||||
|
_eraseAtPoint(normalizedPoint);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_currentLine = DrawingLine(
|
_currentLine = DrawingLine(
|
||||||
points: [normalizedPoint],
|
points: [normalizedPoint],
|
||||||
color: _currentPreset.color,
|
color: _currentPreset.color,
|
||||||
@@ -62,22 +103,44 @@ class DrawingController extends ChangeNotifier {
|
|||||||
|
|
||||||
/// Adds a point to the current line.
|
/// Adds a point to the current line.
|
||||||
void addPoint(Offset normalizedPoint) {
|
void addPoint(Offset normalizedPoint) {
|
||||||
|
if (_isEraserMode) {
|
||||||
|
_eraseAtPoint(normalizedPoint);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (_currentLine == null) return;
|
if (_currentLine == null) return;
|
||||||
|
|
||||||
|
// Filter points that are too close to reduce memory usage
|
||||||
|
if (_currentLine!.isPointTooClose(normalizedPoint)) return;
|
||||||
|
|
||||||
_currentLine = _currentLine!.addPoint(normalizedPoint);
|
_currentLine = _currentLine!.addPoint(normalizedPoint);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Completes the current line and adds it to the history.
|
/// Completes the current line and adds it to the history.
|
||||||
void endLine() {
|
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;
|
if (_currentLine == null) return;
|
||||||
|
|
||||||
// Only add lines with at least 2 points
|
// Only add lines with at least 2 points
|
||||||
if (_currentLine!.points.length >= 2) {
|
if (_currentLine!.points.length >= 2) {
|
||||||
_lines.add(_currentLine!);
|
_lines.add(_currentLine!);
|
||||||
// Clear redo stack when new action is performed
|
_undoStack.add(AddLineAction(_currentLine!));
|
||||||
_undoneLines.clear();
|
_redoStack.clear();
|
||||||
_trimHistory();
|
_trimHistory();
|
||||||
|
_hasUnsavedChanges = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
_currentLine = null;
|
_currentLine = null;
|
||||||
@@ -86,8 +149,98 @@ class DrawingController extends ChangeNotifier {
|
|||||||
|
|
||||||
/// Trims history to maxHistorySteps to prevent memory growth.
|
/// Trims history to maxHistorySteps to prevent memory growth.
|
||||||
void _trimHistory() {
|
void _trimHistory() {
|
||||||
while (_lines.length > maxHistorySteps) {
|
while (_undoStack.length > maxHistorySteps) {
|
||||||
_lines.removeAt(0);
|
_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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,29 +248,58 @@ class DrawingController extends ChangeNotifier {
|
|||||||
// Undo/Redo
|
// Undo/Redo
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Undoes the last line drawn.
|
/// Undoes the last action.
|
||||||
void undo() {
|
void undo() {
|
||||||
if (!canUndo) return;
|
if (!canUndo) return;
|
||||||
|
|
||||||
final line = _lines.removeLast();
|
final action = _undoStack.removeLast();
|
||||||
_undoneLines.add(line);
|
|
||||||
|
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();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Redoes the last undone line.
|
/// Redoes the last undone action.
|
||||||
void redo() {
|
void redo() {
|
||||||
if (!canRedo) return;
|
if (!canRedo) return;
|
||||||
|
|
||||||
final line = _undoneLines.removeLast();
|
final action = _redoStack.removeLast();
|
||||||
_lines.add(line);
|
|
||||||
|
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();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clears all lines from the canvas.
|
/// Clears all lines from the canvas.
|
||||||
void clear() {
|
void clear() {
|
||||||
_lines.clear();
|
_lines.clear();
|
||||||
_undoneLines.clear();
|
_undoStack.clear();
|
||||||
|
_redoStack.clear();
|
||||||
_currentLine = null;
|
_currentLine = null;
|
||||||
|
_currentErasedLines.clear();
|
||||||
|
_hasUnsavedChanges = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,9 +307,10 @@ class DrawingController extends ChangeNotifier {
|
|||||||
// Paint Preset
|
// Paint Preset
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Sets the current paint preset.
|
/// Sets the current paint preset and disables eraser mode.
|
||||||
void setPreset(PaintPreset preset) {
|
void setPreset(PaintPreset preset) {
|
||||||
_currentPreset = preset;
|
_currentPreset = preset;
|
||||||
|
_isEraserMode = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,8 +331,11 @@ class DrawingController extends ChangeNotifier {
|
|||||||
/// Imports lines from a JSON-serializable list.
|
/// Imports lines from a JSON-serializable list.
|
||||||
void fromJsonList(List<Map<String, dynamic>> jsonList) {
|
void fromJsonList(List<Map<String, dynamic>> jsonList) {
|
||||||
_lines.clear();
|
_lines.clear();
|
||||||
_undoneLines.clear();
|
_undoStack.clear();
|
||||||
|
_redoStack.clear();
|
||||||
_currentLine = null;
|
_currentLine = null;
|
||||||
|
_currentErasedLines.clear();
|
||||||
|
_hasUnsavedChanges = false;
|
||||||
|
|
||||||
for (final json in jsonList) {
|
for (final json in jsonList) {
|
||||||
_lines.add(DrawingLine.fromJson(json));
|
_lines.add(DrawingLine.fromJson(json));
|
||||||
@@ -180,10 +366,16 @@ class DrawingController extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marks the current state as saved (resets unsaved changes flag).
|
||||||
|
void markSaved() {
|
||||||
|
_hasUnsavedChanges = false;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_lines.clear();
|
_lines.clear();
|
||||||
_undoneLines.clear();
|
_undoStack.clear();
|
||||||
|
_redoStack.clear();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import 'dart:ui';
|
|||||||
///
|
///
|
||||||
/// This allows drawings to scale correctly when the canvas size changes.
|
/// This allows drawings to scale correctly when the canvas size changes.
|
||||||
class DrawingLine {
|
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)
|
/// Points in normalized coordinates (0.0 to 1.0)
|
||||||
final List<Offset> points;
|
final List<Offset> points;
|
||||||
|
|
||||||
@@ -27,10 +30,9 @@ class DrawingLine {
|
|||||||
/// Creates a DrawingLine from JSON data.
|
/// Creates a DrawingLine from JSON data.
|
||||||
factory DrawingLine.fromJson(Map<String, dynamic> json) {
|
factory DrawingLine.fromJson(Map<String, dynamic> json) {
|
||||||
final pointsList = (json['points'] as List)
|
final pointsList = (json['points'] as List)
|
||||||
.map((p) => Offset(
|
.map(
|
||||||
(p['x'] as num).toDouble(),
|
(p) => Offset((p['x'] as num).toDouble(), (p['y'] as num).toDouble()),
|
||||||
(p['y'] as num).toDouble(),
|
)
|
||||||
))
|
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
return DrawingLine(
|
return DrawingLine(
|
||||||
@@ -58,6 +60,14 @@ class DrawingLine {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool isPointTooClose(Offset nextNormalizedPoint) {
|
||||||
|
if (points.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (points.last - nextNormalizedPoint).distanceSquared <
|
||||||
|
minNormalizedPointDistanceSquared;
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates a copy with updated points.
|
/// Creates a copy with updated points.
|
||||||
DrawingLine copyWith({
|
DrawingLine copyWith({
|
||||||
List<Offset>? points,
|
List<Offset>? points,
|
||||||
@@ -85,9 +95,5 @@ class DrawingLine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(
|
int get hashCode => Object.hash(Object.hashAll(points), color, strokeWidth);
|
||||||
Object.hashAll(points),
|
|
||||||
color,
|
|
||||||
strokeWidth,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,13 +43,24 @@ class DrawingToolbar extends StatelessWidget {
|
|||||||
...PaintPreset.quickAccess.map((preset) => _buildPresetButton(
|
...PaintPreset.quickAccess.map((preset) => _buildPresetButton(
|
||||||
context,
|
context,
|
||||||
preset,
|
preset,
|
||||||
isSelected: controller.currentPreset == preset,
|
isSelected: !controller.isEraserMode &&
|
||||||
|
controller.currentPreset == preset,
|
||||||
)),
|
)),
|
||||||
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_buildDivider(context),
|
_buildDivider(context),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
|
// Eraser button
|
||||||
|
_buildEraserButton(
|
||||||
|
context,
|
||||||
|
isSelected: controller.isEraserMode,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildDivider(context),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
// Undo button
|
// Undo button
|
||||||
_buildActionButton(
|
_buildActionButton(
|
||||||
context,
|
context,
|
||||||
@@ -127,6 +138,37 @@ class DrawingToolbar extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
Widget _buildActionButton(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class PaintPreset {
|
|||||||
static const yellowMarker = PaintPreset(
|
static const yellowMarker = PaintPreset(
|
||||||
name: 'Yellow Marker',
|
name: 'Yellow Marker',
|
||||||
color: Color(0x80FFEB3B), // Yellow with 50% opacity
|
color: Color(0x80FFEB3B), // Yellow with 50% opacity
|
||||||
strokeWidth: 0.015, // Thicker for highlighting
|
strokeWidth: 0.02, // Thicker for highlighting
|
||||||
icon: Icons.highlight,
|
icon: Icons.highlight,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ class PaintPreset {
|
|||||||
static const greenMarker = PaintPreset(
|
static const greenMarker = PaintPreset(
|
||||||
name: 'Green Marker',
|
name: 'Green Marker',
|
||||||
color: Color(0x804CAF50), // Green with 50% opacity
|
color: Color(0x804CAF50), // Green with 50% opacity
|
||||||
strokeWidth: 0.015,
|
strokeWidth: 0.018,
|
||||||
icon: Icons.highlight,
|
icon: Icons.highlight,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -91,7 +91,10 @@ class PaintPreset {
|
|||||||
static const List<PaintPreset> quickAccess = [
|
static const List<PaintPreset> quickAccess = [
|
||||||
blackPen,
|
blackPen,
|
||||||
redPen,
|
redPen,
|
||||||
|
bluePen,
|
||||||
yellowMarker,
|
yellowMarker,
|
||||||
|
greenMarker,
|
||||||
|
pinkMarker,
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:logging/logging.dart';
|
|||||||
import 'package:pdfrx/pdfrx.dart';
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
import 'package:sheetless/core/models/config.dart';
|
import 'package:sheetless/core/models/config.dart';
|
||||||
import 'package:sheetless/core/models/sheet.dart';
|
import 'package:sheetless/core/models/sheet.dart';
|
||||||
|
import 'package:sheetless/core/services/annotation_sync_service.dart';
|
||||||
import 'package:sheetless/core/services/api_client.dart';
|
import 'package:sheetless/core/services/api_client.dart';
|
||||||
import 'package:sheetless/core/services/storage_service.dart';
|
import 'package:sheetless/core/services/storage_service.dart';
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
with FullScreenListener {
|
with FullScreenListener {
|
||||||
final _log = Logger('SheetViewerPage');
|
final _log = Logger('SheetViewerPage');
|
||||||
final _storageService = StorageService();
|
final _storageService = StorageService();
|
||||||
|
late final AnnotationSyncService _syncService;
|
||||||
|
|
||||||
PdfDocument? _document;
|
PdfDocument? _document;
|
||||||
late Future<bool> _documentLoaded;
|
late Future<bool> _documentLoaded;
|
||||||
@@ -52,20 +54,29 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
|
// Initialize sync service
|
||||||
|
_syncService = AnnotationSyncService(
|
||||||
|
apiClient: widget.apiClient,
|
||||||
|
storageService: _storageService,
|
||||||
|
);
|
||||||
|
|
||||||
// Initialize drawing controllers
|
// Initialize drawing controllers
|
||||||
_leftDrawingController = DrawingController(maxHistorySteps: 50);
|
_leftDrawingController = DrawingController(maxHistorySteps: 50);
|
||||||
_rightDrawingController = DrawingController(maxHistorySteps: 50);
|
_rightDrawingController = DrawingController(maxHistorySteps: 50);
|
||||||
|
|
||||||
FullScreen.addListener(this);
|
FullScreen.addListener(this);
|
||||||
FullScreen.setFullScreen(widget.config.fullscreen);
|
if (FullScreen.isFullScreen != widget.config.fullscreen) {
|
||||||
|
FullScreen.setFullScreen(widget.config.fullscreen);
|
||||||
|
}
|
||||||
_documentLoaded = _loadPdf();
|
_documentLoaded = _loadPdf();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
// Save current annotations synchronously before disposing
|
// Make sure annotations are saved before exiting
|
||||||
// Note: This is fire-and-forget, but Hive operations are fast enough
|
if (_isPaintMode) {
|
||||||
_saveCurrentAnnotationsSync();
|
_saveCurrentAnnotations();
|
||||||
|
}
|
||||||
|
|
||||||
_leftDrawingController.dispose();
|
_leftDrawingController.dispose();
|
||||||
_rightDrawingController.dispose();
|
_rightDrawingController.dispose();
|
||||||
@@ -74,27 +85,6 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
super.dispose();
|
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
|
// PDF Loading
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -114,6 +104,9 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
_totalPages = _document!.pages.length;
|
_totalPages = _document!.pages.length;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sync annotations from server (downloads newer versions)
|
||||||
|
await _syncService.syncFromServer(widget.sheet.uuid);
|
||||||
|
|
||||||
// Load annotations for current page(s)
|
// Load annotations for current page(s)
|
||||||
await _loadAnnotationsForCurrentPages();
|
await _loadAnnotationsForCurrentPages();
|
||||||
|
|
||||||
@@ -151,24 +144,58 @@ 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 {
|
Future<void> _saveCurrentAnnotations() async {
|
||||||
// Save left page
|
final now = DateTime.now();
|
||||||
final leftJson = _leftDrawingController.toJsonString();
|
|
||||||
await _storageService.writeAnnotations(
|
|
||||||
widget.sheet.uuid,
|
|
||||||
_currentPage,
|
|
||||||
leftJson.isEmpty || leftJson == '[]' ? null : leftJson,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Save right page (two-page mode)
|
// Save left page only if changed
|
||||||
if (widget.config.twoPageMode && _currentPage < _totalPages) {
|
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 rightJson = _rightDrawingController.toJsonString();
|
||||||
await _storageService.writeAnnotations(
|
final rightHasContent = rightJson.isNotEmpty && rightJson != '[]';
|
||||||
|
|
||||||
|
await _storageService.writeAnnotationsWithMetadata(
|
||||||
widget.sheet.uuid,
|
widget.sheet.uuid,
|
||||||
_currentPage + 1,
|
_currentPage + 1,
|
||||||
rightJson.isEmpty || rightJson == '[]' ? null : rightJson,
|
rightHasContent ? rightJson : null,
|
||||||
|
now,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Upload right page to server
|
||||||
|
_syncService.uploadAnnotation(
|
||||||
|
sheetUuid: widget.sheet.uuid,
|
||||||
|
page: _currentPage + 1,
|
||||||
|
annotationsJson: rightJson,
|
||||||
|
lastModified: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
_rightDrawingController.markSaved();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +212,7 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _toggleFullscreen() {
|
void _toggleFullscreen() {
|
||||||
FullScreen.setFullScreen(!widget.config.fullscreen);
|
FullScreen.setFullScreen(!FullScreen.isFullScreen);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -271,8 +298,9 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
icon: Icon(
|
icon: Icon(
|
||||||
widget.config.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
|
widget.config.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
|
||||||
),
|
),
|
||||||
tooltip:
|
tooltip: widget.config.fullscreen
|
||||||
widget.config.fullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen',
|
? 'Exit Fullscreen'
|
||||||
|
: 'Enter Fullscreen',
|
||||||
onPressed: _toggleFullscreen,
|
onPressed: _toggleFullscreen,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -284,8 +312,9 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
icon: Icon(
|
icon: Icon(
|
||||||
widget.config.twoPageMode ? Icons.filter_1 : Icons.filter_2,
|
widget.config.twoPageMode ? Icons.filter_1 : Icons.filter_2,
|
||||||
),
|
),
|
||||||
tooltip:
|
tooltip: widget.config.twoPageMode
|
||||||
widget.config.twoPageMode ? 'Single Page Mode' : 'Two Page Mode',
|
? 'Single Page Mode'
|
||||||
|
: 'Two Page Mode',
|
||||||
onPressed: _toggleTwoPageMode,
|
onPressed: _toggleTwoPageMode,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -317,8 +346,9 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
currentPageNumber: _currentPage,
|
currentPageNumber: _currentPage,
|
||||||
config: widget.config,
|
config: widget.config,
|
||||||
leftDrawingController: _leftDrawingController,
|
leftDrawingController: _leftDrawingController,
|
||||||
rightDrawingController:
|
rightDrawingController: widget.config.twoPageMode
|
||||||
widget.config.twoPageMode ? _rightDrawingController : null,
|
? _rightDrawingController
|
||||||
|
: null,
|
||||||
drawingEnabled: _isPaintMode,
|
drawingEnabled: _isPaintMode,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -334,7 +364,6 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
onToggleFullscreen: _toggleFullscreen,
|
onToggleFullscreen: _toggleFullscreen,
|
||||||
onExit: () => Navigator.pop(context),
|
onExit: () => Navigator.pop(context),
|
||||||
onPageTurn: _turnPage,
|
onPageTurn: _turnPage,
|
||||||
child: pageDisplay,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,10 +385,9 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
message,
|
message,
|
||||||
style: Theme.of(context)
|
style: Theme.of(
|
||||||
.textTheme
|
context,
|
||||||
.titleMedium
|
).textTheme.titleMedium?.copyWith(color: Colors.red),
|
||||||
?.copyWith(color: Colors.red),
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ typedef PageTurnCallback = dynamic Function(int delta);
|
|||||||
/// - Right side: Turn page forward (+1 or +2 in two-page mode)
|
/// - Right side: Turn page forward (+1 or +2 in two-page mode)
|
||||||
class TouchNavigationLayer extends StatelessWidget {
|
class TouchNavigationLayer extends StatelessWidget {
|
||||||
final PdfPageDisplay pageDisplay;
|
final PdfPageDisplay pageDisplay;
|
||||||
final Widget child;
|
|
||||||
final Config config;
|
final Config config;
|
||||||
final VoidCallback onToggleFullscreen;
|
final VoidCallback onToggleFullscreen;
|
||||||
final VoidCallback onExit;
|
final VoidCallback onExit;
|
||||||
@@ -23,7 +22,6 @@ class TouchNavigationLayer extends StatelessWidget {
|
|||||||
const TouchNavigationLayer({
|
const TouchNavigationLayer({
|
||||||
super.key,
|
super.key,
|
||||||
required this.pageDisplay,
|
required this.pageDisplay,
|
||||||
required this.child,
|
|
||||||
required this.config,
|
required this.config,
|
||||||
required this.onToggleFullscreen,
|
required this.onToggleFullscreen,
|
||||||
required this.onExit,
|
required this.onExit,
|
||||||
@@ -35,7 +33,7 @@ class TouchNavigationLayer extends StatelessWidget {
|
|||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTapUp: (details) => _handleTap(context, details),
|
onTapUp: (details) => _handleTap(context, details),
|
||||||
child: child,
|
child: pageDisplay,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,7 @@ class _EditSheetBottomSheetState extends State<EditSheetBottomSheet> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_nameController = TextEditingController(text: widget.sheet.name);
|
_nameController = TextEditingController(text: widget.sheet.name);
|
||||||
_composerController = TextEditingController(
|
_composerController = TextEditingController(text: widget.sheet.composer);
|
||||||
text: widget.sheet.composerName,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -88,7 +86,6 @@ class _EditSheetBottomSheetState extends State<EditSheetBottomSheet> {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _composerController,
|
controller: _composerController,
|
||||||
validator: _validateNotEmpty,
|
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Composer',
|
labelText: 'Composer',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
|
|||||||
@@ -364,10 +364,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: objective_c
|
name: objective_c
|
||||||
sha256: "983c7fa1501f6dcc0cb7af4e42072e9993cb28d73604d25ebf4dab08165d997e"
|
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.2.5"
|
version: "9.3.0"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -617,10 +617,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: source_span
|
name: source_span
|
||||||
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.1"
|
version: "1.10.2"
|
||||||
sqflite:
|
sqflite:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -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
|
# 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
|
# 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.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 0.1.2
|
version: 0.2.0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.0.0
|
sdk: ^3.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user