Compare commits
34 Commits
a2b63083ad
...
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 | |||
| d4d6e41a9d | |||
| e1d72de718 | |||
| 93127cada8 | |||
| d626271ad2 | |||
| 704bd0b928 | |||
| 4f380b5444 |
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
24
devenv.lock
24
devenv.lock
@@ -3,10 +3,10 @@
|
|||||||
"devenv": {
|
"devenv": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"dir": "src/modules",
|
"dir": "src/modules",
|
||||||
"lastModified": 1753981111,
|
"lastModified": 1770167382,
|
||||||
"owner": "cachix",
|
"owner": "cachix",
|
||||||
"repo": "devenv",
|
"repo": "devenv",
|
||||||
"rev": "d4d70df706b153b601a87ab8e81c88a0b1a373b6",
|
"rev": "195375e3f0a3c3ac4f12b21d3f59a7ddb209bd1f",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -19,14 +19,14 @@
|
|||||||
"flake-compat": {
|
"flake-compat": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1747046372,
|
"lastModified": 1767039857,
|
||||||
"owner": "edolstra",
|
"owner": "NixOS",
|
||||||
"repo": "flake-compat",
|
"repo": "flake-compat",
|
||||||
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
|
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "edolstra",
|
"owner": "NixOS",
|
||||||
"repo": "flake-compat",
|
"repo": "flake-compat",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
@@ -40,10 +40,10 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1750779888,
|
"lastModified": 1769939035,
|
||||||
"owner": "cachix",
|
"owner": "cachix",
|
||||||
"repo": "git-hooks.nix",
|
"repo": "git-hooks.nix",
|
||||||
"rev": "16ec914f6fb6f599ce988427d9d94efddf25fe6d",
|
"rev": "a8ca480175326551d6c4121498316261cbb5b260",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -60,10 +60,10 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1709087332,
|
"lastModified": 1762808025,
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "gitignore.nix",
|
"repo": "gitignore.nix",
|
||||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
"rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -74,10 +74,10 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1753939845,
|
"lastModified": 1770115704,
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "94def634a20494ee057c76998843c015909d6311",
|
"rev": "e6eae2ee2110f3d31110d5c222cd395303343b08",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
163
lib/api.dart
163
lib/api.dart
@@ -1,163 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
import 'package:path_provider/path_provider.dart'; // For cache storage
|
|
||||||
|
|
||||||
import 'sheet.dart';
|
|
||||||
|
|
||||||
class ApiClient {
|
|
||||||
final log = Logger("ApiClient");
|
|
||||||
final String baseUrl;
|
|
||||||
String? token;
|
|
||||||
|
|
||||||
ApiClient({required this.baseUrl, this.token});
|
|
||||||
|
|
||||||
Future<void> login(String username, String password) async {
|
|
||||||
log.info("Logging in...");
|
|
||||||
final url = '$baseUrl/auth/login';
|
|
||||||
final response = await http.post(
|
|
||||||
Uri.parse(url),
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: jsonEncode({'username': username, 'password': password}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
|
||||||
final responseData = jsonDecode(response.body);
|
|
||||||
token = responseData['token'];
|
|
||||||
log.info('Login successful');
|
|
||||||
} else {
|
|
||||||
throw Exception(
|
|
||||||
"Failed logging in: Response code ${response.statusCode}\nResponse: ${response.body}",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void logout() {
|
|
||||||
token = null;
|
|
||||||
log.info('Logged out successfully.');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<http.Response> get(
|
|
||||||
String endpoint, {
|
|
||||||
bool isBinary = false,
|
|
||||||
bool throwExceptionIfStatusCodeNot200 = false,
|
|
||||||
}) async {
|
|
||||||
final url = '$baseUrl$endpoint';
|
|
||||||
final headers = {
|
|
||||||
'Authorization': 'Bearer $token',
|
|
||||||
if (!isBinary) 'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
|
|
||||||
final response = await http.get(Uri.parse(url), headers: headers);
|
|
||||||
|
|
||||||
if (!throwExceptionIfStatusCodeNot200 || response.statusCode == 200) {
|
|
||||||
return response;
|
|
||||||
} else {
|
|
||||||
log.warning(
|
|
||||||
"Failed get request to '$url'! StatusCode: ${response.statusCode}\nResponseBody: ${response.body}",
|
|
||||||
);
|
|
||||||
throw Exception(
|
|
||||||
'GET request failed: ${response.statusCode} ${response.body}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<http.Response?> post(
|
|
||||||
String endpoint,
|
|
||||||
Map<String, dynamic> body,
|
|
||||||
) async {
|
|
||||||
try {
|
|
||||||
final url = '$baseUrl$endpoint';
|
|
||||||
final headers = {
|
|
||||||
'Authorization': 'Bearer $token',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
|
|
||||||
final response = await http.post(
|
|
||||||
Uri.parse(url),
|
|
||||||
headers: headers,
|
|
||||||
body: jsonEncode(body),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
|
||||||
return response;
|
|
||||||
} else {
|
|
||||||
log.info(
|
|
||||||
'POST request failed: ${response.statusCode} ${response.body}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
log.info('Error during POST request: $e');
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<http.Response?> postFormData(String endpoint, String body) async {
|
|
||||||
try {
|
|
||||||
final url = '$baseUrl$endpoint';
|
|
||||||
final headers = {
|
|
||||||
'Authorization': 'Bearer $token',
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
};
|
|
||||||
|
|
||||||
final response = await http.post(
|
|
||||||
Uri.parse(url),
|
|
||||||
headers: headers,
|
|
||||||
body: body,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
|
||||||
return response;
|
|
||||||
} else {
|
|
||||||
log.info(
|
|
||||||
'POST Form Data request failed: ${response.statusCode} ${response.body}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
log.info('Error during POST Form Data request: $e');
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<Sheet>> fetchSheets() async {
|
|
||||||
final response = await get(
|
|
||||||
"/api/sheets/list",
|
|
||||||
throwExceptionIfStatusCodeNot200: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
final data = jsonDecode(response.body);
|
|
||||||
return (data['sheets'] as List<dynamic>)
|
|
||||||
.map((sheet) => Sheet.fromJson(sheet as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Uint8List> fetchPdfFileData(String sheetUuid) async {
|
|
||||||
final response = await get(
|
|
||||||
'/api/sheets/get/$sheetUuid',
|
|
||||||
isBinary: true,
|
|
||||||
throwExceptionIfStatusCodeNot200: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
log.info("PDF downloaded");
|
|
||||||
return response.bodyBytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<File> getPdfFileCached(String sheetUuid) async {
|
|
||||||
final cacheDir = await getTemporaryDirectory();
|
|
||||||
final cachedPdfPath = '${cacheDir.path}/$sheetUuid.pdf';
|
|
||||||
|
|
||||||
final cachedFile = File(cachedPdfPath);
|
|
||||||
if (await cachedFile.exists()) {
|
|
||||||
log.info("PDF found in cache: $cachedPdfPath");
|
|
||||||
return cachedFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
final pdfFileData = await fetchPdfFileData(sheetUuid);
|
|
||||||
await cachedFile.writeAsBytes(pdfFileData);
|
|
||||||
log.info("PDF cached at: $cachedPdfPath");
|
|
||||||
return cachedFile;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
23
lib/app.dart
Normal file
23
lib/app.dart
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'features/auth/login_page.dart';
|
||||||
|
|
||||||
|
/// Global route observer for tracking navigation events.
|
||||||
|
///
|
||||||
|
/// Used by pages that need to respond to navigation changes
|
||||||
|
final RouteObserver<ModalRoute> routeObserver = RouteObserver<ModalRoute>();
|
||||||
|
|
||||||
|
/// The main Sheetless application widget.
|
||||||
|
class SheetlessApp extends StatelessWidget {
|
||||||
|
const SheetlessApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp(
|
||||||
|
title: 'Sheetless',
|
||||||
|
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blue),
|
||||||
|
home: const LoginPage(),
|
||||||
|
navigatorObservers: [routeObserver],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
|
|
||||||
class BtPedalShortcuts extends StatefulWidget {
|
|
||||||
final Widget child;
|
|
||||||
final VoidCallback? onTurnPageForward;
|
|
||||||
final VoidCallback? onTurnPageBackward;
|
|
||||||
|
|
||||||
const BtPedalShortcuts({
|
|
||||||
super.key,
|
|
||||||
required this.child,
|
|
||||||
this.onTurnPageForward,
|
|
||||||
this.onTurnPageBackward,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<BtPedalShortcuts> createState() => _BtPedalShortcutsState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _BtPedalShortcutsState extends State<BtPedalShortcuts> {
|
|
||||||
String lastAction = "Press pedal...";
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return CallbackShortcuts(
|
|
||||||
bindings: <ShortcutActivator, VoidCallback>{
|
|
||||||
// Shortcuts for page forward
|
|
||||||
const SingleActivator(LogicalKeyboardKey.arrowDown):
|
|
||||||
widget.onTurnPageForward ?? () => {},
|
|
||||||
const SingleActivator(LogicalKeyboardKey.arrowRight):
|
|
||||||
widget.onTurnPageForward ?? () => {},
|
|
||||||
|
|
||||||
// Shortcuts for page backward
|
|
||||||
const SingleActivator(LogicalKeyboardKey.arrowUp):
|
|
||||||
widget.onTurnPageBackward ?? () => {},
|
|
||||||
const SingleActivator(LogicalKeyboardKey.arrowLeft):
|
|
||||||
widget.onTurnPageBackward ?? () => {},
|
|
||||||
},
|
|
||||||
child: Focus(autofocus: true, child: widget.child),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
110
lib/core/models/change.dart
Normal file
110
lib/core/models/change.dart
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'sheet.dart';
|
||||||
|
|
||||||
|
/// Types of changes that can be queued for server synchronization.
|
||||||
|
enum ChangeType {
|
||||||
|
sheetNameChange,
|
||||||
|
composerNameChange,
|
||||||
|
addTagChange,
|
||||||
|
removeTagChange,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a single pending change to be synced with the server.
|
||||||
|
///
|
||||||
|
/// Changes are stored locally when offline and applied once
|
||||||
|
/// the device regains connectivity. Each change has a [createdAt] timestamp
|
||||||
|
/// that the server uses to resolve conflicts between devices.
|
||||||
|
class Change {
|
||||||
|
final ChangeType type;
|
||||||
|
final String sheetUuid;
|
||||||
|
final String value;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
Change({
|
||||||
|
required this.type,
|
||||||
|
required this.sheetUuid,
|
||||||
|
required this.value,
|
||||||
|
DateTime? createdAt,
|
||||||
|
}) : createdAt = createdAt ?? DateTime.now();
|
||||||
|
|
||||||
|
/// Serializes this change to a map for storage.
|
||||||
|
Map<String, dynamic> toMap() => {
|
||||||
|
'type': type.index,
|
||||||
|
'sheetUuid': sheetUuid,
|
||||||
|
'value': value,
|
||||||
|
'createdAt': createdAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Serializes this change to JSON for API requests.
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'type': type.name,
|
||||||
|
'sheetUuid': sheetUuid,
|
||||||
|
'value': value,
|
||||||
|
'createdAt': createdAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Deserializes a change from a stored map.
|
||||||
|
///
|
||||||
|
/// Note: Adding new [ChangeType] values may cause issues with
|
||||||
|
/// previously stored changes that use index-based serialization.
|
||||||
|
factory Change.fromMap(Map<dynamic, dynamic> map) {
|
||||||
|
return Change(
|
||||||
|
type: ChangeType.values[map['type']],
|
||||||
|
sheetUuid: map['sheetUuid'],
|
||||||
|
value: map['value'],
|
||||||
|
createdAt: map['createdAt'] != null
|
||||||
|
? DateTime.parse(map['createdAt'])
|
||||||
|
: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A queue of pending changes to be synchronized with the server.
|
||||||
|
///
|
||||||
|
/// Changes are stored in FIFO order (oldest first) and applied
|
||||||
|
/// to sheets in sequence when syncing.
|
||||||
|
class ChangeQueue {
|
||||||
|
final Queue<Change> _queue = Queue();
|
||||||
|
|
||||||
|
ChangeQueue();
|
||||||
|
|
||||||
|
/// Adds a change to the end of the queue.
|
||||||
|
void addChange(Change change) {
|
||||||
|
_queue.addLast(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of pending changes.
|
||||||
|
int get length => _queue.length;
|
||||||
|
|
||||||
|
/// Whether the queue has any pending changes.
|
||||||
|
bool get isEmpty => _queue.isEmpty;
|
||||||
|
|
||||||
|
/// Whether the queue has pending changes.
|
||||||
|
bool get isNotEmpty => _queue.isNotEmpty;
|
||||||
|
|
||||||
|
/// Applies all queued changes to the provided list of sheets.
|
||||||
|
///
|
||||||
|
/// Each change modifies the corresponding sheet's properties
|
||||||
|
/// based on the change type.
|
||||||
|
void applyToSheets(List<Sheet> sheets) {
|
||||||
|
for (final change in _queue) {
|
||||||
|
final sheet = sheets.firstWhere(
|
||||||
|
(s) => s.uuid == change.sheetUuid,
|
||||||
|
orElse: () =>
|
||||||
|
throw StateError('Sheet with UUID ${change.sheetUuid} not found'),
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (change.type) {
|
||||||
|
case ChangeType.sheetNameChange:
|
||||||
|
sheet.name = change.value;
|
||||||
|
case ChangeType.composerNameChange:
|
||||||
|
sheet.composer = change.value;
|
||||||
|
case ChangeType.addTagChange:
|
||||||
|
throw UnimplementedError('Tag support not yet implemented');
|
||||||
|
case ChangeType.removeTagChange:
|
||||||
|
throw UnimplementedError('Tag support not yet implemented');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
lib/core/models/config.dart
Normal file
37
lib/core/models/config.dart
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/// Application configuration model.
|
||||||
|
///
|
||||||
|
/// Stores user preferences that are persisted across sessions,
|
||||||
|
/// such as display mode and fullscreen settings.
|
||||||
|
class Config {
|
||||||
|
/// Storage keys for persistence
|
||||||
|
static const String keyTwoPageMode = 'twoPageMode';
|
||||||
|
static const String keyFullscreen = 'fullscreen';
|
||||||
|
|
||||||
|
/// Whether to display two pages side-by-side (for tablets/landscape).
|
||||||
|
bool twoPageMode;
|
||||||
|
|
||||||
|
/// Whether the app is in fullscreen mode.
|
||||||
|
bool fullscreen;
|
||||||
|
|
||||||
|
Config({
|
||||||
|
required this.twoPageMode,
|
||||||
|
required this.fullscreen,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Creates a default configuration with all options disabled.
|
||||||
|
factory Config.defaultConfig() => Config(
|
||||||
|
twoPageMode: false,
|
||||||
|
fullscreen: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Creates a copy of this config with optional overrides.
|
||||||
|
Config copyWith({
|
||||||
|
bool? twoPageMode,
|
||||||
|
bool? fullscreen,
|
||||||
|
}) {
|
||||||
|
return Config(
|
||||||
|
twoPageMode: twoPageMode ?? this.twoPageMode,
|
||||||
|
fullscreen: fullscreen ?? this.fullscreen,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
lib/core/models/sheet.dart
Normal file
35
lib/core/models/sheet.dart
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/// Data model representing a sheet music document.
|
||||||
|
///
|
||||||
|
/// A [Sheet] contains metadata about a piece of sheet music,
|
||||||
|
/// including its title, composer information, and timestamps.
|
||||||
|
class Sheet {
|
||||||
|
final String uuid;
|
||||||
|
String name;
|
||||||
|
String composer;
|
||||||
|
DateTime updatedAt;
|
||||||
|
|
||||||
|
Sheet({
|
||||||
|
required this.uuid,
|
||||||
|
required this.name,
|
||||||
|
required this.composer,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Creates a [Sheet] from a JSON map returned by the API.
|
||||||
|
factory Sheet.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Sheet(
|
||||||
|
uuid: json['uuid'],
|
||||||
|
name: json['title'],
|
||||||
|
composer: json['composer'],
|
||||||
|
updatedAt: DateTime.parse(json['updated_at']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts this sheet to a JSON map for API requests.
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'uuid': uuid,
|
||||||
|
'title': name,
|
||||||
|
'composer': composer,
|
||||||
|
'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');
|
||||||
|
}
|
||||||
|
}
|
||||||
294
lib/core/services/api_client.dart
Normal file
294
lib/core/services/api_client.dart
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../models/change.dart';
|
||||||
|
import '../models/sheet.dart';
|
||||||
|
|
||||||
|
/// HTTP client for communicating with the Sheetless API server.
|
||||||
|
///
|
||||||
|
/// Handles authentication, sheet listing, and PDF downloads.
|
||||||
|
/// Provides caching for PDF files on native platforms.
|
||||||
|
class ApiClient {
|
||||||
|
final _log = Logger('ApiClient');
|
||||||
|
final String baseUrl;
|
||||||
|
String? token;
|
||||||
|
|
||||||
|
ApiClient({required this.baseUrl, this.token});
|
||||||
|
|
||||||
|
/// Whether the client has an authentication token set.
|
||||||
|
bool get isAuthenticated => token != null;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Authentication
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Authenticates with the server and stores the JWT token.
|
||||||
|
///
|
||||||
|
/// Throws an [Exception] if login fails.
|
||||||
|
Future<void> login(String username, String password) async {
|
||||||
|
_log.info('Logging in...');
|
||||||
|
final url = Uri.parse('$baseUrl/auth/login');
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({'username': username, 'password': password}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final responseData = jsonDecode(response.body);
|
||||||
|
token = responseData['token'];
|
||||||
|
_log.info('Login successful');
|
||||||
|
} else {
|
||||||
|
throw ApiException(
|
||||||
|
'Login failed',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
body: response.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears the authentication token.
|
||||||
|
void logout() {
|
||||||
|
token = null;
|
||||||
|
_log.info('Logged out successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTP Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Map<String, String> _buildHeaders({bool isBinary = false}) {
|
||||||
|
return {
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
if (!isBinary) 'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs a GET request to the given endpoint.
|
||||||
|
Future<http.Response> get(String endpoint, {bool isBinary = false}) async {
|
||||||
|
final url = Uri.parse('$baseUrl$endpoint');
|
||||||
|
final response = await http.get(
|
||||||
|
url,
|
||||||
|
headers: _buildHeaders(isBinary: isBinary),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
_log.warning(
|
||||||
|
"GET '$endpoint' failed: ${response.statusCode}\n${response.body}",
|
||||||
|
);
|
||||||
|
throw ApiException(
|
||||||
|
'GET request failed',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
body: response.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs a POST request with JSON body.
|
||||||
|
Future<http.Response> post(String endpoint, Map<String, dynamic> body) async {
|
||||||
|
final url = Uri.parse('$baseUrl$endpoint');
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: _buildHeaders(),
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||||
|
_log.warning(
|
||||||
|
"POST '$endpoint' failed: ${response.statusCode}\n${response.body}",
|
||||||
|
);
|
||||||
|
throw ApiException(
|
||||||
|
'POST request failed',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
body: response.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs a POST request with form-encoded body.
|
||||||
|
Future<http.Response> postFormData(String endpoint, String body) async {
|
||||||
|
final url = Uri.parse('$baseUrl$endpoint');
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||||
|
_log.warning(
|
||||||
|
"POST Form '$endpoint' failed: ${response.statusCode}\n${response.body}",
|
||||||
|
);
|
||||||
|
throw ApiException(
|
||||||
|
'POST Form request failed',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
body: response.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sheet Operations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Fetches the list of all sheets from the server.
|
||||||
|
Future<List<Sheet>> fetchSheets() async {
|
||||||
|
final response = await get('/api/sheets/list');
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
|
||||||
|
return (data['sheets'] as List<dynamic>)
|
||||||
|
.map((sheet) => Sheet.fromJson(sheet as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads PDF data for a sheet.
|
||||||
|
///
|
||||||
|
/// Returns the raw PDF bytes. For caching, use [getPdfFileCached] instead.
|
||||||
|
Future<Uint8List> fetchPdfData(String sheetUuid) async {
|
||||||
|
final response = await get('/api/sheets/get/$sheetUuid', isBinary: true);
|
||||||
|
_log.info('PDF downloaded for sheet $sheetUuid');
|
||||||
|
return response.bodyBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets a cached PDF file for a sheet, downloading if necessary.
|
||||||
|
///
|
||||||
|
/// On web platforms, use [fetchPdfData] instead as file caching
|
||||||
|
/// is not supported.
|
||||||
|
Future<File> getPdfFileCached(String sheetUuid) async {
|
||||||
|
final cacheDir = await getTemporaryDirectory();
|
||||||
|
final cachedFile = File('${cacheDir.path}/$sheetUuid.pdf');
|
||||||
|
|
||||||
|
if (await cachedFile.exists()) {
|
||||||
|
_log.info('PDF found in cache: ${cachedFile.path}');
|
||||||
|
return cachedFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
final pdfData = await fetchPdfData(sheetUuid);
|
||||||
|
await cachedFile.writeAsBytes(pdfData);
|
||||||
|
_log.info('PDF cached at: ${cachedFile.path}');
|
||||||
|
return cachedFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Annotation Operations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Fetches all annotations for a sheet from the server.
|
||||||
|
///
|
||||||
|
/// Returns a list of [ServerAnnotation] objects containing page number,
|
||||||
|
/// lastModified timestamp, and the annotations JSON string.
|
||||||
|
Future<List<ServerAnnotation>> fetchAnnotations(String sheetUuid) async {
|
||||||
|
final response = await get('/api/sheets/$sheetUuid/annotations');
|
||||||
|
final data = jsonDecode(response.body) as List<dynamic>;
|
||||||
|
|
||||||
|
return data
|
||||||
|
.map((item) => ServerAnnotation.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uploads annotations for a specific page of a sheet.
|
||||||
|
///
|
||||||
|
/// The [lastModified] should be the current time when the annotation was saved.
|
||||||
|
Future<void> uploadAnnotation({
|
||||||
|
required String sheetUuid,
|
||||||
|
required int page,
|
||||||
|
required DateTime lastModified,
|
||||||
|
required String annotationsJson,
|
||||||
|
}) async {
|
||||||
|
await post('/api/sheets/$sheetUuid/annotations', {
|
||||||
|
'page': page,
|
||||||
|
'lastModified': lastModified.toIso8601String(),
|
||||||
|
'annotations': annotationsJson,
|
||||||
|
});
|
||||||
|
_log.info('Annotation uploaded for sheet $sheetUuid page $page');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Change Sync Operations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Uploads a batch of changes to the server.
|
||||||
|
///
|
||||||
|
/// The server will apply changes based on their [createdAt] timestamps,
|
||||||
|
/// using the newest change for each field when resolving conflicts.
|
||||||
|
///
|
||||||
|
/// Returns the list of change indices that were successfully applied.
|
||||||
|
/// Throws [ApiException] if the request fails (e.g., offline).
|
||||||
|
Future<List<int>> uploadChanges(List<Change> changes) async {
|
||||||
|
if (changes.isEmpty) return [];
|
||||||
|
|
||||||
|
final response = await post('/api/changes/sync', {
|
||||||
|
'changes': changes.map((c) => c.toJson()).toList(),
|
||||||
|
});
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
final applied = (data['applied'] as List<dynamic>).cast<int>();
|
||||||
|
_log.info('Uploaded ${changes.length} changes, ${applied.length} applied');
|
||||||
|
return applied;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if the server is reachable.
|
||||||
|
///
|
||||||
|
/// Returns true if the server responds, false otherwise.
|
||||||
|
Future<bool> checkConnection() async {
|
||||||
|
try {
|
||||||
|
final url = Uri.parse('$baseUrl/api/health');
|
||||||
|
final response = await http
|
||||||
|
.get(url, headers: _buildHeaders())
|
||||||
|
.timeout(const Duration(seconds: 5));
|
||||||
|
return response.statusCode == 200;
|
||||||
|
} catch (e) {
|
||||||
|
_log.fine('Connection check failed: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents an annotation from the server.
|
||||||
|
class ServerAnnotation {
|
||||||
|
final int page;
|
||||||
|
final DateTime lastModified;
|
||||||
|
final String annotationsJson;
|
||||||
|
|
||||||
|
ServerAnnotation({
|
||||||
|
required this.page,
|
||||||
|
required this.lastModified,
|
||||||
|
required this.annotationsJson,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ServerAnnotation.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ServerAnnotation(
|
||||||
|
page: json['page'] as int,
|
||||||
|
lastModified: DateTime.parse(json['lastModified'] as String),
|
||||||
|
annotationsJson: json['annotations'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exception thrown when an API request fails.
|
||||||
|
class ApiException implements Exception {
|
||||||
|
final String message;
|
||||||
|
final int statusCode;
|
||||||
|
final String body;
|
||||||
|
|
||||||
|
ApiException(this.message, {required this.statusCode, required this.body});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'ApiException: $message (status: $statusCode)\n$body';
|
||||||
|
}
|
||||||
429
lib/core/services/storage_service.dart
Normal file
429
lib/core/services/storage_service.dart
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:hive/hive.dart';
|
||||||
|
import 'package:sheetless/core/models/change.dart';
|
||||||
|
import 'package:sheetless/core/models/config.dart';
|
||||||
|
import 'package:sheetless/core/models/sheet.dart';
|
||||||
|
|
||||||
|
/// Keys for secure storage (credentials and tokens).
|
||||||
|
enum SecureStorageKey { url, jwt, email }
|
||||||
|
|
||||||
|
/// Data class for storing annotations with metadata.
|
||||||
|
class StoredAnnotation {
|
||||||
|
final String annotationsJson;
|
||||||
|
final DateTime lastModified;
|
||||||
|
|
||||||
|
StoredAnnotation({required this.annotationsJson, required this.lastModified});
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() => {
|
||||||
|
'annotationsJson': annotationsJson,
|
||||||
|
'lastModified': lastModified.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory StoredAnnotation.fromMap(Map<dynamic, dynamic> map) {
|
||||||
|
return StoredAnnotation(
|
||||||
|
annotationsJson: map['annotationsJson'] as String,
|
||||||
|
lastModified: DateTime.parse(map['lastModified'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service for managing local storage operations.
|
||||||
|
///
|
||||||
|
/// Uses [FlutterSecureStorage] for sensitive data (credentials, tokens)
|
||||||
|
/// and [Hive] for general app data (config, sheet access times, change queue,
|
||||||
|
/// and PDF annotations).
|
||||||
|
/// Data class for a pending annotation upload.
|
||||||
|
class PendingAnnotationUpload {
|
||||||
|
final String sheetUuid;
|
||||||
|
final int page;
|
||||||
|
final String annotationsJson;
|
||||||
|
final DateTime lastModified;
|
||||||
|
|
||||||
|
PendingAnnotationUpload({
|
||||||
|
required this.sheetUuid,
|
||||||
|
required this.page,
|
||||||
|
required this.annotationsJson,
|
||||||
|
required this.lastModified,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() => {
|
||||||
|
'sheetUuid': sheetUuid,
|
||||||
|
'page': page,
|
||||||
|
'annotationsJson': annotationsJson,
|
||||||
|
'lastModified': lastModified.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory PendingAnnotationUpload.fromMap(Map<dynamic, dynamic> map) {
|
||||||
|
return PendingAnnotationUpload(
|
||||||
|
sheetUuid: map['sheetUuid'] as String,
|
||||||
|
page: map['page'] as int,
|
||||||
|
annotationsJson: map['annotationsJson'] as String,
|
||||||
|
lastModified: DateTime.parse(map['lastModified'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unique key for deduplication (newer uploads replace older ones).
|
||||||
|
String get key => '${sheetUuid}_page_$page';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service for managing local storage operations.
|
||||||
|
///
|
||||||
|
/// Uses [FlutterSecureStorage] for sensitive data (credentials, tokens)
|
||||||
|
/// and [Hive] for general app data (config, sheet access times, change queue,
|
||||||
|
/// and PDF annotations).
|
||||||
|
class StorageService {
|
||||||
|
// Hive box names
|
||||||
|
static const String _sheetAccessTimesBox = 'sheetAccessTimes';
|
||||||
|
static const String _configBox = 'config';
|
||||||
|
static const String _changeQueueBox = 'changeQueue';
|
||||||
|
static const String _annotationsBox = 'annotations';
|
||||||
|
static const String _sheetsBox = 'sheets';
|
||||||
|
static const String _pendingAnnotationsBox = 'pendingAnnotations';
|
||||||
|
|
||||||
|
late final FlutterSecureStorage _secureStorage;
|
||||||
|
|
||||||
|
StorageService() {
|
||||||
|
_secureStorage = FlutterSecureStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Secure Storage (Credentials & Tokens)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Reads a value from secure storage.
|
||||||
|
Future<String?> readSecure(SecureStorageKey key) {
|
||||||
|
return _secureStorage.read(key: key.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a value to secure storage.
|
||||||
|
///
|
||||||
|
/// Pass `null` to delete the key.
|
||||||
|
Future<void> writeSecure(SecureStorageKey key, String? value) {
|
||||||
|
return _secureStorage.write(key: key.name, value: value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears all user data except URL and email.
|
||||||
|
///
|
||||||
|
/// Called on logout to ensure a clean state for the next user,
|
||||||
|
/// while preserving server URL and email for convenience.
|
||||||
|
Future<void> clearAllUserData() async {
|
||||||
|
// Clear JWT token
|
||||||
|
await writeSecure(SecureStorageKey.jwt, null);
|
||||||
|
|
||||||
|
// Clear all Hive boxes
|
||||||
|
final sheetAccessTimesBox = await Hive.openBox(_sheetAccessTimesBox);
|
||||||
|
await sheetAccessTimesBox.clear();
|
||||||
|
|
||||||
|
final configBox = await Hive.openBox(_configBox);
|
||||||
|
await configBox.clear();
|
||||||
|
|
||||||
|
final changeQueueBox = await Hive.openBox(_changeQueueBox);
|
||||||
|
await changeQueueBox.clear();
|
||||||
|
|
||||||
|
final annotationsBox = await Hive.openBox(_annotationsBox);
|
||||||
|
await annotationsBox.clear();
|
||||||
|
|
||||||
|
final sheetsBox = await Hive.openBox(_sheetsBox);
|
||||||
|
await sheetsBox.clear();
|
||||||
|
|
||||||
|
final pendingAnnotationsBox = await Hive.openBox(_pendingAnnotationsBox);
|
||||||
|
await pendingAnnotationsBox.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Reads the app configuration from storage.
|
||||||
|
Future<Config> readConfig() async {
|
||||||
|
final box = await Hive.openBox(_configBox);
|
||||||
|
return Config(
|
||||||
|
twoPageMode: box.get(Config.keyTwoPageMode) ?? false,
|
||||||
|
fullscreen: box.get(Config.keyFullscreen) ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the app configuration to storage.
|
||||||
|
Future<void> writeConfig(Config config) async {
|
||||||
|
final box = await Hive.openBox(_configBox);
|
||||||
|
await box.put(Config.keyTwoPageMode, config.twoPageMode);
|
||||||
|
await box.put(Config.keyFullscreen, config.fullscreen);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sheet Access Times (for sorting by recency)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Reads all sheet access times.
|
||||||
|
///
|
||||||
|
/// Returns a map of sheet UUID to last access time.
|
||||||
|
Future<Map<String, DateTime>> readSheetAccessTimes() async {
|
||||||
|
final box = await Hive.openBox(_sheetAccessTimesBox);
|
||||||
|
return box.toMap().map(
|
||||||
|
(key, value) => MapEntry(key as String, DateTime.parse(value as String)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records when a sheet was last accessed.
|
||||||
|
Future<void> writeSheetAccessTime(String uuid, DateTime datetime) async {
|
||||||
|
final box = await Hive.openBox(_sheetAccessTimesBox);
|
||||||
|
await box.put(uuid, datetime.toIso8601String());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Change Queue (Offline Sync)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Adds a change to the offline queue.
|
||||||
|
Future<void> writeChange(Change change) async {
|
||||||
|
final box = await Hive.openBox(_changeQueueBox);
|
||||||
|
await box.add(change.toMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads all pending changes from the queue.
|
||||||
|
Future<ChangeQueue> readChangeQueue() async {
|
||||||
|
final box = await Hive.openBox(_changeQueueBox);
|
||||||
|
final queue = ChangeQueue();
|
||||||
|
|
||||||
|
for (final map in box.values) {
|
||||||
|
queue.addChange(Change.fromMap(map as Map<dynamic, dynamic>));
|
||||||
|
}
|
||||||
|
|
||||||
|
return queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the oldest change from the queue.
|
||||||
|
///
|
||||||
|
/// Call this after successfully syncing a change to the server.
|
||||||
|
Future<void> deleteOldestChange() async {
|
||||||
|
final box = await Hive.openBox(_changeQueueBox);
|
||||||
|
if (box.isNotEmpty) {
|
||||||
|
await box.deleteAt(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Annotations (PDF Drawing Persistence)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Generates a storage key for a specific page's annotations.
|
||||||
|
String _annotationKey(String sheetUuid, int pageNumber) {
|
||||||
|
return '${sheetUuid}_page_$pageNumber';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads annotations for a specific sheet page.
|
||||||
|
///
|
||||||
|
/// Returns the JSON string of annotations, or null if none exist.
|
||||||
|
Future<String?> readAnnotations(String sheetUuid, int pageNumber) async {
|
||||||
|
final box = await Hive.openBox(_annotationsBox);
|
||||||
|
final value = box.get(_annotationKey(sheetUuid, pageNumber));
|
||||||
|
|
||||||
|
// Handle legacy format (plain string) and new format (map with metadata)
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is String) return value;
|
||||||
|
if (value is Map) {
|
||||||
|
final stored = StoredAnnotation.fromMap(value);
|
||||||
|
return stored.annotationsJson;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes annotations with a specific lastModified timestamp.
|
||||||
|
///
|
||||||
|
/// Used when syncing from server to preserve server's timestamp.
|
||||||
|
Future<void> writeAnnotationsWithMetadata(
|
||||||
|
String sheetUuid,
|
||||||
|
int pageNumber,
|
||||||
|
String? annotationsJson,
|
||||||
|
DateTime lastModified,
|
||||||
|
) async {
|
||||||
|
final box = await Hive.openBox(_annotationsBox);
|
||||||
|
final key = _annotationKey(sheetUuid, pageNumber);
|
||||||
|
|
||||||
|
if (annotationsJson == null || annotationsJson.isEmpty) {
|
||||||
|
await box.delete(key);
|
||||||
|
} else {
|
||||||
|
final stored = StoredAnnotation(
|
||||||
|
annotationsJson: annotationsJson,
|
||||||
|
lastModified: lastModified,
|
||||||
|
);
|
||||||
|
await box.put(key, stored.toMap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads all annotations for a sheet (all pages).
|
||||||
|
///
|
||||||
|
/// Returns a map of page number to JSON string.
|
||||||
|
Future<Map<int, String>> readAllAnnotations(String sheetUuid) async {
|
||||||
|
final box = await Hive.openBox(_annotationsBox);
|
||||||
|
final prefix = '${sheetUuid}_page_';
|
||||||
|
final result = <int, String>{};
|
||||||
|
|
||||||
|
for (final key in box.keys) {
|
||||||
|
if (key is String && key.startsWith(prefix)) {
|
||||||
|
final pageStr = key.substring(prefix.length);
|
||||||
|
final pageNumber = int.tryParse(pageStr);
|
||||||
|
if (pageNumber != null) {
|
||||||
|
final value = box.get(key);
|
||||||
|
if (value != null) {
|
||||||
|
// Handle legacy format (plain string) and new format (map)
|
||||||
|
if (value is String && value.isNotEmpty) {
|
||||||
|
result[pageNumber] = value;
|
||||||
|
} else if (value is Map) {
|
||||||
|
final stored = StoredAnnotation.fromMap(value);
|
||||||
|
if (stored.annotationsJson.isNotEmpty) {
|
||||||
|
result[pageNumber] = stored.annotationsJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads all annotations with metadata for a sheet (all pages).
|
||||||
|
///
|
||||||
|
/// Returns a map of page number to [StoredAnnotation].
|
||||||
|
Future<Map<int, StoredAnnotation>> readAllAnnotationsWithMetadata(
|
||||||
|
String sheetUuid,
|
||||||
|
) async {
|
||||||
|
final box = await Hive.openBox(_annotationsBox);
|
||||||
|
final prefix = '${sheetUuid}_page_';
|
||||||
|
final result = <int, StoredAnnotation>{};
|
||||||
|
|
||||||
|
for (final key in box.keys) {
|
||||||
|
if (key is String && key.startsWith(prefix)) {
|
||||||
|
final pageStr = key.substring(prefix.length);
|
||||||
|
final pageNumber = int.tryParse(pageStr);
|
||||||
|
if (pageNumber != null) {
|
||||||
|
final value = box.get(key);
|
||||||
|
if (value != null) {
|
||||||
|
StoredAnnotation? stored;
|
||||||
|
// Handle legacy format (plain string) and new format (map)
|
||||||
|
if (value is String && value.isNotEmpty) {
|
||||||
|
stored = StoredAnnotation(
|
||||||
|
annotationsJson: value,
|
||||||
|
lastModified: DateTime.fromMillisecondsSinceEpoch(0),
|
||||||
|
);
|
||||||
|
} else if (value is Map) {
|
||||||
|
stored = StoredAnnotation.fromMap(value);
|
||||||
|
}
|
||||||
|
if (stored != null && stored.annotationsJson.isNotEmpty) {
|
||||||
|
result[pageNumber] = stored;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes all annotations for a sheet.
|
||||||
|
Future<void> deleteAllAnnotations(String sheetUuid) async {
|
||||||
|
final box = await Hive.openBox(_annotationsBox);
|
||||||
|
final prefix = '${sheetUuid}_page_';
|
||||||
|
final keysToDelete = box.keys.where(
|
||||||
|
(key) => key is String && key.startsWith(prefix),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final key in keysToDelete.toList()) {
|
||||||
|
await box.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sheets Cache (Offline Support)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Reads cached sheets from local storage.
|
||||||
|
///
|
||||||
|
/// Returns an empty list if no cached sheets exist.
|
||||||
|
Future<List<Sheet>> readCachedSheets() async {
|
||||||
|
final box = await Hive.openBox(_sheetsBox);
|
||||||
|
final sheetsJson = box.get('sheets');
|
||||||
|
|
||||||
|
if (sheetsJson == null) return [];
|
||||||
|
|
||||||
|
final List<dynamic> sheetsList = jsonDecode(sheetsJson as String);
|
||||||
|
return sheetsList
|
||||||
|
.map((json) => Sheet.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Caches the sheets list to local storage.
|
||||||
|
Future<void> writeCachedSheets(List<Sheet> sheets) async {
|
||||||
|
final box = await Hive.openBox(_sheetsBox);
|
||||||
|
final sheetsJson = jsonEncode(sheets.map((s) => s.toJson()).toList());
|
||||||
|
await box.put('sheets', sheetsJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pending Annotation Uploads (Offline Support)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Adds or updates a pending annotation upload.
|
||||||
|
///
|
||||||
|
/// If an upload for the same sheet/page already exists, it will be replaced
|
||||||
|
/// with the newer version.
|
||||||
|
Future<void> writePendingAnnotationUpload(
|
||||||
|
PendingAnnotationUpload upload,
|
||||||
|
) async {
|
||||||
|
final box = await Hive.openBox(_pendingAnnotationsBox);
|
||||||
|
await box.put(upload.key, upload.toMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads all pending annotation uploads.
|
||||||
|
Future<List<PendingAnnotationUpload>> readPendingAnnotationUploads() async {
|
||||||
|
final box = await Hive.openBox(_pendingAnnotationsBox);
|
||||||
|
final uploads = <PendingAnnotationUpload>[];
|
||||||
|
|
||||||
|
for (final value in box.values) {
|
||||||
|
uploads.add(PendingAnnotationUpload.fromMap(value as Map));
|
||||||
|
}
|
||||||
|
|
||||||
|
return uploads;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a pending annotation upload after successful sync.
|
||||||
|
Future<void> deletePendingAnnotationUpload(String key) async {
|
||||||
|
final box = await Hive.openBox(_pendingAnnotationsBox);
|
||||||
|
await box.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if there are any pending annotation uploads.
|
||||||
|
Future<bool> hasPendingAnnotationUploads() async {
|
||||||
|
final box = await Hive.openBox(_pendingAnnotationsBox);
|
||||||
|
return box.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Change Queue Enhancements
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Returns the number of pending changes.
|
||||||
|
Future<int> getChangeQueueLength() async {
|
||||||
|
final box = await Hive.openBox(_changeQueueBox);
|
||||||
|
return box.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears all pending changes.
|
||||||
|
///
|
||||||
|
/// Use with caution - only call after all changes are synced.
|
||||||
|
Future<void> clearChangeQueue() async {
|
||||||
|
final box = await Hive.openBox(_changeQueueBox);
|
||||||
|
await box.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets all changes as a list (for batch upload).
|
||||||
|
Future<List<Change>> readChangeList() async {
|
||||||
|
final box = await Hive.openBox(_changeQueueBox);
|
||||||
|
return box.values
|
||||||
|
.map((map) => Change.fromMap(map as Map<dynamic, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:sheetless/sheet.dart';
|
|
||||||
|
|
||||||
typedef SheetEditedCallback = void Function(String newName, String newComposer);
|
|
||||||
|
|
||||||
class EditItemBottomSheet extends StatefulWidget {
|
|
||||||
final Sheet sheet;
|
|
||||||
final SheetEditedCallback onSheetEdited;
|
|
||||||
|
|
||||||
const EditItemBottomSheet({
|
|
||||||
super.key,
|
|
||||||
required this.sheet,
|
|
||||||
required this.onSheetEdited,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<EditItemBottomSheet> createState() => _EditItemBottomSheetState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EditItemBottomSheetState extends State<EditItemBottomSheet> {
|
|
||||||
late TextEditingController sheetNameController;
|
|
||||||
late TextEditingController composerNameController;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
sheetNameController = TextEditingController(text: widget.sheet.name);
|
|
||||||
composerNameController = TextEditingController(
|
|
||||||
text: widget.sheet.composerName,
|
|
||||||
);
|
|
||||||
super.initState();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Padding(
|
|
||||||
padding: EdgeInsets.fromLTRB(
|
|
||||||
16,
|
|
||||||
16,
|
|
||||||
16,
|
|
||||||
MediaQuery.of(context).viewInsets.bottom + 16,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
TextField(
|
|
||||||
controller: sheetNameController,
|
|
||||||
decoration: InputDecoration(labelText: "Sheet"),
|
|
||||||
),
|
|
||||||
TextField(
|
|
||||||
controller: composerNameController,
|
|
||||||
decoration: InputDecoration(labelText: "Composer"),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: check text fields are not empty
|
|
||||||
// TODO: save on pressing enter
|
|
||||||
widget.onSheetEdited(
|
|
||||||
sheetNameController.text,
|
|
||||||
composerNameController.text,
|
|
||||||
);
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
child: Text("Save"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
242
lib/features/auth/login_page.dart
Normal file
242
lib/features/auth/login_page.dart
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:jwt_decoder/jwt_decoder.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:sheetless/core/services/api_client.dart';
|
||||||
|
import 'package:sheetless/core/services/storage_service.dart';
|
||||||
|
|
||||||
|
import '../home/home_page.dart';
|
||||||
|
|
||||||
|
/// Login page for authenticating with the Sheetless server.
|
||||||
|
class LoginPage extends StatefulWidget {
|
||||||
|
const LoginPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LoginPage> createState() => _LoginPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginPageState extends State<LoginPage> {
|
||||||
|
final _log = Logger('LoginPage');
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _storageService = StorageService();
|
||||||
|
|
||||||
|
final _urlController = TextEditingController(
|
||||||
|
text: 'https://sheetless.julian-mutter.de',
|
||||||
|
);
|
||||||
|
final _usernameController = TextEditingController();
|
||||||
|
final _passwordController = TextEditingController();
|
||||||
|
|
||||||
|
String? _errorMessage;
|
||||||
|
bool _isLoggingIn = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tryAutoLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_urlController.dispose();
|
||||||
|
_usernameController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auto-Login
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Attempts to auto-login using a stored JWT token.
|
||||||
|
Future<void> _tryAutoLogin() async {
|
||||||
|
final jwt = await _storageService.readSecure(SecureStorageKey.jwt);
|
||||||
|
|
||||||
|
if (jwt != null && _isTokenValid(jwt)) {
|
||||||
|
await _navigateToHome();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore saved credentials for convenience
|
||||||
|
await _restoreCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a JWT token is still valid (not expired).
|
||||||
|
bool _isTokenValid(String jwt) {
|
||||||
|
try {
|
||||||
|
return !JwtDecoder.isExpired(jwt);
|
||||||
|
} on FormatException {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores previously saved URL and username for convenience.
|
||||||
|
Future<void> _restoreCredentials() async {
|
||||||
|
final url = await _storageService.readSecure(SecureStorageKey.url);
|
||||||
|
final username = await _storageService.readSecure(SecureStorageKey.email);
|
||||||
|
|
||||||
|
if (url != null) _urlController.text = url;
|
||||||
|
if (username != null) _usernameController.text = username;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Login
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Handles the login button press.
|
||||||
|
Future<void> _handleLogin() async {
|
||||||
|
if (_isLoggingIn) return;
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoggingIn = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final apiClient = ApiClient(baseUrl: _urlController.text);
|
||||||
|
await apiClient.login(_usernameController.text, _passwordController.text);
|
||||||
|
|
||||||
|
// Save credentials for next time
|
||||||
|
await _saveCredentials(apiClient.token!);
|
||||||
|
await _navigateToHome();
|
||||||
|
} catch (e) {
|
||||||
|
_log.warning('Login failed', e);
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Login failed.\n$e';
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isLoggingIn = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves credentials after successful login.
|
||||||
|
Future<void> _saveCredentials(String token) async {
|
||||||
|
await _storageService.writeSecure(
|
||||||
|
SecureStorageKey.url,
|
||||||
|
_urlController.text,
|
||||||
|
);
|
||||||
|
await _storageService.writeSecure(SecureStorageKey.jwt, token);
|
||||||
|
await _storageService.writeSecure(
|
||||||
|
SecureStorageKey.email,
|
||||||
|
_usernameController.text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the home page after successful authentication.
|
||||||
|
Future<void> _navigateToHome() async {
|
||||||
|
final config = await _storageService.readConfig();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.of(context).pushReplacement(
|
||||||
|
MaterialPageRoute(builder: (_) => HomePage(config: config)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Validation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
String? _validateNotEmpty(String? value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'This field is required';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UI
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Login')),
|
||||||
|
body: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildUrlField(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildUsernameField(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildPasswordField(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildLoginButton(),
|
||||||
|
if (_errorMessage != null) _buildErrorMessage(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildUrlField() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _urlController,
|
||||||
|
validator: _validateNotEmpty,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Server URL',
|
||||||
|
prefixIcon: Icon(Icons.dns),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildUsernameField() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _usernameController,
|
||||||
|
validator: _validateNotEmpty,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Username',
|
||||||
|
prefixIcon: Icon(Icons.person),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPasswordField() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _passwordController,
|
||||||
|
validator: _validateNotEmpty,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Password',
|
||||||
|
prefixIcon: Icon(Icons.lock),
|
||||||
|
),
|
||||||
|
obscureText: true,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onFieldSubmitted: (_) => _handleLogin(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLoginButton() {
|
||||||
|
return ElevatedButton(
|
||||||
|
onPressed: _isLoggingIn ? null : _handleLogin,
|
||||||
|
child: _isLoggingIn
|
||||||
|
? const SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('Login'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildErrorMessage() {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16.0),
|
||||||
|
child: Text(
|
||||||
|
_errorMessage!,
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
268
lib/features/home/home_page.dart
Normal file
268
lib/features/home/home_page.dart
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:sheetless/core/models/config.dart';
|
||||||
|
import 'package:sheetless/core/models/sheet.dart';
|
||||||
|
import 'package:sheetless/core/services/api_client.dart';
|
||||||
|
import 'package:sheetless/core/services/storage_service.dart';
|
||||||
|
import 'package:sheetless/core/services/sync_service.dart';
|
||||||
|
|
||||||
|
import '../../app.dart';
|
||||||
|
import '../auth/login_page.dart';
|
||||||
|
import '../sheet_viewer/sheet_viewer_page.dart';
|
||||||
|
import 'widgets/app_drawer.dart';
|
||||||
|
import 'widgets/sheets_list.dart';
|
||||||
|
|
||||||
|
/// Main home page displaying the list of sheet music.
|
||||||
|
///
|
||||||
|
/// Features:
|
||||||
|
/// - Pull-to-refresh sheet list
|
||||||
|
/// - Shuffle mode for random practice
|
||||||
|
/// - Navigation to sheet viewer
|
||||||
|
/// - Logout functionality
|
||||||
|
class HomePage extends StatefulWidget {
|
||||||
|
final Config config;
|
||||||
|
|
||||||
|
const HomePage({super.key, required this.config});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HomePage> createState() => _HomePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomePageState extends State<HomePage> with RouteAware {
|
||||||
|
final _log = Logger('HomePage');
|
||||||
|
final _storageService = StorageService();
|
||||||
|
|
||||||
|
ApiClient? _apiClient;
|
||||||
|
SyncService? _syncService;
|
||||||
|
late Future<SyncResult> _syncFuture;
|
||||||
|
List<Sheet> _sheets = [];
|
||||||
|
bool _isShuffling = false;
|
||||||
|
bool _isOnline = true;
|
||||||
|
String? _appName;
|
||||||
|
String? _appVersion;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
// Exit fullscreen when entering home page
|
||||||
|
if (FullScreen.isFullScreen) {
|
||||||
|
FullScreen.setFullScreen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to route changes
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
routeObserver.subscribe(this, ModalRoute.of(context)!);
|
||||||
|
});
|
||||||
|
|
||||||
|
_loadAppInfo();
|
||||||
|
_syncFuture = _loadSheets();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
routeObserver.unsubscribe(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Route Aware (Fullscreen Management)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didPush() {
|
||||||
|
// Exit fullscreen when entering home page
|
||||||
|
if (FullScreen.isFullScreen) {
|
||||||
|
FullScreen.setFullScreen(false);
|
||||||
|
}
|
||||||
|
super.didPush();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didPopNext() {
|
||||||
|
// Exit fullscreen when returning to home page
|
||||||
|
if (FullScreen.isFullScreen) {
|
||||||
|
FullScreen.setFullScreen(false);
|
||||||
|
}
|
||||||
|
super.didPopNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Data Loading
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Future<void> _loadAppInfo() async {
|
||||||
|
final info = await PackageInfo.fromPlatform();
|
||||||
|
setState(() {
|
||||||
|
_appName = info.appName;
|
||||||
|
_appVersion = info.version;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SyncResult> _loadSheets() async {
|
||||||
|
final url = await _storageService.readSecure(SecureStorageKey.url);
|
||||||
|
final jwt = await _storageService.readSecure(SecureStorageKey.jwt);
|
||||||
|
|
||||||
|
_apiClient = ApiClient(baseUrl: url!, token: jwt);
|
||||||
|
_syncService = SyncService(
|
||||||
|
apiClient: _apiClient!,
|
||||||
|
storageService: _storageService,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Perform sync (fetches sheets, uploads pending changes/annotations)
|
||||||
|
final result = await _syncService!.sync();
|
||||||
|
_log.info(
|
||||||
|
'${result.sheets.length} sheets loaded (online: ${result.isOnline}, '
|
||||||
|
'changes synced: ${result.changesSynced}, '
|
||||||
|
'annotations synced: ${result.annotationsSynced})',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort and store sheets
|
||||||
|
_sheets = await _sortSheetsByRecency(result.sheets);
|
||||||
|
_isOnline = result.isOnline;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Sheet>> _sortSheetsByRecency(List<Sheet> sheets) async {
|
||||||
|
final accessTimes = await _storageService.readSheetAccessTimes();
|
||||||
|
|
||||||
|
sheets.sort((a, b) {
|
||||||
|
// Use local access time if available and more recent than server update
|
||||||
|
var dateA = accessTimes[a.uuid];
|
||||||
|
var dateB = accessTimes[b.uuid];
|
||||||
|
|
||||||
|
if (dateA == null || a.updatedAt.isAfter(dateA)) {
|
||||||
|
dateA = a.updatedAt;
|
||||||
|
}
|
||||||
|
if (dateB == null || b.updatedAt.isAfter(dateB)) {
|
||||||
|
dateB = b.updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dateB.compareTo(dateA); // Most recent first
|
||||||
|
});
|
||||||
|
|
||||||
|
return sheets;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshSheets() async {
|
||||||
|
setState(() {
|
||||||
|
_syncFuture = _loadSheets();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Actions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void _handleShuffleChanged(bool enabled) async {
|
||||||
|
if (enabled) {
|
||||||
|
_sheets.shuffle();
|
||||||
|
} else {
|
||||||
|
await _sortSheetsByRecency(_sheets);
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isShuffling = enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleLogout() async {
|
||||||
|
await _storageService.clearAllUserData();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
Navigator.of(
|
||||||
|
context,
|
||||||
|
).pushReplacement(MaterialPageRoute(builder: (_) => const LoginPage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openSheet(Sheet sheet) {
|
||||||
|
// Record access time for recency sorting
|
||||||
|
_storageService.writeSheetAccessTime(sheet.uuid, DateTime.now());
|
||||||
|
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => SheetViewerPage(
|
||||||
|
sheet: sheet,
|
||||||
|
apiClient: _apiClient!,
|
||||||
|
config: widget.config,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UI
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text('Sheetless'),
|
||||||
|
if (!_isOnline)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(left: 8),
|
||||||
|
child: Icon(Icons.cloud_off, color: Colors.orange, size: 20),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
endDrawer: AppDrawer(
|
||||||
|
isShuffling: _isShuffling,
|
||||||
|
onShuffleChanged: _handleShuffleChanged,
|
||||||
|
onLogout: _handleLogout,
|
||||||
|
appName: _appName,
|
||||||
|
appVersion: _appVersion,
|
||||||
|
syncFuture: _syncFuture,
|
||||||
|
),
|
||||||
|
body: RefreshIndicator(onRefresh: _refreshSheets, child: _buildBody()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody() {
|
||||||
|
return FutureBuilder<SyncResult>(
|
||||||
|
future: _syncFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
_log.warning('Error loading sheets', snapshot.error);
|
||||||
|
return _buildError(snapshot.error.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasData) {
|
||||||
|
return SheetsList(
|
||||||
|
sheets: _sheets,
|
||||||
|
onSheetSelected: _openSheet,
|
||||||
|
syncService: _syncService!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildError(String message) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleMedium?.copyWith(color: Colors.red),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
114
lib/features/home/widgets/app_drawer.dart
Normal file
114
lib/features/home/widgets/app_drawer.dart
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sheetless/core/services/sync_service.dart';
|
||||||
|
|
||||||
|
/// Callback for shuffle state changes.
|
||||||
|
typedef ShuffleCallback = void Function(bool enabled);
|
||||||
|
|
||||||
|
/// Navigation drawer for the home page.
|
||||||
|
///
|
||||||
|
/// Provides access to app-level actions like shuffle mode and logout.
|
||||||
|
class AppDrawer extends StatelessWidget {
|
||||||
|
final bool isShuffling;
|
||||||
|
final ShuffleCallback onShuffleChanged;
|
||||||
|
final VoidCallback onLogout;
|
||||||
|
final String? appName;
|
||||||
|
final String? appVersion;
|
||||||
|
final Future<SyncResult> syncFuture;
|
||||||
|
|
||||||
|
const AppDrawer({
|
||||||
|
super.key,
|
||||||
|
required this.isShuffling,
|
||||||
|
required this.onShuffleChanged,
|
||||||
|
required this.onLogout,
|
||||||
|
required this.syncFuture,
|
||||||
|
this.appName,
|
||||||
|
this.appVersion,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Drawer(
|
||||||
|
child: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 30),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
_buildActions(),
|
||||||
|
Column(children: [_buildSyncStatus(), _buildAppInfo()]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActions() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(Icons.shuffle, color: isShuffling ? Colors.blue : null),
|
||||||
|
title: const Text('Shuffle'),
|
||||||
|
onTap: () => onShuffleChanged(!isShuffling),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.logout),
|
||||||
|
title: const Text('Logout'),
|
||||||
|
onTap: onLogout,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSyncStatus() {
|
||||||
|
return Center(
|
||||||
|
// padding: const EdgeInsets.all(5.0),
|
||||||
|
child: FutureBuilder<SyncResult>(
|
||||||
|
future: syncFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return Text(
|
||||||
|
"Error: ${snapshot.error.toString()}",
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasData) {
|
||||||
|
final changes = snapshot.data!.changesUnsynced;
|
||||||
|
final annotations = snapshot.data!.annotationsUnsynced;
|
||||||
|
if (changes == 0 && annotations == 0) {
|
||||||
|
return Text(
|
||||||
|
"All synced!",
|
||||||
|
style: const TextStyle(color: Colors.black),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Text(
|
||||||
|
"$changes changes and $annotations annotations unsynchronized!",
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppInfo() {
|
||||||
|
final versionText = appName != null && appVersion != null
|
||||||
|
? '$appName v$appVersion'
|
||||||
|
: 'Loading...';
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Text(versionText, style: const TextStyle(color: Colors.grey)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
lib/features/home/widgets/sheet_list_item.dart
Normal file
29
lib/features/home/widgets/sheet_list_item.dart
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../core/models/sheet.dart';
|
||||||
|
|
||||||
|
/// A list tile displaying a single sheet's information.
|
||||||
|
///
|
||||||
|
/// Shows the sheet name and composer, with tap and long-press handlers.
|
||||||
|
class SheetListItem extends StatelessWidget {
|
||||||
|
final Sheet sheet;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onLongPress;
|
||||||
|
|
||||||
|
const SheetListItem({
|
||||||
|
super.key,
|
||||||
|
required this.sheet,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onLongPress,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListTile(
|
||||||
|
title: Text(sheet.name),
|
||||||
|
subtitle: Text(sheet.composer),
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
lib/features/home/widgets/sheet_search_bar.dart
Normal file
38
lib/features/home/widgets/sheet_search_bar.dart
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Search bar for filtering sheets.
|
||||||
|
///
|
||||||
|
/// Provides a text input with search icon and clear button.
|
||||||
|
class SheetSearchBar extends StatelessWidget {
|
||||||
|
final TextEditingController controller;
|
||||||
|
final VoidCallback onClear;
|
||||||
|
|
||||||
|
const SheetSearchBar({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
required this.onClear,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search sheets...',
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
suffixIcon: controller.text.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: onClear,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8.0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
191
lib/features/home/widgets/sheets_list.dart
Normal file
191
lib/features/home/widgets/sheets_list.dart
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sheetless/core/models/change.dart';
|
||||||
|
import 'package:sheetless/core/models/sheet.dart';
|
||||||
|
import 'package:sheetless/core/services/sync_service.dart';
|
||||||
|
|
||||||
|
import '../../../shared/widgets/edit_sheet_bottom_sheet.dart';
|
||||||
|
import 'sheet_list_item.dart';
|
||||||
|
import 'sheet_search_bar.dart';
|
||||||
|
|
||||||
|
/// Widget displaying a searchable list of sheets.
|
||||||
|
///
|
||||||
|
/// Features:
|
||||||
|
/// - Debounced search filtering by name and composer
|
||||||
|
/// - Long-press to edit sheet metadata
|
||||||
|
/// - Cross-platform scroll support (mouse and touch)
|
||||||
|
class SheetsList extends StatefulWidget {
|
||||||
|
final List<Sheet> sheets;
|
||||||
|
final ValueSetter<Sheet> onSheetSelected;
|
||||||
|
final SyncService syncService;
|
||||||
|
|
||||||
|
const SheetsList({
|
||||||
|
super.key,
|
||||||
|
required this.sheets,
|
||||||
|
required this.onSheetSelected,
|
||||||
|
required this.syncService,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SheetsList> createState() => _SheetsListState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SheetsListState extends State<SheetsList> {
|
||||||
|
static const _searchDebounceMs = 500;
|
||||||
|
|
||||||
|
final _searchController = TextEditingController();
|
||||||
|
Timer? _debounceTimer;
|
||||||
|
late List<Sheet> _filteredSheets;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_filteredSheets = widget.sheets;
|
||||||
|
_searchController.addListener(_onSearchChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.removeListener(_onSearchChanged);
|
||||||
|
_searchController.dispose();
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Search
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void _onSearchChanged() {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
_debounceTimer = Timer(
|
||||||
|
const Duration(milliseconds: _searchDebounceMs),
|
||||||
|
_filterSheets,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _filterSheets() {
|
||||||
|
final query = _searchController.text.toLowerCase().trim();
|
||||||
|
|
||||||
|
if (query.isEmpty) {
|
||||||
|
setState(() => _filteredSheets = widget.sheets);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split query into terms for multi-word search
|
||||||
|
final terms = query.split(RegExp(r'\s+'));
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_filteredSheets = widget.sheets.where((sheet) {
|
||||||
|
final name = sheet.name.toLowerCase();
|
||||||
|
final composer = sheet.composer.toLowerCase();
|
||||||
|
|
||||||
|
// Each term must appear in either name or composer
|
||||||
|
return terms.every(
|
||||||
|
(term) => name.contains(term) || composer.contains(term),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearSearch() {
|
||||||
|
_searchController.clear();
|
||||||
|
setState(() => _filteredSheets = widget.sheets);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Edit Sheet
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void _openEditSheet(BuildContext context, Sheet sheet) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (_) => EditSheetBottomSheet(
|
||||||
|
sheet: sheet,
|
||||||
|
onSave: (newName, newComposer) =>
|
||||||
|
_handleSheetEdit(sheet, newName, newComposer),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSheetEdit(Sheet sheet, String newName, String newComposer) {
|
||||||
|
// Queue changes for server sync (with timestamp for conflict resolution)
|
||||||
|
if (newName != sheet.name) {
|
||||||
|
widget.syncService.queueChange(
|
||||||
|
Change(
|
||||||
|
type: ChangeType.sheetNameChange,
|
||||||
|
sheetUuid: sheet.uuid,
|
||||||
|
value: newName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (newComposer != sheet.composer) {
|
||||||
|
widget.syncService.queueChange(
|
||||||
|
Change(
|
||||||
|
type: ChangeType.composerNameChange,
|
||||||
|
sheetUuid: sheet.uuid,
|
||||||
|
value: newComposer,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setState(() {
|
||||||
|
sheet.name = newName;
|
||||||
|
sheet.composer = newComposer;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update cached sheets
|
||||||
|
widget.syncService.updateCachedSheets(widget.sheets);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sheet Selection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void _handleSheetTap(Sheet sheet) {
|
||||||
|
// Move selected sheet to top of list (most recently accessed)
|
||||||
|
widget.sheets.remove(sheet);
|
||||||
|
widget.sheets.insert(0, sheet);
|
||||||
|
|
||||||
|
widget.onSheetSelected(sheet);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UI
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
SheetSearchBar(controller: _searchController, onClear: _clearSearch),
|
||||||
|
Expanded(child: _buildList()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildList() {
|
||||||
|
// Enable both mouse and touch scrolling for web compatibility
|
||||||
|
return ScrollConfiguration(
|
||||||
|
behavior: ScrollConfiguration.of(context).copyWith(
|
||||||
|
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse},
|
||||||
|
),
|
||||||
|
child: ListView.builder(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
itemCount: _filteredSheets.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final sheet = _filteredSheets[index];
|
||||||
|
return SheetListItem(
|
||||||
|
sheet: sheet,
|
||||||
|
onTap: () => _handleSheetTap(sheet),
|
||||||
|
onLongPress: () => _openEditSheet(context, sheet),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
lib/features/sheet_viewer/drawing/drawing.dart
Normal file
12
lib/features/sheet_viewer/drawing/drawing.dart
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/// Custom drawing library for PDF annotations.
|
||||||
|
///
|
||||||
|
/// Provides scalable drawing with normalized coordinates (0-1 range)
|
||||||
|
/// that work correctly when the canvas size changes.
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'drawing_board.dart';
|
||||||
|
export 'drawing_canvas.dart';
|
||||||
|
export 'drawing_controller.dart';
|
||||||
|
export 'drawing_line.dart';
|
||||||
|
export 'drawing_toolbar.dart';
|
||||||
|
export 'paint_preset.dart';
|
||||||
200
lib/features/sheet_viewer/drawing/drawing_board.dart
Normal file
200
lib/features/sheet_viewer/drawing/drawing_board.dart
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'drawing_canvas.dart';
|
||||||
|
import 'drawing_controller.dart';
|
||||||
|
|
||||||
|
/// A drawing board that overlays a child widget with a drawing canvas.
|
||||||
|
///
|
||||||
|
/// Supports:
|
||||||
|
/// - Drawing with normalized coordinates that scale correctly
|
||||||
|
/// - Zooming and panning via InteractiveViewer
|
||||||
|
/// - Toggle between view-only and drawing modes
|
||||||
|
class DrawingBoard extends StatefulWidget {
|
||||||
|
/// The widget to display behind the drawing (e.g., PDF page)
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
/// Size of the drawing area (should match child size)
|
||||||
|
final Size boardSize;
|
||||||
|
|
||||||
|
/// Controller for managing drawing state
|
||||||
|
final DrawingController controller;
|
||||||
|
|
||||||
|
/// Whether drawing is enabled (false = view only)
|
||||||
|
final bool drawingEnabled;
|
||||||
|
|
||||||
|
/// Minimum zoom scale
|
||||||
|
final double minScale;
|
||||||
|
|
||||||
|
/// Maximum zoom scale
|
||||||
|
final double maxScale;
|
||||||
|
|
||||||
|
/// Alignment of the board within available space
|
||||||
|
final Alignment alignment;
|
||||||
|
|
||||||
|
const DrawingBoard({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
required this.boardSize,
|
||||||
|
required this.controller,
|
||||||
|
this.drawingEnabled = true,
|
||||||
|
this.minScale = 1.0,
|
||||||
|
this.maxScale = 3.0,
|
||||||
|
this.alignment = Alignment.topCenter,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DrawingBoard> createState() => _DrawingBoardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DrawingBoardState extends State<DrawingBoard> {
|
||||||
|
final TransformationController _transformationController =
|
||||||
|
TransformationController();
|
||||||
|
|
||||||
|
/// Tracks whether we're currently in a drawing gesture
|
||||||
|
bool _isDrawing = false;
|
||||||
|
|
||||||
|
/// Tracks the number of active pointers for gesture disambiguation
|
||||||
|
int _pointerCount = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_transformationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// The content that will be transformed (zoomed/panned)
|
||||||
|
final content = SizedBox(
|
||||||
|
width: widget.boardSize.width,
|
||||||
|
height: widget.boardSize.height,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
// Background child (e.g., PDF)
|
||||||
|
Positioned.fill(child: widget.child),
|
||||||
|
|
||||||
|
// Drawing overlay
|
||||||
|
Positioned.fill(
|
||||||
|
child: widget.drawingEnabled
|
||||||
|
? _buildDrawingLayer()
|
||||||
|
: DrawingOverlay(
|
||||||
|
controller: widget.controller,
|
||||||
|
canvasSize: widget.boardSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!widget.drawingEnabled) {
|
||||||
|
// View-only mode: just show the content (no zoom/pan here,
|
||||||
|
// let parent handle navigation)
|
||||||
|
return Align(
|
||||||
|
alignment: widget.alignment,
|
||||||
|
child: content,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drawing mode: wrap with InteractiveViewer for zoom/pan
|
||||||
|
// Use LayoutBuilder to get available size and center content manually
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
// Calculate padding to center the content
|
||||||
|
final horizontalPadding =
|
||||||
|
(constraints.maxWidth - widget.boardSize.width) / 2;
|
||||||
|
final verticalPadding =
|
||||||
|
(constraints.maxHeight - widget.boardSize.height) / 2;
|
||||||
|
|
||||||
|
return InteractiveViewer(
|
||||||
|
transformationController: _transformationController,
|
||||||
|
minScale: widget.minScale,
|
||||||
|
maxScale: widget.maxScale,
|
||||||
|
boundaryMargin: const EdgeInsets.all(double.infinity),
|
||||||
|
constrained: false,
|
||||||
|
panEnabled: !_isDrawing,
|
||||||
|
scaleEnabled: !_isDrawing,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: horizontalPadding > 0 ? horizontalPadding : 0,
|
||||||
|
top: verticalPadding > 0 ? verticalPadding : 0,
|
||||||
|
),
|
||||||
|
child: content,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the drawing layer with gesture handling.
|
||||||
|
///
|
||||||
|
/// Uses Listener for drawing and distinguishes between:
|
||||||
|
/// - Single finger: Draw
|
||||||
|
/// - Two+ fingers: Pan/Zoom (handled by InteractiveViewer)
|
||||||
|
Widget _buildDrawingLayer() {
|
||||||
|
return Listener(
|
||||||
|
onPointerDown: _onPointerDown,
|
||||||
|
onPointerMove: _onPointerMove,
|
||||||
|
onPointerUp: _onPointerUp,
|
||||||
|
onPointerCancel: _onPointerCancel,
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
child: DrawingOverlay(
|
||||||
|
controller: widget.controller,
|
||||||
|
canvasSize: widget.boardSize,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPointerDown(PointerDownEvent event) {
|
||||||
|
_pointerCount++;
|
||||||
|
|
||||||
|
// Only start drawing with single finger touch
|
||||||
|
if (_pointerCount == 1) {
|
||||||
|
_isDrawing = true;
|
||||||
|
final normalized = _toNormalized(event.localPosition);
|
||||||
|
widget.controller.startLine(normalized);
|
||||||
|
setState(() {}); // Update to disable pan/scale
|
||||||
|
} else {
|
||||||
|
// Multiple fingers: cancel drawing, enable pan/zoom
|
||||||
|
if (_isDrawing) {
|
||||||
|
widget.controller.endLine();
|
||||||
|
_isDrawing = false;
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPointerMove(PointerMoveEvent event) {
|
||||||
|
if (_isDrawing && _pointerCount == 1) {
|
||||||
|
final normalized = _toNormalized(event.localPosition);
|
||||||
|
widget.controller.addPoint(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPointerUp(PointerUpEvent event) {
|
||||||
|
_pointerCount = (_pointerCount - 1).clamp(0, 10);
|
||||||
|
|
||||||
|
if (_isDrawing) {
|
||||||
|
widget.controller.endLine();
|
||||||
|
_isDrawing = false;
|
||||||
|
setState(() {}); // Re-enable pan/scale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPointerCancel(PointerCancelEvent event) {
|
||||||
|
_pointerCount = (_pointerCount - 1).clamp(0, 10);
|
||||||
|
|
||||||
|
if (_isDrawing) {
|
||||||
|
widget.controller.endLine();
|
||||||
|
_isDrawing = false;
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts local position to normalized coordinates (0-1).
|
||||||
|
Offset _toNormalized(Offset localPosition) {
|
||||||
|
return Offset(
|
||||||
|
(localPosition.dx / widget.boardSize.width).clamp(0.0, 1.0),
|
||||||
|
(localPosition.dy / widget.boardSize.height).clamp(0.0, 1.0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
lib/features/sheet_viewer/drawing/drawing_canvas.dart
Normal file
115
lib/features/sheet_viewer/drawing/drawing_canvas.dart
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'drawing_controller.dart';
|
||||||
|
import 'drawing_line.dart';
|
||||||
|
|
||||||
|
/// Custom painter that renders drawing lines on a canvas.
|
||||||
|
///
|
||||||
|
/// Converts normalized coordinates (0-1) to actual canvas coordinates
|
||||||
|
/// based on the provided canvas size.
|
||||||
|
class DrawingPainter extends CustomPainter {
|
||||||
|
final List<DrawingLine> lines;
|
||||||
|
final DrawingLine? currentLine;
|
||||||
|
final Size canvasSize;
|
||||||
|
|
||||||
|
DrawingPainter({
|
||||||
|
required this.lines,
|
||||||
|
required this.currentLine,
|
||||||
|
required this.canvasSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
// Draw all completed lines
|
||||||
|
for (final line in lines) {
|
||||||
|
_drawLine(canvas, line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw the current line being drawn
|
||||||
|
if (currentLine != null) {
|
||||||
|
_drawLine(canvas, currentLine!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawLine(Canvas canvas, DrawingLine line) {
|
||||||
|
if (line.points.length < 2) return;
|
||||||
|
|
||||||
|
final paint = Paint()
|
||||||
|
..color = line.color
|
||||||
|
..strokeWidth = line.strokeWidth * canvasSize.width
|
||||||
|
..strokeCap = StrokeCap.round
|
||||||
|
..strokeJoin = StrokeJoin.round
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..isAntiAlias = true;
|
||||||
|
|
||||||
|
// Create path from normalized points
|
||||||
|
final path = Path();
|
||||||
|
final firstPoint = _toCanvasPoint(line.points.first);
|
||||||
|
path.moveTo(firstPoint.dx, firstPoint.dy);
|
||||||
|
|
||||||
|
// Use quadratic bezier curves for smooth lines
|
||||||
|
if (line.points.length == 2) {
|
||||||
|
final endPoint = _toCanvasPoint(line.points.last);
|
||||||
|
path.lineTo(endPoint.dx, endPoint.dy);
|
||||||
|
} else {
|
||||||
|
for (int i = 1; i < line.points.length - 1; i++) {
|
||||||
|
final p0 = _toCanvasPoint(line.points[i]);
|
||||||
|
final p1 = _toCanvasPoint(line.points[i + 1]);
|
||||||
|
final midPoint = Offset((p0.dx + p1.dx) / 2, (p0.dy + p1.dy) / 2);
|
||||||
|
path.quadraticBezierTo(p0.dx, p0.dy, midPoint.dx, midPoint.dy);
|
||||||
|
}
|
||||||
|
// Draw to the last point
|
||||||
|
final lastPoint = _toCanvasPoint(line.points.last);
|
||||||
|
path.lineTo(lastPoint.dx, lastPoint.dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.drawPath(path, paint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a normalized point (0-1) to canvas coordinates.
|
||||||
|
Offset _toCanvasPoint(Offset normalizedPoint) {
|
||||||
|
return Offset(
|
||||||
|
normalizedPoint.dx * canvasSize.width,
|
||||||
|
normalizedPoint.dy * canvasSize.height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant DrawingPainter oldDelegate) {
|
||||||
|
return lines != oldDelegate.lines ||
|
||||||
|
currentLine != oldDelegate.currentLine ||
|
||||||
|
canvasSize != oldDelegate.canvasSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A widget that displays drawing lines on a transparent canvas.
|
||||||
|
///
|
||||||
|
/// This widget only shows the drawings, it doesn't handle input.
|
||||||
|
/// Use [DrawingCanvas] or [DrawingBoard] for input handling.
|
||||||
|
class DrawingOverlay extends StatelessWidget {
|
||||||
|
final DrawingController controller;
|
||||||
|
final Size canvasSize;
|
||||||
|
|
||||||
|
const DrawingOverlay({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
required this.canvasSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
return CustomPaint(
|
||||||
|
size: canvasSize,
|
||||||
|
painter: DrawingPainter(
|
||||||
|
lines: controller.lines,
|
||||||
|
currentLine: controller.currentLine,
|
||||||
|
canvasSize: canvasSize,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
381
lib/features/sheet_viewer/drawing/drawing_controller.dart
Normal file
381
lib/features/sheet_viewer/drawing/drawing_controller.dart
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'drawing_line.dart';
|
||||||
|
import 'paint_preset.dart';
|
||||||
|
|
||||||
|
/// Represents an undoable action.
|
||||||
|
sealed class DrawingAction {
|
||||||
|
const DrawingAction();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Action for adding a line.
|
||||||
|
class AddLineAction extends DrawingAction {
|
||||||
|
final DrawingLine line;
|
||||||
|
const AddLineAction(this.line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Action for erasing lines.
|
||||||
|
class EraseAction extends DrawingAction {
|
||||||
|
final List<DrawingLine> erasedLines;
|
||||||
|
const EraseAction(this.erasedLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Controller for managing drawing state with undo/redo support.
|
||||||
|
///
|
||||||
|
/// Manages a stack of [DrawingLine] objects and provides methods for
|
||||||
|
/// drawing, undoing, redoing, and serializing the drawing state.
|
||||||
|
class DrawingController extends ChangeNotifier {
|
||||||
|
/// All completed lines in the drawing
|
||||||
|
final List<DrawingLine> _lines = [];
|
||||||
|
|
||||||
|
/// Stack of actions for undo functionality
|
||||||
|
final List<DrawingAction> _undoStack = [];
|
||||||
|
|
||||||
|
/// Stack of actions for redo functionality
|
||||||
|
final List<DrawingAction> _redoStack = [];
|
||||||
|
|
||||||
|
/// The line currently being drawn (null when not drawing)
|
||||||
|
DrawingLine? _currentLine;
|
||||||
|
|
||||||
|
/// Current paint preset being used
|
||||||
|
PaintPreset _currentPreset = PaintPreset.blackPen;
|
||||||
|
|
||||||
|
/// Whether eraser mode is active
|
||||||
|
bool _isEraserMode = false;
|
||||||
|
|
||||||
|
/// Lines erased in the current eraser stroke (for undo as single action)
|
||||||
|
final List<DrawingLine> _currentErasedLines = [];
|
||||||
|
|
||||||
|
/// Maximum number of history steps to keep
|
||||||
|
final int maxHistorySteps;
|
||||||
|
|
||||||
|
/// Whether there are unsaved changes since last load/clear
|
||||||
|
bool _hasUnsavedChanges = false;
|
||||||
|
|
||||||
|
DrawingController({this.maxHistorySteps = 50});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Getters
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// All completed lines (read-only)
|
||||||
|
List<DrawingLine> get lines => List.unmodifiable(_lines);
|
||||||
|
|
||||||
|
/// The line currently being drawn
|
||||||
|
DrawingLine? get currentLine => _currentLine;
|
||||||
|
|
||||||
|
/// Current paint preset
|
||||||
|
PaintPreset get currentPreset => _currentPreset;
|
||||||
|
|
||||||
|
/// Whether eraser mode is active
|
||||||
|
bool get isEraserMode => _isEraserMode;
|
||||||
|
|
||||||
|
/// Whether undo is available
|
||||||
|
bool get canUndo => _undoStack.isNotEmpty;
|
||||||
|
|
||||||
|
/// Whether redo is available
|
||||||
|
bool get canRedo => _redoStack.isNotEmpty;
|
||||||
|
|
||||||
|
/// Whether there are unsaved changes since last load/clear/markSaved
|
||||||
|
bool get hasUnsavedChanges => _hasUnsavedChanges;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Drawing Operations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Starts a new line at the given normalized position.
|
||||||
|
void startLine(Offset normalizedPoint) {
|
||||||
|
if (_isEraserMode) {
|
||||||
|
_currentErasedLines.clear();
|
||||||
|
_eraseAtPoint(normalizedPoint);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentLine = DrawingLine(
|
||||||
|
points: [normalizedPoint],
|
||||||
|
color: _currentPreset.color,
|
||||||
|
strokeWidth: _currentPreset.strokeWidth,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a point to the current line.
|
||||||
|
void addPoint(Offset normalizedPoint) {
|
||||||
|
if (_isEraserMode) {
|
||||||
|
_eraseAtPoint(normalizedPoint);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_currentLine == null) return;
|
||||||
|
|
||||||
|
// Filter points that are too close to reduce memory usage
|
||||||
|
if (_currentLine!.isPointTooClose(normalizedPoint)) return;
|
||||||
|
|
||||||
|
_currentLine = _currentLine!.addPoint(normalizedPoint);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completes the current line and adds it to the history.
|
||||||
|
void endLine() {
|
||||||
|
if (_isEraserMode) {
|
||||||
|
// If we erased lines in this stroke, record as single action
|
||||||
|
if (_currentErasedLines.isNotEmpty) {
|
||||||
|
_undoStack.add(EraseAction(List.from(_currentErasedLines)));
|
||||||
|
_redoStack.clear();
|
||||||
|
_trimHistory();
|
||||||
|
_currentErasedLines.clear();
|
||||||
|
_hasUnsavedChanges = true;
|
||||||
|
notifyListeners(); // Update UI to enable undo button
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_currentLine == null) return;
|
||||||
|
|
||||||
|
// Only add lines with at least 2 points
|
||||||
|
if (_currentLine!.points.length >= 2) {
|
||||||
|
_lines.add(_currentLine!);
|
||||||
|
_undoStack.add(AddLineAction(_currentLine!));
|
||||||
|
_redoStack.clear();
|
||||||
|
_trimHistory();
|
||||||
|
_hasUnsavedChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentLine = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trims history to maxHistorySteps to prevent memory growth.
|
||||||
|
void _trimHistory() {
|
||||||
|
while (_undoStack.length > maxHistorySteps) {
|
||||||
|
_undoStack.removeAt(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Eraser Operations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Eraser hit radius in normalized coordinates
|
||||||
|
static const double _eraserRadius = 0.015;
|
||||||
|
|
||||||
|
/// Checks if a point is near a line and erases it if so.
|
||||||
|
void _eraseAtPoint(Offset point) {
|
||||||
|
// Find all lines that intersect with the eraser point
|
||||||
|
final linesToRemove = <DrawingLine>[];
|
||||||
|
|
||||||
|
for (final line in _lines) {
|
||||||
|
if (_lineIntersectsPoint(
|
||||||
|
line, point, _eraserRadius + line.strokeWidth / 2)) {
|
||||||
|
linesToRemove.add(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove intersecting lines and track them for undo
|
||||||
|
for (final line in linesToRemove) {
|
||||||
|
_lines.remove(line);
|
||||||
|
_currentErasedLines.add(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linesToRemove.isNotEmpty) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a line intersects with a circular point.
|
||||||
|
bool _lineIntersectsPoint(DrawingLine line, Offset point, double radius) {
|
||||||
|
// Check if any point is within radius
|
||||||
|
for (final linePoint in line.points) {
|
||||||
|
if ((linePoint - point).distance <= radius) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check line segments
|
||||||
|
for (int i = 0; i < line.points.length - 1; i++) {
|
||||||
|
if (_pointToSegmentDistance(point, line.points[i], line.points[i + 1]) <=
|
||||||
|
radius) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates the distance from a point to a line segment.
|
||||||
|
double _pointToSegmentDistance(Offset point, Offset segStart, Offset segEnd) {
|
||||||
|
final dx = segEnd.dx - segStart.dx;
|
||||||
|
final dy = segEnd.dy - segStart.dy;
|
||||||
|
final lengthSquared = dx * dx + dy * dy;
|
||||||
|
|
||||||
|
if (lengthSquared == 0) {
|
||||||
|
// Segment is a point
|
||||||
|
return (point - segStart).distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project point onto the line, clamping to segment
|
||||||
|
var t = ((point.dx - segStart.dx) * dx + (point.dy - segStart.dy) * dy) /
|
||||||
|
lengthSquared;
|
||||||
|
t = t.clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
final projection = Offset(
|
||||||
|
segStart.dx + t * dx,
|
||||||
|
segStart.dy + t * dy,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (point - projection).distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Eraser Mode
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Toggles eraser mode.
|
||||||
|
void toggleEraserMode() {
|
||||||
|
_isEraserMode = !_isEraserMode;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets eraser mode.
|
||||||
|
void setEraserMode(bool enabled) {
|
||||||
|
if (_isEraserMode != enabled) {
|
||||||
|
_isEraserMode = enabled;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Undo/Redo
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Undoes the last action.
|
||||||
|
void undo() {
|
||||||
|
if (!canUndo) return;
|
||||||
|
|
||||||
|
final action = _undoStack.removeLast();
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case AddLineAction(:final line):
|
||||||
|
// Remove the line that was added
|
||||||
|
_lines.remove(line);
|
||||||
|
_redoStack.add(action);
|
||||||
|
case EraseAction(:final erasedLines):
|
||||||
|
// Restore the lines that were erased
|
||||||
|
_lines.addAll(erasedLines);
|
||||||
|
_redoStack.add(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
_hasUnsavedChanges = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Redoes the last undone action.
|
||||||
|
void redo() {
|
||||||
|
if (!canRedo) return;
|
||||||
|
|
||||||
|
final action = _redoStack.removeLast();
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case AddLineAction(:final line):
|
||||||
|
// Re-add the line
|
||||||
|
_lines.add(line);
|
||||||
|
_undoStack.add(action);
|
||||||
|
case EraseAction(:final erasedLines):
|
||||||
|
// Re-erase the lines
|
||||||
|
for (final line in erasedLines) {
|
||||||
|
_lines.remove(line);
|
||||||
|
}
|
||||||
|
_undoStack.add(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
_hasUnsavedChanges = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears all lines from the canvas.
|
||||||
|
void clear() {
|
||||||
|
_lines.clear();
|
||||||
|
_undoStack.clear();
|
||||||
|
_redoStack.clear();
|
||||||
|
_currentLine = null;
|
||||||
|
_currentErasedLines.clear();
|
||||||
|
_hasUnsavedChanges = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Paint Preset
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Sets the current paint preset and disables eraser mode.
|
||||||
|
void setPreset(PaintPreset preset) {
|
||||||
|
_currentPreset = preset;
|
||||||
|
_isEraserMode = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Serialization
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Exports all lines to a JSON-serializable list.
|
||||||
|
List<Map<String, dynamic>> toJsonList() {
|
||||||
|
return _lines.map((line) => line.toJson()).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exports all lines to a JSON string.
|
||||||
|
String toJsonString() {
|
||||||
|
return jsonEncode(toJsonList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Imports lines from a JSON-serializable list.
|
||||||
|
void fromJsonList(List<Map<String, dynamic>> jsonList) {
|
||||||
|
_lines.clear();
|
||||||
|
_undoStack.clear();
|
||||||
|
_redoStack.clear();
|
||||||
|
_currentLine = null;
|
||||||
|
_currentErasedLines.clear();
|
||||||
|
_hasUnsavedChanges = false;
|
||||||
|
|
||||||
|
for (final json in jsonList) {
|
||||||
|
_lines.add(DrawingLine.fromJson(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Imports lines from a JSON string.
|
||||||
|
void fromJsonString(String jsonString) {
|
||||||
|
if (jsonString.isEmpty || jsonString == '[]') {
|
||||||
|
clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(jsonString) as List;
|
||||||
|
if (decoded.isEmpty) {
|
||||||
|
clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fromJsonList(decoded.cast<Map<String, dynamic>>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds existing lines without clearing (for merging annotations).
|
||||||
|
void addLines(List<DrawingLine> newLines) {
|
||||||
|
_lines.addAll(newLines);
|
||||||
|
_trimHistory();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the current state as saved (resets unsaved changes flag).
|
||||||
|
void markSaved() {
|
||||||
|
_hasUnsavedChanges = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_lines.clear();
|
||||||
|
_undoStack.clear();
|
||||||
|
_redoStack.clear();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
99
lib/features/sheet_viewer/drawing/drawing_line.dart
Normal file
99
lib/features/sheet_viewer/drawing/drawing_line.dart
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
/// Represents a single stroke/line drawn on the canvas.
|
||||||
|
///
|
||||||
|
/// Points are stored in normalized coordinates (0.0 to 1.0) where:
|
||||||
|
/// - (0, 0) is the top-left corner of the drawing area
|
||||||
|
/// - (1, 1) is the bottom-right corner of the drawing area
|
||||||
|
///
|
||||||
|
/// This allows drawings to scale correctly when the canvas size changes.
|
||||||
|
class DrawingLine {
|
||||||
|
/// The minimal squared distance between to points which are normalized so that this point is allowed to be added to the line
|
||||||
|
static const minNormalizedPointDistanceSquared = 0.001 * 0.001;
|
||||||
|
|
||||||
|
/// Points in normalized coordinates (0.0 to 1.0)
|
||||||
|
final List<Offset> points;
|
||||||
|
|
||||||
|
/// Color of the line (stored as ARGB integer for JSON serialization)
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
/// Stroke width in normalized units (relative to canvas width)
|
||||||
|
/// A value of 0.01 means the stroke is 1% of the canvas width
|
||||||
|
final double strokeWidth;
|
||||||
|
|
||||||
|
const DrawingLine({
|
||||||
|
required this.points,
|
||||||
|
required this.color,
|
||||||
|
required this.strokeWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Creates a DrawingLine from JSON data.
|
||||||
|
factory DrawingLine.fromJson(Map<String, dynamic> json) {
|
||||||
|
final pointsList = (json['points'] as List)
|
||||||
|
.map(
|
||||||
|
(p) => Offset((p['x'] as num).toDouble(), (p['y'] as num).toDouble()),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return DrawingLine(
|
||||||
|
points: pointsList,
|
||||||
|
color: Color(json['color'] as int),
|
||||||
|
strokeWidth: (json['strokeWidth'] as num).toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts this line to a JSON-serializable map.
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'points': points.map((p) => {'x': p.dx, 'y': p.dy}).toList(),
|
||||||
|
'color': color.toARGB32(),
|
||||||
|
'strokeWidth': strokeWidth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a copy of this line with an additional point.
|
||||||
|
DrawingLine addPoint(Offset normalizedPoint) {
|
||||||
|
return DrawingLine(
|
||||||
|
points: [...points, normalizedPoint],
|
||||||
|
color: color,
|
||||||
|
strokeWidth: strokeWidth,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isPointTooClose(Offset nextNormalizedPoint) {
|
||||||
|
if (points.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (points.last - nextNormalizedPoint).distanceSquared <
|
||||||
|
minNormalizedPointDistanceSquared;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a copy with updated points.
|
||||||
|
DrawingLine copyWith({
|
||||||
|
List<Offset>? points,
|
||||||
|
Color? color,
|
||||||
|
double? strokeWidth,
|
||||||
|
}) {
|
||||||
|
return DrawingLine(
|
||||||
|
points: points ?? this.points,
|
||||||
|
color: color ?? this.color,
|
||||||
|
strokeWidth: strokeWidth ?? this.strokeWidth,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
if (other is! DrawingLine) return false;
|
||||||
|
if (points.length != other.points.length) return false;
|
||||||
|
if (color != other.color) return false;
|
||||||
|
if (strokeWidth != other.strokeWidth) return false;
|
||||||
|
for (int i = 0; i < points.length; i++) {
|
||||||
|
if (points[i] != other.points[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(Object.hashAll(points), color, strokeWidth);
|
||||||
|
}
|
||||||
236
lib/features/sheet_viewer/drawing/drawing_toolbar.dart
Normal file
236
lib/features/sheet_viewer/drawing/drawing_toolbar.dart
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'drawing_controller.dart';
|
||||||
|
import 'paint_preset.dart';
|
||||||
|
|
||||||
|
/// A floating toolbar for drawing controls.
|
||||||
|
///
|
||||||
|
/// Provides quick access to:
|
||||||
|
/// - Paint presets (pens and markers)
|
||||||
|
/// - Undo/Redo buttons
|
||||||
|
class DrawingToolbar extends StatelessWidget {
|
||||||
|
final DrawingController controller;
|
||||||
|
final VoidCallback? onClose;
|
||||||
|
|
||||||
|
const DrawingToolbar({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
this.onClose,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.2),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Paint presets
|
||||||
|
...PaintPreset.quickAccess.map((preset) => _buildPresetButton(
|
||||||
|
context,
|
||||||
|
preset,
|
||||||
|
isSelected: !controller.isEraserMode &&
|
||||||
|
controller.currentPreset == preset,
|
||||||
|
)),
|
||||||
|
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildDivider(context),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
|
// Eraser button
|
||||||
|
_buildEraserButton(
|
||||||
|
context,
|
||||||
|
isSelected: controller.isEraserMode,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildDivider(context),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
|
// Undo button
|
||||||
|
_buildActionButton(
|
||||||
|
context,
|
||||||
|
icon: Icons.undo,
|
||||||
|
onPressed: controller.canUndo ? controller.undo : null,
|
||||||
|
tooltip: 'Undo',
|
||||||
|
),
|
||||||
|
|
||||||
|
// Redo button
|
||||||
|
_buildActionButton(
|
||||||
|
context,
|
||||||
|
icon: Icons.redo,
|
||||||
|
onPressed: controller.canRedo ? controller.redo : null,
|
||||||
|
tooltip: 'Redo',
|
||||||
|
),
|
||||||
|
|
||||||
|
if (onClose != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildDivider(context),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildActionButton(
|
||||||
|
context,
|
||||||
|
icon: Icons.close,
|
||||||
|
onPressed: onClose,
|
||||||
|
tooltip: 'Exit Drawing Mode',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPresetButton(
|
||||||
|
BuildContext context,
|
||||||
|
PaintPreset preset, {
|
||||||
|
required bool isSelected,
|
||||||
|
}) {
|
||||||
|
final isMarker = preset.strokeWidth > 0.01;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Tooltip(
|
||||||
|
message: preset.name,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => controller.setPreset(preset),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: isSelected
|
||||||
|
? Border.all(color: colorScheme.primary, width: 2)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
width: isMarker ? 24 : 18,
|
||||||
|
height: isMarker ? 12 : 18,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: preset.color,
|
||||||
|
borderRadius: isMarker
|
||||||
|
? BorderRadius.circular(2)
|
||||||
|
: BorderRadius.circular(9),
|
||||||
|
border: preset.color.a < 1
|
||||||
|
? Border.all(color: Colors.grey.shade400, width: 0.5)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEraserButton(
|
||||||
|
BuildContext context, {
|
||||||
|
required bool isSelected,
|
||||||
|
}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Tooltip(
|
||||||
|
message: 'Eraser',
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => controller.setEraserMode(true),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: isSelected
|
||||||
|
? Border.all(color: colorScheme.primary, width: 2)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.auto_fix_high,
|
||||||
|
size: 20,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActionButton(
|
||||||
|
BuildContext context, {
|
||||||
|
required IconData icon,
|
||||||
|
required VoidCallback? onPressed,
|
||||||
|
required String tooltip,
|
||||||
|
}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onPressed,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
size: 20,
|
||||||
|
color: onPressed != null
|
||||||
|
? colorScheme.onSurface
|
||||||
|
: colorScheme.onSurface.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDivider(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: 1,
|
||||||
|
height: 24,
|
||||||
|
color: Theme.of(context).dividerColor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A compact floating action button to toggle paint mode.
|
||||||
|
class DrawingModeButton extends StatelessWidget {
|
||||||
|
final bool isActive;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
const DrawingModeButton({
|
||||||
|
super.key,
|
||||||
|
required this.isActive,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FloatingActionButton.small(
|
||||||
|
onPressed: onPressed,
|
||||||
|
backgroundColor: isActive
|
||||||
|
? Theme.of(context).colorScheme.primaryContainer
|
||||||
|
: Theme.of(context).colorScheme.surface,
|
||||||
|
child: Icon(
|
||||||
|
isActive ? Icons.brush : Icons.brush_outlined,
|
||||||
|
color: isActive
|
||||||
|
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||||
|
: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
111
lib/features/sheet_viewer/drawing/paint_preset.dart
Normal file
111
lib/features/sheet_viewer/drawing/paint_preset.dart
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Predefined paint configurations for common annotation styles.
|
||||||
|
///
|
||||||
|
/// Each preset defines a color and stroke width for drawing.
|
||||||
|
/// Stroke width is normalized (relative to canvas width).
|
||||||
|
class PaintPreset {
|
||||||
|
/// Display name for the preset
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
/// Color of the paint (including opacity)
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
/// Stroke width in normalized units (relative to canvas width)
|
||||||
|
/// A value of 0.005 means the stroke is 0.5% of the canvas width
|
||||||
|
final double strokeWidth;
|
||||||
|
|
||||||
|
/// Icon to display for this preset
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
const PaintPreset({
|
||||||
|
required this.name,
|
||||||
|
required this.color,
|
||||||
|
required this.strokeWidth,
|
||||||
|
required this.icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Default Presets
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Black pen for writing/notes
|
||||||
|
static const blackPen = PaintPreset(
|
||||||
|
name: 'Black Pen',
|
||||||
|
color: Colors.black,
|
||||||
|
strokeWidth: 0.003, // Thin line for writing
|
||||||
|
icon: Icons.edit,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Red pen for corrections/markings
|
||||||
|
static const redPen = PaintPreset(
|
||||||
|
name: 'Red Pen',
|
||||||
|
color: Colors.red,
|
||||||
|
strokeWidth: 0.003,
|
||||||
|
icon: Icons.edit,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Blue pen for annotations
|
||||||
|
static const bluePen = PaintPreset(
|
||||||
|
name: 'Blue Pen',
|
||||||
|
color: Colors.blue,
|
||||||
|
strokeWidth: 0.003,
|
||||||
|
icon: Icons.edit,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Yellow highlighter (semi-transparent, thicker)
|
||||||
|
static const yellowMarker = PaintPreset(
|
||||||
|
name: 'Yellow Marker',
|
||||||
|
color: Color(0x80FFEB3B), // Yellow with 50% opacity
|
||||||
|
strokeWidth: 0.02, // Thicker for highlighting
|
||||||
|
icon: Icons.highlight,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Green highlighter
|
||||||
|
static const greenMarker = PaintPreset(
|
||||||
|
name: 'Green Marker',
|
||||||
|
color: Color(0x804CAF50), // Green with 50% opacity
|
||||||
|
strokeWidth: 0.018,
|
||||||
|
icon: Icons.highlight,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Pink highlighter
|
||||||
|
static const pinkMarker = PaintPreset(
|
||||||
|
name: 'Pink Marker',
|
||||||
|
color: Color(0x80E91E63), // Pink with 50% opacity
|
||||||
|
strokeWidth: 0.015,
|
||||||
|
icon: Icons.highlight,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// All available default presets
|
||||||
|
static const List<PaintPreset> defaults = [
|
||||||
|
blackPen,
|
||||||
|
redPen,
|
||||||
|
bluePen,
|
||||||
|
yellowMarker,
|
||||||
|
greenMarker,
|
||||||
|
pinkMarker,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Quick access presets (shown in main toolbar)
|
||||||
|
static const List<PaintPreset> quickAccess = [
|
||||||
|
blackPen,
|
||||||
|
redPen,
|
||||||
|
bluePen,
|
||||||
|
yellowMarker,
|
||||||
|
greenMarker,
|
||||||
|
pinkMarker,
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
return other is PaintPreset &&
|
||||||
|
other.name == name &&
|
||||||
|
other.color == color &&
|
||||||
|
other.strokeWidth == strokeWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(name, color, strokeWidth);
|
||||||
|
}
|
||||||
402
lib/features/sheet_viewer/sheet_viewer_page.dart
Normal file
402
lib/features/sheet_viewer/sheet_viewer_page.dart
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
|
import 'package:sheetless/core/models/config.dart';
|
||||||
|
import 'package:sheetless/core/models/sheet.dart';
|
||||||
|
import 'package:sheetless/core/services/annotation_sync_service.dart';
|
||||||
|
import 'package:sheetless/core/services/api_client.dart';
|
||||||
|
import 'package:sheetless/core/services/storage_service.dart';
|
||||||
|
|
||||||
|
import '../../shared/input/pedal_shortcuts.dart';
|
||||||
|
import 'drawing/drawing.dart';
|
||||||
|
import 'widgets/pdf_page_display.dart';
|
||||||
|
import 'widgets/touch_navigation_layer.dart';
|
||||||
|
|
||||||
|
/// Page for viewing and annotating PDF sheet music.
|
||||||
|
class SheetViewerPage extends StatefulWidget {
|
||||||
|
final Sheet sheet;
|
||||||
|
final ApiClient apiClient;
|
||||||
|
final Config config;
|
||||||
|
|
||||||
|
const SheetViewerPage({
|
||||||
|
super.key,
|
||||||
|
required this.sheet,
|
||||||
|
required this.apiClient,
|
||||||
|
required this.config,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SheetViewerPage> createState() => _SheetViewerPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SheetViewerPageState extends State<SheetViewerPage>
|
||||||
|
with FullScreenListener {
|
||||||
|
final _log = Logger('SheetViewerPage');
|
||||||
|
final _storageService = StorageService();
|
||||||
|
late final AnnotationSyncService _syncService;
|
||||||
|
|
||||||
|
PdfDocument? _document;
|
||||||
|
late Future<bool> _documentLoaded;
|
||||||
|
int _currentPage = 1;
|
||||||
|
int _totalPages = 1;
|
||||||
|
bool _isPaintMode = false;
|
||||||
|
|
||||||
|
/// Drawing controller for the current left page
|
||||||
|
late DrawingController _leftDrawingController;
|
||||||
|
|
||||||
|
/// Drawing controller for the right page (two-page mode)
|
||||||
|
late DrawingController _rightDrawingController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
// Initialize sync service
|
||||||
|
_syncService = AnnotationSyncService(
|
||||||
|
apiClient: widget.apiClient,
|
||||||
|
storageService: _storageService,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize drawing controllers
|
||||||
|
_leftDrawingController = DrawingController(maxHistorySteps: 50);
|
||||||
|
_rightDrawingController = DrawingController(maxHistorySteps: 50);
|
||||||
|
|
||||||
|
FullScreen.addListener(this);
|
||||||
|
if (FullScreen.isFullScreen != widget.config.fullscreen) {
|
||||||
|
FullScreen.setFullScreen(widget.config.fullscreen);
|
||||||
|
}
|
||||||
|
_documentLoaded = _loadPdf();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
// Make sure annotations are saved before exiting
|
||||||
|
if (_isPaintMode) {
|
||||||
|
_saveCurrentAnnotations();
|
||||||
|
}
|
||||||
|
|
||||||
|
_leftDrawingController.dispose();
|
||||||
|
_rightDrawingController.dispose();
|
||||||
|
FullScreen.removeListener(this);
|
||||||
|
_document?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PDF Loading
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Future<bool> _loadPdf() async {
|
||||||
|
if (kIsWeb) {
|
||||||
|
// Web: load directly into memory
|
||||||
|
final data = await widget.apiClient.fetchPdfData(widget.sheet.uuid);
|
||||||
|
_document = await PdfDocument.openData(data);
|
||||||
|
} else {
|
||||||
|
// Native: use file cache
|
||||||
|
final file = await widget.apiClient.getPdfFileCached(widget.sheet.uuid);
|
||||||
|
_document = await PdfDocument.openFile(file.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_totalPages = _document!.pages.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync annotations from server (downloads newer versions)
|
||||||
|
await _syncService.syncFromServer(widget.sheet.uuid);
|
||||||
|
|
||||||
|
// Load annotations for current page(s)
|
||||||
|
await _loadAnnotationsForCurrentPages();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Annotation Persistence
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Loads annotations for the current page(s) from storage.
|
||||||
|
Future<void> _loadAnnotationsForCurrentPages() async {
|
||||||
|
// Load left page annotations
|
||||||
|
final leftJson = await _storageService.readAnnotations(
|
||||||
|
widget.sheet.uuid,
|
||||||
|
_currentPage,
|
||||||
|
);
|
||||||
|
if (leftJson != null && leftJson.isNotEmpty) {
|
||||||
|
_leftDrawingController.fromJsonString(leftJson);
|
||||||
|
} else {
|
||||||
|
_leftDrawingController.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load right page annotations (two-page mode)
|
||||||
|
if (widget.config.twoPageMode && _currentPage < _totalPages) {
|
||||||
|
final rightJson = await _storageService.readAnnotations(
|
||||||
|
widget.sheet.uuid,
|
||||||
|
_currentPage + 1,
|
||||||
|
);
|
||||||
|
if (rightJson != null && rightJson.isNotEmpty) {
|
||||||
|
_rightDrawingController.fromJsonString(rightJson);
|
||||||
|
} else {
|
||||||
|
_rightDrawingController.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the current page(s) annotations to storage and uploads to server.
|
||||||
|
///
|
||||||
|
/// Only saves if there are actual changes to avoid unnecessary writes/uploads.
|
||||||
|
Future<void> _saveCurrentAnnotations() async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
// Save left page only if changed
|
||||||
|
if (_leftDrawingController.hasUnsavedChanges) {
|
||||||
|
final leftJson = _leftDrawingController.toJsonString();
|
||||||
|
final leftHasContent = leftJson.isNotEmpty && leftJson != '[]';
|
||||||
|
|
||||||
|
await _storageService.writeAnnotationsWithMetadata(
|
||||||
|
widget.sheet.uuid,
|
||||||
|
_currentPage,
|
||||||
|
leftHasContent ? leftJson : null,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Upload left page to server
|
||||||
|
_syncService.uploadAnnotation(
|
||||||
|
sheetUuid: widget.sheet.uuid,
|
||||||
|
page: _currentPage,
|
||||||
|
annotationsJson: leftJson,
|
||||||
|
lastModified: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
_leftDrawingController.markSaved();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save right page (two-page mode) only if changed
|
||||||
|
if (widget.config.twoPageMode &&
|
||||||
|
_currentPage < _totalPages &&
|
||||||
|
_rightDrawingController.hasUnsavedChanges) {
|
||||||
|
final rightJson = _rightDrawingController.toJsonString();
|
||||||
|
final rightHasContent = rightJson.isNotEmpty && rightJson != '[]';
|
||||||
|
|
||||||
|
await _storageService.writeAnnotationsWithMetadata(
|
||||||
|
widget.sheet.uuid,
|
||||||
|
_currentPage + 1,
|
||||||
|
rightHasContent ? rightJson : null,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Upload right page to server
|
||||||
|
_syncService.uploadAnnotation(
|
||||||
|
sheetUuid: widget.sheet.uuid,
|
||||||
|
page: _currentPage + 1,
|
||||||
|
annotationsJson: rightJson,
|
||||||
|
lastModified: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
_rightDrawingController.markSaved();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fullscreen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onFullScreenChanged(bool enabled, SystemUiMode? systemUiMode) {
|
||||||
|
setState(() {
|
||||||
|
widget.config.fullscreen = enabled;
|
||||||
|
_storageService.writeConfig(widget.config);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleFullscreen() {
|
||||||
|
FullScreen.setFullScreen(!FullScreen.isFullScreen);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Navigation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Future<void> _turnPage(int delta) async {
|
||||||
|
// Save current annotations before turning
|
||||||
|
await _saveCurrentAnnotations();
|
||||||
|
|
||||||
|
// Calculate new page
|
||||||
|
final newPage = (_currentPage + delta).clamp(1, _totalPages);
|
||||||
|
|
||||||
|
// Load annotations for new page(s) BEFORE updating state
|
||||||
|
_currentPage = newPage;
|
||||||
|
await _loadAnnotationsForCurrentPages();
|
||||||
|
|
||||||
|
// Now update UI
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mode Switching
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void _togglePaintMode() {
|
||||||
|
if (widget.config.twoPageMode) {
|
||||||
|
_showSnackBar('Paint mode is only available in single page mode');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isPaintMode) {
|
||||||
|
// Exiting paint mode - save annotations
|
||||||
|
_saveCurrentAnnotations();
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isPaintMode = !_isPaintMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleTwoPageMode() {
|
||||||
|
if (_isPaintMode) {
|
||||||
|
_showSnackBar('Cannot enter two-page mode while painting');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
widget.config.twoPageMode = !widget.config.twoPageMode;
|
||||||
|
_storageService.writeConfig(widget.config);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload annotations for new mode
|
||||||
|
_loadAnnotationsForCurrentPages();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UI
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PedalShortcuts(
|
||||||
|
onPageForward: () => _turnPage(1),
|
||||||
|
onPageBackward: () => _turnPage(-1),
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: _buildAppBar(),
|
||||||
|
body: _buildBody(),
|
||||||
|
floatingActionButton: _isPaintMode ? _buildDrawingToolbar() : null,
|
||||||
|
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PreferredSizeWidget? _buildAppBar() {
|
||||||
|
// Hide app bar in fullscreen when document is loaded
|
||||||
|
if (widget.config.fullscreen && _document != null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AppBar(
|
||||||
|
title: Text(widget.sheet.name),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
widget.config.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
|
||||||
|
),
|
||||||
|
tooltip: widget.config.fullscreen
|
||||||
|
? 'Exit Fullscreen'
|
||||||
|
: 'Enter Fullscreen',
|
||||||
|
onPressed: _toggleFullscreen,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(_isPaintMode ? Icons.brush : Icons.brush_outlined),
|
||||||
|
tooltip: _isPaintMode ? 'Exit Paint Mode' : 'Enter Paint Mode',
|
||||||
|
onPressed: _togglePaintMode,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
widget.config.twoPageMode ? Icons.filter_1 : Icons.filter_2,
|
||||||
|
),
|
||||||
|
tooltip: widget.config.twoPageMode
|
||||||
|
? 'Single Page Mode'
|
||||||
|
: 'Two Page Mode',
|
||||||
|
onPressed: _toggleTwoPageMode,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody() {
|
||||||
|
return FutureBuilder<bool>(
|
||||||
|
future: _documentLoaded,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
_log.warning('Error loading PDF', snapshot.error);
|
||||||
|
return _buildError(snapshot.error.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasData && _document != null) {
|
||||||
|
return _buildPdfViewer();
|
||||||
|
}
|
||||||
|
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPdfViewer() {
|
||||||
|
final pageDisplay = PdfPageDisplay(
|
||||||
|
document: _document!,
|
||||||
|
numPages: _totalPages,
|
||||||
|
currentPageNumber: _currentPage,
|
||||||
|
config: widget.config,
|
||||||
|
leftDrawingController: _leftDrawingController,
|
||||||
|
rightDrawingController: widget.config.twoPageMode
|
||||||
|
? _rightDrawingController
|
||||||
|
: null,
|
||||||
|
drawingEnabled: _isPaintMode,
|
||||||
|
);
|
||||||
|
|
||||||
|
// When in paint mode, show the page display directly (DrawingBoard handles zoom/pan)
|
||||||
|
if (_isPaintMode) {
|
||||||
|
return pageDisplay;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When not in paint mode, wrap with touch navigation
|
||||||
|
return TouchNavigationLayer(
|
||||||
|
pageDisplay: pageDisplay,
|
||||||
|
config: widget.config,
|
||||||
|
onToggleFullscreen: _toggleFullscreen,
|
||||||
|
onExit: () => Navigator.pop(context),
|
||||||
|
onPageTurn: _turnPage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDrawingToolbar() {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: DrawingToolbar(
|
||||||
|
controller: _leftDrawingController,
|
||||||
|
onClose: _togglePaintMode,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildError(String message) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleMedium?.copyWith(color: Colors.red),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSnackBar(String message) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
197
lib/features/sheet_viewer/widgets/pdf_page_display.dart
Normal file
197
lib/features/sheet_viewer/widgets/pdf_page_display.dart
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
|
|
||||||
|
import '../../../core/models/config.dart';
|
||||||
|
import '../drawing/drawing.dart';
|
||||||
|
|
||||||
|
/// Displays PDF pages with optional two-page mode and drawing overlay.
|
||||||
|
class PdfPageDisplay extends StatelessWidget {
|
||||||
|
final PdfDocument document;
|
||||||
|
final int numPages;
|
||||||
|
final int currentPageNumber;
|
||||||
|
final Config config;
|
||||||
|
|
||||||
|
/// Controller for the left/main page drawing
|
||||||
|
final DrawingController? leftDrawingController;
|
||||||
|
|
||||||
|
/// Controller for the right page drawing (two-page mode only)
|
||||||
|
final DrawingController? rightDrawingController;
|
||||||
|
|
||||||
|
/// Whether drawing is enabled
|
||||||
|
final bool drawingEnabled;
|
||||||
|
|
||||||
|
const PdfPageDisplay({
|
||||||
|
super.key,
|
||||||
|
required this.document,
|
||||||
|
required this.numPages,
|
||||||
|
required this.currentPageNumber,
|
||||||
|
required this.config,
|
||||||
|
this.leftDrawingController,
|
||||||
|
this.rightDrawingController,
|
||||||
|
this.drawingEnabled = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Whether two-page mode is active and we have enough pages.
|
||||||
|
bool get _showTwoPages => config.twoPageMode && numPages >= 2;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final maxSize = Size(constraints.maxWidth, constraints.maxHeight);
|
||||||
|
final (leftSize, rightSize) = calculateScaledPageSizes(maxSize);
|
||||||
|
|
||||||
|
if (_showTwoPages) {
|
||||||
|
// Two-page mode: pages touch each other and are centered together
|
||||||
|
return Center(
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
_buildPage(
|
||||||
|
pageNumber: currentPageNumber,
|
||||||
|
pageSize: leftSize,
|
||||||
|
controller: leftDrawingController,
|
||||||
|
),
|
||||||
|
// Only show right page if there is one
|
||||||
|
if (currentPageNumber < numPages)
|
||||||
|
_buildPage(
|
||||||
|
pageNumber: currentPageNumber + 1,
|
||||||
|
pageSize: rightSize!,
|
||||||
|
controller: rightDrawingController,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
// Empty space to keep left page position consistent on last page
|
||||||
|
SizedBox(width: rightSize!.width, height: rightSize.height),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single page mode
|
||||||
|
return Center(
|
||||||
|
child: _buildPage(
|
||||||
|
pageNumber: currentPageNumber,
|
||||||
|
pageSize: leftSize,
|
||||||
|
controller: leftDrawingController,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPage({
|
||||||
|
required int pageNumber,
|
||||||
|
required Size pageSize,
|
||||||
|
required DrawingController? controller,
|
||||||
|
}) {
|
||||||
|
final pdfPage = SizedBox(
|
||||||
|
width: pageSize.width,
|
||||||
|
height: pageSize.height,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
PdfPageView(
|
||||||
|
key: ValueKey(pageNumber),
|
||||||
|
document: document,
|
||||||
|
pageNumber: pageNumber,
|
||||||
|
maximumDpi: 300,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
),
|
||||||
|
_buildPageIndicator(pageNumber, pageSize),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// If no controller, just show the PDF
|
||||||
|
if (controller == null) {
|
||||||
|
return pdfPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap with DrawingBoard
|
||||||
|
return DrawingBoard(
|
||||||
|
boardSize: pageSize,
|
||||||
|
controller: controller,
|
||||||
|
drawingEnabled: drawingEnabled,
|
||||||
|
minScale: 1.0,
|
||||||
|
maxScale: 3.0,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: pdfPage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPageIndicator(int pageNumber, Size pageSize) {
|
||||||
|
return Positioned(
|
||||||
|
bottom: 5,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'$pageNumber / $numPages',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.black54,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page Size Calculations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Calculates scaled page sizes for the current view.
|
||||||
|
///
|
||||||
|
/// Returns a tuple of (leftPageSize, rightPageSize).
|
||||||
|
/// rightPageSize is null when not in two-page mode.
|
||||||
|
(Size, Size?) calculateScaledPageSizes(Size parentSize) {
|
||||||
|
if (config.twoPageMode) {
|
||||||
|
return _calculateTwoPageSizes(parentSize);
|
||||||
|
}
|
||||||
|
return (_calculateSinglePageSize(parentSize), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
(Size, Size?) _calculateTwoPageSizes(Size parentSize) {
|
||||||
|
final leftSize = _getUnscaledPageSize(currentPageNumber);
|
||||||
|
final rightSize = numPages > currentPageNumber
|
||||||
|
? _getUnscaledPageSize(currentPageNumber + 1)
|
||||||
|
: leftSize;
|
||||||
|
|
||||||
|
// Combine pages for scaling calculation
|
||||||
|
final combinedSize = Size(
|
||||||
|
leftSize.width + rightSize.width,
|
||||||
|
max(leftSize.height, rightSize.height),
|
||||||
|
);
|
||||||
|
|
||||||
|
final scaledCombined = _scaleToFit(parentSize, combinedSize);
|
||||||
|
final scaleFactor = scaledCombined.width / combinedSize.width;
|
||||||
|
|
||||||
|
return (leftSize * scaleFactor, rightSize * scaleFactor);
|
||||||
|
}
|
||||||
|
|
||||||
|
Size _calculateSinglePageSize(Size parentSize) {
|
||||||
|
return _scaleToFit(parentSize, _getUnscaledPageSize(currentPageNumber));
|
||||||
|
}
|
||||||
|
|
||||||
|
Size _getUnscaledPageSize(int pageNumber) {
|
||||||
|
return document.pages.elementAt(pageNumber - 1).size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scales a page size to fit within parent bounds while maintaining aspect ratio.
|
||||||
|
Size _scaleToFit(Size parentSize, Size pageSize) {
|
||||||
|
// Determine if height or width is the limiting factor
|
||||||
|
if (parentSize.aspectRatio > pageSize.aspectRatio) {
|
||||||
|
// Constrained by height
|
||||||
|
final height = parentSize.height;
|
||||||
|
final width = height * pageSize.aspectRatio;
|
||||||
|
return Size(width, height);
|
||||||
|
} else {
|
||||||
|
// Constrained by width
|
||||||
|
final width = parentSize.width;
|
||||||
|
final height = width / pageSize.aspectRatio;
|
||||||
|
return Size(width, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
126
lib/features/sheet_viewer/widgets/touch_navigation_layer.dart
Normal file
126
lib/features/sheet_viewer/widgets/touch_navigation_layer.dart
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../core/models/config.dart';
|
||||||
|
import 'pdf_page_display.dart';
|
||||||
|
|
||||||
|
/// Callback for page turn events.
|
||||||
|
typedef PageTurnCallback = dynamic Function(int delta);
|
||||||
|
|
||||||
|
/// Gesture layer for touch-based navigation over PDF pages.
|
||||||
|
///
|
||||||
|
/// Touch zones:
|
||||||
|
/// - Top 2cm: Toggle fullscreen (or exit if in fullscreen + top-right corner)
|
||||||
|
/// - Left side: Turn page backward (-1 or -2 in two-page mode)
|
||||||
|
/// - Right side: Turn page forward (+1 or +2 in two-page mode)
|
||||||
|
class TouchNavigationLayer extends StatelessWidget {
|
||||||
|
final PdfPageDisplay pageDisplay;
|
||||||
|
final Config config;
|
||||||
|
final VoidCallback onToggleFullscreen;
|
||||||
|
final VoidCallback onExit;
|
||||||
|
final PageTurnCallback onPageTurn;
|
||||||
|
|
||||||
|
const TouchNavigationLayer({
|
||||||
|
super.key,
|
||||||
|
required this.pageDisplay,
|
||||||
|
required this.config,
|
||||||
|
required this.onToggleFullscreen,
|
||||||
|
required this.onExit,
|
||||||
|
required this.onPageTurn,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTapUp: (details) => _handleTap(context, details),
|
||||||
|
child: pageDisplay,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTap(BuildContext context, TapUpDetails details) {
|
||||||
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
final position = details.localPosition;
|
||||||
|
|
||||||
|
// Calculate physical measurements for consistent touch zones
|
||||||
|
final pixelsPerCm = _calculatePixelsPerCm(mediaQuery.devicePixelRatio);
|
||||||
|
final touchZoneHeight = 2 * pixelsPerCm;
|
||||||
|
final touchZoneWidth = 2 * pixelsPerCm;
|
||||||
|
|
||||||
|
final screenWidth = mediaQuery.size.width;
|
||||||
|
final screenCenter = screenWidth / 2;
|
||||||
|
|
||||||
|
// Get page sizes for accurate touch zone calculation
|
||||||
|
final (leftPageSize, rightPageSize) = pageDisplay.calculateScaledPageSizes(
|
||||||
|
mediaQuery.size,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check top zone first
|
||||||
|
if (position.dy < touchZoneHeight) {
|
||||||
|
_handleTopZoneTap(position, screenWidth, touchZoneWidth);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle page turning based on tap position
|
||||||
|
_handlePageTurnTap(position, screenCenter, leftPageSize, rightPageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTopZoneTap(
|
||||||
|
Offset position,
|
||||||
|
double screenWidth,
|
||||||
|
double touchZoneWidth,
|
||||||
|
) {
|
||||||
|
// Top-right corner in fullscreen mode = exit
|
||||||
|
if (config.fullscreen && position.dx >= screenWidth - touchZoneWidth) {
|
||||||
|
onExit();
|
||||||
|
} else {
|
||||||
|
onToggleFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handlePageTurnTap(
|
||||||
|
Offset position,
|
||||||
|
double screenCenter,
|
||||||
|
Size leftPageSize,
|
||||||
|
Size? rightPageSize,
|
||||||
|
) {
|
||||||
|
final isLeftSide = position.dx < screenCenter;
|
||||||
|
|
||||||
|
if (config.twoPageMode) {
|
||||||
|
_handleTwoPageModeTap(
|
||||||
|
position,
|
||||||
|
screenCenter,
|
||||||
|
leftPageSize,
|
||||||
|
rightPageSize,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Single page mode: simple left/right
|
||||||
|
onPageTurn(isLeftSide ? -1 : 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTwoPageModeTap(
|
||||||
|
Offset position,
|
||||||
|
double screenCenter,
|
||||||
|
Size leftPageSize,
|
||||||
|
Size? rightPageSize,
|
||||||
|
) {
|
||||||
|
final leftEdge = screenCenter - leftPageSize.width / 2;
|
||||||
|
final rightEdge = screenCenter + (rightPageSize?.width ?? 0) / 2;
|
||||||
|
|
||||||
|
if (position.dx < leftEdge) {
|
||||||
|
onPageTurn(-2);
|
||||||
|
} else if (position.dx < screenCenter) {
|
||||||
|
onPageTurn(-1);
|
||||||
|
} else if (position.dx > rightEdge) {
|
||||||
|
onPageTurn(2);
|
||||||
|
} else {
|
||||||
|
onPageTurn(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double _calculatePixelsPerCm(double devicePixelRatio) {
|
||||||
|
const baseDpi = 160.0; // Android baseline DPI
|
||||||
|
const cmPerInch = 2.54;
|
||||||
|
return (devicePixelRatio * baseDpi) / cmPerInch;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
import 'package:sheetless/login_page.dart';
|
|
||||||
import 'package:sheetless/main.dart';
|
|
||||||
import 'package:sheetless/sheet_viewer_page.dart';
|
|
||||||
import 'package:sheetless/storage_helper.dart';
|
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
|
||||||
|
|
||||||
import 'api.dart';
|
|
||||||
import 'sheet.dart';
|
|
||||||
|
|
||||||
class MyHomePage extends StatefulWidget {
|
|
||||||
final Config config;
|
|
||||||
|
|
||||||
const MyHomePage({super.key, required this.config});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<MyHomePage> createState() => _MyHomePageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MyHomePageState extends State<MyHomePage> with RouteAware {
|
|
||||||
ApiClient? apiClient;
|
|
||||||
Future<bool> apiLoggedIn = Future.value(false);
|
|
||||||
final StorageHelper _storageHelper = StorageHelper();
|
|
||||||
final log = Logger("MyHomePage");
|
|
||||||
String? appName;
|
|
||||||
String? appVersion;
|
|
||||||
bool shuffling = false;
|
|
||||||
late Future<List<Sheet>> sheets;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
// Deactivate fullscreen by default
|
|
||||||
FullScreen.setFullScreen(false);
|
|
||||||
|
|
||||||
// So RouteAware gets bound
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
|
||||||
routeObserver.subscribe(this, ModalRoute.of(context)!);
|
|
||||||
});
|
|
||||||
|
|
||||||
super.initState();
|
|
||||||
_loadAppInfo();
|
|
||||||
sheets = acquireSheets();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didPush() {
|
|
||||||
FullScreen.setFullScreen(false);
|
|
||||||
super.didPush();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didPopNext() {
|
|
||||||
FullScreen.setFullScreen(false);
|
|
||||||
super.didPopNext();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadAppInfo() async {
|
|
||||||
final info = await PackageInfo.fromPlatform();
|
|
||||||
setState(() {
|
|
||||||
appName = info.appName;
|
|
||||||
appVersion = info.version;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<Sheet>> acquireSheets() async {
|
|
||||||
final url = await _storageHelper.readSecure(SecureStorageKey.url);
|
|
||||||
final jwt = await _storageHelper.readSecure(SecureStorageKey.jwt);
|
|
||||||
apiClient = ApiClient(baseUrl: url!, token: jwt);
|
|
||||||
// TODO: check if really logged in
|
|
||||||
final sheets = await apiClient!.fetchSheets();
|
|
||||||
log.info("${sheets.length} sheets fetched");
|
|
||||||
final sheetsSorted = await sortSheetsByRecency(sheets);
|
|
||||||
log.info("${sheetsSorted.length} sheets sorted");
|
|
||||||
|
|
||||||
// TODO: make work
|
|
||||||
// final changeQueue = await _storageHelper.readChangeQueue();
|
|
||||||
// changeQueue.applyToSheets(sheetsSorted);
|
|
||||||
// log.info("${changeQueue.length()} changes applied");
|
|
||||||
|
|
||||||
return sheetsSorted;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<Sheet>> sortSheetsByRecency(List<Sheet> sheets) async {
|
|
||||||
final accessTimes = await _storageHelper.readSheetAccessTimes();
|
|
||||||
|
|
||||||
sheets.sort((a, b) {
|
|
||||||
var dateA = accessTimes[a.uuid];
|
|
||||||
var dateB = accessTimes[b.uuid];
|
|
||||||
|
|
||||||
if (dateA == null || a.updatedAt.isAfter(dateA)) {
|
|
||||||
dateA = a.updatedAt;
|
|
||||||
}
|
|
||||||
if (dateB == null || b.updatedAt.isAfter(dateB)) {
|
|
||||||
dateB = b.updatedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
return dateB.compareTo(dateA);
|
|
||||||
});
|
|
||||||
|
|
||||||
return sheets;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _refreshSheets() async {
|
|
||||||
setState(() {
|
|
||||||
sheets = acquireSheets();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _logOut() async {
|
|
||||||
// Delete saved jwt
|
|
||||||
await _storageHelper.writeSecure(SecureStorageKey.jwt, null);
|
|
||||||
|
|
||||||
if (!mounted) return; // Widget already removed
|
|
||||||
|
|
||||||
Navigator.of(
|
|
||||||
context,
|
|
||||||
).pushReplacement(MaterialPageRoute(builder: (_) => LoginPage()));
|
|
||||||
}
|
|
||||||
|
|
||||||
void switchShufflingState(bool newState) async {
|
|
||||||
if (newState) {
|
|
||||||
(await sheets).shuffle();
|
|
||||||
} else {
|
|
||||||
sheets = sortSheetsByRecency(await sheets);
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
shuffling = newState;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Drawer _buildDrawer() {
|
|
||||||
return Drawer(
|
|
||||||
child: SafeArea(
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsetsGeometry.directional(start: 10, end: 10, top: 30),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
// Drawer Actions
|
|
||||||
Column(
|
|
||||||
children: [
|
|
||||||
ListTile(
|
|
||||||
leading: Icon(
|
|
||||||
Icons.shuffle,
|
|
||||||
color: shuffling ? Colors.blue : null,
|
|
||||||
),
|
|
||||||
title: const Text('Shuffle'),
|
|
||||||
onTap: () {
|
|
||||||
switchShufflingState(!shuffling);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.logout),
|
|
||||||
title: const Text('Logout'),
|
|
||||||
onTap: _logOut,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
// App Info at bottom
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: Text(
|
|
||||||
'$appName v$appVersion',
|
|
||||||
style: const TextStyle(color: Colors.grey),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
// Icon for drawer appears automatically
|
|
||||||
appBar: AppBar(title: const Text("Sheetless")),
|
|
||||||
endDrawer: _buildDrawer(),
|
|
||||||
body: RefreshIndicator(
|
|
||||||
onRefresh: _refreshSheets,
|
|
||||||
child: FutureBuilder(
|
|
||||||
future: sheets,
|
|
||||||
builder: (BuildContext context, AsyncSnapshot<List<Sheet>> snapshot) {
|
|
||||||
if (snapshot.connectionState != ConnectionState.done) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
if (snapshot.hasData) {
|
|
||||||
return SheetsWidget(
|
|
||||||
sheets: snapshot.data!,
|
|
||||||
onSheetOpenRequest: (sheet) {
|
|
||||||
_storageHelper.writeSheetAccessTime(
|
|
||||||
sheet.uuid,
|
|
||||||
DateTime.now(),
|
|
||||||
);
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => SheetViewerPage(
|
|
||||||
sheet: sheet,
|
|
||||||
apiClient: apiClient!,
|
|
||||||
config: widget.config,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else if (snapshot.hasError) {
|
|
||||||
log.warning("Error loading sheets:", snapshot.error);
|
|
||||||
return Center(
|
|
||||||
child: Text(
|
|
||||||
style: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.displaySmall!.copyWith(color: Colors.red),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
snapshot.error.toString(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:jwt_decoder/jwt_decoder.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
import 'package:sheetless/api.dart';
|
|
||||||
import 'package:sheetless/home_page.dart';
|
|
||||||
import 'package:sheetless/storage_helper.dart';
|
|
||||||
|
|
||||||
class LoginPage extends StatefulWidget {
|
|
||||||
const LoginPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<LoginPage> createState() => _LoginPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LoginPageState extends State<LoginPage> {
|
|
||||||
final log = Logger("_LoginPageState");
|
|
||||||
|
|
||||||
final TextEditingController _urlController = TextEditingController(
|
|
||||||
text: "https://sheetable.julian-mutter.de",
|
|
||||||
);
|
|
||||||
final TextEditingController _usernameController = TextEditingController();
|
|
||||||
final TextEditingController _passwordController = TextEditingController();
|
|
||||||
|
|
||||||
final _formKey = GlobalKey<FormState>();
|
|
||||||
|
|
||||||
final StorageHelper _storageHelper = StorageHelper();
|
|
||||||
String? _error;
|
|
||||||
bool loggingIn = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_restoreStoredValues();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _restoreStoredValues() async {
|
|
||||||
final jwt = await _storageHelper.readSecure(SecureStorageKey.jwt);
|
|
||||||
if (jwt != null && await _isJwtValid(jwt)) {
|
|
||||||
await _navigateToMainPage();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final url = await _storageHelper.readSecure(SecureStorageKey.url);
|
|
||||||
final username = await _storageHelper.readSecure(SecureStorageKey.email);
|
|
||||||
if (url != null) {
|
|
||||||
_urlController.text = url;
|
|
||||||
}
|
|
||||||
if (username != null) {
|
|
||||||
_usernameController.text = username;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> _isJwtValid(String jwt) async {
|
|
||||||
try {
|
|
||||||
bool expired = JwtDecoder.isExpired(jwt);
|
|
||||||
return !expired;
|
|
||||||
} on FormatException {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _login(
|
|
||||||
String serverUrl, String username, String password) async {
|
|
||||||
setState(() {
|
|
||||||
_error = null;
|
|
||||||
});
|
|
||||||
final apiClient = ApiClient(baseUrl: serverUrl);
|
|
||||||
try {
|
|
||||||
await apiClient.login(username, password);
|
|
||||||
|
|
||||||
await _storageHelper.writeSecure(SecureStorageKey.url, serverUrl);
|
|
||||||
await _storageHelper.writeSecure(SecureStorageKey.jwt, apiClient.token!);
|
|
||||||
await _storageHelper.writeSecure(SecureStorageKey.email, username);
|
|
||||||
await _navigateToMainPage();
|
|
||||||
} catch (e) {
|
|
||||||
setState(() {
|
|
||||||
_error = "Login failed.\n$e";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _navigateToMainPage() async {
|
|
||||||
final config = await _storageHelper.readConfig();
|
|
||||||
Navigator.of(context).pushReplacement(
|
|
||||||
MaterialPageRoute(builder: (_) => MyHomePage(config: config)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String? validateNotEmpty(String? content) {
|
|
||||||
if (content == null || content.isEmpty) {
|
|
||||||
return "Do not leave this field empty";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
void handleLoginPressed() async {
|
|
||||||
if (loggingIn) return;
|
|
||||||
|
|
||||||
loggingIn = true;
|
|
||||||
if (_formKey.currentState!.validate()) {
|
|
||||||
await _login(
|
|
||||||
_urlController.text,
|
|
||||||
_usernameController.text,
|
|
||||||
_passwordController.text,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
loggingIn = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: Text('Login')),
|
|
||||||
body: Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: Form(
|
|
||||||
key: _formKey,
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
TextFormField(
|
|
||||||
controller: _urlController,
|
|
||||||
validator: validateNotEmpty,
|
|
||||||
decoration: InputDecoration(labelText: 'Url'),
|
|
||||||
textInputAction: TextInputAction.next,
|
|
||||||
),
|
|
||||||
TextFormField(
|
|
||||||
controller: _usernameController,
|
|
||||||
validator: validateNotEmpty,
|
|
||||||
decoration: InputDecoration(labelText: 'Username'),
|
|
||||||
textInputAction: TextInputAction.next,
|
|
||||||
),
|
|
||||||
TextFormField(
|
|
||||||
controller: _passwordController,
|
|
||||||
validator: validateNotEmpty,
|
|
||||||
// focusNode: _passwordFocusNode,
|
|
||||||
decoration: InputDecoration(labelText: 'Password'),
|
|
||||||
obscureText: true,
|
|
||||||
textInputAction: TextInputAction
|
|
||||||
.next, // with submit or go, onFieldSubmitted is not called
|
|
||||||
onFieldSubmitted: (_) => handleLoginPressed(),
|
|
||||||
),
|
|
||||||
// ),
|
|
||||||
SizedBox(height: 5),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: loggingIn ? null : handleLoginPressed,
|
|
||||||
child: Text('Login'),
|
|
||||||
),
|
|
||||||
if (_error != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
|
||||||
child: Text(_error!, style: TextStyle(color: Colors.red)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +1,47 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
|
||||||
import 'package:hive/hive.dart';
|
import 'package:hive/hive.dart';
|
||||||
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 'package:pdfrx/pdfrx.dart';
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
|
|
||||||
|
|
||||||
import 'login_page.dart';
|
import 'app.dart';
|
||||||
|
|
||||||
final RouteObserver<ModalRoute> routeObserver = RouteObserver<ModalRoute>();
|
/// Application entry point.
|
||||||
|
///
|
||||||
|
/// Performs platform-specific initialization before running the app
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
Logger.root.level = Level.ALL; // defaults to Level.INFO
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
await _initializeLogging();
|
||||||
|
await _initializePlugins();
|
||||||
|
|
||||||
|
runApp(const SheetlessApp());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configures the logging system.
|
||||||
|
Future<void> _initializeLogging() async {
|
||||||
|
Logger.root.level = Level.ALL;
|
||||||
Logger.root.onRecord.listen((record) {
|
Logger.root.onRecord.listen((record) {
|
||||||
debugPrint('${record.level.name}: ${record.time}: ${record.message}');
|
debugPrint('${record.level.name}: ${record.time}: ${record.message}');
|
||||||
if (record.error != null) {
|
if (record.error != null) {
|
||||||
debugPrint('${record.error}');
|
debugPrint('${record.error}');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// setup for flutter_fullscreen
|
/// Initializes platform-specific plugins and services.
|
||||||
WidgetsFlutterBinding.ensureInitialized(); // Needs to be initialized first, otherwise app gets stuck in splash screen
|
Future<void> _initializePlugins() async {
|
||||||
|
// Fullscreen support
|
||||||
await FullScreen.ensureInitialized();
|
await FullScreen.ensureInitialized();
|
||||||
|
|
||||||
pdfrxFlutterInitialize(); // Needed especially for web
|
// PDF rendering (especially needed for web)
|
||||||
|
pdfrxFlutterInitialize();
|
||||||
|
|
||||||
|
// Local storage (not supported on web)
|
||||||
if (!kIsWeb) {
|
if (!kIsWeb) {
|
||||||
Directory dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
Hive.init(dir.path); // Needed only if not web
|
Hive.init(dir.path);
|
||||||
}
|
|
||||||
|
|
||||||
runApp(const MyApp());
|
|
||||||
}
|
|
||||||
|
|
||||||
class MyApp extends StatelessWidget {
|
|
||||||
const MyApp({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return MaterialApp(
|
|
||||||
title: 'Sheetless',
|
|
||||||
theme: ThemeData(useMaterial3: true, primarySwatch: Colors.blue),
|
|
||||||
home: const LoginPage(),
|
|
||||||
navigatorObservers: [routeObserver],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
lib/shared/input/pedal_shortcuts.dart
Normal file
40
lib/shared/input/pedal_shortcuts.dart
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// Widget that handles Bluetooth pedal and keyboard shortcuts for page turning.
|
||||||
|
///
|
||||||
|
/// Listens for arrow key presses and invokes the appropriate callback:
|
||||||
|
/// - Arrow Down/Right: Turn page forward
|
||||||
|
/// - Arrow Up/Left: Turn page backward
|
||||||
|
class PedalShortcuts extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
final VoidCallback onPageForward;
|
||||||
|
final VoidCallback onPageBackward;
|
||||||
|
|
||||||
|
const PedalShortcuts({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
required this.onPageForward,
|
||||||
|
required this.onPageBackward,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return CallbackShortcuts(
|
||||||
|
bindings: _buildBindings(),
|
||||||
|
child: Focus(autofocus: true, child: child),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<ShortcutActivator, VoidCallback> _buildBindings() {
|
||||||
|
return {
|
||||||
|
// Forward navigation
|
||||||
|
const SingleActivator(LogicalKeyboardKey.arrowDown): onPageForward,
|
||||||
|
const SingleActivator(LogicalKeyboardKey.arrowRight): onPageForward,
|
||||||
|
|
||||||
|
// Backward navigation
|
||||||
|
const SingleActivator(LogicalKeyboardKey.arrowUp): onPageBackward,
|
||||||
|
const SingleActivator(LogicalKeyboardKey.arrowLeft): onPageBackward,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
103
lib/shared/widgets/edit_sheet_bottom_sheet.dart
Normal file
103
lib/shared/widgets/edit_sheet_bottom_sheet.dart
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../core/models/sheet.dart';
|
||||||
|
|
||||||
|
/// Callback when sheet metadata is saved.
|
||||||
|
typedef SheetEditCallback = void Function(String newName, String newComposer);
|
||||||
|
|
||||||
|
/// Bottom sheet for editing sheet metadata (name and composer).
|
||||||
|
class EditSheetBottomSheet extends StatefulWidget {
|
||||||
|
final Sheet sheet;
|
||||||
|
final SheetEditCallback onSave;
|
||||||
|
|
||||||
|
const EditSheetBottomSheet({
|
||||||
|
super.key,
|
||||||
|
required this.sheet,
|
||||||
|
required this.onSave,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EditSheetBottomSheet> createState() => _EditSheetBottomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EditSheetBottomSheetState extends State<EditSheetBottomSheet> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
late final TextEditingController _nameController;
|
||||||
|
late final TextEditingController _composerController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_nameController = TextEditingController(text: widget.sheet.name);
|
||||||
|
_composerController = TextEditingController(text: widget.sheet.composer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameController.dispose();
|
||||||
|
_composerController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSave() {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
widget.onSave(_nameController.text.trim(), _composerController.text.trim());
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validateNotEmpty(String? value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return 'This field cannot be empty';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
MediaQuery.of(context).viewInsets.bottom + 16,
|
||||||
|
),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Edit Sheet',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _nameController,
|
||||||
|
validator: _validateNotEmpty,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Sheet Name',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: _composerController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Composer',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onFieldSubmitted: (_) => _handleSave(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(onPressed: _handleSave, child: const Text('Save')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
185
lib/sheet.dart
185
lib/sheet.dart
@@ -1,185 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:ui';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:sheetless/edit_bottom_sheet.dart';
|
|
||||||
import 'package:sheetless/storage_helper.dart';
|
|
||||||
import 'package:sheetless/upload_queue.dart';
|
|
||||||
|
|
||||||
class Sheet {
|
|
||||||
final String uuid;
|
|
||||||
String name;
|
|
||||||
String composerUuid;
|
|
||||||
String composerName;
|
|
||||||
DateTime updatedAt;
|
|
||||||
|
|
||||||
Sheet({
|
|
||||||
required this.uuid,
|
|
||||||
required this.name,
|
|
||||||
required this.composerUuid,
|
|
||||||
required this.composerName,
|
|
||||||
required this.updatedAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Factory constructor for creating a Sheet from JSON
|
|
||||||
factory Sheet.fromJson(Map<String, dynamic> json) {
|
|
||||||
final composer = json['composer'] as Map<String, dynamic>?;
|
|
||||||
return Sheet(
|
|
||||||
uuid: json['uuid'].toString(),
|
|
||||||
name: json['title'],
|
|
||||||
composerUuid: json['composer_uuid']?.toString() ?? '',
|
|
||||||
composerName: composer?['name'] ?? 'Unknown',
|
|
||||||
updatedAt: DateTime.parse(json['updated_at']),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SheetsWidget extends StatefulWidget {
|
|
||||||
final List<Sheet> sheets;
|
|
||||||
final ValueSetter<Sheet> onSheetOpenRequest;
|
|
||||||
|
|
||||||
const SheetsWidget({
|
|
||||||
super.key,
|
|
||||||
required this.sheets,
|
|
||||||
required this.onSheetOpenRequest,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<SheetsWidget> createState() => _SheetsWidgetState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SheetsWidgetState extends State<SheetsWidget> {
|
|
||||||
late List<Sheet> filteredSheets;
|
|
||||||
final StorageHelper storageHelper = StorageHelper();
|
|
||||||
final TextEditingController _searchController = TextEditingController();
|
|
||||||
Timer? _debounce;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
filteredSheets = widget.sheets;
|
|
||||||
_searchController.addListener(_onSearchChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_searchController.removeListener(_onSearchChanged);
|
|
||||||
_searchController.dispose();
|
|
||||||
_debounce?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onSearchChanged() {
|
|
||||||
if (_debounce?.isActive ?? false) _debounce!.cancel();
|
|
||||||
|
|
||||||
_debounce = Timer(const Duration(milliseconds: 500), () {
|
|
||||||
_filterSheets();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _filterSheets() {
|
|
||||||
setState(() {
|
|
||||||
String query = _searchController.text.toLowerCase().trim();
|
|
||||||
List<String> terms = query.split(RegExp(r'\s+')); // Split by whitespace
|
|
||||||
|
|
||||||
filteredSheets = widget.sheets.where((sheet) {
|
|
||||||
String name = sheet.name.toLowerCase();
|
|
||||||
String composer = sheet.composerName.toLowerCase();
|
|
||||||
|
|
||||||
// Each term must be found in either the name or composer
|
|
||||||
return terms.every(
|
|
||||||
(term) => name.contains(term) || composer.contains(term),
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _clearSearch() {
|
|
||||||
_searchController.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _openEditSheet(BuildContext context, Sheet sheet) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
builder: (_) => EditItemBottomSheet(
|
|
||||||
sheet: sheet,
|
|
||||||
onSheetEdited: (String newName, String newComposer) {
|
|
||||||
if (newName != sheet.name) {
|
|
||||||
storageHelper.writeChange(
|
|
||||||
Change(
|
|
||||||
type: ChangeType.sheetNameChange,
|
|
||||||
sheetUuid: sheet.uuid,
|
|
||||||
value: newName,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (newComposer != sheet.composerName) {
|
|
||||||
storageHelper.writeChange(
|
|
||||||
Change(
|
|
||||||
type: ChangeType.composerNameChange,
|
|
||||||
sheetUuid: sheet.uuid,
|
|
||||||
value: newComposer,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setState(() {
|
|
||||||
sheet.name = newName;
|
|
||||||
sheet.composerName = newComposer;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: TextField(
|
|
||||||
controller: _searchController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'Search...',
|
|
||||||
prefixIcon: const Icon(Icons.search),
|
|
||||||
suffixIcon: _searchController.text.isNotEmpty
|
|
||||||
? IconButton(
|
|
||||||
icon: const Icon(Icons.clear),
|
|
||||||
onPressed: _clearSearch,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8.0),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
// Fixes scroll on web
|
|
||||||
child: ScrollConfiguration(
|
|
||||||
behavior: ScrollConfiguration.of(context).copyWith(
|
|
||||||
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse},
|
|
||||||
),
|
|
||||||
child: ListView.builder(
|
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
|
||||||
itemCount: filteredSheets.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
var sheet = filteredSheets[index];
|
|
||||||
return ListTile(
|
|
||||||
title: Text(sheet.name),
|
|
||||||
subtitle: Text(sheet.composerName),
|
|
||||||
onTap: () => setState(() {
|
|
||||||
widget.onSheetOpenRequest(sheet);
|
|
||||||
widget.sheets.remove(sheet);
|
|
||||||
widget.sheets.insert(0, sheet);
|
|
||||||
}),
|
|
||||||
onLongPress: () => _openEditSheet(context, sheet),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,426 +0,0 @@
|
|||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_drawing_board/flutter_drawing_board.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
import 'package:pdfrx/pdfrx.dart';
|
|
||||||
import 'package:sheetless/api.dart';
|
|
||||||
import 'package:sheetless/bt_pedal_shortcuts.dart';
|
|
||||||
import 'package:sheetless/sheet.dart';
|
|
||||||
import 'package:sheetless/storage_helper.dart';
|
|
||||||
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
|
|
||||||
|
|
||||||
class SheetViewerPage extends StatefulWidget {
|
|
||||||
final Sheet sheet;
|
|
||||||
final ApiClient apiClient;
|
|
||||||
final Config config;
|
|
||||||
|
|
||||||
const SheetViewerPage({
|
|
||||||
super.key,
|
|
||||||
required this.sheet,
|
|
||||||
required this.apiClient,
|
|
||||||
required this.config,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<SheetViewerPage> createState() => _SheetViewerPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SheetViewerPageState extends State<SheetViewerPage>
|
|
||||||
with FullScreenListener {
|
|
||||||
final log = Logger("SheetViewerPage");
|
|
||||||
final StorageHelper storageHelper = StorageHelper();
|
|
||||||
|
|
||||||
int currentPageNumber = 1;
|
|
||||||
int numPages = 1;
|
|
||||||
late Future<bool> documentLoaded;
|
|
||||||
PdfDocument? document;
|
|
||||||
bool paintMode = false;
|
|
||||||
Pages? pages;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
FullScreen.addListener(this);
|
|
||||||
|
|
||||||
// Load saved fullscreen
|
|
||||||
FullScreen.setFullScreen(widget.config.fullscreen);
|
|
||||||
|
|
||||||
super.initState();
|
|
||||||
documentLoaded = loadPdf();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
FullScreen.removeListener(this);
|
|
||||||
document?.dispose(); // Make sure document gets garbage collected
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> loadPdf() async {
|
|
||||||
if (kIsWeb) {
|
|
||||||
final data = await widget.apiClient.fetchPdfFileData(widget.sheet.uuid);
|
|
||||||
|
|
||||||
document = await PdfDocument.openData(data);
|
|
||||||
} else {
|
|
||||||
final file = await widget.apiClient.getPdfFileCached(widget.sheet.uuid);
|
|
||||||
|
|
||||||
document = await PdfDocument.openFile(file.path);
|
|
||||||
}
|
|
||||||
setState(() {
|
|
||||||
// document changed
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void onFullScreenChanged(bool enabled, SystemUiMode? systemUiMode) {
|
|
||||||
setState(() {
|
|
||||||
widget.config.fullscreen = enabled;
|
|
||||||
storageHelper.writeConfig(widget.config);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void toggleFullscreen() {
|
|
||||||
FullScreen.setFullScreen(!widget.config.fullscreen);
|
|
||||||
}
|
|
||||||
|
|
||||||
void turnPage(int numTurns) {
|
|
||||||
setState(() {
|
|
||||||
currentPageNumber += numTurns;
|
|
||||||
currentPageNumber = currentPageNumber.clamp(1, numPages);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
AppBar? buildAppBar() {
|
|
||||||
if (widget.config.fullscreen && document != null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return AppBar(
|
|
||||||
title: Text(widget.sheet.name),
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
widget.config.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
|
|
||||||
),
|
|
||||||
tooltip: widget.config.fullscreen
|
|
||||||
? 'Exit Fullscreen'
|
|
||||||
: 'Enter Fullscreen',
|
|
||||||
onPressed: () {
|
|
||||||
toggleFullscreen();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
if (widget.config.twoPageMode) {
|
|
||||||
// TODO: notification that paint mode only in single page mode
|
|
||||||
} else {
|
|
||||||
paintMode = !paintMode;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
icon: Icon(Icons.brush),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
widget.config.twoPageMode = !widget.config.twoPageMode;
|
|
||||||
storageHelper.writeConfig(widget.config);
|
|
||||||
if (widget.config.twoPageMode) {
|
|
||||||
paintMode = false;
|
|
||||||
// TODO: notification that paint mode was deactivated since only possible in single page mode
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
icon: Icon(
|
|
||||||
widget.config.twoPageMode ? Icons.filter_1 : Icons.filter_2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return BtPedalShortcuts(
|
|
||||||
onTurnPageForward: () => turnPage(1),
|
|
||||||
onTurnPageBackward: () => turnPage(-1),
|
|
||||||
child: Scaffold(
|
|
||||||
appBar: buildAppBar(),
|
|
||||||
body: FutureBuilder(
|
|
||||||
future: documentLoaded,
|
|
||||||
builder: (BuildContext context, AsyncSnapshot<bool> snapshot) {
|
|
||||||
if (snapshot.hasData && document != null) {
|
|
||||||
numPages = document!.pages.length;
|
|
||||||
pages = Pages(
|
|
||||||
document: document!,
|
|
||||||
numPages: numPages,
|
|
||||||
config: widget.config,
|
|
||||||
currentPageNumber: currentPageNumber,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
Stack(
|
|
||||||
children: [
|
|
||||||
Visibility(
|
|
||||||
visible: !paintMode,
|
|
||||||
child: TouchablePages(
|
|
||||||
pages: pages!,
|
|
||||||
onToggleFullscreen: () {
|
|
||||||
toggleFullscreen();
|
|
||||||
},
|
|
||||||
onExitSheetViewer: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
onTurnPage: (int numTurns) {
|
|
||||||
turnPage(numTurns);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Visibility(
|
|
||||||
visible: paintMode,
|
|
||||||
child: PaintablePages(pages: pages!),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
} else if (snapshot.hasError) {
|
|
||||||
log.warning("Error loading pdf:", snapshot.error);
|
|
||||||
return Center(
|
|
||||||
child: Text(
|
|
||||||
style: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.displaySmall!.copyWith(color: Colors.red),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
snapshot.error.toString(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
typedef PageturnCallback = void Function(int numTurns);
|
|
||||||
|
|
||||||
class TouchablePages extends StatelessWidget {
|
|
||||||
final Pages pages;
|
|
||||||
final VoidCallback onToggleFullscreen;
|
|
||||||
final VoidCallback onExitSheetViewer;
|
|
||||||
final PageturnCallback onTurnPage;
|
|
||||||
|
|
||||||
const TouchablePages({
|
|
||||||
super.key,
|
|
||||||
required this.pages,
|
|
||||||
required this.onToggleFullscreen,
|
|
||||||
required this.onExitSheetViewer,
|
|
||||||
required this.onTurnPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return GestureDetector(
|
|
||||||
behavior:
|
|
||||||
HitTestBehavior.opaque, // Also register when outside of pdf pages
|
|
||||||
onTapUp: (TapUpDetails details) {
|
|
||||||
final mediaQuery = MediaQuery.of(context);
|
|
||||||
final pixelsPerInch =
|
|
||||||
mediaQuery.devicePixelRatio *
|
|
||||||
160; // 160 dpi = 1 logical inch baseline
|
|
||||||
final pixelsPerCm = pixelsPerInch / 2.54;
|
|
||||||
|
|
||||||
final touchAreaWidth = mediaQuery.size.width;
|
|
||||||
|
|
||||||
final (leftPageSize, rightPageSize) = pages.calcPageSizesScaled(
|
|
||||||
mediaQuery.size,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (details.localPosition.dy < 2 * pixelsPerCm &&
|
|
||||||
details.localPosition.dx >= touchAreaWidth - 2 * pixelsPerCm &&
|
|
||||||
pages.config.fullscreen) {
|
|
||||||
onExitSheetViewer();
|
|
||||||
} else if (details.localPosition.dy < 2 * pixelsPerCm) {
|
|
||||||
onToggleFullscreen();
|
|
||||||
} else if (pages.config.twoPageMode &&
|
|
||||||
details.localPosition.dx <
|
|
||||||
touchAreaWidth / 2 - leftPageSize.width / 2) {
|
|
||||||
onTurnPage(-2);
|
|
||||||
} else if (details.localPosition.dx < touchAreaWidth / 2) {
|
|
||||||
onTurnPage(-1);
|
|
||||||
} else if (pages.config.twoPageMode &&
|
|
||||||
details.localPosition.dx >
|
|
||||||
touchAreaWidth / 2 + rightPageSize!.width / 2) {
|
|
||||||
onTurnPage(2);
|
|
||||||
} else {
|
|
||||||
onTurnPage(1);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: pages,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class PaintablePages extends StatelessWidget {
|
|
||||||
final Pages pages;
|
|
||||||
|
|
||||||
const PaintablePages({super.key, required this.pages});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return SizedBox.expand(
|
|
||||||
child: LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final maxSize = Size(constraints.maxWidth, constraints.maxHeight);
|
|
||||||
final (pageSizeScaled, _) = pages.calcPageSizesScaled(maxSize);
|
|
||||||
return DrawingBoard(
|
|
||||||
background: SizedBox(
|
|
||||||
width: pageSizeScaled.width,
|
|
||||||
height: pageSizeScaled.height,
|
|
||||||
child: pages,
|
|
||||||
),
|
|
||||||
// showDefaultTools: true,
|
|
||||||
// showDefaultActions: true,
|
|
||||||
boardConstrained: true,
|
|
||||||
minScale: 1,
|
|
||||||
maxScale: 3,
|
|
||||||
alignment: Alignment.topRight,
|
|
||||||
boardBoundaryMargin: EdgeInsets.all(0),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Pages extends StatelessWidget {
|
|
||||||
final PdfDocument document;
|
|
||||||
final int numPages;
|
|
||||||
final int currentPageNumber; // Starts at 1
|
|
||||||
final Config config;
|
|
||||||
|
|
||||||
const Pages({
|
|
||||||
super.key,
|
|
||||||
required this.document,
|
|
||||||
required this.numPages,
|
|
||||||
required this.currentPageNumber,
|
|
||||||
required this.config,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
spacing: 0,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
PdfPageView(
|
|
||||||
key: ValueKey(currentPageNumber),
|
|
||||||
document: document,
|
|
||||||
pageNumber: currentPageNumber,
|
|
||||||
maximumDpi: 300,
|
|
||||||
alignment: config.twoPageMode && numPages >= 2
|
|
||||||
? Alignment.centerRight
|
|
||||||
: Alignment.center,
|
|
||||||
),
|
|
||||||
Positioned.fill(
|
|
||||||
child: Container(
|
|
||||||
alignment: Alignment.bottomCenter,
|
|
||||||
padding: EdgeInsets.only(bottom: 5),
|
|
||||||
child: Text('$currentPageNumber / $numPages'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Visibility(
|
|
||||||
visible: config.twoPageMode == true && numPages >= 2,
|
|
||||||
child: Expanded(
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
PdfPageView(
|
|
||||||
key: ValueKey(currentPageNumber + 1),
|
|
||||||
document: document,
|
|
||||||
pageNumber: currentPageNumber + 1,
|
|
||||||
maximumDpi: 300,
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
// alignment: Alignment.center,
|
|
||||||
),
|
|
||||||
Positioned.fill(
|
|
||||||
child: Container(
|
|
||||||
alignment: Alignment.bottomCenter,
|
|
||||||
padding: EdgeInsets.only(bottom: 5),
|
|
||||||
child: Text('${currentPageNumber + 1} / $numPages'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
(Size, Size?) calcPageSizesScaled(Size parentSize) {
|
|
||||||
if (config.twoPageMode) {
|
|
||||||
Size leftPageSizeUnscaled = _getPageSizeUnscaled(currentPageNumber);
|
|
||||||
Size rightPageSizeUnscaled;
|
|
||||||
if (numPages > currentPageNumber) {
|
|
||||||
rightPageSizeUnscaled = _getPageSizeUnscaled(currentPageNumber + 1);
|
|
||||||
} else {
|
|
||||||
rightPageSizeUnscaled = leftPageSizeUnscaled;
|
|
||||||
}
|
|
||||||
Size combinedPageSizesUnscaled = Size(
|
|
||||||
leftPageSizeUnscaled.width + rightPageSizeUnscaled.width,
|
|
||||||
max(leftPageSizeUnscaled.height, rightPageSizeUnscaled.height),
|
|
||||||
);
|
|
||||||
Size combinedPageSizesScaled = _calcScaledPageSize(
|
|
||||||
parentSize,
|
|
||||||
combinedPageSizesUnscaled,
|
|
||||||
);
|
|
||||||
double scaleFactor =
|
|
||||||
combinedPageSizesScaled.width / combinedPageSizesUnscaled.width;
|
|
||||||
return (
|
|
||||||
leftPageSizeUnscaled * scaleFactor,
|
|
||||||
rightPageSizeUnscaled * scaleFactor,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
_calcScaledPageSize(
|
|
||||||
parentSize,
|
|
||||||
_getPageSizeUnscaled(currentPageNumber),
|
|
||||||
),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Size _getPageSizeUnscaled(int pageNumber) {
|
|
||||||
return document.pages.elementAt(pageNumber - 1).size;
|
|
||||||
}
|
|
||||||
|
|
||||||
Size _calcScaledPageSize(Size parentSize, Size pageSize) {
|
|
||||||
// page restricted by height
|
|
||||||
if (parentSize.aspectRatio > pageSize.aspectRatio) {
|
|
||||||
final height = parentSize.height;
|
|
||||||
final width = height * pageSize.aspectRatio;
|
|
||||||
return Size(width, height);
|
|
||||||
}
|
|
||||||
// page restricted by width
|
|
||||||
else {
|
|
||||||
final width = parentSize.width;
|
|
||||||
final height = width / pageSize.aspectRatio;
|
|
||||||
return Size(width, height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
||||||
import 'package:hive/hive.dart';
|
|
||||||
import 'package:sheetless/upload_queue.dart';
|
|
||||||
|
|
||||||
enum SecureStorageKey { url, jwt, email }
|
|
||||||
|
|
||||||
enum ConfigKey { twoPageMode }
|
|
||||||
|
|
||||||
class Config {
|
|
||||||
Config({required this.twoPageMode, required this.fullscreen});
|
|
||||||
|
|
||||||
static const String keyTwoPageMode = "twoPageMode";
|
|
||||||
static const String keyFullscreen = "fullscreen";
|
|
||||||
|
|
||||||
bool twoPageMode;
|
|
||||||
bool fullscreen;
|
|
||||||
}
|
|
||||||
|
|
||||||
class StorageHelper {
|
|
||||||
final sheetAccessTimesBox = "sheetAccessTimes";
|
|
||||||
final configBox = "config";
|
|
||||||
final changeQueueBox = "changeQueue";
|
|
||||||
|
|
||||||
late FlutterSecureStorage secureStorage;
|
|
||||||
|
|
||||||
StorageHelper() {
|
|
||||||
AndroidOptions getAndroidOptions() =>
|
|
||||||
const AndroidOptions(encryptedSharedPreferences: true);
|
|
||||||
secureStorage = FlutterSecureStorage(aOptions: getAndroidOptions());
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String?> readSecure(SecureStorageKey key) {
|
|
||||||
return secureStorage.read(key: key.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> writeSecure(SecureStorageKey key, String? value) {
|
|
||||||
return secureStorage.write(key: key.name, value: value);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Config> readConfig() async {
|
|
||||||
final box = await Hive.openBox(configBox);
|
|
||||||
return Config(
|
|
||||||
twoPageMode: box.get(Config.keyTwoPageMode) ?? false,
|
|
||||||
fullscreen: box.get(Config.keyFullscreen) ?? false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> writeConfig(Config config) async {
|
|
||||||
final box = await Hive.openBox(configBox);
|
|
||||||
box.put(Config.keyTwoPageMode, config.twoPageMode);
|
|
||||||
box.put(Config.keyFullscreen, config.fullscreen);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Map<String, DateTime>> readSheetAccessTimes() async {
|
|
||||||
final box = await Hive.openBox(sheetAccessTimesBox);
|
|
||||||
return box.toMap().map(
|
|
||||||
(k, v) => MapEntry(k as String, DateTime.parse(v as String)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> writeSheetAccessTime(String uuid, DateTime datetime) async {
|
|
||||||
final box = await Hive.openBox(sheetAccessTimesBox);
|
|
||||||
await box.put(uuid, datetime.toIso8601String());
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> writeChange(Change change) async {
|
|
||||||
final box = await Hive.openBox(changeQueueBox);
|
|
||||||
box.add(change.toMap());
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<ChangeQueue> readChangeQueue() async {
|
|
||||||
final box = await Hive.openBox(changeQueueBox);
|
|
||||||
ChangeQueue queue = ChangeQueue();
|
|
||||||
for (Map<dynamic, dynamic> map in box.values) {
|
|
||||||
queue.addChange(Change.fromMap(map));
|
|
||||||
}
|
|
||||||
return queue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> deleteOldestChange(Change change) async {
|
|
||||||
final box = await Hive.openBox(changeQueueBox);
|
|
||||||
box.deleteAt(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import 'dart:collection';
|
|
||||||
|
|
||||||
import 'package:sheetless/sheet.dart';
|
|
||||||
|
|
||||||
enum ChangeType {
|
|
||||||
sheetNameChange,
|
|
||||||
composerNameChange,
|
|
||||||
addTagChange,
|
|
||||||
removeTagChange,
|
|
||||||
}
|
|
||||||
|
|
||||||
class Change {
|
|
||||||
final ChangeType type;
|
|
||||||
final String sheetUuid;
|
|
||||||
final String value;
|
|
||||||
|
|
||||||
Change({required this.type, required this.sheetUuid, required this.value});
|
|
||||||
|
|
||||||
Map<String, dynamic> toMap() => {
|
|
||||||
"type": type.index,
|
|
||||||
"sheetUuid": sheetUuid,
|
|
||||||
"value": value,
|
|
||||||
};
|
|
||||||
|
|
||||||
factory Change.fromMap(Map<dynamic, dynamic> map) {
|
|
||||||
return Change(
|
|
||||||
type: ChangeType
|
|
||||||
.values[map["type"]], // TODO: this will create problems if new enums are added
|
|
||||||
sheetUuid: map["sheetUuid"],
|
|
||||||
value: map["value"],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ChangeQueue {
|
|
||||||
// Queue with oldest change first
|
|
||||||
final Queue<Change> queue = Queue();
|
|
||||||
|
|
||||||
ChangeQueue() {}
|
|
||||||
|
|
||||||
void addChange(Change change) {
|
|
||||||
queue.addLast(change);
|
|
||||||
}
|
|
||||||
|
|
||||||
int length() {
|
|
||||||
return queue.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
void applyToSheets(List<Sheet> sheets) {
|
|
||||||
for (Change change in queue) {
|
|
||||||
var sheet = sheets.where((sheet) => sheet.uuid == change.sheetUuid).first;
|
|
||||||
switch (change.type) {
|
|
||||||
case ChangeType.sheetNameChange:
|
|
||||||
sheet.name = change.value;
|
|
||||||
case ChangeType.composerNameChange:
|
|
||||||
sheet.composerName = change.value;
|
|
||||||
case ChangeType.addTagChange:
|
|
||||||
// TODO: Handle this case.
|
|
||||||
throw UnimplementedError();
|
|
||||||
case ChangeType.removeTagChange:
|
|
||||||
// TODO: Handle this case.
|
|
||||||
throw UnimplementedError();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
pdfrx
|
pdfium_flutter
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
# This file was automatically generated by nix-shell.
|
# This file was automatically generated by nix-shell.
|
||||||
sdk.dir=/nix/store/0sbpck94abij5q1hcq1jzps3lr68p1dq-androidsdk/libexec/android-sdk
|
sdk.dir=/nix/store/ri84qrj5wk61x6cja29cfcv5x0iarhs5-androidsdk/libexec/android-sdk
|
||||||
ndk.dir=/nix/store/0sbpck94abij5q1hcq1jzps3lr68p1dq-androidsdk/libexec/android-sdk/ndk-bundle
|
ndk.dir=/nix/store/ri84qrj5wk61x6cja29cfcv5x0iarhs5-androidsdk/libexec/android-sdk/ndk-bundle
|
||||||
|
|||||||
@@ -5,18 +5,16 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import flutter_secure_storage_macos
|
import flutter_secure_storage_darwin
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
import path_provider_foundation
|
|
||||||
import screen_retriever_macos
|
import screen_retriever_macos
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
import window_manager
|
import window_manager
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
|
||||||
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
|
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
|
|||||||
252
pubspec.lock
252
pubspec.lock
@@ -65,6 +65,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "1.1.2"
|
||||||
|
code_assets:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: code_assets
|
||||||
|
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
collection:
|
collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -77,10 +85,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: crypto
|
name: crypto
|
||||||
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.6"
|
version: "3.0.7"
|
||||||
cupertino_icons:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -89,14 +97,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.8"
|
version: "1.0.8"
|
||||||
dart_pubspec_licenses:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: dart_pubspec_licenses
|
|
||||||
sha256: "23ddb78ff9204d08e3109ced67cd3c6c6a066f581b0edf5ee092fc3e1127f4ea"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.0.4"
|
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -109,10 +109,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: ffi
|
name: ffi
|
||||||
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
|
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.1.5"
|
||||||
file:
|
file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -142,14 +142,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.4.1"
|
version: "3.4.1"
|
||||||
flutter_drawing_board:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: flutter_drawing_board
|
|
||||||
sha256: b7eef8c506a12485853e8b4cdc110d1d307993279b588f3a35902a7d7082dbd4
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.9.8"
|
|
||||||
flutter_fullscreen:
|
flutter_fullscreen:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -178,50 +170,50 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage
|
name: flutter_secure_storage
|
||||||
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
|
sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.2.4"
|
version: "10.0.0"
|
||||||
|
flutter_secure_storage_darwin:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_darwin
|
||||||
|
sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.0"
|
||||||
flutter_secure_storage_linux:
|
flutter_secure_storage_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage_linux
|
name: flutter_secure_storage_linux
|
||||||
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.3"
|
version: "3.0.0"
|
||||||
flutter_secure_storage_macos:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_secure_storage_macos
|
|
||||||
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.1.3"
|
|
||||||
flutter_secure_storage_platform_interface:
|
flutter_secure_storage_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage_platform_interface
|
name: flutter_secure_storage_platform_interface
|
||||||
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "2.0.1"
|
||||||
flutter_secure_storage_web:
|
flutter_secure_storage_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage_web
|
name: flutter_secure_storage_web
|
||||||
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
|
sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.1"
|
version: "2.1.0"
|
||||||
flutter_secure_storage_windows:
|
flutter_secure_storage_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage_windows
|
name: flutter_secure_storage_windows
|
||||||
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
|
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
version: "4.1.0"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -232,6 +224,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
glob:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: glob
|
||||||
|
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
hive:
|
hive:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -240,14 +240,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.3"
|
version: "2.2.3"
|
||||||
|
hooks:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: hooks
|
||||||
|
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b"
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.6.0"
|
||||||
http_parser:
|
http_parser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -260,26 +268,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: image
|
name: image
|
||||||
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
|
sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.4"
|
version: "4.7.2"
|
||||||
js:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: js
|
|
||||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.6.7"
|
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: json_annotation
|
name: json_annotation
|
||||||
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.9.0"
|
version: "4.10.0"
|
||||||
jwt_decoder:
|
jwt_decoder:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -292,34 +292,34 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker
|
name: leak_tracker
|
||||||
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
|
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.9"
|
version: "11.0.2"
|
||||||
leak_tracker_flutter_testing:
|
leak_tracker_flutter_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_flutter_testing
|
name: leak_tracker_flutter_testing
|
||||||
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
|
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.9"
|
version: "3.0.10"
|
||||||
leak_tracker_testing:
|
leak_tracker_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_testing
|
name: leak_tracker_testing
|
||||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.1"
|
version: "3.0.2"
|
||||||
lints:
|
lints:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: lints
|
name: lints
|
||||||
sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0
|
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.1.0"
|
||||||
logging:
|
logging:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -348,10 +348,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.17.0"
|
||||||
|
native_toolchain_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: native_toolchain_c
|
||||||
|
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.17.4"
|
||||||
|
objective_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: objective_c
|
||||||
|
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.3.0"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -388,18 +404,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_android
|
name: path_provider_android
|
||||||
sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9
|
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.17"
|
version: "2.2.22"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_foundation
|
name: path_provider_foundation
|
||||||
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942"
|
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.6.0"
|
||||||
path_provider_linux:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -424,22 +440,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
pdfium_dart:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pdfium_dart
|
||||||
|
sha256: f1683b9070ddc5c9189c6ee008c285791da66328ce1b882c7162d3393f3a4a74
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.3"
|
||||||
|
pdfium_flutter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pdfium_flutter
|
||||||
|
sha256: "0c8b7d5d11d20a1486eade599648e907067568955bd14a1b06de076a968b60a1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.9"
|
||||||
pdfrx:
|
pdfrx:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: pdfrx
|
name: pdfrx
|
||||||
sha256: a88e7797623d3b89d4a863f583c973c681f39aa48eadceb4aa61c0c99cc7fa19
|
sha256: e32e0c786528eec2b3c56b43f59ef1debce3a27c7accd862b95413f949afcfa9
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.3"
|
version: "2.2.24"
|
||||||
pdfrx_engine:
|
pdfrx_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: pdfrx_engine
|
name: pdfrx_engine
|
||||||
sha256: "69a2448ca87d0536fe4eb78fbe753fb2fb2f8ae3a1b73e2fd1937585e717c2b1"
|
sha256: a8914433d1f6188b903c53d36b9d7dc908bfa89131591a9db22f1a22470d3a48
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.12"
|
version: "0.3.9"
|
||||||
permission_handler:
|
permission_handler:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -492,10 +524,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: petitparser
|
name: petitparser
|
||||||
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646"
|
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.0"
|
version: "7.0.1"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -520,6 +552,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.3"
|
version: "6.0.3"
|
||||||
|
pub_semver:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pub_semver
|
||||||
|
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
rxdart:
|
rxdart:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -577,18 +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"
|
||||||
sprintf:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: sprintf
|
|
||||||
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "7.0.0"
|
|
||||||
sqflite:
|
sqflite:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -601,10 +633,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sqflite_android
|
name: sqflite_android
|
||||||
sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b"
|
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.4.2+2"
|
||||||
sqflite_common:
|
sqflite_common:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -673,10 +705,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.4"
|
version: "0.7.7"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -697,34 +729,34 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_android
|
name: url_launcher_android
|
||||||
sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79"
|
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.16"
|
version: "6.3.28"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_ios
|
name: url_launcher_ios
|
||||||
sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb"
|
sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.3"
|
version: "6.3.6"
|
||||||
url_launcher_linux:
|
url_launcher_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_linux
|
name: url_launcher_linux
|
||||||
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
|
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.1"
|
version: "3.2.2"
|
||||||
url_launcher_macos:
|
url_launcher_macos:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_macos
|
name: url_launcher_macos
|
||||||
sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2"
|
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.2"
|
version: "3.2.5"
|
||||||
url_launcher_platform_interface:
|
url_launcher_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -737,42 +769,42 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_web
|
name: url_launcher_web
|
||||||
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
|
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.4.2"
|
||||||
url_launcher_windows:
|
url_launcher_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_windows
|
name: url_launcher_windows
|
||||||
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
|
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.4"
|
version: "3.1.5"
|
||||||
uuid:
|
uuid:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: uuid
|
name: uuid
|
||||||
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
|
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.1"
|
version: "4.5.2"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_math
|
name: vector_math
|
||||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.2.0"
|
||||||
vm_service:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vm_service
|
name: vm_service
|
||||||
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
|
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.0"
|
version: "15.0.2"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -785,10 +817,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: win32
|
name: win32
|
||||||
sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
|
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.14.0"
|
version: "5.15.0"
|
||||||
window_manager:
|
window_manager:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -809,10 +841,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: xml
|
name: xml
|
||||||
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
|
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.5.0"
|
version: "6.6.1"
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -822,5 +854,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.8.1 <4.0.0"
|
dart: ">=3.10.3 <4.0.0"
|
||||||
flutter: ">=3.29.0"
|
flutter: ">=3.38.4"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -41,11 +41,10 @@ dependencies:
|
|||||||
http: ^1.2.2
|
http: ^1.2.2
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
flutter_cache_manager: ^3.4.1
|
flutter_cache_manager: ^3.4.1
|
||||||
flutter_secure_storage: ^9.2.2
|
flutter_secure_storage: ^10.0.0
|
||||||
jwt_decoder: ^2.0.1
|
jwt_decoder: ^2.0.1
|
||||||
pdfrx: ^2.0.4
|
pdfrx: ^2.0.4
|
||||||
logging: ^1.3.0
|
logging: ^1.3.0
|
||||||
flutter_drawing_board: ^0.9.8
|
|
||||||
flutter_launcher_icons: ^0.14.4
|
flutter_launcher_icons: ^0.14.4
|
||||||
hive: ^2.2.3
|
hive: ^2.2.3
|
||||||
flutter_fullscreen: ^1.2.0
|
flutter_fullscreen: ^1.2.0
|
||||||
|
|||||||
@@ -7,13 +7,12 @@
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sheetless/app.dart';
|
||||||
import 'package:sheetless/main.dart';
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||||
// Build our app and trigger a frame.
|
// Build our app and trigger a frame.
|
||||||
await tester.pumpWidget(const MyApp());
|
await tester.pumpWidget(const SheetlessApp());
|
||||||
|
|
||||||
// Verify that our counter starts at 0.
|
// Verify that our counter starts at 0.
|
||||||
expect(find.text('0'), findsOneWidget);
|
expect(find.text('0'), findsOneWidget);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
pdfrx
|
pdfium_flutter
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
Reference in New Issue
Block a user