Complete refactor to clean up project
This commit is contained in:
242
lib/features/auth/login_page.dart
Normal file
242
lib/features/auth/login_page.dart
Normal file
@@ -0,0 +1,242 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:jwt_decoder/jwt_decoder.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:sheetless/core/services/api_client.dart';
|
||||
import 'package:sheetless/core/services/storage_service.dart';
|
||||
|
||||
import '../home/home_page.dart';
|
||||
|
||||
/// Login page for authenticating with the Sheetless server.
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
final _log = Logger('LoginPage');
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _storageService = StorageService();
|
||||
|
||||
final _urlController = TextEditingController(
|
||||
text: 'https://sheetless.julian-mutter.de',
|
||||
);
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
String? _errorMessage;
|
||||
bool _isLoggingIn = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tryAutoLogin();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_urlController.dispose();
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto-Login
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Attempts to auto-login using a stored JWT token.
|
||||
Future<void> _tryAutoLogin() async {
|
||||
final jwt = await _storageService.readSecure(SecureStorageKey.jwt);
|
||||
|
||||
if (jwt != null && _isTokenValid(jwt)) {
|
||||
await _navigateToHome();
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore saved credentials for convenience
|
||||
await _restoreCredentials();
|
||||
}
|
||||
|
||||
/// Checks if a JWT token is still valid (not expired).
|
||||
bool _isTokenValid(String jwt) {
|
||||
try {
|
||||
return !JwtDecoder.isExpired(jwt);
|
||||
} on FormatException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores previously saved URL and username for convenience.
|
||||
Future<void> _restoreCredentials() async {
|
||||
final url = await _storageService.readSecure(SecureStorageKey.url);
|
||||
final username = await _storageService.readSecure(SecureStorageKey.email);
|
||||
|
||||
if (url != null) _urlController.text = url;
|
||||
if (username != null) _usernameController.text = username;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Login
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Handles the login button press.
|
||||
Future<void> _handleLogin() async {
|
||||
if (_isLoggingIn) return;
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() {
|
||||
_isLoggingIn = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final apiClient = ApiClient(baseUrl: _urlController.text);
|
||||
await apiClient.login(_usernameController.text, _passwordController.text);
|
||||
|
||||
// Save credentials for next time
|
||||
await _saveCredentials(apiClient.token!);
|
||||
await _navigateToHome();
|
||||
} catch (e) {
|
||||
_log.warning('Login failed', e);
|
||||
setState(() {
|
||||
_errorMessage = 'Login failed.\n$e';
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoggingIn = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves credentials after successful login.
|
||||
Future<void> _saveCredentials(String token) async {
|
||||
await _storageService.writeSecure(
|
||||
SecureStorageKey.url,
|
||||
_urlController.text,
|
||||
);
|
||||
await _storageService.writeSecure(SecureStorageKey.jwt, token);
|
||||
await _storageService.writeSecure(
|
||||
SecureStorageKey.email,
|
||||
_usernameController.text,
|
||||
);
|
||||
}
|
||||
|
||||
/// Navigates to the home page after successful authentication.
|
||||
Future<void> _navigateToHome() async {
|
||||
final config = await _storageService.readConfig();
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => HomePage(config: config)),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
String? _validateNotEmpty(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'This field is required';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Login')),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildUrlField(),
|
||||
const SizedBox(height: 16),
|
||||
_buildUsernameField(),
|
||||
const SizedBox(height: 16),
|
||||
_buildPasswordField(),
|
||||
const SizedBox(height: 24),
|
||||
_buildLoginButton(),
|
||||
if (_errorMessage != null) _buildErrorMessage(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUrlField() {
|
||||
return TextFormField(
|
||||
controller: _urlController,
|
||||
validator: _validateNotEmpty,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Server URL',
|
||||
prefixIcon: Icon(Icons.dns),
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
textInputAction: TextInputAction.next,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUsernameField() {
|
||||
return TextFormField(
|
||||
controller: _usernameController,
|
||||
validator: _validateNotEmpty,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Username',
|
||||
prefixIcon: Icon(Icons.person),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPasswordField() {
|
||||
return TextFormField(
|
||||
controller: _passwordController,
|
||||
validator: _validateNotEmpty,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Password',
|
||||
prefixIcon: Icon(Icons.lock),
|
||||
),
|
||||
obscureText: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _handleLogin(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoginButton() {
|
||||
return ElevatedButton(
|
||||
onPressed: _isLoggingIn ? null : _handleLogin,
|
||||
child: _isLoggingIn
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Login'),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorMessage() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
235
lib/features/home/home_page.dart
Normal file
235
lib/features/home/home_page.dart
Normal file
@@ -0,0 +1,235 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:sheetless/core/models/config.dart';
|
||||
import 'package:sheetless/core/models/sheet.dart';
|
||||
import 'package:sheetless/core/services/api_client.dart';
|
||||
import 'package:sheetless/core/services/storage_service.dart';
|
||||
|
||||
import '../../app.dart';
|
||||
import '../auth/login_page.dart';
|
||||
import '../sheet_viewer/sheet_viewer_page.dart';
|
||||
import 'widgets/app_drawer.dart';
|
||||
import 'widgets/sheets_list.dart';
|
||||
|
||||
/// Main home page displaying the list of sheet music.
|
||||
///
|
||||
/// Features:
|
||||
/// - Pull-to-refresh sheet list
|
||||
/// - Shuffle mode for random practice
|
||||
/// - Navigation to sheet viewer
|
||||
/// - Logout functionality
|
||||
class HomePage extends StatefulWidget {
|
||||
final Config config;
|
||||
|
||||
const HomePage({super.key, required this.config});
|
||||
|
||||
@override
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> with RouteAware {
|
||||
final _log = Logger('HomePage');
|
||||
final _storageService = StorageService();
|
||||
|
||||
ApiClient? _apiClient;
|
||||
late Future<List<Sheet>> _sheetsFuture;
|
||||
bool _isShuffling = false;
|
||||
String? _appName;
|
||||
String? _appVersion;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Exit fullscreen when entering home page
|
||||
FullScreen.setFullScreen(false);
|
||||
|
||||
// Subscribe to route changes
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
routeObserver.subscribe(this, ModalRoute.of(context)!);
|
||||
});
|
||||
|
||||
_loadAppInfo();
|
||||
_sheetsFuture = _loadSheets();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
routeObserver.unsubscribe(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route Aware (Fullscreen Management)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@override
|
||||
void didPush() {
|
||||
FullScreen.setFullScreen(false);
|
||||
super.didPush();
|
||||
}
|
||||
|
||||
@override
|
||||
void didPopNext() {
|
||||
// Exit fullscreen when returning to home page
|
||||
FullScreen.setFullScreen(false);
|
||||
super.didPopNext();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data Loading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Future<void> _loadAppInfo() async {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
setState(() {
|
||||
_appName = info.appName;
|
||||
_appVersion = info.version;
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<Sheet>> _loadSheets() async {
|
||||
final url = await _storageService.readSecure(SecureStorageKey.url);
|
||||
final jwt = await _storageService.readSecure(SecureStorageKey.jwt);
|
||||
|
||||
_apiClient = ApiClient(baseUrl: url!, token: jwt);
|
||||
|
||||
final sheets = await _apiClient!.fetchSheets();
|
||||
_log.info('${sheets.length} sheets fetched');
|
||||
|
||||
final sortedSheets = await _sortSheetsByRecency(sheets);
|
||||
_log.info('${sortedSheets.length} sheets sorted');
|
||||
|
||||
return sortedSheets;
|
||||
}
|
||||
|
||||
Future<List<Sheet>> _sortSheetsByRecency(List<Sheet> sheets) async {
|
||||
final accessTimes = await _storageService.readSheetAccessTimes();
|
||||
|
||||
sheets.sort((a, b) {
|
||||
// Use local access time if available and more recent than server update
|
||||
var dateA = accessTimes[a.uuid];
|
||||
var dateB = accessTimes[b.uuid];
|
||||
|
||||
if (dateA == null || a.updatedAt.isAfter(dateA)) {
|
||||
dateA = a.updatedAt;
|
||||
}
|
||||
if (dateB == null || b.updatedAt.isAfter(dateB)) {
|
||||
dateB = b.updatedAt;
|
||||
}
|
||||
|
||||
return dateB.compareTo(dateA); // Most recent first
|
||||
});
|
||||
|
||||
return sheets;
|
||||
}
|
||||
|
||||
Future<void> _refreshSheets() async {
|
||||
setState(() {
|
||||
_sheetsFuture = _loadSheets();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void _handleShuffleChanged(bool enabled) async {
|
||||
final sheets = await _sheetsFuture;
|
||||
|
||||
if (enabled) {
|
||||
sheets.shuffle();
|
||||
} else {
|
||||
await _sortSheetsByRecency(sheets);
|
||||
}
|
||||
|
||||
setState(() => _isShuffling = enabled);
|
||||
}
|
||||
|
||||
Future<void> _handleLogout() async {
|
||||
await _storageService.clearToken();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.of(
|
||||
context,
|
||||
).pushReplacement(MaterialPageRoute(builder: (_) => const LoginPage()));
|
||||
}
|
||||
|
||||
void _openSheet(Sheet sheet) {
|
||||
// Record access time for recency sorting
|
||||
_storageService.writeSheetAccessTime(sheet.uuid, DateTime.now());
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => SheetViewerPage(
|
||||
sheet: sheet,
|
||||
apiClient: _apiClient!,
|
||||
config: widget.config,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Sheetless')),
|
||||
endDrawer: AppDrawer(
|
||||
isShuffling: _isShuffling,
|
||||
onShuffleChanged: _handleShuffleChanged,
|
||||
onLogout: _handleLogout,
|
||||
appName: _appName,
|
||||
appVersion: _appVersion,
|
||||
),
|
||||
body: RefreshIndicator(onRefresh: _refreshSheets, child: _buildBody()),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
return FutureBuilder<List<Sheet>>(
|
||||
future: _sheetsFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
_log.warning('Error loading sheets', snapshot.error);
|
||||
return _buildError(snapshot.error.toString());
|
||||
}
|
||||
|
||||
if (snapshot.hasData) {
|
||||
return SheetsList(
|
||||
sheets: snapshot.data!,
|
||||
onSheetSelected: _openSheet,
|
||||
);
|
||||
}
|
||||
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError(String message) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
message,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(color: Colors.red),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
76
lib/features/home/widgets/app_drawer.dart
Normal file
76
lib/features/home/widgets/app_drawer.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Callback for shuffle state changes.
|
||||
typedef ShuffleCallback = void Function(bool enabled);
|
||||
|
||||
/// Navigation drawer for the home page.
|
||||
///
|
||||
/// Provides access to app-level actions like shuffle mode and logout.
|
||||
class AppDrawer extends StatelessWidget {
|
||||
final bool isShuffling;
|
||||
final ShuffleCallback onShuffleChanged;
|
||||
final VoidCallback onLogout;
|
||||
final String? appName;
|
||||
final String? appVersion;
|
||||
|
||||
const AppDrawer({
|
||||
super.key,
|
||||
required this.isShuffling,
|
||||
required this.onShuffleChanged,
|
||||
required this.onLogout,
|
||||
this.appName,
|
||||
this.appVersion,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Drawer(
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 30),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildActions(),
|
||||
_buildAppInfo(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActions() {
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.shuffle,
|
||||
color: isShuffling ? Colors.blue : null,
|
||||
),
|
||||
title: const Text('Shuffle'),
|
||||
onTap: () => onShuffleChanged(!isShuffling),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.logout),
|
||||
title: const Text('Logout'),
|
||||
onTap: onLogout,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppInfo() {
|
||||
final versionText = appName != null && appVersion != null
|
||||
? '$appName v$appVersion'
|
||||
: 'Loading...';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
versionText,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
29
lib/features/home/widgets/sheet_list_item.dart
Normal file
29
lib/features/home/widgets/sheet_list_item.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/models/sheet.dart';
|
||||
|
||||
/// A list tile displaying a single sheet's information.
|
||||
///
|
||||
/// Shows the sheet name and composer, with tap and long-press handlers.
|
||||
class SheetListItem extends StatelessWidget {
|
||||
final Sheet sheet;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onLongPress;
|
||||
|
||||
const SheetListItem({
|
||||
super.key,
|
||||
required this.sheet,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
title: Text(sheet.name),
|
||||
subtitle: Text(sheet.composerName),
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
);
|
||||
}
|
||||
}
|
||||
38
lib/features/home/widgets/sheet_search_bar.dart
Normal file
38
lib/features/home/widgets/sheet_search_bar.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Search bar for filtering sheets.
|
||||
///
|
||||
/// Provides a text input with search icon and clear button.
|
||||
class SheetSearchBar extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final VoidCallback onClear;
|
||||
|
||||
const SheetSearchBar({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.onClear,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search sheets...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: controller.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: onClear,
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
187
lib/features/home/widgets/sheets_list.dart
Normal file
187
lib/features/home/widgets/sheets_list.dart
Normal file
@@ -0,0 +1,187 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sheetless/core/models/change.dart';
|
||||
import 'package:sheetless/core/models/sheet.dart';
|
||||
import 'package:sheetless/core/services/storage_service.dart';
|
||||
|
||||
import '../../../shared/widgets/edit_sheet_bottom_sheet.dart';
|
||||
import 'sheet_list_item.dart';
|
||||
import 'sheet_search_bar.dart';
|
||||
|
||||
/// Widget displaying a searchable list of sheets.
|
||||
///
|
||||
/// Features:
|
||||
/// - Debounced search filtering by name and composer
|
||||
/// - Long-press to edit sheet metadata
|
||||
/// - Cross-platform scroll support (mouse and touch)
|
||||
class SheetsList extends StatefulWidget {
|
||||
final List<Sheet> sheets;
|
||||
final ValueSetter<Sheet> onSheetSelected;
|
||||
|
||||
const SheetsList({
|
||||
super.key,
|
||||
required this.sheets,
|
||||
required this.onSheetSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SheetsList> createState() => _SheetsListState();
|
||||
}
|
||||
|
||||
class _SheetsListState extends State<SheetsList> {
|
||||
static const _searchDebounceMs = 500;
|
||||
|
||||
final _storageService = StorageService();
|
||||
final _searchController = TextEditingController();
|
||||
Timer? _debounceTimer;
|
||||
late List<Sheet> _filteredSheets;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_filteredSheets = widget.sheets;
|
||||
_searchController.addListener(_onSearchChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.removeListener(_onSearchChanged);
|
||||
_searchController.dispose();
|
||||
_debounceTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void _onSearchChanged() {
|
||||
_debounceTimer?.cancel();
|
||||
_debounceTimer = Timer(
|
||||
const Duration(milliseconds: _searchDebounceMs),
|
||||
_filterSheets,
|
||||
);
|
||||
}
|
||||
|
||||
void _filterSheets() {
|
||||
final query = _searchController.text.toLowerCase().trim();
|
||||
|
||||
if (query.isEmpty) {
|
||||
setState(() => _filteredSheets = widget.sheets);
|
||||
return;
|
||||
}
|
||||
|
||||
// Split query into terms for multi-word search
|
||||
final terms = query.split(RegExp(r'\s+'));
|
||||
|
||||
setState(() {
|
||||
_filteredSheets = widget.sheets.where((sheet) {
|
||||
final name = sheet.name.toLowerCase();
|
||||
final composer = sheet.composerName.toLowerCase();
|
||||
|
||||
// Each term must appear in either name or composer
|
||||
return terms.every(
|
||||
(term) => name.contains(term) || composer.contains(term),
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
void _clearSearch() {
|
||||
_searchController.clear();
|
||||
setState(() => _filteredSheets = widget.sheets);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit Sheet
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void _openEditSheet(BuildContext context, Sheet sheet) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => EditSheetBottomSheet(
|
||||
sheet: sheet,
|
||||
onSave: (newName, newComposer) =>
|
||||
_handleSheetEdit(sheet, newName, newComposer),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleSheetEdit(Sheet sheet, String newName, String newComposer) {
|
||||
// Queue changes for server sync
|
||||
if (newName != sheet.name) {
|
||||
_storageService.writeChange(
|
||||
Change(
|
||||
type: ChangeType.sheetNameChange,
|
||||
sheetUuid: sheet.uuid,
|
||||
value: newName,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (newComposer != sheet.composerName) {
|
||||
_storageService.writeChange(
|
||||
Change(
|
||||
type: ChangeType.composerNameChange,
|
||||
sheetUuid: sheet.uuid,
|
||||
value: newComposer,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Update local state
|
||||
setState(() {
|
||||
sheet.name = newName;
|
||||
sheet.composerName = newComposer;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sheet Selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void _handleSheetTap(Sheet sheet) {
|
||||
// Move selected sheet to top of list (most recently accessed)
|
||||
widget.sheets.remove(sheet);
|
||||
widget.sheets.insert(0, sheet);
|
||||
|
||||
widget.onSheetSelected(sheet);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
SheetSearchBar(controller: _searchController, onClear: _clearSearch),
|
||||
Expanded(child: _buildList()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildList() {
|
||||
// Enable both mouse and touch scrolling for web compatibility
|
||||
return ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(
|
||||
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse},
|
||||
),
|
||||
child: ListView.builder(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: _filteredSheets.length,
|
||||
itemBuilder: (context, index) {
|
||||
final sheet = _filteredSheets[index];
|
||||
return SheetListItem(
|
||||
sheet: sheet,
|
||||
onTap: () => _handleSheetTap(sheet),
|
||||
onLongPress: () => _openEditSheet(context, sheet),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
255
lib/features/sheet_viewer/sheet_viewer_page.dart
Normal file
255
lib/features/sheet_viewer/sheet_viewer_page.dart
Normal file
@@ -0,0 +1,255 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
import 'package:sheetless/core/models/config.dart';
|
||||
import 'package:sheetless/core/models/sheet.dart';
|
||||
import 'package:sheetless/core/services/api_client.dart';
|
||||
import 'package:sheetless/core/services/storage_service.dart';
|
||||
|
||||
import '../../shared/input/pedal_shortcuts.dart';
|
||||
import 'widgets/paint_mode_layer.dart';
|
||||
import 'widgets/pdf_page_display.dart';
|
||||
import 'widgets/touch_navigation_layer.dart';
|
||||
|
||||
/// Page for viewing and annotating PDF sheet music.
|
||||
class SheetViewerPage extends StatefulWidget {
|
||||
final Sheet sheet;
|
||||
final ApiClient apiClient;
|
||||
final Config config;
|
||||
|
||||
const SheetViewerPage({
|
||||
super.key,
|
||||
required this.sheet,
|
||||
required this.apiClient,
|
||||
required this.config,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SheetViewerPage> createState() => _SheetViewerPageState();
|
||||
}
|
||||
|
||||
class _SheetViewerPageState extends State<SheetViewerPage>
|
||||
with FullScreenListener {
|
||||
final _log = Logger('SheetViewerPage');
|
||||
final _storageService = StorageService();
|
||||
|
||||
PdfDocument? _document;
|
||||
late Future<bool> _documentLoaded;
|
||||
int _currentPage = 1;
|
||||
int _totalPages = 1;
|
||||
bool _isPaintMode = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
FullScreen.addListener(this);
|
||||
FullScreen.setFullScreen(widget.config.fullscreen);
|
||||
_documentLoaded = _loadPdf();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
FullScreen.removeListener(this);
|
||||
_document?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PDF Loading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Future<bool> _loadPdf() async {
|
||||
if (kIsWeb) {
|
||||
// Web: load directly into memory
|
||||
final data = await widget.apiClient.fetchPdfData(widget.sheet.uuid);
|
||||
_document = await PdfDocument.openData(data);
|
||||
} else {
|
||||
// Native: use file cache
|
||||
final file = await widget.apiClient.getPdfFileCached(widget.sheet.uuid);
|
||||
_document = await PdfDocument.openFile(file.path);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_totalPages = _document!.pages.length;
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fullscreen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@override
|
||||
void onFullScreenChanged(bool enabled, SystemUiMode? systemUiMode) {
|
||||
setState(() {
|
||||
widget.config.fullscreen = enabled;
|
||||
_storageService.writeConfig(widget.config);
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleFullscreen() {
|
||||
FullScreen.setFullScreen(!widget.config.fullscreen);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void _turnPage(int delta) {
|
||||
setState(() {
|
||||
_currentPage = (_currentPage + delta).clamp(1, _totalPages);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mode Switching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void _togglePaintMode() {
|
||||
if (widget.config.twoPageMode) {
|
||||
// Paint mode only works in single page mode
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Paint mode is only available in single page mode'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isPaintMode = !_isPaintMode);
|
||||
}
|
||||
|
||||
void _toggleTwoPageMode() {
|
||||
setState(() {
|
||||
widget.config.twoPageMode = !widget.config.twoPageMode;
|
||||
_storageService.writeConfig(widget.config);
|
||||
|
||||
if (widget.config.twoPageMode && _isPaintMode) {
|
||||
_isPaintMode = false;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Paint mode disabled in two-page mode'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PedalShortcuts(
|
||||
onPageForward: () => _turnPage(1),
|
||||
onPageBackward: () => _turnPage(-1),
|
||||
child: Scaffold(appBar: _buildAppBar(), body: _buildBody()),
|
||||
);
|
||||
}
|
||||
|
||||
PreferredSizeWidget? _buildAppBar() {
|
||||
// Hide app bar in fullscreen when document is loaded
|
||||
if (widget.config.fullscreen && _document != null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return AppBar(
|
||||
title: Text(widget.sheet.name),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
widget.config.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
|
||||
),
|
||||
tooltip: widget.config.fullscreen
|
||||
? 'Exit Fullscreen'
|
||||
: 'Enter Fullscreen',
|
||||
onPressed: _toggleFullscreen,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(_isPaintMode ? Icons.brush : Icons.brush_outlined),
|
||||
tooltip: 'Toggle Paint Mode',
|
||||
onPressed: _togglePaintMode,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
widget.config.twoPageMode ? Icons.filter_1 : Icons.filter_2,
|
||||
),
|
||||
tooltip: widget.config.twoPageMode
|
||||
? 'Single Page Mode'
|
||||
: 'Two Page Mode',
|
||||
onPressed: _toggleTwoPageMode,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
return FutureBuilder<bool>(
|
||||
future: _documentLoaded,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
_log.warning('Error loading PDF', snapshot.error);
|
||||
return _buildError(snapshot.error.toString());
|
||||
}
|
||||
|
||||
if (snapshot.hasData && _document != null) {
|
||||
return _buildPdfViewer();
|
||||
}
|
||||
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPdfViewer() {
|
||||
final pageDisplay = PdfPageDisplay(
|
||||
document: _document!,
|
||||
numPages: _totalPages,
|
||||
currentPageNumber: _currentPage,
|
||||
config: widget.config,
|
||||
);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Show touch navigation when not in paint mode
|
||||
Visibility(
|
||||
visible: !_isPaintMode,
|
||||
child: TouchNavigationLayer(
|
||||
pageDisplay: pageDisplay,
|
||||
config: widget.config,
|
||||
onToggleFullscreen: _toggleFullscreen,
|
||||
onExit: () => Navigator.pop(context),
|
||||
onPageTurn: _turnPage,
|
||||
),
|
||||
),
|
||||
// Show paint mode layer when active
|
||||
Visibility(
|
||||
visible: _isPaintMode,
|
||||
child: PaintModeLayer(pageDisplay: pageDisplay),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError(String message) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
message,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(color: Colors.red),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
42
lib/features/sheet_viewer/widgets/paint_mode_layer.dart
Normal file
42
lib/features/sheet_viewer/widgets/paint_mode_layer.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_drawing_board/flutter_drawing_board.dart';
|
||||
|
||||
import 'pdf_page_display.dart';
|
||||
|
||||
/// Drawing overlay for annotating PDF pages.
|
||||
///
|
||||
/// Uses flutter_drawing_board to provide a paint canvas over the PDF.
|
||||
/// Only available in single-page mode.
|
||||
class PaintModeLayer extends StatelessWidget {
|
||||
final PdfPageDisplay pageDisplay;
|
||||
|
||||
const PaintModeLayer({
|
||||
super.key,
|
||||
required this.pageDisplay,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox.expand(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxSize = Size(constraints.maxWidth, constraints.maxHeight);
|
||||
final (pageSize, _) = pageDisplay.calculateScaledPageSizes(maxSize);
|
||||
|
||||
return DrawingBoard(
|
||||
background: SizedBox(
|
||||
width: pageSize.width,
|
||||
height: pageSize.height,
|
||||
child: pageDisplay,
|
||||
),
|
||||
boardConstrained: true,
|
||||
minScale: 1,
|
||||
maxScale: 3,
|
||||
alignment: Alignment.topRight,
|
||||
boardBoundaryMargin: EdgeInsets.zero,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
137
lib/features/sheet_viewer/widgets/pdf_page_display.dart
Normal file
137
lib/features/sheet_viewer/widgets/pdf_page_display.dart
Normal file
@@ -0,0 +1,137 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
|
||||
import '../../../core/models/config.dart';
|
||||
|
||||
/// Displays PDF pages with optional two-page mode.
|
||||
class PdfPageDisplay extends StatelessWidget {
|
||||
final PdfDocument document;
|
||||
final int numPages;
|
||||
final int currentPageNumber;
|
||||
final Config config;
|
||||
|
||||
const PdfPageDisplay({
|
||||
super.key,
|
||||
required this.document,
|
||||
required this.numPages,
|
||||
required this.currentPageNumber,
|
||||
required this.config,
|
||||
});
|
||||
|
||||
/// Whether two-page mode is active and we have enough pages.
|
||||
bool get _showTwoPages => config.twoPageMode && numPages >= 2;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [_buildLeftPage(), if (_showTwoPages) _buildRightPage()],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLeftPage() {
|
||||
return Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
PdfPageView(
|
||||
key: ValueKey(currentPageNumber),
|
||||
document: document,
|
||||
pageNumber: currentPageNumber,
|
||||
maximumDpi: 300,
|
||||
alignment: _showTwoPages ? Alignment.centerRight : Alignment.center,
|
||||
),
|
||||
_buildPageIndicator(currentPageNumber),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRightPage() {
|
||||
final rightPageNumber = currentPageNumber + 1;
|
||||
|
||||
return Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
PdfPageView(
|
||||
key: ValueKey(rightPageNumber),
|
||||
document: document,
|
||||
pageNumber: rightPageNumber,
|
||||
maximumDpi: 300,
|
||||
alignment: Alignment.centerLeft,
|
||||
),
|
||||
_buildPageIndicator(rightPageNumber),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPageIndicator(int pageNumber) {
|
||||
return Positioned.fill(
|
||||
child: Container(
|
||||
alignment: Alignment.bottomCenter,
|
||||
padding: const EdgeInsets.only(bottom: 5),
|
||||
child: Text('$pageNumber / $numPages'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page Size Calculations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Calculates scaled page sizes for the current view.
|
||||
///
|
||||
/// Returns a tuple of (leftPageSize, rightPageSize).
|
||||
/// rightPageSize is null when not in two-page mode.
|
||||
(Size, Size?) calculateScaledPageSizes(Size parentSize) {
|
||||
if (config.twoPageMode) {
|
||||
return _calculateTwoPageSizes(parentSize);
|
||||
}
|
||||
return (_calculateSinglePageSize(parentSize), null);
|
||||
}
|
||||
|
||||
(Size, Size?) _calculateTwoPageSizes(Size parentSize) {
|
||||
final leftSize = _getUnscaledPageSize(currentPageNumber);
|
||||
final rightSize = numPages > currentPageNumber
|
||||
? _getUnscaledPageSize(currentPageNumber + 1)
|
||||
: leftSize;
|
||||
|
||||
// Combine pages for scaling calculation
|
||||
final combinedSize = Size(
|
||||
leftSize.width + rightSize.width,
|
||||
max(leftSize.height, rightSize.height),
|
||||
);
|
||||
|
||||
final scaledCombined = _scaleToFit(parentSize, combinedSize);
|
||||
final scaleFactor = scaledCombined.width / combinedSize.width;
|
||||
|
||||
return (leftSize * scaleFactor, rightSize * scaleFactor);
|
||||
}
|
||||
|
||||
Size _calculateSinglePageSize(Size parentSize) {
|
||||
return _scaleToFit(parentSize, _getUnscaledPageSize(currentPageNumber));
|
||||
}
|
||||
|
||||
Size _getUnscaledPageSize(int pageNumber) {
|
||||
return document.pages.elementAt(pageNumber - 1).size;
|
||||
}
|
||||
|
||||
/// Scales a page size to fit within parent bounds while maintaining aspect ratio.
|
||||
Size _scaleToFit(Size parentSize, Size pageSize) {
|
||||
// Determine if height or width is the limiting factor
|
||||
if (parentSize.aspectRatio > pageSize.aspectRatio) {
|
||||
// Constrained by height
|
||||
final height = parentSize.height;
|
||||
final width = height * pageSize.aspectRatio;
|
||||
return Size(width, height);
|
||||
} else {
|
||||
// Constrained by width
|
||||
final width = parentSize.width;
|
||||
final height = width / pageSize.aspectRatio;
|
||||
return Size(width, height);
|
||||
}
|
||||
}
|
||||
}
|
||||
126
lib/features/sheet_viewer/widgets/touch_navigation_layer.dart
Normal file
126
lib/features/sheet_viewer/widgets/touch_navigation_layer.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/models/config.dart';
|
||||
import 'pdf_page_display.dart';
|
||||
|
||||
/// Callback for page turn events.
|
||||
typedef PageTurnCallback = void Function(int delta);
|
||||
|
||||
/// Gesture layer for touch-based navigation over PDF pages.
|
||||
///
|
||||
/// Touch zones:
|
||||
/// - Top 2cm: Toggle fullscreen (or exit if in fullscreen + top-right corner)
|
||||
/// - Left side: Turn page backward (-1 or -2 in two-page mode)
|
||||
/// - Right side: Turn page forward (+1 or +2 in two-page mode)
|
||||
class TouchNavigationLayer extends StatelessWidget {
|
||||
final PdfPageDisplay pageDisplay;
|
||||
final Config config;
|
||||
final VoidCallback onToggleFullscreen;
|
||||
final VoidCallback onExit;
|
||||
final PageTurnCallback onPageTurn;
|
||||
|
||||
const TouchNavigationLayer({
|
||||
super.key,
|
||||
required this.pageDisplay,
|
||||
required this.config,
|
||||
required this.onToggleFullscreen,
|
||||
required this.onExit,
|
||||
required this.onPageTurn,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapUp: (details) => _handleTap(context, details),
|
||||
child: pageDisplay,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTap(BuildContext context, TapUpDetails details) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final position = details.localPosition;
|
||||
|
||||
// Calculate physical measurements for consistent touch zones
|
||||
final pixelsPerCm = _calculatePixelsPerCm(mediaQuery.devicePixelRatio);
|
||||
final touchZoneHeight = 2 * pixelsPerCm;
|
||||
final touchZoneWidth = 2 * pixelsPerCm;
|
||||
|
||||
final screenWidth = mediaQuery.size.width;
|
||||
final screenCenter = screenWidth / 2;
|
||||
|
||||
// Get page sizes for accurate touch zone calculation
|
||||
final (leftPageSize, rightPageSize) = pageDisplay.calculateScaledPageSizes(
|
||||
mediaQuery.size,
|
||||
);
|
||||
|
||||
// Check top zone first
|
||||
if (position.dy < touchZoneHeight) {
|
||||
_handleTopZoneTap(position, screenWidth, touchZoneWidth);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle page turning based on tap position
|
||||
_handlePageTurnTap(position, screenCenter, leftPageSize, rightPageSize);
|
||||
}
|
||||
|
||||
void _handleTopZoneTap(
|
||||
Offset position,
|
||||
double screenWidth,
|
||||
double touchZoneWidth,
|
||||
) {
|
||||
// Top-right corner in fullscreen mode = exit
|
||||
if (config.fullscreen && position.dx >= screenWidth - touchZoneWidth) {
|
||||
onExit();
|
||||
} else {
|
||||
onToggleFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePageTurnTap(
|
||||
Offset position,
|
||||
double screenCenter,
|
||||
Size leftPageSize,
|
||||
Size? rightPageSize,
|
||||
) {
|
||||
final isLeftSide = position.dx < screenCenter;
|
||||
|
||||
if (config.twoPageMode) {
|
||||
_handleTwoPageModeTap(
|
||||
position,
|
||||
screenCenter,
|
||||
leftPageSize,
|
||||
rightPageSize,
|
||||
);
|
||||
} else {
|
||||
// Single page mode: simple left/right
|
||||
onPageTurn(isLeftSide ? -1 : 1);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTwoPageModeTap(
|
||||
Offset position,
|
||||
double screenCenter,
|
||||
Size leftPageSize,
|
||||
Size? rightPageSize,
|
||||
) {
|
||||
final leftEdge = screenCenter - leftPageSize.width / 2;
|
||||
final rightEdge = screenCenter + (rightPageSize?.width ?? 0) / 2;
|
||||
|
||||
if (position.dx < leftEdge) {
|
||||
onPageTurn(-2);
|
||||
} else if (position.dx < screenCenter) {
|
||||
onPageTurn(-1);
|
||||
} else if (position.dx > rightEdge) {
|
||||
onPageTurn(2);
|
||||
} else {
|
||||
onPageTurn(1);
|
||||
}
|
||||
}
|
||||
|
||||
double _calculatePixelsPerCm(double devicePixelRatio) {
|
||||
const baseDpi = 160.0; // Android baseline DPI
|
||||
const cmPerInch = 2.54;
|
||||
return (devicePixelRatio * baseDpi) / cmPerInch;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user