diff --git a/lib/api.dart b/lib/api.dart deleted file mode 100644 index ca18879..0000000 --- a/lib/api.dart +++ /dev/null @@ -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 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 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 post( - String endpoint, - Map 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 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> fetchSheets() async { - final response = await get( - "/api/sheets/list", - throwExceptionIfStatusCodeNot200: true, - ); - - final data = jsonDecode(response.body); - return (data['sheets'] as List) - .map((sheet) => Sheet.fromJson(sheet as Map)) - .toList(); - } - - Future fetchPdfFileData(String sheetUuid) async { - final response = await get( - '/api/sheets/get/$sheetUuid', - isBinary: true, - throwExceptionIfStatusCodeNot200: true, - ); - - log.info("PDF downloaded"); - return response.bodyBytes; - } - - Future 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; - } -} diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..3350eb1 --- /dev/null +++ b/lib/app.dart @@ -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 routeObserver = RouteObserver(); + +/// 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], + ); + } +} diff --git a/lib/bt_pedal_shortcuts.dart b/lib/bt_pedal_shortcuts.dart deleted file mode 100644 index a221f0d..0000000 --- a/lib/bt_pedal_shortcuts.dart +++ /dev/null @@ -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 createState() => _BtPedalShortcutsState(); -} - -class _BtPedalShortcutsState extends State { - String lastAction = "Press pedal..."; - - @override - Widget build(BuildContext context) { - return CallbackShortcuts( - bindings: { - // 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), - ); - } -} diff --git a/lib/core/models/change.dart b/lib/core/models/change.dart new file mode 100644 index 0000000..47b05b2 --- /dev/null +++ b/lib/core/models/change.dart @@ -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 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 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 _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 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'); + } + } + } +} diff --git a/lib/core/models/config.dart b/lib/core/models/config.dart new file mode 100644 index 0000000..03bd853 --- /dev/null +++ b/lib/core/models/config.dart @@ -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, + ); + } +} diff --git a/lib/core/models/sheet.dart b/lib/core/models/sheet.dart new file mode 100644 index 0000000..0099e1c --- /dev/null +++ b/lib/core/models/sheet.dart @@ -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 json) { + final composer = json['composer'] as Map?; + 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 toJson() => { + 'uuid': uuid, + 'title': name, + 'composer_uuid': composerUuid, + 'composer_name': composerName, + 'updated_at': updatedAt.toIso8601String(), + }; +} diff --git a/lib/core/services/api_client.dart b/lib/core/services/api_client.dart new file mode 100644 index 0000000..55f7bd9 --- /dev/null +++ b/lib/core/services/api_client.dart @@ -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 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 _buildHeaders({bool isBinary = false}) { + return { + 'Authorization': 'Bearer $token', + if (!isBinary) 'Content-Type': 'application/json', + }; + } + + /// Performs a GET request to the given endpoint. + Future 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 post( + String endpoint, + Map 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 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> fetchSheets() async { + final response = await get('/api/sheets/list'); + final data = jsonDecode(response.body); + + return (data['sheets'] as List) + .map((sheet) => Sheet.fromJson(sheet as Map)) + .toList(); + } + + /// Downloads PDF data for a sheet. + /// + /// Returns the raw PDF bytes. For caching, use [getPdfFileCached] instead. + Future 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 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'; +} diff --git a/lib/core/services/storage_service.dart b/lib/core/services/storage_service.dart new file mode 100644 index 0000000..f27f662 --- /dev/null +++ b/lib/core/services/storage_service.dart @@ -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 readSecure(SecureStorageKey key) { + return _secureStorage.read(key: key.name); + } + + /// Writes a value to secure storage. + /// + /// Pass `null` to delete the key. + Future writeSecure(SecureStorageKey key, String? value) { + return _secureStorage.write(key: key.name, value: value); + } + + /// Clears the JWT token from secure storage. + Future clearToken() { + return writeSecure(SecureStorageKey.jwt, null); + } + + // --------------------------------------------------------------------------- + // Config + // --------------------------------------------------------------------------- + + /// Reads the app configuration from storage. + Future 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 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> 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 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 writeChange(Change change) async { + final box = await Hive.openBox(_changeQueueBox); + await box.add(change.toMap()); + } + + /// Reads all pending changes from the queue. + Future readChangeQueue() async { + final box = await Hive.openBox(_changeQueueBox); + final queue = ChangeQueue(); + + for (final map in box.values) { + queue.addChange(Change.fromMap(map as Map)); + } + + return queue; + } + + /// Removes the oldest change from the queue. + /// + /// Call this after successfully syncing a change to the server. + Future deleteOldestChange() async { + final box = await Hive.openBox(_changeQueueBox); + if (box.isNotEmpty) { + await box.deleteAt(0); + } + } +} diff --git a/lib/edit_bottom_sheet.dart b/lib/edit_bottom_sheet.dart deleted file mode 100644 index 7caece9..0000000 --- a/lib/edit_bottom_sheet.dart +++ /dev/null @@ -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 createState() => _EditItemBottomSheetState(); -} - -class _EditItemBottomSheetState extends State { - 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"), - ), - ], - ), - ); - } -} diff --git a/lib/features/auth/login_page.dart b/lib/features/auth/login_page.dart new file mode 100644 index 0000000..83e8cbf --- /dev/null +++ b/lib/features/auth/login_page.dart @@ -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 createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final _log = Logger('LoginPage'); + final _formKey = GlobalKey(); + 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 _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 _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 _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 _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 _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, + ), + ); + } +} diff --git a/lib/features/home/home_page.dart b/lib/features/home/home_page.dart new file mode 100644 index 0000000..796d333 --- /dev/null +++ b/lib/features/home/home_page.dart @@ -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 createState() => _HomePageState(); +} + +class _HomePageState extends State with RouteAware { + final _log = Logger('HomePage'); + final _storageService = StorageService(); + + ApiClient? _apiClient; + late Future> _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 _loadAppInfo() async { + final info = await PackageInfo.fromPlatform(); + setState(() { + _appName = info.appName; + _appVersion = info.version; + }); + } + + Future> _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> _sortSheetsByRecency(List 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 _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 _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>( + 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, + ), + ), + ); + } +} diff --git a/lib/features/home/widgets/app_drawer.dart b/lib/features/home/widgets/app_drawer.dart new file mode 100644 index 0000000..c403d71 --- /dev/null +++ b/lib/features/home/widgets/app_drawer.dart @@ -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), + ), + ); + } +} diff --git a/lib/features/home/widgets/sheet_list_item.dart b/lib/features/home/widgets/sheet_list_item.dart new file mode 100644 index 0000000..07cd8ee --- /dev/null +++ b/lib/features/home/widgets/sheet_list_item.dart @@ -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, + ); + } +} diff --git a/lib/features/home/widgets/sheet_search_bar.dart b/lib/features/home/widgets/sheet_search_bar.dart new file mode 100644 index 0000000..ed48f8f --- /dev/null +++ b/lib/features/home/widgets/sheet_search_bar.dart @@ -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), + ), + ), + ), + ); + } +} diff --git a/lib/features/home/widgets/sheets_list.dart b/lib/features/home/widgets/sheets_list.dart new file mode 100644 index 0000000..8072163 --- /dev/null +++ b/lib/features/home/widgets/sheets_list.dart @@ -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 sheets; + final ValueSetter onSheetSelected; + + const SheetsList({ + super.key, + required this.sheets, + required this.onSheetSelected, + }); + + @override + State createState() => _SheetsListState(); +} + +class _SheetsListState extends State { + static const _searchDebounceMs = 500; + + final _storageService = StorageService(); + final _searchController = TextEditingController(); + Timer? _debounceTimer; + late List _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), + ); + }, + ), + ); + } +} diff --git a/lib/features/sheet_viewer/sheet_viewer_page.dart b/lib/features/sheet_viewer/sheet_viewer_page.dart new file mode 100644 index 0000000..cfd03d6 --- /dev/null +++ b/lib/features/sheet_viewer/sheet_viewer_page.dart @@ -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 createState() => _SheetViewerPageState(); +} + +class _SheetViewerPageState extends State + with FullScreenListener { + final _log = Logger('SheetViewerPage'); + final _storageService = StorageService(); + + PdfDocument? _document; + late Future _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 _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( + 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, + ), + ), + ); + } +} diff --git a/lib/features/sheet_viewer/widgets/paint_mode_layer.dart b/lib/features/sheet_viewer/widgets/paint_mode_layer.dart new file mode 100644 index 0000000..a254314 --- /dev/null +++ b/lib/features/sheet_viewer/widgets/paint_mode_layer.dart @@ -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, + ); + }, + ), + ); + } +} diff --git a/lib/features/sheet_viewer/widgets/pdf_page_display.dart b/lib/features/sheet_viewer/widgets/pdf_page_display.dart new file mode 100644 index 0000000..cf817aa --- /dev/null +++ b/lib/features/sheet_viewer/widgets/pdf_page_display.dart @@ -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); + } + } +} diff --git a/lib/features/sheet_viewer/widgets/touch_navigation_layer.dart b/lib/features/sheet_viewer/widgets/touch_navigation_layer.dart new file mode 100644 index 0000000..4f08679 --- /dev/null +++ b/lib/features/sheet_viewer/widgets/touch_navigation_layer.dart @@ -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; + } +} diff --git a/lib/home_page.dart b/lib/home_page.dart deleted file mode 100644 index 7e5842b..0000000 --- a/lib/home_page.dart +++ /dev/null @@ -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 createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State with RouteAware { - ApiClient? apiClient; - Future apiLoggedIn = Future.value(false); - final StorageHelper _storageHelper = StorageHelper(); - final log = Logger("MyHomePage"); - String? appName; - String? appVersion; - bool shuffling = false; - late Future> 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 _loadAppInfo() async { - final info = await PackageInfo.fromPlatform(); - setState(() { - appName = info.appName; - appVersion = info.version; - }); - } - - Future> 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> sortSheetsByRecency(List 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 _refreshSheets() async { - setState(() { - sheets = acquireSheets(); - }); - } - - Future _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> 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()); - } - }, - ), - ), - ); - } -} diff --git a/lib/login_page.dart b/lib/login_page.dart deleted file mode 100644 index d827386..0000000 --- a/lib/login_page.dart +++ /dev/null @@ -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 createState() => _LoginPageState(); -} - -class _LoginPageState extends State { - 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(); - - final StorageHelper _storageHelper = StorageHelper(); - String? _error; - bool loggingIn = false; - - @override - void initState() { - super.initState(); - _restoreStoredValues(); - } - - Future _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 _isJwtValid(String jwt) async { - try { - bool expired = JwtDecoder.isExpired(jwt); - return !expired; - } on FormatException { - return false; - } - } - - Future _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 _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)), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/main.dart b/lib/main.dart index 3d7417e..9ff91b2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 routeObserver = RouteObserver(); +/// Application entry point. +/// +/// Performs platform-specific initialization before running the app Future 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 _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 _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); } } diff --git a/lib/shared/input/pedal_shortcuts.dart b/lib/shared/input/pedal_shortcuts.dart new file mode 100644 index 0000000..ca73e3a --- /dev/null +++ b/lib/shared/input/pedal_shortcuts.dart @@ -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 _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, + }; + } +} diff --git a/lib/shared/widgets/edit_sheet_bottom_sheet.dart b/lib/shared/widgets/edit_sheet_bottom_sheet.dart new file mode 100644 index 0000000..7932928 --- /dev/null +++ b/lib/shared/widgets/edit_sheet_bottom_sheet.dart @@ -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 createState() => _EditSheetBottomSheetState(); +} + +class _EditSheetBottomSheetState extends State { + final _formKey = GlobalKey(); + 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')), + ], + ), + ), + ); + } +} diff --git a/lib/sheet.dart b/lib/sheet.dart deleted file mode 100644 index 2b331dc..0000000 --- a/lib/sheet.dart +++ /dev/null @@ -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 json) { - final composer = json['composer'] as Map?; - 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 sheets; - final ValueSetter onSheetOpenRequest; - - const SheetsWidget({ - super.key, - required this.sheets, - required this.onSheetOpenRequest, - }); - - @override - State createState() => _SheetsWidgetState(); -} - -class _SheetsWidgetState extends State { - late List 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 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), - ); - }, - ), - ), - ), - ], - ); - } -} diff --git a/lib/sheet_viewer_page.dart b/lib/sheet_viewer_page.dart deleted file mode 100644 index aab8af9..0000000 --- a/lib/sheet_viewer_page.dart +++ /dev/null @@ -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 createState() => _SheetViewerPageState(); -} - -class _SheetViewerPageState extends State - with FullScreenListener { - final log = Logger("SheetViewerPage"); - final StorageHelper storageHelper = StorageHelper(); - - int currentPageNumber = 1; - int numPages = 1; - late Future 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 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 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); - } - } -} diff --git a/lib/storage_helper.dart b/lib/storage_helper.dart deleted file mode 100644 index 8da4d70..0000000 --- a/lib/storage_helper.dart +++ /dev/null @@ -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 readSecure(SecureStorageKey key) { - return secureStorage.read(key: key.name); - } - - Future writeSecure(SecureStorageKey key, String? value) { - return secureStorage.write(key: key.name, value: value); - } - - Future readConfig() async { - final box = await Hive.openBox(configBox); - return Config( - twoPageMode: box.get(Config.keyTwoPageMode) ?? false, - fullscreen: box.get(Config.keyFullscreen) ?? false, - ); - } - - Future writeConfig(Config config) async { - final box = await Hive.openBox(configBox); - box.put(Config.keyTwoPageMode, config.twoPageMode); - box.put(Config.keyFullscreen, config.fullscreen); - } - - Future> readSheetAccessTimes() async { - final box = await Hive.openBox(sheetAccessTimesBox); - return box.toMap().map( - (k, v) => MapEntry(k as String, DateTime.parse(v as String)), - ); - } - - Future writeSheetAccessTime(String uuid, DateTime datetime) async { - final box = await Hive.openBox(sheetAccessTimesBox); - await box.put(uuid, datetime.toIso8601String()); - } - - Future writeChange(Change change) async { - final box = await Hive.openBox(changeQueueBox); - box.add(change.toMap()); - } - - Future readChangeQueue() async { - final box = await Hive.openBox(changeQueueBox); - ChangeQueue queue = ChangeQueue(); - for (Map map in box.values) { - queue.addChange(Change.fromMap(map)); - } - return queue; - } - - Future deleteOldestChange(Change change) async { - final box = await Hive.openBox(changeQueueBox); - box.deleteAt(0); - } -} diff --git a/lib/upload_queue.dart b/lib/upload_queue.dart deleted file mode 100644 index 655530e..0000000 --- a/lib/upload_queue.dart +++ /dev/null @@ -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 toMap() => { - "type": type.index, - "sheetUuid": sheetUuid, - "value": value, - }; - - factory Change.fromMap(Map 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 queue = Queue(); - - ChangeQueue() {} - - void addChange(Change change) { - queue.addLast(change); - } - - int length() { - return queue.length; - } - - void applyToSheets(List 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(); - } - } - } -} diff --git a/test/widget_test.dart b/test/widget_test.dart index c82a791..65a56c5 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -7,13 +7,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; - -import 'package:sheetless/main.dart'; +import 'package:sheetless/app.dart'; void main() { testWidgets('Counter increments smoke test', (WidgetTester tester) async { // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + await tester.pumpWidget(const SheetlessApp()); // Verify that our counter starts at 0. expect(find.text('0'), findsOneWidget);