import 'package:flutter/material.dart'; import 'package:photo_view/photo_view.dart'; import 'package:worldhopper/screens/reader/page_grouping.dart'; /// Renders a two-page spread reader using a PageView. /// /// Each "page" in the PageView corresponds to one [PageSpread] — either a /// single page (cover, landscape, or unpaired) or a pair of portrait pages. class TwoPageReader extends StatelessWidget { final List spreads; final PageController pageController; final ImageProvider Function(int pageIndex) getImageProvider; final FilterQuality filterQuality; final bool reverse; final bool einkMode; final ValueChanged onPageChanged; final VoidCallback onTap; const TwoPageReader({ super.key, required this.spreads, required this.pageController, required this.getImageProvider, required this.filterQuality, required this.reverse, this.einkMode = false, required this.onPageChanged, required this.onTap, }); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: PageView.builder( controller: pageController, reverse: reverse, itemCount: spreads.length, onPageChanged: onPageChanged, physics: einkMode ? const NeverScrollableScrollPhysics() : const BouncingScrollPhysics(), itemBuilder: (context, spreadIndex) { final spread = spreads[spreadIndex]; if (spread.isSingle) { return _buildSinglePage(spread.pages[0]); } else { return _PairedPageSpread( leftPageIndex: spread.pages[0], rightPageIndex: spread.pages[1], getImageProvider: getImageProvider, filterQuality: filterQuality, parentController: pageController, spreadCount: spreads.length, reverse: reverse, einkMode: einkMode, ); } }, ), ); } /// Single page: use PhotoView for full zoom/pan support. Widget _buildSinglePage(int pageIndex) { return PhotoView( imageProvider: getImageProvider(pageIndex), filterQuality: filterQuality, minScale: PhotoViewComputedScale.contained, maxScale: PhotoViewComputedScale.covered * 3, backgroundDecoration: const BoxDecoration(color: Colors.black), loadingBuilder: (context, event) => Center( child: CircularProgressIndicator( value: event == null ? 0 : event.cumulativeBytesLoaded / (event.expectedTotalBytes ?? 1), color: Colors.white, ), ), heroAttributes: PhotoViewHeroAttributes(tag: 'page-$pageIndex'), ); } } /// Paired page spread that uses a [Listener] to detect horizontal swipes /// without participating in the gesture arena, working around /// [InteractiveViewer] consuming drag gestures inside a [PageView]. class _PairedPageSpread extends StatefulWidget { final int leftPageIndex; final int rightPageIndex; final ImageProvider Function(int pageIndex) getImageProvider; final FilterQuality filterQuality; final PageController parentController; final int spreadCount; final bool reverse; final bool einkMode; const _PairedPageSpread({ required this.leftPageIndex, required this.rightPageIndex, required this.getImageProvider, required this.filterQuality, required this.parentController, required this.spreadCount, required this.reverse, required this.einkMode, }); @override State<_PairedPageSpread> createState() => _PairedPageSpreadState(); } class _PairedPageSpreadState extends State<_PairedPageSpread> { final _transformController = TransformationController(); int? _activePointer; double? _swipeStartX; bool get _isZoomed => _transformController.value.getMaxScaleOnAxis() > 1.01; @override void dispose() { _transformController.dispose(); super.dispose(); } void _onPointerDown(PointerDownEvent e) { if (_activePointer != null) { // Second pointer (pinch gesture): cancel swipe tracking. _activePointer = null; _swipeStartX = null; return; } _activePointer = e.pointer; _swipeStartX = e.position.dx; } void _onPointerUp(PointerUpEvent e) { if (e.pointer != _activePointer) return; final startX = _swipeStartX; _activePointer = null; _swipeStartX = null; // Skip when zoomed (let InteractiveViewer handle panning) or in e-ink // mode (the outer _wrapWithEinkSwipe Listener handles navigation). if (startX == null || _isZoomed || widget.einkMode) return; final dx = e.position.dx - startX; if (dx.abs() < 50) return; final direction = dx < 0 ? 1 : -1; final effectiveDirection = widget.reverse ? -direction : direction; final currentIdx = widget.parentController.page?.round() ?? 0; final target = currentIdx + effectiveDirection; if (target >= 0 && target < widget.spreadCount) { widget.parentController.animateToPage( target, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); } } void _onPointerCancel(PointerCancelEvent e) { if (e.pointer == _activePointer) { _activePointer = null; _swipeStartX = null; } } @override Widget build(BuildContext context) { return Listener( onPointerDown: _onPointerDown, onPointerUp: _onPointerUp, onPointerCancel: _onPointerCancel, child: LayoutBuilder( builder: (context, constraints) { final halfWidth = constraints.maxWidth / 2; final maxHeight = constraints.maxHeight; return InteractiveViewer( transformationController: _transformController, minScale: 1.0, maxScale: 3.0, child: SizedBox( width: constraints.maxWidth, height: maxHeight, child: Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ _buildHalfPage(widget.leftPageIndex, halfWidth, maxHeight, Alignment.centerRight), _buildHalfPage(widget.rightPageIndex, halfWidth, maxHeight, Alignment.centerLeft), ], ), ), ); }, ), ); } Widget _buildHalfPage( int pageIndex, double width, double height, Alignment alignment) { return SizedBox( width: width, height: height, child: Image( image: widget.getImageProvider(pageIndex), filterQuality: widget.filterQuality, fit: BoxFit.contain, alignment: alignment, loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) return child; return const Center( child: CircularProgressIndicator(color: Colors.white), ); }, errorBuilder: (context, error, stackTrace) => const Center( child: Icon(Icons.error, color: Colors.white), ), ), ); } }