Files
sheetless/lib/features/sheet_viewer/sheet_viewer_page.dart

395 lines
11 KiB
Dart

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<SheetViewerPage> createState() => _SheetViewerPageState();
}
class _SheetViewerPageState extends State<SheetViewerPage>
with FullScreenListener {
final _log = Logger('SheetViewerPage');
final _storageService = StorageService();
late final AnnotationSyncService _syncService;
PdfDocument? _document;
late Future<bool> _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<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;
});
// Sync annotations from server (downloads newer versions)
await _syncService.syncAnnotationsFromServer(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<void> _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.
Future<void> _saveCurrentAnnotations() async {
final now = DateTime.now();
// Save left page
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,
);
}
// Save right page (two-page mode)
if (widget.config.twoPageMode && _currentPage < _totalPages) {
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,
);
}
}
}
// ---------------------------------------------------------------------------
// 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<void> _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<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,
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)),
);
}
}