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/annotation_sync_service.dart'; import 'package:sheetless/core/services/api_client.dart'; import 'package:sheetless/core/services/storage_service.dart'; import '../../shared/input/pedal_shortcuts.dart'; import 'drawing/drawing.dart'; import 'widgets/pdf_page_display.dart'; import 'widgets/touch_navigation_layer.dart'; /// Page for viewing and annotating PDF sheet music. class SheetViewerPage extends StatefulWidget { final Sheet sheet; final ApiClient apiClient; final Config config; const SheetViewerPage({ super.key, required this.sheet, required this.apiClient, required this.config, }); @override State createState() => _SheetViewerPageState(); } class _SheetViewerPageState extends State with FullScreenListener { final _log = Logger('SheetViewerPage'); final _storageService = StorageService(); late final AnnotationSyncService _syncService; PdfDocument? _document; late Future _documentLoaded; int _currentPage = 1; int _totalPages = 1; bool _isPaintMode = false; /// Drawing controller for the current left page late DrawingController _leftDrawingController; /// Drawing controller for the right page (two-page mode) late DrawingController _rightDrawingController; @override void initState() { super.initState(); // Initialize sync service _syncService = AnnotationSyncService( apiClient: widget.apiClient, storageService: _storageService, ); // Initialize drawing controllers _leftDrawingController = DrawingController(maxHistorySteps: 50); _rightDrawingController = DrawingController(maxHistorySteps: 50); FullScreen.addListener(this); FullScreen.setFullScreen(widget.config.fullscreen); _documentLoaded = _loadPdf(); } @override void dispose() { // Make sure annotations are saved before exiting if (_isPaintMode) { _saveCurrentAnnotations(); } _leftDrawingController.dispose(); _rightDrawingController.dispose(); FullScreen.removeListener(this); _document?.dispose(); super.dispose(); } // --------------------------------------------------------------------------- // PDF Loading // --------------------------------------------------------------------------- Future _loadPdf() async { if (kIsWeb) { // Web: load directly into memory final data = await widget.apiClient.fetchPdfData(widget.sheet.uuid); _document = await PdfDocument.openData(data); } else { // Native: use file cache final file = await widget.apiClient.getPdfFileCached(widget.sheet.uuid); _document = await PdfDocument.openFile(file.path); } setState(() { _totalPages = _document!.pages.length; }); // Sync annotations from server (downloads newer versions) await _syncService.syncFromServer(widget.sheet.uuid); // Load annotations for current page(s) await _loadAnnotationsForCurrentPages(); return true; } // --------------------------------------------------------------------------- // Annotation Persistence // --------------------------------------------------------------------------- /// Loads annotations for the current page(s) from storage. Future _loadAnnotationsForCurrentPages() async { // Load left page annotations final leftJson = await _storageService.readAnnotations( widget.sheet.uuid, _currentPage, ); if (leftJson != null && leftJson.isNotEmpty) { _leftDrawingController.fromJsonString(leftJson); } else { _leftDrawingController.clear(); } // Load right page annotations (two-page mode) if (widget.config.twoPageMode && _currentPage < _totalPages) { final rightJson = await _storageService.readAnnotations( widget.sheet.uuid, _currentPage + 1, ); if (rightJson != null && rightJson.isNotEmpty) { _rightDrawingController.fromJsonString(rightJson); } else { _rightDrawingController.clear(); } } } /// Saves the current page(s) annotations to storage and uploads to server. /// /// Only saves if there are actual changes to avoid unnecessary writes/uploads. Future _saveCurrentAnnotations() async { final now = DateTime.now(); // Save left page only if changed if (_leftDrawingController.hasUnsavedChanges) { final leftJson = _leftDrawingController.toJsonString(); final leftHasContent = leftJson.isNotEmpty && leftJson != '[]'; await _storageService.writeAnnotationsWithMetadata( widget.sheet.uuid, _currentPage, leftHasContent ? leftJson : null, now, ); // Upload left page to server if (leftHasContent) { _syncService.uploadAnnotation( sheetUuid: widget.sheet.uuid, page: _currentPage, annotationsJson: leftJson, lastModified: now, ); } _leftDrawingController.markSaved(); } // Save right page (two-page mode) only if changed if (widget.config.twoPageMode && _currentPage < _totalPages && _rightDrawingController.hasUnsavedChanges) { final rightJson = _rightDrawingController.toJsonString(); final rightHasContent = rightJson.isNotEmpty && rightJson != '[]'; await _storageService.writeAnnotationsWithMetadata( widget.sheet.uuid, _currentPage + 1, rightHasContent ? rightJson : null, now, ); // Upload right page to server if (rightHasContent) { _syncService.uploadAnnotation( sheetUuid: widget.sheet.uuid, page: _currentPage + 1, annotationsJson: rightJson, lastModified: now, ); } _rightDrawingController.markSaved(); } } // --------------------------------------------------------------------------- // 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 // --------------------------------------------------------------------------- Future _turnPage(int delta) async { // Save current annotations before turning await _saveCurrentAnnotations(); // Calculate new page final newPage = (_currentPage + delta).clamp(1, _totalPages); // Load annotations for new page(s) BEFORE updating state _currentPage = newPage; await _loadAnnotationsForCurrentPages(); // Now update UI setState(() {}); } // --------------------------------------------------------------------------- // Mode Switching // --------------------------------------------------------------------------- void _togglePaintMode() { if (widget.config.twoPageMode) { _showSnackBar('Paint mode is only available in single page mode'); return; } if (_isPaintMode) { // Exiting paint mode - save annotations _saveCurrentAnnotations(); } 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); }); // Reload annotations for new mode _loadAnnotationsForCurrentPages(); } // --------------------------------------------------------------------------- // UI // --------------------------------------------------------------------------- @override Widget build(BuildContext context) { return PedalShortcuts( onPageForward: () => _turnPage(1), onPageBackward: () => _turnPage(-1), child: Scaffold( appBar: _buildAppBar(), body: _buildBody(), floatingActionButton: _isPaintMode ? _buildDrawingToolbar() : null, floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, ), ); } 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: _isPaintMode ? 'Exit Paint Mode' : 'Enter Paint Mode', onPressed: _togglePaintMode, ), IconButton( icon: Icon( widget.config.twoPageMode ? Icons.filter_1 : Icons.filter_2, ), tooltip: widget.config.twoPageMode ? 'Single Page Mode' : 'Two Page Mode', onPressed: _toggleTwoPageMode, ), ], ); } Widget _buildBody() { return FutureBuilder( future: _documentLoaded, builder: (context, snapshot) { if (snapshot.hasError) { _log.warning('Error loading PDF', snapshot.error); return _buildError(snapshot.error.toString()); } if (snapshot.hasData && _document != null) { return _buildPdfViewer(); } return const Center(child: CircularProgressIndicator()); }, ); } Widget _buildPdfViewer() { final pageDisplay = PdfPageDisplay( document: _document!, numPages: _totalPages, currentPageNumber: _currentPage, config: widget.config, leftDrawingController: _leftDrawingController, rightDrawingController: widget.config.twoPageMode ? _rightDrawingController : null, drawingEnabled: _isPaintMode, ); // When in paint mode, show the page display directly (DrawingBoard handles zoom/pan) if (_isPaintMode) { return pageDisplay; } // When not in paint mode, wrap with touch navigation return TouchNavigationLayer( pageDisplay: pageDisplay, config: widget.config, onToggleFullscreen: _toggleFullscreen, onExit: () => Navigator.pop(context), onPageTurn: _turnPage, ); } Widget _buildDrawingToolbar() { return SafeArea( child: Padding( padding: const EdgeInsets.only(bottom: 16), child: DrawingToolbar( controller: _leftDrawingController, onClose: _togglePaintMode, ), ), ); } 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: const Duration(seconds: 2)), ); } }