import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_drawing_board/flutter_drawing_board.dart'; import 'package:logging/logging.dart'; import 'package:pdfrx/pdfrx.dart'; import 'package:sheetless/api.dart'; import 'package:sheetless/bt_pedal_shortcuts.dart'; import 'package:sheetless/sheet.dart'; import 'package:sheetless/storage_helper.dart'; import 'package:flutter_fullscreen/flutter_fullscreen.dart'; import 'package:sheetless/utility.dart'; 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 StorageHelper storageHelper = StorageHelper(); int currentPageNumber = 1; int numPages = 1; late Future documentLoaded; PdfDocument? document; bool paintMode = false; Pages? pages; @override void initState() { FullScreen.addListener(this); super.initState(); documentLoaded = loadPdf(); } @override void dispose() { FullScreen.removeListener(this); document?.dispose(); // Make sure document gets garbage collected super.dispose(); } Future loadPdf() async { if (kIsWeb) { var data = await widget.apiClient.fetchPdfFileData(widget.sheet.uuid); if (data == null) { throw Exception("Failed fetching pdf file"); } document = await PdfDocument.openData(data); } else { var file = await widget.apiClient.getPdfFileCached(widget.sheet.uuid); if (file == null) { throw Exception("Failed fetching pdf file"); } document = await PdfDocument.openFile(file.path); } return true; } @override void onFullScreenChanged(bool enabled, SystemUiMode? systemUiMode) { setState(() { widget.config.fullscreen = enabled; storageHelper.writeConfig(widget.config); }); } void toggleFullscreen() { FullScreen.setFullScreen(!widget.config.fullscreen); } void turnPage(int numTurns) { setState(() { currentPageNumber += numTurns; currentPageNumber = currentPageNumber.clamp(1, numPages); }); } AppBar? buildAppBar() { if (widget.config.fullscreen) { return null; } return AppBar( title: Text(widget.sheet.name), actions: [ IconButton( onPressed: () { setState(() { if (widget.config.twoPageMode) { // TODO: notification that paint mode only in single page mode } else { paintMode = !paintMode; } }); }, icon: Icon(Icons.brush), ), IconButton( onPressed: () { setState(() { widget.config.twoPageMode = !widget.config.twoPageMode; storageHelper.writeConfig(widget.config); if (widget.config.twoPageMode) { paintMode = false; // TODO: notification that paint mode was deactivated since only possible in single page mode } }); }, icon: Icon( widget.config.twoPageMode ? Icons.filter_1 : Icons.filter_2, ), ), ], ); } @override Widget build(BuildContext context) { return BtPedalShortcuts( onTurnPageForward: () => turnPage(1), onTurnPageBackward: () => turnPage(-1), child: Scaffold( appBar: buildAppBar(), body: FutureBuilder( future: documentLoaded, builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.hasData && document != null) { numPages = document!.pages.length; pages = Pages( document: document!, numPages: numPages, config: widget.config, currentPageNumber: currentPageNumber, ); return Stack( children: [ Stack( children: [ Visibility( visible: !paintMode, child: TouchablePages( pages: pages!, onToggleFullscreen: () { toggleFullscreen(); }, onExitSheetViewer: () { Navigator.pop(context); }, onTurnPage: (int numTurns) { turnPage(numTurns); }, ), ), Visibility( visible: paintMode, child: PaintablePages(pages: pages!), ), ], ), ], ); } else if (snapshot.hasError) { return Center( child: Text( style: Theme.of( context, ).textTheme.displaySmall!.copyWith(color: Colors.red), textAlign: TextAlign.center, snapshot.error.toString(), ), ); } else { return const Center(child: CircularProgressIndicator()); } }, ), ), ); } } typedef PageturnCallback = void Function(int numTurns); class TouchablePages extends StatelessWidget { final Pages pages; final VoidCallback onToggleFullscreen; final VoidCallback onExitSheetViewer; final PageturnCallback onTurnPage; const TouchablePages({ super.key, required this.pages, required this.onToggleFullscreen, required this.onExitSheetViewer, required this.onTurnPage, }); @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, // Also register when outside of pdf pages onTapUp: (TapUpDetails details) { final mediaQuery = MediaQuery.of(context); final pixelsPerInch = mediaQuery.devicePixelRatio * 160; // 160 dpi = 1 logical inch baseline final pixelsPerCm = pixelsPerInch / 2.54; final touchAreaWidth = mediaQuery.size.width; final (leftPageSize, rightPageSize) = pages.calcPageSizesScaled( mediaQuery.size, ); if (details.localPosition.dy < 2 * pixelsPerCm && details.localPosition.dx >= touchAreaWidth - 2 * pixelsPerCm && pages.config.fullscreen) { onExitSheetViewer(); } else if (details.localPosition.dy < 2 * pixelsPerCm) { onToggleFullscreen(); } else if (pages.config.twoPageMode && details.localPosition.dx < touchAreaWidth / 2 - leftPageSize.width / 2) { onTurnPage(-2); } else if (details.localPosition.dx < touchAreaWidth / 2) { onTurnPage(-1); } else if (pages.config.twoPageMode && details.localPosition.dx > touchAreaWidth / 2 + rightPageSize!.width / 2) { onTurnPage(2); } else { onTurnPage(1); } }, child: pages, ); } } class PaintablePages extends StatelessWidget { final Pages pages; const PaintablePages({super.key, required this.pages}); @override Widget build(BuildContext context) { return SizedBox.expand( child: LayoutBuilder( builder: (context, constraints) { final maxSize = Size(constraints.maxWidth, constraints.maxHeight); final (pageSizeScaled, _) = pages.calcPageSizesScaled(maxSize); return DrawingBoard( background: SizedBox( width: pageSizeScaled.width, height: pageSizeScaled.height, child: pages, ), // showDefaultTools: true, // showDefaultActions: true, boardConstrained: true, minScale: 1, maxScale: 3, alignment: Alignment.topRight, boardBoundaryMargin: EdgeInsets.all(0), ); }, ), ); } } class Pages extends StatelessWidget { final PdfDocument document; final int numPages; final int currentPageNumber; // Starts at 1 final Config config; const Pages({ super.key, required this.document, required this.numPages, required this.currentPageNumber, required this.config, }); @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, spacing: 0, children: [ Expanded( child: Stack( children: [ PdfPageView( key: ValueKey(currentPageNumber), document: document, pageNumber: currentPageNumber, maximumDpi: 300, alignment: config.twoPageMode ? Alignment.centerRight : Alignment.center, ), Positioned.fill( child: Container( alignment: Alignment.bottomCenter, padding: EdgeInsets.only(bottom: 5), child: Text('$currentPageNumber / $numPages'), ), ), ], ), ), Visibility( visible: config.twoPageMode == true, child: Expanded( child: Stack( children: [ PdfPageView( key: ValueKey(currentPageNumber + 1), document: document, pageNumber: currentPageNumber + 1, maximumDpi: 300, alignment: Alignment.centerLeft, // alignment: Alignment.center, ), Positioned.fill( child: Container( alignment: Alignment.bottomCenter, padding: EdgeInsets.only(bottom: 5), child: Text('${currentPageNumber + 1} / $numPages'), ), ), ], ), ), ), ], ); } (Size, Size?) calcPageSizesScaled(Size parentSize) { if (config.twoPageMode) { Size leftPageSizeUnscaled = _getPageSizeUnscaled(currentPageNumber); Size rightPageSizeUnscaled; if (numPages > currentPageNumber) { rightPageSizeUnscaled = _getPageSizeUnscaled(currentPageNumber + 1); } else { rightPageSizeUnscaled = leftPageSizeUnscaled; } Size combinedPageSizesUnscaled = Size( leftPageSizeUnscaled.width + rightPageSizeUnscaled.width, max(leftPageSizeUnscaled.height, rightPageSizeUnscaled.height), ); Size combinedPageSizesScaled = _calcScaledPageSize( parentSize, combinedPageSizesUnscaled, ); double scaleFactor = combinedPageSizesScaled.width / combinedPageSizesUnscaled.width; return ( leftPageSizeUnscaled * scaleFactor, rightPageSizeUnscaled * scaleFactor, ); } else { return ( _calcScaledPageSize( parentSize, _getPageSizeUnscaled(currentPageNumber), ), null, ); } } Size _getPageSizeUnscaled(int pageNumber) { return document.pages.elementAt(pageNumber - 1).size; } Size _calcScaledPageSize(Size parentSize, Size pageSize) { // page restricted by height if (parentSize.aspectRatio > pageSize.aspectRatio) { final height = parentSize.height; final width = height * pageSize.aspectRatio; return Size(width, height); } // page restricted by width else { final width = parentSize.width; final height = width / pageSize.aspectRatio; return Size(width, height); } } }