Complete refactor to clean up project
This commit is contained in:
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
96
lib/core/models/change.dart
Normal file
96
lib/core/models/change.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
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.
|
||||
class Change {
|
||||
final ChangeType type;
|
||||
final String sheetUuid;
|
||||
final String value;
|
||||
|
||||
Change({
|
||||
required this.type,
|
||||
required this.sheetUuid,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
/// Serializes this change to a map for storage.
|
||||
Map<String, dynamic> toMap() => {
|
||||
'type': type.index,
|
||||
'sheetUuid': sheetUuid,
|
||||
'value': value,
|
||||
};
|
||||
|
||||
/// 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'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.composerName = 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
40
lib/core/models/sheet.dart
Normal file
40
lib/core/models/sheet.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
/// 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 composerUuid;
|
||||
String composerName;
|
||||
DateTime updatedAt;
|
||||
|
||||
Sheet({
|
||||
required this.uuid,
|
||||
required this.name,
|
||||
required this.composerUuid,
|
||||
required this.composerName,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
/// Creates a [Sheet] from a JSON map returned by the API.
|
||||
factory Sheet.fromJson(Map<String, dynamic> json) {
|
||||
final composer = json['composer'] as Map<String, dynamic>?;
|
||||
return Sheet(
|
||||
uuid: json['uuid'].toString(),
|
||||
name: json['title'],
|
||||
composerUuid: json['composer_uuid']?.toString() ?? '',
|
||||
composerName: composer?['name'] ?? 'Unknown',
|
||||
updatedAt: DateTime.parse(json['updated_at']),
|
||||
);
|
||||
}
|
||||
|
||||
/// Converts this sheet to a JSON map for API requests.
|
||||
Map<String, dynamic> toJson() => {
|
||||
'uuid': uuid,
|
||||
'title': name,
|
||||
'composer_uuid': composerUuid,
|
||||
'composer_name': composerName,
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
202
lib/core/services/api_client.dart
Normal file
202
lib/core/services/api_client.dart
Normal file
@@ -0,0 +1,202 @@
|
||||
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/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;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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';
|
||||
}
|
||||
119
lib/core/services/storage_service.dart
Normal file
119
lib/core/services/storage_service.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
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';
|
||||
|
||||
/// Keys for secure storage (credentials and tokens).
|
||||
enum SecureStorageKey { url, jwt, email }
|
||||
|
||||
/// 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).
|
||||
class StorageService {
|
||||
// Hive box names
|
||||
static const String _sheetAccessTimesBox = 'sheetAccessTimes';
|
||||
static const String _configBox = 'config';
|
||||
static const String _changeQueueBox = 'changeQueue';
|
||||
|
||||
late final FlutterSecureStorage _secureStorage;
|
||||
|
||||
StorageService() {
|
||||
_secureStorage = FlutterSecureStorage(
|
||||
aOptions: const AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 the JWT token from secure storage.
|
||||
Future<void> clearToken() {
|
||||
return writeSecure(SecureStorageKey.jwt, null);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
235
lib/features/home/home_page.dart
Normal file
235
lib/features/home/home_page.dart
Normal file
@@ -0,0 +1,235 @@
|
||||
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 '../../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;
|
||||
late Future<List<Sheet>> _sheetsFuture;
|
||||
bool _isShuffling = false;
|
||||
String? _appName;
|
||||
String? _appVersion;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Exit fullscreen when entering home page
|
||||
FullScreen.setFullScreen(false);
|
||||
|
||||
// Subscribe to route changes
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
routeObserver.subscribe(this, ModalRoute.of(context)!);
|
||||
});
|
||||
|
||||
_loadAppInfo();
|
||||
_sheetsFuture = _loadSheets();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
routeObserver.unsubscribe(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route Aware (Fullscreen Management)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@override
|
||||
void didPush() {
|
||||
FullScreen.setFullScreen(false);
|
||||
super.didPush();
|
||||
}
|
||||
|
||||
@override
|
||||
void didPopNext() {
|
||||
// Exit fullscreen when returning to home page
|
||||
FullScreen.setFullScreen(false);
|
||||
super.didPopNext();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data Loading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Future<void> _loadAppInfo() async {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
setState(() {
|
||||
_appName = info.appName;
|
||||
_appVersion = info.version;
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<Sheet>> _loadSheets() async {
|
||||
final url = await _storageService.readSecure(SecureStorageKey.url);
|
||||
final jwt = await _storageService.readSecure(SecureStorageKey.jwt);
|
||||
|
||||
_apiClient = ApiClient(baseUrl: url!, token: jwt);
|
||||
|
||||
final sheets = await _apiClient!.fetchSheets();
|
||||
_log.info('${sheets.length} sheets fetched');
|
||||
|
||||
final sortedSheets = await _sortSheetsByRecency(sheets);
|
||||
_log.info('${sortedSheets.length} sheets sorted');
|
||||
|
||||
return sortedSheets;
|
||||
}
|
||||
|
||||
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(() {
|
||||
_sheetsFuture = _loadSheets();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void _handleShuffleChanged(bool enabled) async {
|
||||
final sheets = await _sheetsFuture;
|
||||
|
||||
if (enabled) {
|
||||
sheets.shuffle();
|
||||
} else {
|
||||
await _sortSheetsByRecency(sheets);
|
||||
}
|
||||
|
||||
setState(() => _isShuffling = enabled);
|
||||
}
|
||||
|
||||
Future<void> _handleLogout() async {
|
||||
await _storageService.clearToken();
|
||||
|
||||
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: const Text('Sheetless')),
|
||||
endDrawer: AppDrawer(
|
||||
isShuffling: _isShuffling,
|
||||
onShuffleChanged: _handleShuffleChanged,
|
||||
onLogout: _handleLogout,
|
||||
appName: _appName,
|
||||
appVersion: _appVersion,
|
||||
),
|
||||
body: RefreshIndicator(onRefresh: _refreshSheets, child: _buildBody()),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
return FutureBuilder<List<Sheet>>(
|
||||
future: _sheetsFuture,
|
||||
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: snapshot.data!,
|
||||
onSheetSelected: _openSheet,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
76
lib/features/home/widgets/app_drawer.dart
Normal file
76
lib/features/home/widgets/app_drawer.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.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;
|
||||
|
||||
const AppDrawer({
|
||||
super.key,
|
||||
required this.isShuffling,
|
||||
required this.onShuffleChanged,
|
||||
required this.onLogout,
|
||||
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(),
|
||||
_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 _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.composerName),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
187
lib/features/home/widgets/sheets_list.dart
Normal file
187
lib/features/home/widgets/sheets_list.dart
Normal file
@@ -0,0 +1,187 @@
|
||||
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/storage_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;
|
||||
|
||||
const SheetsList({
|
||||
super.key,
|
||||
required this.sheets,
|
||||
required this.onSheetSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SheetsList> createState() => _SheetsListState();
|
||||
}
|
||||
|
||||
class _SheetsListState extends State<SheetsList> {
|
||||
static const _searchDebounceMs = 500;
|
||||
|
||||
final _storageService = StorageService();
|
||||
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.composerName.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
|
||||
if (newName != sheet.name) {
|
||||
_storageService.writeChange(
|
||||
Change(
|
||||
type: ChangeType.sheetNameChange,
|
||||
sheetUuid: sheet.uuid,
|
||||
value: newName,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (newComposer != sheet.composerName) {
|
||||
_storageService.writeChange(
|
||||
Change(
|
||||
type: ChangeType.composerNameChange,
|
||||
sheetUuid: sheet.uuid,
|
||||
value: newComposer,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Update local state
|
||||
setState(() {
|
||||
sheet.name = newName;
|
||||
sheet.composerName = newComposer;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
255
lib/features/sheet_viewer/sheet_viewer_page.dart
Normal file
255
lib/features/sheet_viewer/sheet_viewer_page.dart
Normal file
@@ -0,0 +1,255 @@
|
||||
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/api_client.dart';
|
||||
import 'package:sheetless/core/services/storage_service.dart';
|
||||
|
||||
import '../../shared/input/pedal_shortcuts.dart';
|
||||
import 'widgets/paint_mode_layer.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();
|
||||
|
||||
PdfDocument? _document;
|
||||
late Future<bool> _documentLoaded;
|
||||
int _currentPage = 1;
|
||||
int _totalPages = 1;
|
||||
bool _isPaintMode = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
FullScreen.addListener(this);
|
||||
FullScreen.setFullScreen(widget.config.fullscreen);
|
||||
_documentLoaded = _loadPdf();
|
||||
}
|
||||
|
||||
@override
|
||||
void 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;
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fullscreen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@override
|
||||
void onFullScreenChanged(bool enabled, SystemUiMode? systemUiMode) {
|
||||
setState(() {
|
||||
widget.config.fullscreen = enabled;
|
||||
_storageService.writeConfig(widget.config);
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleFullscreen() {
|
||||
FullScreen.setFullScreen(!widget.config.fullscreen);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void _turnPage(int delta) {
|
||||
setState(() {
|
||||
_currentPage = (_currentPage + delta).clamp(1, _totalPages);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mode Switching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void _togglePaintMode() {
|
||||
if (widget.config.twoPageMode) {
|
||||
// Paint mode only works in single page mode
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Paint mode is only available in single page mode'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isPaintMode = !_isPaintMode);
|
||||
}
|
||||
|
||||
void _toggleTwoPageMode() {
|
||||
setState(() {
|
||||
widget.config.twoPageMode = !widget.config.twoPageMode;
|
||||
_storageService.writeConfig(widget.config);
|
||||
|
||||
if (widget.config.twoPageMode && _isPaintMode) {
|
||||
_isPaintMode = false;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Paint mode disabled in two-page mode'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PedalShortcuts(
|
||||
onPageForward: () => _turnPage(1),
|
||||
onPageBackward: () => _turnPage(-1),
|
||||
child: Scaffold(appBar: _buildAppBar(), body: _buildBody()),
|
||||
);
|
||||
}
|
||||
|
||||
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: 'Toggle 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,
|
||||
);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Show touch navigation when not in paint mode
|
||||
Visibility(
|
||||
visible: !_isPaintMode,
|
||||
child: TouchNavigationLayer(
|
||||
pageDisplay: pageDisplay,
|
||||
config: widget.config,
|
||||
onToggleFullscreen: _toggleFullscreen,
|
||||
onExit: () => Navigator.pop(context),
|
||||
onPageTurn: _turnPage,
|
||||
),
|
||||
),
|
||||
// Show paint mode layer when active
|
||||
Visibility(
|
||||
visible: _isPaintMode,
|
||||
child: PaintModeLayer(pageDisplay: pageDisplay),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
42
lib/features/sheet_viewer/widgets/paint_mode_layer.dart
Normal file
42
lib/features/sheet_viewer/widgets/paint_mode_layer.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_drawing_board/flutter_drawing_board.dart';
|
||||
|
||||
import 'pdf_page_display.dart';
|
||||
|
||||
/// Drawing overlay for annotating PDF pages.
|
||||
///
|
||||
/// Uses flutter_drawing_board to provide a paint canvas over the PDF.
|
||||
/// Only available in single-page mode.
|
||||
class PaintModeLayer extends StatelessWidget {
|
||||
final PdfPageDisplay pageDisplay;
|
||||
|
||||
const PaintModeLayer({
|
||||
super.key,
|
||||
required this.pageDisplay,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox.expand(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxSize = Size(constraints.maxWidth, constraints.maxHeight);
|
||||
final (pageSize, _) = pageDisplay.calculateScaledPageSizes(maxSize);
|
||||
|
||||
return DrawingBoard(
|
||||
background: SizedBox(
|
||||
width: pageSize.width,
|
||||
height: pageSize.height,
|
||||
child: pageDisplay,
|
||||
),
|
||||
boardConstrained: true,
|
||||
minScale: 1,
|
||||
maxScale: 3,
|
||||
alignment: Alignment.topRight,
|
||||
boardBoundaryMargin: EdgeInsets.zero,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
137
lib/features/sheet_viewer/widgets/pdf_page_display.dart
Normal file
137
lib/features/sheet_viewer/widgets/pdf_page_display.dart
Normal file
@@ -0,0 +1,137 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
|
||||
import '../../../core/models/config.dart';
|
||||
|
||||
/// Displays PDF pages with optional two-page mode.
|
||||
class PdfPageDisplay extends StatelessWidget {
|
||||
final PdfDocument document;
|
||||
final int numPages;
|
||||
final int currentPageNumber;
|
||||
final Config config;
|
||||
|
||||
const PdfPageDisplay({
|
||||
super.key,
|
||||
required this.document,
|
||||
required this.numPages,
|
||||
required this.currentPageNumber,
|
||||
required this.config,
|
||||
});
|
||||
|
||||
/// Whether two-page mode is active and we have enough pages.
|
||||
bool get _showTwoPages => config.twoPageMode && numPages >= 2;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [_buildLeftPage(), if (_showTwoPages) _buildRightPage()],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLeftPage() {
|
||||
return Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
PdfPageView(
|
||||
key: ValueKey(currentPageNumber),
|
||||
document: document,
|
||||
pageNumber: currentPageNumber,
|
||||
maximumDpi: 300,
|
||||
alignment: _showTwoPages ? Alignment.centerRight : Alignment.center,
|
||||
),
|
||||
_buildPageIndicator(currentPageNumber),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRightPage() {
|
||||
final rightPageNumber = currentPageNumber + 1;
|
||||
|
||||
return Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
PdfPageView(
|
||||
key: ValueKey(rightPageNumber),
|
||||
document: document,
|
||||
pageNumber: rightPageNumber,
|
||||
maximumDpi: 300,
|
||||
alignment: Alignment.centerLeft,
|
||||
),
|
||||
_buildPageIndicator(rightPageNumber),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPageIndicator(int pageNumber) {
|
||||
return Positioned.fill(
|
||||
child: Container(
|
||||
alignment: Alignment.bottomCenter,
|
||||
padding: const EdgeInsets.only(bottom: 5),
|
||||
child: Text('$pageNumber / $numPages'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 = void 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,162 +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://sheetless.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/material.dart';
|
||||
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.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 {
|
||||
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) {
|
||||
debugPrint('${record.level.name}: ${record.time}: ${record.message}');
|
||||
if (record.error != null) {
|
||||
debugPrint('${record.error}');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// setup for flutter_fullscreen
|
||||
WidgetsFlutterBinding.ensureInitialized(); // Needs to be initialized first, otherwise app gets stuck in splash screen
|
||||
/// Initializes platform-specific plugins and services.
|
||||
Future<void> _initializePlugins() async {
|
||||
// Fullscreen support
|
||||
await FullScreen.ensureInitialized();
|
||||
|
||||
pdfrxFlutterInitialize(); // Needed especially for web
|
||||
// PDF rendering (especially needed for web)
|
||||
pdfrxFlutterInitialize();
|
||||
|
||||
// Local storage (not supported on web)
|
||||
if (!kIsWeb) {
|
||||
Directory dir = await getApplicationDocumentsDirectory();
|
||||
Hive.init(dir.path); // Needed only if not web
|
||||
}
|
||||
|
||||
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],
|
||||
);
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
Hive.init(dir.path);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
106
lib/shared/widgets/edit_sheet_bottom_sheet.dart
Normal file
106
lib/shared/widgets/edit_sheet_bottom_sheet.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
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.composerName,
|
||||
);
|
||||
}
|
||||
|
||||
@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,
|
||||
validator: _validateNotEmpty,
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user