worldhopper/lib/screens/reader/two_page_reader.dart
Felipe M. d2497f8aed
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
feat: add background download service with error handling improvements
Add Android foreground service support for downloads to prevent OS
process killing during long operations. Fix multiple error handling
issues: foreground service leak on exceptions, polling stuck forever
on failure, double-counting chapters on retry, silent retry failures
without user feedback, and app crash if background service init fails.
Replace inaccurate failed chapter count heuristic with actual data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 11:44:37 +01:00

229 lines
7.1 KiB
Dart

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<PageSpread> spreads;
final PageController pageController;
final ImageProvider Function(int pageIndex) getImageProvider;
final FilterQuality filterQuality;
final bool reverse;
final bool einkMode;
final ValueChanged<int> 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),
),
),
);
}
}