Compare commits

..

5 Commits

36 changed files with 2237 additions and 1585 deletions

View File

@@ -3,10 +3,10 @@
"devenv": { "devenv": {
"locked": { "locked": {
"dir": "src/modules", "dir": "src/modules",
"lastModified": 1753981111, "lastModified": 1770167382,
"owner": "cachix", "owner": "cachix",
"repo": "devenv", "repo": "devenv",
"rev": "d4d70df706b153b601a87ab8e81c88a0b1a373b6", "rev": "195375e3f0a3c3ac4f12b21d3f59a7ddb209bd1f",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -19,14 +19,14 @@
"flake-compat": { "flake-compat": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1747046372, "lastModified": 1767039857,
"owner": "edolstra", "owner": "NixOS",
"repo": "flake-compat", "repo": "flake-compat",
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "edolstra", "owner": "NixOS",
"repo": "flake-compat", "repo": "flake-compat",
"type": "github" "type": "github"
} }
@@ -40,10 +40,10 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1750779888, "lastModified": 1769939035,
"owner": "cachix", "owner": "cachix",
"repo": "git-hooks.nix", "repo": "git-hooks.nix",
"rev": "16ec914f6fb6f599ce988427d9d94efddf25fe6d", "rev": "a8ca480175326551d6c4121498316261cbb5b260",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -60,10 +60,10 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1709087332, "lastModified": 1762808025,
"owner": "hercules-ci", "owner": "hercules-ci",
"repo": "gitignore.nix", "repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394", "rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -74,10 +74,10 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1753939845, "lastModified": 1770115704,
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "94def634a20494ee057c76998843c015909d6311", "rev": "e6eae2ee2110f3d31110d5c222cd395303343b08",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -1,163 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart'; // For cache storage
import 'sheet.dart';
class ApiClient {
final log = Logger("ApiClient");
final String baseUrl;
String? token;
ApiClient({required this.baseUrl, this.token});
Future<void> login(String username, String password) async {
log.info("Logging in...");
final url = '$baseUrl/auth/login';
final response = await http.post(
Uri.parse(url),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'username': username, 'password': password}),
);
if (response.statusCode == 200) {
final responseData = jsonDecode(response.body);
token = responseData['token'];
log.info('Login successful');
} else {
throw Exception(
"Failed logging in: Response code ${response.statusCode}\nResponse: ${response.body}",
);
}
}
void logout() {
token = null;
log.info('Logged out successfully.');
}
Future<http.Response> get(
String endpoint, {
bool isBinary = false,
bool throwExceptionIfStatusCodeNot200 = false,
}) async {
final url = '$baseUrl$endpoint';
final headers = {
'Authorization': 'Bearer $token',
if (!isBinary) 'Content-Type': 'application/json',
};
final response = await http.get(Uri.parse(url), headers: headers);
if (!throwExceptionIfStatusCodeNot200 || response.statusCode == 200) {
return response;
} else {
log.warning(
"Failed get request to '$url'! StatusCode: ${response.statusCode}\nResponseBody: ${response.body}",
);
throw Exception(
'GET request failed: ${response.statusCode} ${response.body}',
);
}
}
Future<http.Response?> post(
String endpoint,
Map<String, dynamic> body,
) async {
try {
final url = '$baseUrl$endpoint';
final headers = {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
};
final response = await http.post(
Uri.parse(url),
headers: headers,
body: jsonEncode(body),
);
if (response.statusCode == 200 || response.statusCode == 201) {
return response;
} else {
log.info(
'POST request failed: ${response.statusCode} ${response.body}',
);
}
} catch (e) {
log.info('Error during POST request: $e');
}
return null;
}
Future<http.Response?> postFormData(String endpoint, String body) async {
try {
final url = '$baseUrl$endpoint';
final headers = {
'Authorization': 'Bearer $token',
'Content-Type': 'application/x-www-form-urlencoded',
};
final response = await http.post(
Uri.parse(url),
headers: headers,
body: body,
);
if (response.statusCode == 200 || response.statusCode == 201) {
return response;
} else {
log.info(
'POST Form Data request failed: ${response.statusCode} ${response.body}',
);
}
} catch (e) {
log.info('Error during POST Form Data request: $e');
}
return null;
}
Future<List<Sheet>> fetchSheets() async {
final response = await get(
"/api/sheets/list",
throwExceptionIfStatusCodeNot200: true,
);
final data = jsonDecode(response.body);
return (data['sheets'] as List<dynamic>)
.map((sheet) => Sheet.fromJson(sheet as Map<String, dynamic>))
.toList();
}
Future<Uint8List> fetchPdfFileData(String sheetUuid) async {
final response = await get(
'/api/sheets/get/$sheetUuid',
isBinary: true,
throwExceptionIfStatusCodeNot200: true,
);
log.info("PDF downloaded");
return response.bodyBytes;
}
Future<File> getPdfFileCached(String sheetUuid) async {
final cacheDir = await getTemporaryDirectory();
final cachedPdfPath = '${cacheDir.path}/$sheetUuid.pdf';
final cachedFile = File(cachedPdfPath);
if (await cachedFile.exists()) {
log.info("PDF found in cache: $cachedPdfPath");
return cachedFile;
}
final pdfFileData = await fetchPdfFileData(sheetUuid);
await cachedFile.writeAsBytes(pdfFileData);
log.info("PDF cached at: $cachedPdfPath");
return cachedFile;
}
}

23
lib/app.dart Normal file
View File

@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'features/auth/login_page.dart';
/// Global route observer for tracking navigation events.
///
/// Used by pages that need to respond to navigation changes
final RouteObserver<ModalRoute> routeObserver = RouteObserver<ModalRoute>();
/// The main Sheetless application widget.
class SheetlessApp extends StatelessWidget {
const SheetlessApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Sheetless',
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blue),
home: const LoginPage(),
navigatorObservers: [routeObserver],
);
}
}

View File

@@ -1,42 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class BtPedalShortcuts extends StatefulWidget {
final Widget child;
final VoidCallback? onTurnPageForward;
final VoidCallback? onTurnPageBackward;
const BtPedalShortcuts({
super.key,
required this.child,
this.onTurnPageForward,
this.onTurnPageBackward,
});
@override
State<BtPedalShortcuts> createState() => _BtPedalShortcutsState();
}
class _BtPedalShortcutsState extends State<BtPedalShortcuts> {
String lastAction = "Press pedal...";
@override
Widget build(BuildContext context) {
return CallbackShortcuts(
bindings: <ShortcutActivator, VoidCallback>{
// Shortcuts for page forward
const SingleActivator(LogicalKeyboardKey.arrowDown):
widget.onTurnPageForward ?? () => {},
const SingleActivator(LogicalKeyboardKey.arrowRight):
widget.onTurnPageForward ?? () => {},
// Shortcuts for page backward
const SingleActivator(LogicalKeyboardKey.arrowUp):
widget.onTurnPageBackward ?? () => {},
const SingleActivator(LogicalKeyboardKey.arrowLeft):
widget.onTurnPageBackward ?? () => {},
},
child: Focus(autofocus: true, child: widget.child),
);
}
}

View File

@@ -0,0 +1,96 @@
import 'dart:collection';
import 'sheet.dart';
/// Types of changes that can be queued for server synchronization.
enum ChangeType {
sheetNameChange,
composerNameChange,
addTagChange,
removeTagChange,
}
/// Represents a single pending change to be synced with the server.
///
/// Changes are stored locally when offline and applied once
/// the device regains connectivity.
class Change {
final ChangeType type;
final String sheetUuid;
final String value;
Change({
required this.type,
required this.sheetUuid,
required this.value,
});
/// Serializes this change to a map for storage.
Map<String, dynamic> toMap() => {
'type': type.index,
'sheetUuid': sheetUuid,
'value': value,
};
/// Deserializes a change from a stored map.
///
/// Note: Adding new [ChangeType] values may cause issues with
/// previously stored changes that use index-based serialization.
factory Change.fromMap(Map<dynamic, dynamic> map) {
return Change(
type: ChangeType.values[map['type']],
sheetUuid: map['sheetUuid'],
value: map['value'],
);
}
}
/// A queue of pending changes to be synchronized with the server.
///
/// Changes are stored in FIFO order (oldest first) and applied
/// to sheets in sequence when syncing.
class ChangeQueue {
final Queue<Change> _queue = Queue();
ChangeQueue();
/// Adds a change to the end of the queue.
void addChange(Change change) {
_queue.addLast(change);
}
/// Returns the number of pending changes.
int get length => _queue.length;
/// Whether the queue has any pending changes.
bool get isEmpty => _queue.isEmpty;
/// Whether the queue has pending changes.
bool get isNotEmpty => _queue.isNotEmpty;
/// Applies all queued changes to the provided list of sheets.
///
/// Each change modifies the corresponding sheet's properties
/// based on the change type.
void applyToSheets(List<Sheet> sheets) {
for (final change in _queue) {
final sheet = sheets.firstWhere(
(s) => s.uuid == change.sheetUuid,
orElse: () => throw StateError(
'Sheet with UUID ${change.sheetUuid} not found',
),
);
switch (change.type) {
case ChangeType.sheetNameChange:
sheet.name = change.value;
case ChangeType.composerNameChange:
sheet.composerName = change.value;
case ChangeType.addTagChange:
throw UnimplementedError('Tag support not yet implemented');
case ChangeType.removeTagChange:
throw UnimplementedError('Tag support not yet implemented');
}
}
}
}

View File

@@ -0,0 +1,37 @@
/// Application configuration model.
///
/// Stores user preferences that are persisted across sessions,
/// such as display mode and fullscreen settings.
class Config {
/// Storage keys for persistence
static const String keyTwoPageMode = 'twoPageMode';
static const String keyFullscreen = 'fullscreen';
/// Whether to display two pages side-by-side (for tablets/landscape).
bool twoPageMode;
/// Whether the app is in fullscreen mode.
bool fullscreen;
Config({
required this.twoPageMode,
required this.fullscreen,
});
/// Creates a default configuration with all options disabled.
factory Config.defaultConfig() => Config(
twoPageMode: false,
fullscreen: false,
);
/// Creates a copy of this config with optional overrides.
Config copyWith({
bool? twoPageMode,
bool? fullscreen,
}) {
return Config(
twoPageMode: twoPageMode ?? this.twoPageMode,
fullscreen: fullscreen ?? this.fullscreen,
);
}
}

View File

