worldhopper/lib/screens/reader/two_page_reader.dart
Felipe M. 0b57896793 feat: eink-mode (#1)
Co-authored-by: Felipe M. <me@fmartingr.com>
Co-committed-by: Felipe M. <me@fmartingr.com>
2026-02-12 22:25:38 +01:00

132 lines
4.2 KiB
Dart

import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.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 String Function(int pageIndex) getPageUrl;
final Map<String, String> headers;
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.getPageUrl,
required this.headers,
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 _buildPairedPages(spread.pages[0], spread.pages[1]);
}
},
),
);
}
/// Single page: use PhotoView for full zoom/pan support.
Widget _buildSinglePage(int pageIndex) {
return PhotoView(
imageProvider: CachedNetworkImageProvider(
getPageUrl(pageIndex),
headers: headers,
),
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 pages: InteractiveViewer wrapping two images centered together.
Widget _buildPairedPages(int leftPageIndex, int rightPageIndex) {
return LayoutBuilder(
builder: (context, constraints) {
final halfWidth = constraints.maxWidth / 2;
final maxHeight = constraints.maxHeight;
return InteractiveViewer(
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(
leftPageIndex, halfWidth, maxHeight, Alignment.centerRight),
_buildHalfPage(
rightPageIndex, halfWidth, maxHeight, Alignment.centerLeft),
],
),
),
);
},
);
}
Widget _buildHalfPage(
int pageIndex, double width, double height, Alignment alignment) {
return SizedBox(
width: width,
height: height,
child: CachedNetworkImage(
imageUrl: getPageUrl(pageIndex),
httpHeaders: headers,
filterQuality: filterQuality,
fit: BoxFit.contain,
alignment: alignment,
placeholder: (context, url) => const Center(
child: CircularProgressIndicator(color: Colors.white),
),
errorWidget: (context, url, error) => const Center(
child: Icon(Icons.error, color: Colors.white),
),
),
);
}
}