402 lines
12 KiB
Dart
402 lines
12 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.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<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.
|
|
///
|
|
/// Only saves if there are actual changes to avoid unnecessary writes/uploads.
|
|
Future<void> _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<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)),
|
|
);
|
|
}
|
|
}
|