@@ -0,0 +1,40 @@
/// Data model representing a sheet music document.
///
/// A [Sheet] contains metadata about a piece of sheet music,
/// including its title, composer information, and timestamps.
class Sheet {
final String uuid;
String name;
String composerUuid;
String composerName;
DateTime updatedAt;
Sheet({
required this.uuid,
required this.name,
required this.composerUuid,
required this.composerName,
required this.updatedAt,
});
/// Creates a [Sheet] from a JSON map returned by the API.
factory Sheet.fromJson(Map<String, dynamic> json) {
final composer = json['composer'] as Map<String, dynamic>?;
return Sheet(
uuid: json['uuid'].toString(),
name: json['title'],
composerUuid: json['composer_uuid']?.toString() ?? '',
composerName: composer?['name'] ?? 'Unknown',
updatedAt: DateTime.parse(json['updated_at']),
);
}
/// Converts this sheet to a JSON map for API requests.
Map<String, dynamic> toJson() => {
'uuid': uuid,
'title': name,
'composer_uuid': composerUuid,
'composer_name': composerName,
'updated_at': updatedAt.toIso8601String(),
};
}

View File

@@ -0,0 +1,202 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import '../models/sheet.dart';
/// HTTP client for communicating with the Sheetless API server.
///
/// Handles authentication, sheet listing, and PDF downloads.
/// Provides caching for PDF files on native platforms.
class ApiClient {
final _log = Logger('ApiClient');
final String baseUrl;
String? token;
ApiClient({required this.baseUrl, this.token});
/// Whether the client has an authentication token set.
bool get isAuthenticated => token != null;
// ---------------------------------------------------------------------------
// Authentication
// ---------------------------------------------------------------------------
/// Authenticates with the server and stores the JWT token.
///
/// Throws an [Exception] if login fails.
Future<void> login(String username, String password) async {
_log.info('Logging in...');
final url = Uri.parse('$baseUrl/auth/login');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'username': username, 'password': password}),
);
if (response.statusCode == 200) {
final responseData = jsonDecode(response.body);
token = responseData['token'];
_log.info('Login successful');
} else {
throw ApiException(
'Login failed',
statusCode: response.statusCode,
body: response.body,
);
}
}
/// Clears the authentication token.
void logout() {
token = null;
_log.info('Logged out successfully');
}
// ---------------------------------------------------------------------------
// HTTP Helpers
// ---------------------------------------------------------------------------
Map<String, String> _buildHeaders({bool isBinary = false}) {
return {
'Authorization': 'Bearer $token',
if (!isBinary) 'Content-Type': 'application/json',
};
}
/// Performs a GET request to the given endpoint.
Future<http.Response> get(
String endpoint, {
bool isBinary = false,
}) async {
final url = Uri.parse('$baseUrl$endpoint');
final response =
await http.get(url, headers: _buildHeaders(isBinary: isBinary));
if (response.statusCode != 200) {
_log.warning(
"GET '$endpoint' failed: ${response.statusCode}\n${response.body}",
);
throw ApiException(
'GET request failed',
statusCode: response.statusCode,
body: response.body,
);
}
return response;
}
/// Performs a POST request with JSON body.
Future<http.Response> post(
String endpoint,
Map<String, dynamic> body,
) async {
final url = Uri.parse('$baseUrl$endpoint');
final response = await http.post(
url,
headers: _buildHeaders(),
body: jsonEncode(body),
);
if (response.statusCode != 200 && response.statusCode != 201) {
_log.warning(
"POST '$endpoint' failed: ${response.statusCode}\n${response.body}",
);
throw ApiException(
'POST request failed',
statusCode: response.statusCode,
body: response.body,
);
}
return response;
}
/// Performs a POST request with form-encoded body.
Future<http.Response> postFormData(String endpoint, String body) async {
final url = Uri.parse('$baseUrl$endpoint');
final response = await http.post(
url,
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body,
);
if (response.statusCode != 200 && response.statusCode != 201) {
_log.warning(
"POST Form '$endpoint' failed: ${response.statusCode}\n${response.body}",
);
throw ApiException(
'POST Form request failed',
statusCode: response.statusCode,
body: response.body,
);
}
return response;
}
// ---------------------------------------------------------------------------
// Sheet Operations
// ---------------------------------------------------------------------------
/// Fetches the list of all sheets from the server.
Future<List<Sheet>> fetchSheets() async {
final response = await get('/api/sheets/list');
final data = jsonDecode(response.body);
return (data['sheets'] as List<dynamic>)
.map((sheet) => Sheet.fromJson(sheet as Map<String, dynamic>))
.toList();
}
/// Downloads PDF data for a sheet.
///
/// Returns the raw PDF bytes. For caching, use [getPdfFileCached] instead.
Future<Uint8List> fetchPdfData(String sheetUuid) async {
final response = await get('/api/sheets/get/$sheetUuid', isBinary: true);
_log.info('PDF downloaded for sheet $sheetUuid');
return response.bodyBytes;
}
/// Gets a cached PDF file for a sheet, downloading if necessary.
///
/// On web platforms, use [fetchPdfData] instead as file caching
/// is not supported.
Future<File> getPdfFileCached(String sheetUuid) async {
final cacheDir = await getTemporaryDirectory();
final cachedFile = File('${cacheDir.path}/$sheetUuid.pdf');
if (await cachedFile.exists()) {
_log.info('PDF found in cache: ${cachedFile.path}');
return cachedFile;
}
final pdfData = await fetchPdfData(sheetUuid);
await cachedFile.writeAsBytes(pdfData);
_log.info('PDF cached at: ${cachedFile.path}');
return cachedFile;
}
}
/// Exception thrown when an API request fails.
class ApiException implements Exception {
final String message;
final int statusCode;
final String body;
ApiException(this.message, {required this.statusCode, required this.body});
@override
String toString() => 'ApiException: $message (status: $statusCode)\n$body';
}

View File

@@ -0,0 +1,119 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:hive/hive.dart';
import 'package:sheetless/core/models/change.dart';
import 'package:sheetless/core/models/config.dart';
/// Keys for secure storage (credentials and tokens).
enum SecureStorageKey { url, jwt, email }
/// Service for managing local storage operations.
///
/// Uses [FlutterSecureStorage] for sensitive data (credentials, tokens)
/// and [Hive] for general app data (config, sheet access times, change queue).
class StorageService {
// Hive box names
static const String _sheetAccessTimesBox = 'sheetAccessTimes';
static const String _configBox = 'config';
static const String _changeQueueBox = 'changeQueue';
late final FlutterSecureStorage _secureStorage;
StorageService() {
_secureStorage = FlutterSecureStorage(
aOptions: const AndroidOptions(encryptedSharedPreferences: true),
);
}
// ---------------------------------------------------------------------------
// Secure Storage (Credentials & Tokens)
// ---------------------------------------------------------------------------
/// Reads a value from secure storage.
Future<String?> readSecure(SecureStorageKey key) {
return _secureStorage.read(key: key.name);
}
/// Writes a value to secure storage.
///
/// Pass `null` to delete the key.
Future<void> writeSecure(SecureStorageKey key, String? value) {
return _secureStorage.write(key: key.name, value: value);
}
/// Clears the JWT token from secure storage.
Future<void> clearToken() {
return writeSecure(SecureStorageKey.jwt, null);
}
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
/// Reads the app configuration from storage.
Future<Config> readConfig() async {
final box = await Hive.openBox(_configBox);
return Config(
twoPageMode: box.get(Config.keyTwoPageMode) ?? false,
fullscreen: box.get(Config.keyFullscreen) ?? false,
);
}
/// Writes the app configuration to storage.
Future<void> writeConfig(Config config) async {
final box = await Hive.openBox(_configBox);
await box.put(Config.keyTwoPageMode, config.twoPageMode);
await box.put(Config.keyFullscreen, config.fullscreen);
}
// ---------------------------------------------------------------------------
// Sheet Access Times (for sorting by recency)
// ---------------------------------------------------------------------------
/// Reads all sheet access times.
///
/// Returns a map of sheet UUID to last access time.
Future<Map<String, DateTime>> readSheetAccessTimes() async {
final box = await Hive.openBox(_sheetAccessTimesBox);
return box.toMap().map(
(key, value) => MapEntry(key as String, DateTime.parse(value as String)),
);
}
/// Records when a sheet was last accessed.
Future<void> writeSheetAccessTime(String uuid, DateTime datetime) async {
final box = await Hive.openBox(_sheetAccessTimesBox);
await box.put(uuid, datetime.toIso8601String());
}
// ---------------------------------------------------------------------------
// Change Queue (Offline Sync)
// ---------------------------------------------------------------------------
/// Adds a change to the offline queue.
Future<void> writeChange(Change change) async {
final box = await Hive.openBox(_changeQueueBox);
await box.add(change.toMap());
}
/// Reads all pending changes from the queue.
Future<ChangeQueue> readChangeQueue() async {
final box = await Hive.openBox(_changeQueueBox);
final queue = ChangeQueue();
for (final map in box.values) {
queue.addChange(Change.fromMap(map as Map<dynamic, dynamic>));
}
return queue;
}
/// Removes the oldest change from the queue.
///
/// Call this after successfully syncing a change to the server.
Future<void> deleteOldestChange() async {
final box = await Hive.openBox(_changeQueueBox);
if (box.isNotEmpty) {
await box.deleteAt(0);
}
}
}

View File

@@ -1,70 +0,0 @@
import 'package:flutter/material.dart';
import 'package:sheetless/sheet.dart';
typedef SheetEditedCallback = void Function(String newName, String newComposer);
class EditItemBottomSheet extends StatefulWidget {
final Sheet sheet;
final SheetEditedCallback onSheetEdited;
const EditItemBottomSheet({
super.key,
required this.sheet,
required this.onSheetEdited,
});
@override
State<EditItemBottomSheet> createState() => _EditItemBottomSheetState();
}
class _EditItemBottomSheetState extends State<EditItemBottomSheet> {
late TextEditingController sheetNameController;
late TextEditingController composerNameController;
@override
void initState() {
sheetNameController = TextEditingController(text: widget.sheet.name);
composerNameController = TextEditingController(
text: widget.sheet.composerName,
);
super.initState();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
MediaQuery.of(context).viewInsets.bottom + 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: sheetNameController,
decoration: InputDecoration(labelText: "Sheet"),
),
TextField(
controller: composerNameController,
decoration: InputDecoration(labelText: "Composer"),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
// TODO: check text fields are not empty
// TODO: save on pressing enter
widget.onSheetEdited(
sheetNameController.text,
composerNameController.text,
);
Navigator.pop(context);
},
child: Text("Save"),
),
],
),
);
}
}

View File

