Complete refactor to clean up project

This commit is contained in:
2026-02-04 11:36:02 +01:00
parent 4f380b5444
commit 704bd0b928
29 changed files with 2057 additions and 1464 deletions

View 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,
),
),
);
}
}

View 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,
);
},
),
);
}
}

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