All checks were successful
ci/woodpecker/tag/release Pipeline was successful
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>
229 lines
7.1 KiB
Dart
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),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|