@@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import 'package:jwt_decoder/jwt_decoder.dart';
import 'package:logging/logging.dart';
import 'package:sheetless/core/services/api_client.dart';
import 'package:sheetless/core/services/storage_service.dart';
import '../home/home_page.dart';
/// Login page for authenticating with the Sheetless server.
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _log = Logger('LoginPage');
final _formKey = GlobalKey<FormState>();
final _storageService = StorageService();
final _urlController = TextEditingController(
text: 'https://sheetless.julian-mutter.de',
);
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
String? _errorMessage;
bool _isLoggingIn = false;
@override
void initState() {
super.initState();
_tryAutoLogin();
}
@override
void dispose() {
_urlController.dispose();
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
// ---------------------------------------------------------------------------
// Auto-Login
// ---------------------------------------------------------------------------
/// Attempts to auto-login using a stored JWT token.
Future<void> _tryAutoLogin() async {
final jwt = await _storageService.readSecure(SecureStorageKey.jwt);
if (jwt != null && _isTokenValid(jwt)) {
await _navigateToHome();
return;
}
// Restore saved credentials for convenience
await _restoreCredentials();
}
/// Checks if a JWT token is still valid (not expired).
bool _isTokenValid(String jwt) {
try {
return !JwtDecoder.isExpired(jwt);
} on FormatException {
return false;
}
}
/// Restores previously saved URL and username for convenience.
Future<void> _restoreCredentials() async {
final url = await _storageService.readSecure(SecureStorageKey.url);
final username = await _storageService.readSecure(SecureStorageKey.email);
if (url != null) _urlController.text = url;
if (username != null) _usernameController.text = username;
}
// ---------------------------------------------------------------------------
// Login
// ---------------------------------------------------------------------------
/// Handles the login button press.
Future<void> _handleLogin() async {
if (_isLoggingIn) return;
if (!_formKey.currentState!.validate()) return;
setState(() {
_isLoggingIn = true;
_errorMessage = null;
});
try {
final apiClient = ApiClient(baseUrl: _urlController.text);
await apiClient.login(_usernameController.text, _passwordController.text);
// Save credentials for next time
await _saveCredentials(apiClient.token!);
await _navigateToHome();
} catch (e) {
_log.warning('Login failed', e);
setState(() {
_errorMessage = 'Login failed.\n$e';
});
} finally {
if (mounted) {
setState(() => _isLoggingIn = false);
}
}
}
/// Saves credentials after successful login.
Future<void> _saveCredentials(String token) async {
await _storageService.writeSecure(
SecureStorageKey.url,
_urlController.text,
);
await _storageService.writeSecure(SecureStorageKey.jwt, token);
await _storageService.writeSecure(
SecureStorageKey.email,
_usernameController.text,
);
}
/// Navigates to the home page after successful authentication.
Future<void> _navigateToHome() async {
final config = await _storageService.readConfig();
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => HomePage(config: config)),
);
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
String? _validateNotEmpty(String? value) {
if (value == null || value.isEmpty) {
return 'This field is required';
}
return null;
}
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Login')),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildUrlField(),
const SizedBox(height: 16),
_buildUsernameField(),
const SizedBox(height: 16),
_buildPasswordField(),
const SizedBox(height: 24),
_buildLoginButton(),
if (_errorMessage != null) _buildErrorMessage(),
],
),
),
),
),
);
}
Widget _buildUrlField() {
return TextFormField(
controller: _urlController,
validator: _validateNotEmpty,
decoration: const InputDecoration(
labelText: 'Server URL',
prefixIcon: Icon(Icons.dns),
),
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
);
}
Widget _buildUsernameField() {
return TextFormField(
controller: _usernameController,
validator: _validateNotEmpty,
decoration: const InputDecoration(
labelText: 'Username',
prefixIcon: Icon(Icons.person),
),
textInputAction: TextInputAction.next,
);
}
Widget _buildPasswordField() {
return TextFormField(
controller: _passwordController,
validator: _validateNotEmpty,
decoration: const InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock),
),
obscureText: true,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _handleLogin(),
);
}
Widget _buildLoginButton() {
return ElevatedButton(
onPressed: _isLoggingIn ? null : _handleLogin,
child: _isLoggingIn
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Login'),
);
}
Widget _buildErrorMessage() {
return Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
);
}
}

View File

@@ -0,0 +1,235 @@
import 'package:flutter/material.dart';
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
import 'package:logging/logging.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sheetless/core/models/config.dart';
import 'package:sheetless/core/models/sheet.dart';
import 'package:sheetless/core/services/api_client.dart';
import 'package:sheetless/core/services/storage_service.dart';
import '../../app.dart';
import '../auth/login_page.dart';
import '../sheet_viewer/sheet_viewer_page.dart';
import 'widgets/app_drawer.dart';
import 'widgets/sheets_list.dart';
/// Main home page displaying the list of sheet music.
///
/// Features:
/// - Pull-to-refresh sheet list
/// - Shuffle mode for random practice
/// - Navigation to sheet viewer
/// - Logout functionality
class HomePage extends StatefulWidget {
final Config config;
const HomePage({super.key, required this.config});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> with RouteAware {
final _log = Logger('HomePage');
final _storageService = StorageService();
ApiClient? _apiClient;
late Future<List<Sheet>> _sheetsFuture;
bool _isShuffling = false;
String? _appName;
String? _appVersion;
@override
void initState() {
super.initState();
// Exit fullscreen when entering home page
FullScreen.setFullScreen(false);
// Subscribe to route changes
WidgetsBinding.instance.addPostFrameCallback((_) {
routeObserver.subscribe(this, ModalRoute.of(context)!);
});
_loadAppInfo();
_sheetsFuture = _loadSheets();
}
@override
void dispose() {
routeObserver.unsubscribe(this);
super.dispose();
}
// ---------------------------------------------------------------------------
// Route Aware (Fullscreen Management)
// ---------------------------------------------------------------------------
@override
void didPush() {
FullScreen.setFullScreen(false);
super.didPush();
}
@override
void didPopNext() {
// Exit fullscreen when returning to home page
FullScreen.setFullScreen(false);
super.didPopNext();
}
// ---------------------------------------------------------------------------
// Data Loading
// ---------------------------------------------------------------------------
Future<void> _loadAppInfo() async {
final info = await PackageInfo.fromPlatform();
setState(() {
_appName = info.appName;
_appVersion = info.version;
});
}
Future<List<Sheet>> _loadSheets() async {
final url = await _storageService.readSecure(SecureStorageKey.url);
final jwt = await _storageService.readSecure(SecureStorageKey.jwt);
_apiClient = ApiClient(baseUrl: url!, token: jwt);
final sheets = await _apiClient!.fetchSheets();
_log.info('${sheets.length} sheets fetched');
final sortedSheets = await _sortSheetsByRecency(sheets);
_log.info('${sortedSheets.length} sheets sorted');
return sortedSheets;
}
Future<List<Sheet>> _sortSheetsByRecency(List<Sheet> sheets) async {
final accessTimes = await _storageService.readSheetAccessTimes();
sheets.sort((a, b) {
// Use local access time if available and more recent than server update
var dateA = accessTimes[a.uuid];
var dateB = accessTimes[b.uuid];
if (dateA == null || a.updatedAt.isAfter(dateA)) {
dateA = a.updatedAt;
}
if (dateB == null || b.updatedAt.isAfter(dateB)) {
dateB = b.updatedAt;
}
return dateB.compareTo(dateA); // Most recent first
});
return sheets;
}
Future<void> _refreshSheets() async {
setState(() {
_sheetsFuture = _loadSheets();
});
}
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
void _handleShuffleChanged(bool enabled) async {
final sheets = await _sheetsFuture;
if (enabled) {
sheets.shuffle();
} else {
await _sortSheetsByRecency(sheets);
}
setState(() => _isShuffling = enabled);
}
Future<void> _handleLogout() async {
await _storageService.clearToken();
if (!mounted) return;
Navigator.of(
context,
).pushReplacement(MaterialPageRoute(builder: (_) => const LoginPage()));
}
void _openSheet(Sheet sheet) {
// Record access time for recency sorting
_storageService.writeSheetAccessTime(sheet.uuid, DateTime.now());
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => SheetViewerPage(
sheet: sheet,
apiClient: _apiClient!,
config: widget.config,
),
),
);
}
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sheetless')),
endDrawer: AppDrawer(
isShuffling: _isShuffling,
onShuffleChanged: _handleShuffleChanged,
onLogout: _handleLogout,
appName: _appName,
appVersion: _appVersion,
),
body: RefreshIndicator(onRefresh: _refreshSheets, child: _buildBody()),
);
}
Widget _buildBody() {
return FutureBuilder<List<Sheet>>(
future: _sheetsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
_log.warning('Error loading sheets', snapshot.error);
return _buildError(snapshot.error.toString());
}
if (snapshot.hasData) {
return SheetsList(
sheets: snapshot.data!,
onSheetSelected: _openSheet,
);
}
return const Center(child: CircularProgressIndicator());
},
);
}
Widget _buildError(String message) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
message,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: Colors.red),
textAlign: TextAlign.center,
),
),
);
}
}

View File

@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
/// Callback for shuffle state changes.
typedef ShuffleCallback = void Function(bool enabled);
/// Navigation drawer for the home page.
///
/// Provides access to app-level actions like shuffle mode and logout.
class AppDrawer extends StatelessWidget {
final bool isShuffling;
final ShuffleCallback onShuffleChanged;
final VoidCallback onLogout;
final String? appName;
final String? appVersion;
const AppDrawer({
super.key,
required this.isShuffling,
required this.onShuffleChanged,
required this.onLogout,
this.appName,
this.appVersion,
});
@override
Widget build(BuildContext context) {
return Drawer(
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 30),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildActions(),
_buildAppInfo(),
],
),
),
),
);
}
Widget _buildActions() {
return Column(
children: [
ListTile(
leading: Icon(
Icons.shuffle,
color: isShuffling ? Colors.blue : null,
),
title: const Text('Shuffle'),
onTap: () => onShuffleChanged(!isShuffling),
),
ListTile(
leading: const Icon(Icons.logout),
title: const Text('Logout'),
onTap: onLogout,
),
],
);
}
Widget _buildAppInfo() {
final versionText = appName != null && appVersion != null
? '$appName v$appVersion'
: 'Loading...';
return Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
versionText,
style: const TextStyle(color: Colors.grey),
),
);
}
}

View File

@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import '../../../core/models/sheet.dart';
/// A list tile displaying a single sheet's information.
///
/// Shows the sheet name and composer, with tap and long-press handlers.
class SheetListItem extends StatelessWidget {
final Sheet sheet;
final VoidCallback onTap;
final VoidCallback onLongPress;
const SheetListItem({
super.key,
required this.sheet,
required this.onTap,
required this.onLongPress,
});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(sheet.name),
subtitle: Text(sheet.composerName),
onTap: onTap,
onLongPress: onLongPress,
);
}
}

View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
/// Search bar for filtering sheets.
///
/// Provides a text input with search icon and clear button.
class SheetSearchBar extends StatelessWidget {
final TextEditingController controller;
final VoidCallback onClear;
const SheetSearchBar({
super.key,
required this.controller,
required this.onClear,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: 'Search sheets...',
prefixIcon: const Icon(Icons.search),
suffixIcon: controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: onClear,
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
),
),
),
);
}
}

View File

@@ -0,0 +1,187 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:sheetless/core/models/change.dart';
import 'package:sheetless/core/models/sheet.dart';
import 'package:sheetless/core/services/storage_service.dart';
import '../../../shared/widgets/edit_sheet_bottom_sheet.dart';
import 'sheet_list_item.dart';
import 'sheet_search_bar.dart';
/// Widget displaying a searchable list of sheets.
///
/// Features:
/// - Debounced search filtering by name and composer
/// - Long-press to edit sheet metadata
/// - Cross-platform scroll support (mouse and touch)
class SheetsList extends StatefulWidget {
final List<Sheet> sheets;
final ValueSetter<Sheet> onSheetSelected;
const SheetsList({
super.key,
required this.sheets,
required this.onSheetSelected,
});
@override
State<SheetsList> createState() => _SheetsListState();
}
class _SheetsListState extends State<SheetsList> {
static const _searchDebounceMs = 500;
final _storageService = StorageService();
final _searchController = TextEditingController();
Timer? _debounceTimer;
late List<Sheet> _filteredSheets;
@override
void initState() {
super.initState();
_filteredSheets = widget.sheets;
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_searchController.removeListener(_onSearchChanged);
_searchController.dispose();
_debounceTimer?.cancel();
super.dispose();
}
// ---------------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------------
void _onSearchChanged() {
_debounceTimer?.cancel();
_debounceTimer = Timer(
const Duration(milliseconds: _searchDebounceMs),
_filterSheets,
);
}
void _filterSheets() {
final query = _searchController.text.toLowerCase().trim();
if (query.isEmpty) {
setState(() => _filteredSheets = widget.sheets);
return;
}
// Split query into terms for multi-word search
final terms = query.split(RegExp(r'\s+'));
setState(() {
_filteredSheets = widget.sheets.where((sheet) {
final name = sheet.name.toLowerCase();
final composer = sheet.composerName.toLowerCase();
// Each term must appear in either name or composer
return terms.every(
(term) => name.contains(term) || composer.contains(term),
);
}).toList();
});
}
void _clearSearch() {
_searchController.clear();
setState(() => _filteredSheets = widget.sheets);
}
// ---------------------------------------------------------------------------
// Edit Sheet
// ---------------------------------------------------------------------------
void _openEditSheet(BuildContext context, Sheet sheet) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => EditSheetBottomSheet(
sheet: sheet,
onSave: (newName, newComposer) =>
_handleSheetEdit(sheet, newName, newComposer),
),
);
}
void _handleSheetEdit(Sheet sheet, String newName, String newComposer) {
// Queue changes for server sync
if (newName != sheet.name) {
_storageService.writeChange(
Change(
type: ChangeType.sheetNameChange,
sheetUuid: sheet.uuid,
value: newName,
),
);
}
if (newComposer != sheet.composerName) {
_storageService.writeChange(
Change(
type: ChangeType.composerNameChange,
sheetUuid: sheet.uuid,
value: newComposer,
),
);
}
// Update local state
setState(() {
sheet.name = newName;
sheet.composerName = newComposer;
});
}
// ---------------------------------------------------------------------------
// Sheet Selection
// ---------------------------------------------------------------------------
void _handleSheetTap(Sheet sheet) {
// Move selected sheet to top of list (most recently accessed)
widget.sheets.remove(sheet);
widget.sheets.insert(0, sheet);
widget.onSheetSelected(sheet);
}
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
return Column(
children: [
SheetSearchBar(controller: _searchController, onClear: _clearSearch),
Expanded(child: _buildList()),
],
);
}
Widget _buildList() {
// Enable both mouse and touch scrolling for web compatibility
return ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse},
),
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _filteredSheets.length,
itemBuilder: (context, index) {
final sheet = _filteredSheets[index];
return SheetListItem(
sheet: sheet,
onTap: () => _handleSheetTap(sheet),
onLongPress: () => _openEditSheet(context, sheet),
);
},
),
);
}
}

View File

@@ -0,0 +1,270 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_drawing_board/flutter_drawing_board.dart';
import 'package:flutter_drawing_board/paint_contents.dart';
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
import 'package:logging/logging.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:sheetless/core/models/config.dart';
import 'package:sheetless/core/models/sheet.dart';
import 'package:sheetless/core/services/api_client.dart';
import 'package:sheetless/core/services/storage_service.dart';
import '../../shared/input/pedal_shortcuts.dart';
import 'widgets/paint_mode_layer.dart';
import 'widgets/pdf_page_display.dart';
import 'widgets/touch_navigation_layer.dart';
/// Page for viewing and annotating PDF sheet music.
class SheetViewerPage extends StatefulWidget {
final Sheet sheet;
final ApiClient apiClient;
final Config config;
const SheetViewerPage({
super.key,
required this.sheet,
required this.apiClient,
required this.config,
});
@override
State<SheetViewerPage> createState() => _SheetViewerPageState();
}
class _SheetViewerPageState extends State<SheetViewerPage>
with FullScreenListener {
final _log = Logger('SheetViewerPage');
final _storageService = StorageService();
PdfDocument? _document;
late Future<bool> _documentLoaded;
int _currentPage = 1;
int _totalPages = 1;
bool _isPaintMode = false;
late DrawingController _drawingController;
@override
void initState() {
super.initState();
// Initialize drawing controller with default configuration
_drawingController = DrawingController(
config: DrawConfig(
contentType: SimpleLine,
strokeWidth: 4.0,
color: Colors.black,
),
maxHistorySteps: 100, // Limit undo/redo history (default: 100)
);
FullScreen.addListener(this);
FullScreen.setFullScreen(widget.config.fullscreen);
_documentLoaded = _loadPdf();
}
@override
void dispose() {
_drawingController.dispose();
FullScreen.removeListener(this);
_document?.dispose();
super.dispose();
}
// ---------------------------------------------------------------------------
// PDF Loading
// ---------------------------------------------------------------------------
Future<bool> _loadPdf() async {
if (kIsWeb) {
// Web: load directly into memory
final data = await widget.apiClient.fetchPdfData(widget.sheet.uuid);
_document = await PdfDocument.openData(data);
} else {
// Native: use file cache
final file = await widget.apiClient.getPdfFileCached(widget.sheet.uuid);
_document = await PdfDocument.openFile(file.path);
}
setState(() {
_totalPages = _document!.pages.length;
});
return true;
}
// ---------------------------------------------------------------------------
// Fullscreen
// ---------------------------------------------------------------------------
@override
void onFullScreenChanged(bool enabled, SystemUiMode? systemUiMode) {
setState(() {
widget.config.fullscreen = enabled;
_storageService.writeConfig(widget.config);
});
}
void _toggleFullscreen() {
if (_isPaintMode) {
_showSnackBar('Cannot enter fullscreen while in paint mode');
return;
}
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) {
_showSnackBar('Paint mode is only available in single page mode');
return;
}
setState(() => _isPaintMode = !_isPaintMode);
}
void _toggleTwoPageMode() {
if (_isPaintMode) {
_showSnackBar('Cannot enter two-page mode while painting');
return;
}
setState(() {
widget.config.twoPageMode = !widget.config.twoPageMode;
_storageService.writeConfig(widget.config);
});
}
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
return PedalShortcuts(
onPageForward: () => _turnPage(1),
onPageBackward: () => _turnPage(-1),
child: Scaffold(appBar: _buildAppBar(), body: _buildBody()),
);
}
PreferredSizeWidget? _buildAppBar() {
// Hide app bar in fullscreen when document is loaded
if (widget.config.fullscreen && _document != null) {
return null;
}
return AppBar(
title: Text(widget.sheet.name),
actions: [
IconButton(
icon: Icon(
widget.config.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
),
tooltip: widget.config.fullscreen
? 'Exit Fullscreen'
: 'Enter Fullscreen',
onPressed: _toggleFullscreen,
),
IconButton(
icon: Icon(_isPaintMode ? Icons.brush : Icons.brush_outlined),
tooltip: 'Toggle Paint Mode',
onPressed: _togglePaintMode,
),
IconButton(
icon: Icon(
widget.config.twoPageMode ? Icons.filter_1 : Icons.filter_2,
),
tooltip: widget.config.twoPageMode
? 'Single Page Mode'
: 'Two Page Mode',
onPressed: _toggleTwoPageMode,
),
],
);
}
Widget _buildBody() {
return FutureBuilder<bool>(
future: _documentLoaded,
builder: (context, snapshot) {
if (snapshot.hasError) {
_log.warning('Error loading PDF', snapshot.error);
return _buildError(snapshot.error.toString());
}
if (snapshot.hasData && _document != null) {
return _buildPdfViewer();
}
return const Center(child: CircularProgressIndicator());
},
);
}
Widget _buildPdfViewer() {
final pageDisplay = PdfPageDisplay(
document: _document!,
numPages: _totalPages,
currentPageNumber: _currentPage,
config: widget.config,
);
return Stack(
children: [
// Show touch navigation when not in paint mode
Visibility(
visible: !_isPaintMode,
child: TouchNavigationLayer(
pageDisplay: pageDisplay,
config: widget.config,
onToggleFullscreen: _toggleFullscreen,
onExit: () => Navigator.pop(context),
onPageTurn: _turnPage,
),
),
// Show paint mode layer when active
Visibility(
visible: _isPaintMode,
child: PaintModeLayer(
pageDisplay: pageDisplay,
drawingController: _drawingController,
),
),
],
);
}
Widget _buildError(String message) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
message,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: Colors.red),
textAlign: TextAlign.center,
),
),
);
}
void _showSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), duration: Duration(seconds: 2)),
);
}
}

View File

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

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import '../../../core/models/config.dart';
import 'pdf_page_display.dart';
/// Callback for page turn events.
typedef PageTurnCallback = void Function(int delta);
/// Gesture layer for touch-based navigation over PDF pages.
///
/// Touch zones:
/// - Top 2cm: Toggle fullscreen (or exit if in fullscreen + top-right corner)
/// - Left side: Turn page backward (-1 or -2 in two-page mode)
/// - Right side: Turn page forward (+1 or +2 in two-page mode)
class TouchNavigationLayer extends StatelessWidget {
final PdfPageDisplay pageDisplay;
final Config config;
final VoidCallback onToggleFullscreen;
final VoidCallback onExit;
final PageTurnCallback onPageTurn;
const TouchNavigationLayer({
super.key,
required this.pageDisplay,
required this.config,
required this.onToggleFullscreen,
required this.onExit,
required this.onPageTurn,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapUp: (details) => _handleTap(context, details),
child: pageDisplay,
);
}
void _handleTap(BuildContext context, TapUpDetails details) {
final mediaQuery = MediaQuery.of(context);
final position = details.localPosition;
// Calculate physical measurements for consistent touch zones
final pixelsPerCm = _calculatePixelsPerCm(mediaQuery.devicePixelRatio);
final touchZoneHeight = 2 * pixelsPerCm;
final touchZoneWidth = 2 * pixelsPerCm;
final screenWidth = mediaQuery.size.width;
final screenCenter = screenWidth / 2;
// Get page sizes for accurate touch zone calculation
final (leftPageSize, rightPageSize) = pageDisplay.calculateScaledPageSizes(
mediaQuery.size,
);
// Check top zone first
if (position.dy < touchZoneHeight) {
_handleTopZoneTap(position, screenWidth, touchZoneWidth);
return;
}
// Handle page turning based on tap position
_handlePageTurnTap(position, screenCenter, leftPageSize, rightPageSize);
}
void _handleTopZoneTap(
Offset position,
double screenWidth,
double touchZoneWidth,
) {
// Top-right corner in fullscreen mode = exit
if (config.fullscreen && position.dx >= screenWidth - touchZoneWidth) {
onExit();
} else {
onToggleFullscreen();
}
}
void _handlePageTurnTap(
Offset position,
double screenCenter,
Size leftPageSize,
Size? rightPageSize,
) {
final isLeftSide = position.dx < screenCenter;
if (config.twoPageMode) {
_handleTwoPageModeTap(
position,
screenCenter,
leftPageSize,
rightPageSize,
);
} else {
// Single page mode: simple left/right
onPageTurn(isLeftSide ? -1 : 1);
}
}
void _handleTwoPageModeTap(
Offset position,
double screenCenter,
Size leftPageSize,
Size? rightPageSize,
) {
final leftEdge = screenCenter - leftPageSize.width / 2;
final rightEdge = screenCenter + (rightPageSize?.width ?? 0) / 2;
if (position.dx < leftEdge) {
onPageTurn(-2);
} else if (position.dx < screenCenter) {
onPageTurn(-1);
} else if (position.dx > rightEdge) {
onPageTurn(2);
} else {
onPageTurn(1);
}
}
double _calculatePixelsPerCm(double devicePixelRatio) {
const baseDpi = 160.0; // Android baseline DPI
const cmPerInch = 2.54;
return (devicePixelRatio * baseDpi) / cmPerInch;
}
}

View File

@@ -1,236 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
import 'package:logging/logging.dart';
import 'package:sheetless/login_page.dart';
import 'package:sheetless/main.dart';
import 'package:sheetless/sheet_viewer_page.dart';
import 'package:sheetless/storage_helper.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'api.dart';
import 'sheet.dart';
class MyHomePage extends StatefulWidget {
final Config config;
const MyHomePage({super.key, required this.config});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> with RouteAware {
ApiClient? apiClient;
Future<bool> apiLoggedIn = Future.value(false);
final StorageHelper _storageHelper = StorageHelper();
final log = Logger("MyHomePage");
String? appName;
String? appVersion;
bool shuffling = false;
late Future<List<Sheet>> sheets;
@override
void initState() {
// Deactivate fullscreen by default
FullScreen.setFullScreen(false);
// So RouteAware gets bound
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
routeObserver.subscribe(this, ModalRoute.of(context)!);
});
super.initState();
_loadAppInfo();
sheets = acquireSheets();
}
@override
void dispose() {
super.dispose();
}
@override
void didPush() {
FullScreen.setFullScreen(false);
super.didPush();
}
@override
void didPopNext() {
FullScreen.setFullScreen(false);
super.didPopNext();
}
Future<void> _loadAppInfo() async {
final info = await PackageInfo.fromPlatform();
setState(() {
appName = info.appName;
appVersion = info.version;
});
}
Future<List<Sheet>> acquireSheets() async {
final url = await _storageHelper.readSecure(SecureStorageKey.url);
final jwt = await _storageHelper.readSecure(SecureStorageKey.jwt);
apiClient = ApiClient(baseUrl: url!, token: jwt);
// TODO: check if really logged in
final sheets = await apiClient!.fetchSheets();
log.info("${sheets.length} sheets fetched");
final sheetsSorted = await sortSheetsByRecency(sheets);
log.info("${sheetsSorted.length} sheets sorted");
// TODO: make work
// final changeQueue = await _storageHelper.readChangeQueue();
// changeQueue.applyToSheets(sheetsSorted);
// log.info("${changeQueue.length()} changes applied");
return sheetsSorted;
}
Future<List<Sheet>> sortSheetsByRecency(List<Sheet> sheets) async {
final accessTimes = await _storageHelper.readSheetAccessTimes();
sheets.sort((a, b) {
var dateA = accessTimes[a.uuid];
var dateB = accessTimes[b.uuid];
if (dateA == null || a.updatedAt.isAfter(dateA)) {
dateA = a.updatedAt;
}
if (dateB == null || b.updatedAt.isAfter(dateB)) {
dateB = b.updatedAt;
}
return dateB.compareTo(dateA);
});
return sheets;
}
Future<void> _refreshSheets() async {
setState(() {
sheets = acquireSheets();
});
}
Future<void> _logOut() async {
// Delete saved jwt
await _storageHelper.writeSecure(SecureStorageKey.jwt, null);
if (!mounted) return; // Widget already removed
Navigator.of(
context,
).pushReplacement(MaterialPageRoute(builder: (_) => LoginPage()));
}
void switchShufflingState(bool newState) async {
if (newState) {
(await sheets).shuffle();
} else {
sheets = sortSheetsByRecency(await sheets);
}
setState(() {
shuffling = newState;
});
}
Drawer _buildDrawer() {
return Drawer(
child: SafeArea(
child: Padding(
padding: EdgeInsetsGeometry.directional(start: 10, end: 10, top: 30),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Drawer Actions
Column(
children: [
ListTile(
leading: Icon(
Icons.shuffle,
color: shuffling ? Colors.blue : null,
),
title: const Text('Shuffle'),
onTap: () {
switchShufflingState(!shuffling);
},
),
ListTile(
leading: const Icon(Icons.logout),
title: const Text('Logout'),
onTap: _logOut,
),
],
),
// App Info at bottom
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'$appName v$appVersion',
style: const TextStyle(color: Colors.grey),
),
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
// Icon for drawer appears automatically
appBar: AppBar(title: const Text("Sheetless")),
endDrawer: _buildDrawer(),
body: RefreshIndicator(
onRefresh: _refreshSheets,
child: FutureBuilder(
future: sheets,
builder: (BuildContext context, AsyncSnapshot<List<Sheet>> snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasData) {
return SheetsWidget(
sheets: snapshot.data!,
onSheetOpenRequest: (sheet) {
_storageHelper.writeSheetAccessTime(
sheet.uuid,
DateTime.now(),
);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SheetViewerPage(
sheet: sheet,
apiClient: apiClient!,
config: widget.config,
),
),
);
},
);
} else if (snapshot.hasError) {
log.warning("Error loading sheets:", snapshot.error);
return Center(
child: Text(
style: Theme.of(
context,
).textTheme.displaySmall!.copyWith(color: Colors.red),
textAlign: TextAlign.center,
snapshot.error.toString(),
),
);
} else {
return const Center(child: CircularProgressIndicator());
}
},
),
),
);
}
}

View File

@@ -1,159 +0,0 @@
import 'package:flutter/material.dart';
import 'package:jwt_decoder/jwt_decoder.dart';
import 'package:logging/logging.dart';
import 'package:sheetless/api.dart';
import 'package:sheetless/home_page.dart';
import 'package:sheetless/storage_helper.dart';
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final log = Logger("_LoginPageState");
final TextEditingController _urlController = TextEditingController(
text: "https://sheetable.julian-mutter.de",
);
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final _formKey = GlobalKey<FormState>();
final StorageHelper _storageHelper = StorageHelper();
String? _error;
bool loggingIn = false;
@override
void initState() {
super.initState();
_restoreStoredValues();
}
Future<void> _restoreStoredValues() async {
final jwt = await _storageHelper.readSecure(SecureStorageKey.jwt);
if (jwt != null && await _isJwtValid(jwt)) {
await _navigateToMainPage();
return;
}
final url = await _storageHelper.readSecure(SecureStorageKey.url);
final username = await _storageHelper.readSecure(SecureStorageKey.email);
if (url != null) {
_urlController.text = url;
}
if (username != null) {
_usernameController.text = username;
}
}
Future<bool> _isJwtValid(String jwt) async {
try {
bool expired = JwtDecoder.isExpired(jwt);
return !expired;
} on FormatException {
return false;
}
}
Future<void> _login(
String serverUrl, String username, String password) async {
setState(() {
_error = null;
});
final apiClient = ApiClient(baseUrl: serverUrl);
try {
await apiClient.login(username, password);
await _storageHelper.writeSecure(SecureStorageKey.url, serverUrl);
await _storageHelper.writeSecure(SecureStorageKey.jwt, apiClient.token!);
await _storageHelper.writeSecure(SecureStorageKey.email, username);
await _navigateToMainPage();
} catch (e) {
setState(() {
_error = "Login failed.\n$e";
});
}
}
Future<void> _navigateToMainPage() async {
final config = await _storageHelper.readConfig();
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => MyHomePage(config: config)),
);
}
String? validateNotEmpty(String? content) {
if (content == null || content.isEmpty) {
return "Do not leave this field empty";
}
return null;
}
void handleLoginPressed() async {
if (loggingIn) return;
loggingIn = true;
if (_formKey.currentState!.validate()) {
await _login(
_urlController.text,
_usernameController.text,
_passwordController.text,
);
}
loggingIn = false;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Login')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextFormField(
controller: _urlController,
validator: validateNotEmpty,
decoration: InputDecoration(labelText: 'Url'),
textInputAction: TextInputAction.next,
),
TextFormField(
controller: _usernameController,
validator: validateNotEmpty,
decoration: InputDecoration(labelText: 'Username'),
textInputAction: TextInputAction.next,
),
TextFormField(
controller: _passwordController,
validator: validateNotEmpty,
// focusNode: _passwordFocusNode,
decoration: InputDecoration(labelText: 'Password'),
obscureText: true,
textInputAction: TextInputAction
.next, // with submit or go, onFieldSubmitted is not called
onFieldSubmitted: (_) => handleLoginPressed(),
),
// ),
SizedBox(height: 5),
ElevatedButton(
onPressed: loggingIn ? null : handleLoginPressed,
child: Text('Login'),
),
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(_error!, style: TextStyle(color: Colors.red)),
),
],
),
),
),
);
}
}

View File

@@ -1,49 +1,47 @@
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:pdfrx/pdfrx.dart'; import 'package:pdfrx/pdfrx.dart';
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
import 'login_page.dart'; import 'app.dart';
final RouteObserver<ModalRoute> routeObserver = RouteObserver<ModalRoute>(); /// Application entry point.
///
/// Performs platform-specific initialization before running the app
Future<void> main() async { Future<void> main() async {
Logger.root.level = Level.ALL; // defaults to Level.INFO WidgetsFlutterBinding.ensureInitialized();
await _initializeLogging();
await _initializePlugins();
runApp(const SheetlessApp());
}
/// Configures the logging system.
Future<void> _initializeLogging() async {
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((record) { Logger.root.onRecord.listen((record) {
debugPrint('${record.level.name}: ${record.time}: ${record.message}'); debugPrint('${record.level.name}: ${record.time}: ${record.message}');
if (record.error != null) { if (record.error != null) {
debugPrint('${record.error}'); debugPrint('${record.error}');
} }
}); });
}
// setup for flutter_fullscreen /// Initializes platform-specific plugins and services.
WidgetsFlutterBinding.ensureInitialized(); // Needs to be initialized first, otherwise app gets stuck in splash screen Future<void> _initializePlugins() async {
// Fullscreen support
await FullScreen.ensureInitialized(); await FullScreen.ensureInitialized();
pdfrxFlutterInitialize(); // Needed especially for web // PDF rendering (especially needed for web)
pdfrxFlutterInitialize();
// Local storage (not supported on web)
if (!kIsWeb) { if (!kIsWeb) {
Directory dir = await getApplicationDocumentsDirectory(); final dir = await getApplicationDocumentsDirectory();
Hive.init(dir.path); // Needed only if not web Hive.init(dir.path);
}
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Sheetless',
theme: ThemeData(useMaterial3: true, primarySwatch: Colors.blue),
home: const LoginPage(),
navigatorObservers: [routeObserver],
);
} }
} }

View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Widget that handles Bluetooth pedal and keyboard shortcuts for page turning.
///
/// Listens for arrow key presses and invokes the appropriate callback:
/// - Arrow Down/Right: Turn page forward
/// - Arrow Up/Left: Turn page backward
class PedalShortcuts extends StatelessWidget {
final Widget child;
final VoidCallback onPageForward;
final VoidCallback onPageBackward;
const PedalShortcuts({
super.key,
required this.child,
required this.onPageForward,
required this.onPageBackward,
});
@override
Widget build(BuildContext context) {
return CallbackShortcuts(
bindings: _buildBindings(),
child: Focus(autofocus: true, child: child),
);
}
Map<ShortcutActivator, VoidCallback> _buildBindings() {
return {
// Forward navigation
const SingleActivator(LogicalKeyboardKey.arrowDown): onPageForward,
const SingleActivator(LogicalKeyboardKey.arrowRight): onPageForward,
// Backward navigation
const SingleActivator(LogicalKeyboardKey.arrowUp): onPageBackward,
const SingleActivator(LogicalKeyboardKey.arrowLeft): onPageBackward,
};
}
}

View File

@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import '../../core/models/sheet.dart';
/// Callback when sheet metadata is saved.
typedef SheetEditCallback = void Function(String newName, String newComposer);
/// Bottom sheet for editing sheet metadata (name and composer).
class EditSheetBottomSheet extends StatefulWidget {
final Sheet sheet;
final SheetEditCallback onSave;
const EditSheetBottomSheet({
super.key,
required this.sheet,
required this.onSave,
});
@override
State<EditSheetBottomSheet> createState() => _EditSheetBottomSheetState();
}
class _EditSheetBottomSheetState extends State<EditSheetBottomSheet> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameController;
late final TextEditingController _composerController;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.sheet.name);
_composerController = TextEditingController(
text: widget.sheet.composerName,
);
}
@override
void dispose() {
_nameController.dispose();
_composerController.dispose();
super.dispose();
}
void _handleSave() {
if (!_formKey.currentState!.validate()) return;
widget.onSave(_nameController.text.trim(), _composerController.text.trim());
Navigator.pop(context);
}
String? _validateNotEmpty(String? value) {
if (value == null || value.trim().isEmpty) {
return 'This field cannot be empty';
}
return null;
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
MediaQuery.of(context).viewInsets.bottom + 16,
),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Edit Sheet',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
TextFormField(
controller: _nameController,
validator: _validateNotEmpty,
decoration: const InputDecoration(
labelText: 'Sheet Name',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
TextFormField(
controller: _composerController,
validator: _validateNotEmpty,
decoration: const InputDecoration(
labelText: 'Composer',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _handleSave(),
),
const SizedBox(height: 16),
ElevatedButton(onPressed: _handleSave, child: const Text('Save')),
],
),
),
);
}
}

View File

@@ -1,185 +0,0 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:sheetless/edit_bottom_sheet.dart';
import 'package:sheetless/storage_helper.dart';
import 'package:sheetless/upload_queue.dart';
class Sheet {
final String uuid;
String name;
String composerUuid;
String composerName;
DateTime updatedAt;
Sheet({
required this.uuid,
required this.name,
required this.composerUuid,
required this.composerName,
required this.updatedAt,
});
// Factory constructor for creating a Sheet from JSON
factory Sheet.fromJson(Map<String, dynamic> json) {
final composer = json['composer'] as Map<String, dynamic>?;
return Sheet(
uuid: json['uuid'].toString(),
name: json['title'],
composerUuid: json['composer_uuid']?.toString() ?? '',
composerName: composer?['name'] ?? 'Unknown',
updatedAt: DateTime.parse(json['updated_at']),
);
}
}
class SheetsWidget extends StatefulWidget {
final List<Sheet> sheets;
final ValueSetter<Sheet> onSheetOpenRequest;
const SheetsWidget({
super.key,
required this.sheets,
required this.onSheetOpenRequest,
});
@override
State<SheetsWidget> createState() => _SheetsWidgetState();
}
class _SheetsWidgetState extends State<SheetsWidget> {
late List<Sheet> filteredSheets;
final StorageHelper storageHelper = StorageHelper();
final TextEditingController _searchController = TextEditingController();
Timer? _debounce;
@override
void initState() {
super.initState();
filteredSheets = widget.sheets;
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_searchController.removeListener(_onSearchChanged);
_searchController.dispose();
_debounce?.cancel();
super.dispose();
}
void _onSearchChanged() {
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 500), () {
_filterSheets();
});
}
void _filterSheets() {
setState(() {
String query = _searchController.text.toLowerCase().trim();
List<String> terms = query.split(RegExp(r'\s+')); // Split by whitespace
filteredSheets = widget.sheets.where((sheet) {
String name = sheet.name.toLowerCase();
String composer = sheet.composerName.toLowerCase();
// Each term must be found in either the name or composer
return terms.every(
(term) => name.contains(term) || composer.contains(term),
);
}).toList();
});
}
void _clearSearch() {
_searchController.clear();
}
void _openEditSheet(BuildContext context, Sheet sheet) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => EditItemBottomSheet(
sheet: sheet,
onSheetEdited: (String newName, String newComposer) {
if (newName != sheet.name) {
storageHelper.writeChange(
Change(
type: ChangeType.sheetNameChange,
sheetUuid: sheet.uuid,
value: newName,
),
);
}
if (newComposer != sheet.composerName) {
storageHelper.writeChange(
Change(
type: ChangeType.composerNameChange,
sheetUuid: sheet.uuid,
value: newComposer,
),
);
}
setState(() {
sheet.name = newName;
sheet.composerName = newComposer;
});
},
),
);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: _clearSearch,
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
),
),
),
),
Expanded(
// Fixes scroll on web
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse},
),
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: filteredSheets.length,
itemBuilder: (context, index) {
var sheet = filteredSheets[index];
return ListTile(
title: Text(sheet.name),
subtitle: Text(sheet.composerName),
onTap: () => setState(() {
widget.onSheetOpenRequest(sheet);
widget.sheets.remove(sheet);
widget.sheets.insert(0, sheet);
}),
onLongPress: () => _openEditSheet(context, sheet),
);
},
),
),
),
],
);
}
}

View File

@@ -1,426 +0,0 @@
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_drawing_board/flutter_drawing_board.dart';
import 'package:logging/logging.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:sheetless/api.dart';
import 'package:sheetless/bt_pedal_shortcuts.dart';
import 'package:sheetless/sheet.dart';
import 'package:sheetless/storage_helper.dart';
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
class SheetViewerPage extends StatefulWidget {
final Sheet sheet;
final ApiClient apiClient;
final Config config;
const SheetViewerPage({
super.key,
required this.sheet,
required this.apiClient,
required this.config,
});
@override
State<SheetViewerPage> createState() => _SheetViewerPageState();
}
class _SheetViewerPageState extends State<SheetViewerPage>
with FullScreenListener {
final log = Logger("SheetViewerPage");
final StorageHelper storageHelper = StorageHelper();
int currentPageNumber = 1;
int numPages = 1;
late Future<bool> documentLoaded;
PdfDocument? document;
bool paintMode = false;
Pages? pages;
@override
void initState() {
FullScreen.addListener(this);
// Load saved fullscreen
FullScreen.setFullScreen(widget.config.fullscreen);
super.initState();
documentLoaded = loadPdf();
}
@override
void dispose() {
FullScreen.removeListener(this);
document?.dispose(); // Make sure document gets garbage collected
super.dispose();
}
Future<bool> loadPdf() async {
if (kIsWeb) {
final data = await widget.apiClient.fetchPdfFileData(widget.sheet.uuid);
document = await PdfDocument.openData(data);
} else {
final file = await widget.apiClient.getPdfFileCached(widget.sheet.uuid);
document = await PdfDocument.openFile(file.path);
}
setState(() {
// document changed
});
return true;
}
@override
void onFullScreenChanged(bool enabled, SystemUiMode? systemUiMode) {
setState(() {
widget.config.fullscreen = enabled;
storageHelper.writeConfig(widget.config);
});
}
void toggleFullscreen() {
FullScreen.setFullScreen(!widget.config.fullscreen);
}
void turnPage(int numTurns) {
setState(() {
currentPageNumber += numTurns;
currentPageNumber = currentPageNumber.clamp(1, numPages);
});
}
AppBar? buildAppBar() {
if (widget.config.fullscreen && document != null) {
return null;
}
return AppBar(
title: Text(widget.sheet.name),
actions: [
IconButton(
icon: Icon(
widget.config.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
),
tooltip: widget.config.fullscreen
? 'Exit Fullscreen'
: 'Enter Fullscreen',
onPressed: () {
toggleFullscreen();
},
),
IconButton(
onPressed: () {
setState(() {
if (widget.config.twoPageMode) {
// TODO: notification that paint mode only in single page mode
} else {
paintMode = !paintMode;
}
});
},
icon: Icon(Icons.brush),
),
IconButton(
onPressed: () {
setState(() {
widget.config.twoPageMode = !widget.config.twoPageMode;
storageHelper.writeConfig(widget.config);
if (widget.config.twoPageMode) {
paintMode = false;
// TODO: notification that paint mode was deactivated since only possible in single page mode
}
});
},
icon: Icon(
widget.config.twoPageMode ? Icons.filter_1 : Icons.filter_2,
),
),
],
);
}
@override
Widget build(BuildContext context) {
return BtPedalShortcuts(
onTurnPageForward: () => turnPage(1),
onTurnPageBackward: () => turnPage(-1),
child: Scaffold(
appBar: buildAppBar(),
body: FutureBuilder(
future: documentLoaded,
builder: (BuildContext context, AsyncSnapshot<bool> snapshot) {
if (snapshot.hasData && document != null) {
numPages = document!.pages.length;
pages = Pages(
document: document!,
numPages: numPages,
config: widget.config,
currentPageNumber: currentPageNumber,
);
return Stack(
children: [
Stack(
children: [
Visibility(
visible: !paintMode,
child: TouchablePages(
pages: pages!,
onToggleFullscreen: () {
toggleFullscreen();
},
onExitSheetViewer: () {
Navigator.pop(context);
},
onTurnPage: (int numTurns) {
turnPage(numTurns);
},
),
),
Visibility(
visible: paintMode,
child: PaintablePages(pages: pages!),
),
],
),
],
);
} else if (snapshot.hasError) {
log.warning("Error loading pdf:", snapshot.error);
return Center(
child: Text(
style: Theme.of(
context,
).textTheme.displaySmall!.copyWith(color: Colors.red),
textAlign: TextAlign.center,
snapshot.error.toString(),
),
);
} else {
return const Center(child: CircularProgressIndicator());
}
},
),
),
);
}
}
typedef PageturnCallback = void Function(int numTurns);
class TouchablePages extends StatelessWidget {
final Pages pages;
final VoidCallback onToggleFullscreen;
final VoidCallback onExitSheetViewer;
final PageturnCallback onTurnPage;
const TouchablePages({
super.key,
required this.pages,
required this.onToggleFullscreen,
required this.onExitSheetViewer,
required this.onTurnPage,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior:
HitTestBehavior.opaque, // Also register when outside of pdf pages
onTapUp: (TapUpDetails details) {
final mediaQuery = MediaQuery.of(context);
final pixelsPerInch =
mediaQuery.devicePixelRatio *
160; // 160 dpi = 1 logical inch baseline
final pixelsPerCm = pixelsPerInch / 2.54;
final touchAreaWidth = mediaQuery.size.width;
final (leftPageSize, rightPageSize) = pages.calcPageSizesScaled(
mediaQuery.size,
);
if (details.localPosition.dy < 2 * pixelsPerCm &&
details.localPosition.dx >= touchAreaWidth - 2 * pixelsPerCm &&
pages.config.fullscreen) {
onExitSheetViewer();
} else if (details.localPosition.dy < 2 * pixelsPerCm) {
onToggleFullscreen();
} else if (pages.config.twoPageMode &&
details.localPosition.dx <
touchAreaWidth / 2 - leftPageSize.width / 2) {
onTurnPage(-2);
} else if (details.localPosition.dx < touchAreaWidth / 2) {
onTurnPage(-1);
} else if (pages.config.twoPageMode &&
details.localPosition.dx >
touchAreaWidth / 2 + rightPageSize!.width / 2) {
onTurnPage(2);
} else {
onTurnPage(1);
}
},
child: pages,
);
}
}
class PaintablePages extends StatelessWidget {
final Pages pages;
const PaintablePages({super.key, required this.pages});
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: LayoutBuilder(
builder: (context, constraints) {
final maxSize = Size(constraints.maxWidth, constraints.maxHeight);
final (pageSizeScaled, _) = pages.calcPageSizesScaled(maxSize);
return DrawingBoard(
background: SizedBox(
width: pageSizeScaled.width,
height: pageSizeScaled.height,
child: pages,
),
// showDefaultTools: true,
// showDefaultActions: true,
boardConstrained: true,
minScale: 1,
maxScale: 3,
alignment: Alignment.topRight,
boardBoundaryMargin: EdgeInsets.all(0),
);
},
),
);
}
}
class Pages extends StatelessWidget {
final PdfDocument document;
final int numPages;
final int currentPageNumber; // Starts at 1
final Config config;
const Pages({
super.key,
required this.document,
required this.numPages,
required this.currentPageNumber,
required this.config,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 0,
children: [
Expanded(
child: Stack(
children: [
PdfPageView(
key: ValueKey(currentPageNumber),
document: document,
pageNumber: currentPageNumber,
maximumDpi: 300,
alignment: config.twoPageMode && numPages >= 2
? Alignment.centerRight
: Alignment.center,
),
Positioned.fill(
child: Container(
alignment: Alignment.bottomCenter,
padding: EdgeInsets.only(bottom: 5),
child: Text('$currentPageNumber / $numPages'),
),
),
],
),
),
Visibility(
visible: config.twoPageMode == true && numPages >= 2,
child: Expanded(
child: Stack(
children: [
PdfPageView(
key: ValueKey(currentPageNumber + 1),
document: document,
pageNumber: currentPageNumber + 1,
maximumDpi: 300,
alignment: Alignment.centerLeft,
// alignment: Alignment.center,
),
Positioned.fill(
child: Container(
alignment: Alignment.bottomCenter,
padding: EdgeInsets.only(bottom: 5),
child: Text('${currentPageNumber + 1} / $numPages'),
),
),
],
),
),
),
],
);
}
(Size, Size?) calcPageSizesScaled(Size parentSize) {
if (config.twoPageMode) {
Size leftPageSizeUnscaled = _getPageSizeUnscaled(currentPageNumber);
Size rightPageSizeUnscaled;
if (numPages > currentPageNumber) {
rightPageSizeUnscaled = _getPageSizeUnscaled(currentPageNumber + 1);
} else {
rightPageSizeUnscaled = leftPageSizeUnscaled;
}
Size combinedPageSizesUnscaled = Size(
leftPageSizeUnscaled.width + rightPageSizeUnscaled.width,
max(leftPageSizeUnscaled.height, rightPageSizeUnscaled.height),
);
Size combinedPageSizesScaled = _calcScaledPageSize(
parentSize,
combinedPageSizesUnscaled,
);
double scaleFactor =
combinedPageSizesScaled.width / combinedPageSizesUnscaled.width;
return (
leftPageSizeUnscaled * scaleFactor,
rightPageSizeUnscaled * scaleFactor,
);
} else {
return (
_calcScaledPageSize(
parentSize,
_getPageSizeUnscaled(currentPageNumber),
),
null,
);
}
}
Size _getPageSizeUnscaled(int pageNumber) {
return document.pages.elementAt(pageNumber - 1).size;
}
Size _calcScaledPageSize(Size parentSize, Size pageSize) {
// page restricted by height
if (parentSize.aspectRatio > pageSize.aspectRatio) {
final height = parentSize.height;
final width = height * pageSize.aspectRatio;
return Size(width, height);
}
// page restricted by width
else {
final width = parentSize.width;
final height = width / pageSize.aspectRatio;
return Size(width, height);
}
}
}

View File

@@ -1,84 +0,0 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:hive/hive.dart';
import 'package:sheetless/upload_queue.dart';
enum SecureStorageKey { url, jwt, email }
enum ConfigKey { twoPageMode }
class Config {
Config({required this.twoPageMode, required this.fullscreen});
static const String keyTwoPageMode = "twoPageMode";
static const String keyFullscreen = "fullscreen";
bool twoPageMode;
bool fullscreen;
}
class StorageHelper {
final sheetAccessTimesBox = "sheetAccessTimes";
final configBox = "config";
final changeQueueBox = "changeQueue";
late FlutterSecureStorage secureStorage;
StorageHelper() {
AndroidOptions getAndroidOptions() =>
const AndroidOptions(encryptedSharedPreferences: true);
secureStorage = FlutterSecureStorage(aOptions: getAndroidOptions());
}
Future<String?> readSecure(SecureStorageKey key) {
return secureStorage.read(key: key.name);
}
Future<void> writeSecure(SecureStorageKey key, String? value) {
return secureStorage.write(key: key.name, value: value);
}
Future<Config> readConfig() async {
final box = await Hive.openBox(configBox);
return Config(
twoPageMode: box.get(Config.keyTwoPageMode) ?? false,
fullscreen: box.get(Config.keyFullscreen) ?? false,
);
}
Future<void> writeConfig(Config config) async {
final box = await Hive.openBox(configBox);
box.put(Config.keyTwoPageMode, config.twoPageMode);
box.put(Config.keyFullscreen, config.fullscreen);
}
Future<Map<String, DateTime>> readSheetAccessTimes() async {
final box = await Hive.openBox(sheetAccessTimesBox);
return box.toMap().map(
(k, v) => MapEntry(k as String, DateTime.parse(v as String)),
);
}
Future<void> writeSheetAccessTime(String uuid, DateTime datetime) async {
final box = await Hive.openBox(sheetAccessTimesBox);
await box.put(uuid, datetime.toIso8601String());
}
Future<void> writeChange(Change change) async {
final box = await Hive.openBox(changeQueueBox);
box.add(change.toMap());
}
Future<ChangeQueue> readChangeQueue() async {
final box = await Hive.openBox(changeQueueBox);
ChangeQueue queue = ChangeQueue();
for (Map<dynamic, dynamic> map in box.values) {
queue.addChange(Change.fromMap(map));
}
return queue;
}
Future<void> deleteOldestChange(Change change) async {
final box = await Hive.openBox(changeQueueBox);
box.deleteAt(0);
}
}

View File

@@ -1,66 +0,0 @@
import 'dart:collection';
import 'package:sheetless/sheet.dart';
enum ChangeType {
sheetNameChange,
composerNameChange,
addTagChange,
removeTagChange,
}
class Change {
final ChangeType type;
final String sheetUuid;
final String value;
Change({required this.type, required this.sheetUuid, required this.value});
Map<String, dynamic> toMap() => {
"type": type.index,
"sheetUuid": sheetUuid,
"value": value,
};
factory Change.fromMap(Map<dynamic, dynamic> map) {
return Change(
type: ChangeType
.values[map["type"]], // TODO: this will create problems if new enums are added
sheetUuid: map["sheetUuid"],
value: map["value"],
);
}
}
class ChangeQueue {
// Queue with oldest change first
final Queue<Change> queue = Queue();
ChangeQueue() {}
void addChange(Change change) {
queue.addLast(change);
}
int length() {
return queue.length;
}
void applyToSheets(List<Sheet> sheets) {
for (Change change in queue) {
var sheet = sheets.where((sheet) => sheet.uuid == change.sheetUuid).first;
switch (change.type) {
case ChangeType.sheetNameChange:
sheet.name = change.value;
case ChangeType.composerNameChange:
sheet.composerName = change.value;
case ChangeType.addTagChange:
// TODO: Handle this case.
throw UnimplementedError();
case ChangeType.removeTagChange:
// TODO: Handle this case.
throw UnimplementedError();
}
}
}
}

View File

@@ -10,7 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
pdfrx pdfium_flutter
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)

View File

@@ -1,3 +1,3 @@
# This file was automatically generated by nix-shell. # This file was automatically generated by nix-shell.
sdk.dir=/nix/store/0sbpck94abij5q1hcq1jzps3lr68p1dq-androidsdk/libexec/android-sdk sdk.dir=/nix/store/ri84qrj5wk61x6cja29cfcv5x0iarhs5-androidsdk/libexec/android-sdk
ndk.dir=/nix/store/0sbpck94abij5q1hcq1jzps3lr68p1dq-androidsdk/libexec/android-sdk/ndk-bundle ndk.dir=/nix/store/ri84qrj5wk61x6cja29cfcv5x0iarhs5-androidsdk/libexec/android-sdk/ndk-bundle

View File

@@ -5,18 +5,16 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import flutter_secure_storage_macos import flutter_secure_storage_darwin
import package_info_plus import package_info_plus
import path_provider_foundation
import screen_retriever_macos import screen_retriever_macos
import sqflite_darwin import sqflite_darwin
import url_launcher_macos import url_launcher_macos
import window_manager import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))

View File

@@ -65,6 +65,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@@ -77,10 +85,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: crypto name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.6" version: "3.0.7"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -89,14 +97,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.8" version: "1.0.8"
dart_pubspec_licenses:
dependency: transitive
description:
name: dart_pubspec_licenses
sha256: "23ddb78ff9204d08e3109ced67cd3c6c6a066f581b0edf5ee092fc3e1127f4ea"
url: "https://pub.dev"
source: hosted
version: "3.0.4"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -109,10 +109,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: ffi name: ffi
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.1.5"
file: file:
dependency: transitive dependency: transitive
description: description:
@@ -146,10 +146,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_drawing_board name: flutter_drawing_board
sha256: b7eef8c506a12485853e8b4cdc110d1d307993279b588f3a35902a7d7082dbd4 sha256: "0fc6b73ac6a54f23d0357ff3f3a804156315f43212a417406062462fe2e3ca7b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.9.8" version: "1.0.1+2"
flutter_fullscreen: flutter_fullscreen:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -178,50 +178,50 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_secure_storage name: flutter_secure_storage
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.2.4" version: "10.0.0"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
flutter_secure_storage_linux: flutter_secure_storage_linux:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_linux name: flutter_secure_storage_linux
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.3" version: "3.0.0"
flutter_secure_storage_macos:
dependency: transitive
description:
name: flutter_secure_storage_macos
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
flutter_secure_storage_platform_interface: flutter_secure_storage_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_platform_interface name: flutter_secure_storage_platform_interface
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "2.0.1"
flutter_secure_storage_web: flutter_secure_storage_web:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_web name: flutter_secure_storage_web
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.1" version: "2.1.0"
flutter_secure_storage_windows: flutter_secure_storage_windows:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_windows name: flutter_secure_storage_windows
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" version: "4.1.0"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -232,6 +232,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
hive: hive:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -240,14 +248,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.3" version: "2.2.3"
hooks:
dependency: transitive
description:
name: hooks
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
name: http name: http
sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.6.0"
http_parser: http_parser:
dependency: transitive dependency: transitive
description: description:
@@ -260,26 +276,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: image name: image
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.4" version: "4.7.2"
js:
dependency: transitive
description:
name: js
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.dev"
source: hosted
version: "0.6.7"
json_annotation: json_annotation:
dependency: transitive dependency: transitive
description: description:
name: json_annotation name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.9.0" version: "4.10.0"
jwt_decoder: jwt_decoder:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -292,34 +300,34 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.9" version: "11.0.2"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.9" version: "3.0.10"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_testing name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" version: "3.0.2"
lints: lints:
dependency: transitive dependency: transitive
description: description:
name: lints name: lints
sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" version: "6.1.0"
logging: logging:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -348,10 +356,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.16.0" version: "1.17.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
url: "https://pub.dev"
source: hosted
version: "0.17.4"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "983c7fa1501f6dcc0cb7af4e42072e9993cb28d73604d25ebf4dab08165d997e"
url: "https://pub.dev"
source: hosted
version: "9.2.5"
package_info_plus: package_info_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -388,18 +412,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: path_provider_android name: path_provider_android
sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.17" version: "2.2.22"
path_provider_foundation: path_provider_foundation:
dependency: transitive dependency: transitive
description: description:
name: path_provider_foundation name: path_provider_foundation
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" version: "2.6.0"
path_provider_linux: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@@ -424,22 +448,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
pdfium_dart:
dependency: transitive
description:
name: pdfium_dart
sha256: f1683b9070ddc5c9189c6ee008c285791da66328ce1b882c7162d3393f3a4a74
url: "https://pub.dev"
source: hosted
version: "0.1.3"
pdfium_flutter:
dependency: transitive
description:
name: pdfium_flutter
sha256: "0c8b7d5d11d20a1486eade599648e907067568955bd14a1b06de076a968b60a1"
url: "https://pub.dev"
source: hosted
version: "0.1.9"
pdfrx: pdfrx:
dependency: "direct main" dependency: "direct main"
description: description:
name: pdfrx name: pdfrx
sha256: a88e7797623d3b89d4a863f583c973c681f39aa48eadceb4aa61c0c99cc7fa19 sha256: e32e0c786528eec2b3c56b43f59ef1debce3a27c7accd862b95413f949afcfa9
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.3" version: "2.2.24"
pdfrx_engine: pdfrx_engine:
dependency: transitive dependency: transitive
description: description:
name: pdfrx_engine name: pdfrx_engine
sha256: "69a2448ca87d0536fe4eb78fbe753fb2fb2f8ae3a1b73e2fd1937585e717c2b1" sha256: a8914433d1f6188b903c53d36b9d7dc908bfa89131591a9db22f1a22470d3a48
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.12" version: "0.3.9"
permission_handler: permission_handler:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -492,10 +532,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: petitparser name: petitparser
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "7.0.1"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@@ -520,6 +560,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.3" version: "6.0.3"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
rxdart: rxdart:
dependency: transitive dependency: transitive
description: description:
@@ -581,14 +629,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.1" version: "1.10.1"
sprintf:
dependency: transitive
description:
name: sprintf
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
sqflite: sqflite:
dependency: transitive dependency: transitive
description: description:
@@ -601,10 +641,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: sqflite_android name: sqflite_android
sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" version: "2.4.2+2"
sqflite_common: sqflite_common:
dependency: transitive dependency: transitive
description: description:
@@ -673,10 +713,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.4" version: "0.7.7"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -697,34 +737,34 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_android name: url_launcher_android
sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.16" version: "6.3.28"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_ios name: url_launcher_ios
sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.3" version: "6.3.6"
url_launcher_linux: url_launcher_linux:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_linux name: url_launcher_linux
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.1" version: "3.2.2"
url_launcher_macos: url_launcher_macos:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_macos name: url_launcher_macos
sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.2" version: "3.2.5"
url_launcher_platform_interface: url_launcher_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -737,42 +777,42 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_web name: url_launcher_web
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" version: "2.4.2"
url_launcher_windows: url_launcher_windows:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_windows name: url_launcher_windows
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.4" version: "3.1.5"
uuid: uuid:
dependency: transitive dependency: transitive
description: description:
name: uuid name: uuid
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.1" version: "4.5.2"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.2.0"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.0.0" version: "15.0.2"
web: web:
dependency: transitive dependency: transitive
description: description:
@@ -785,10 +825,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: win32 name: win32
sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.14.0" version: "5.15.0"
window_manager: window_manager:
dependency: transitive dependency: transitive
description: description:
@@ -809,10 +849,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: xml name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.5.0" version: "6.6.1"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:
@@ -822,5 +862,5 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.8.1 <4.0.0" dart: ">=3.10.3 <4.0.0"
flutter: ">=3.29.0" flutter: ">=3.38.4"

View File

@@ -41,11 +41,11 @@ dependencies:
http: ^1.2.2 http: ^1.2.2
path_provider: ^2.1.5 path_provider: ^2.1.5
flutter_cache_manager: ^3.4.1 flutter_cache_manager: ^3.4.1
flutter_secure_storage: ^9.2.2 flutter_secure_storage: ^10.0.0
jwt_decoder: ^2.0.1 jwt_decoder: ^2.0.1
pdfrx: ^2.0.4 pdfrx: ^2.0.4
logging: ^1.3.0 logging: ^1.3.0
flutter_drawing_board: ^0.9.8 flutter_drawing_board: ^1.0.1+2
flutter_launcher_icons: ^0.14.4 flutter_launcher_icons: ^0.14.4
hive: ^2.2.3 hive: ^2.2.3
flutter_fullscreen: ^1.2.0 flutter_fullscreen: ^1.2.0

View File

@@ -7,13 +7,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sheetless/app.dart';
import 'package:sheetless/main.dart';
void main() { void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async { testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame. // Build our app and trigger a frame.
await tester.pumpWidget(const MyApp()); await tester.pumpWidget(const SheetlessApp());
// Verify that our counter starts at 0. // Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget); expect(find.text('0'), findsOneWidget);

View File

@@ -11,7 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
pdfrx pdfium_flutter
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)