Files
cxyz/cxyz-android/lib/ui/reader_page.dart
T
nsrb a7c6058540 Add cxyz Android project structure and initial configuration
- Introduced a new Flutter project for the cxyz Android manga/comic reader, including essential files such as pubspec.yaml, AndroidManifest.xml, and Gradle configuration.
- Added .gitignore files to exclude unnecessary files and directories for both the main project and Android-specific files.
- Implemented initial README.md with project description, features, setup instructions, and API details for ComicLand, MangaRead, and MangaTown.
- Configured analysis options and dependencies in pubspec.yaml to support Flutter development.
- Established a pnpm workspace and lockfile for dependency management.
- Included various Android resources such as icons, styles, and launch backgrounds for a complete application setup.
2026-08-29 17:50:31 +07:00

1115 lines
38 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:drift/drift.dart' hide Column;
import 'package:flutter/material.dart' hide Title;
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../data/database.dart';
import '../providers/providers.dart';
import '../services/reader_direction.dart';
import '../services/reader_zoom.dart';
import '../services/source_reader_defaults.dart';
import '../sources/source.dart';
/// Approximate height of the under-image end panel (webtoon scroll math).
const double _kEndPanelHeight = 320;
class ReaderPage extends ConsumerStatefulWidget {
const ReaderPage({
super.key,
required this.titleId,
required this.chapterId,
this.initialMode,
});
final int titleId;
final int chapterId;
/// If null, mode is loaded from the title's saved preference.
final ReaderMode? initialMode;
@override
ConsumerState<ReaderPage> createState() => _ReaderPageState();
}
class _ReaderPageState extends ConsumerState<ReaderPage> {
ReaderMode _mode = ReaderMode.webtoon;
ReadingDirection _direction = ReadingDirection.ltr;
Title? _title;
Chapter? _chapter;
List<Chapter> _allChapters = const [];
List<String> _pages = const [];
bool _loading = true;
bool _offline = false;
bool _uiVisible = true;
String? _error;
late final PageController _pageController;
final ScrollController _scrollController = ScrollController();
final ScrollController _hScrollController = ScrollController();
final GlobalKey _webtoonListKey = GlobalKey();
List<GlobalKey> _pageKeys = const [];
int _pageIndex = 0;
bool _viewingEndPanel = false;
double _zoomScale = ReaderZoom.defaultScale;
Timer? _saveDebounce;
bool _restoringScroll = false;
int _restoreAttempts = 0;
/// Content Y of the current page top — used to compensate when images above grow.
double? _anchorContentY;
bool _userScrolling = false;
@override
void initState() {
super.initState();
_pageController = PageController();
_scrollController.addListener(_onWebtoonScroll);
_loadZoom();
_load();
}
@override
void dispose() {
_saveDebounce?.cancel();
// Best-effort; navigation paths await [_flushProgress] before leaving.
unawaited(_saveProgress());
_scrollController.removeListener(_onWebtoonScroll);
_pageController.dispose();
_scrollController.dispose();
_hScrollController.dispose();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
Chapter? get _nextChapter {
final chapter = _chapter;
if (chapter == null || _allChapters.isEmpty) return null;
final idx = _allChapters.indexWhere((c) => c.id == chapter.id);
if (idx < 0 || idx + 1 >= _allChapters.length) return null;
return _allChapters[idx + 1];
}
bool get _isLastChapter => _nextChapter == null;
Future<void> _loadZoom() async {
final scale = await ReaderZoom.load();
if (!mounted) return;
setState(() => _zoomScale = scale);
}
Future<void> _setZoom(double next) async {
final clamped = ReaderZoom.clamp(next);
if ((clamped - _zoomScale).abs() < 0.001) return;
setState(() => _zoomScale = clamped);
await ReaderZoom.save(clamped);
}
void _zoomOut() => _setZoom(_zoomScale - ReaderZoom.step);
void _zoomIn() => _setZoom(_zoomScale + ReaderZoom.step);
void _scheduleSave() {
_saveDebounce?.cancel();
_saveDebounce = Timer(const Duration(milliseconds: 400), () {
unawaited(_saveProgress());
});
}
/// Cancel debounce and persist immediately (call before leaving the reader).
Future<void> _flushProgress() async {
_saveDebounce?.cancel();
_saveDebounce = null;
await _saveProgress();
}
Future<void> _saveProgress() async {
final chapter = _chapter;
if (chapter == null) return;
final db = ref.read(databaseProvider);
if (_pages.isEmpty) {
// Pages not loaded yet — still bump the series continue pointer.
final now = DateTime.now();
await (db.update(db.titles)..where((t) => t.id.equals(widget.titleId)))
.write(
TitlesCompanion(
lastReadChapterId: Value(chapter.id),
lastReadAt: Value(now),
readerMode: Value(_mode.storageValue),
readingDirection: Value(_direction.storageValue),
),
);
await (db.update(db.chapters)..where((c) => c.id.equals(chapter.id)))
.write(ChaptersCompanion(lastReadAt: Value(now)));
ref.read(webDavSyncProvider).scheduleSync();
return;
}
await db.saveReadingProgress(
titleId: widget.titleId,
chapterId: chapter.id,
lastPage: _pageIndex.clamp(0, _pages.length - 1),
pageCount: _pages.length,
readerMode: _mode.storageValue,
readingDirection: _direction.storageValue,
);
ref.read(webDavSyncProvider).scheduleSync();
}
Future<void> _leaveReader(void Function() navigate) async {
await _flushProgress();
if (!mounted) return;
navigate();
}
Future<void> _setMode(ReaderMode mode) async {
if (_mode == mode) return;
setState(() {
_mode = mode;
_viewingEndPanel = false;
});
await ref.read(databaseProvider).saveReaderPrefs(
titleId: widget.titleId,
readerMode: mode.storageValue,
readingDirection: _direction.storageValue,
);
ref.read(webDavSyncProvider).scheduleSync();
_scheduleSave();
if (mode == ReaderMode.paged) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_pageController.hasClients) {
_pageController.jumpToPage(_pageIndex);
}
});
} else {
WidgetsBinding.instance.addPostFrameCallback((_) => _restoreWebtoonScroll());
}
}
Future<void> _setDirection(ReadingDirection direction) async {
if (_direction == direction) return;
setState(() => _direction = direction);
await ref.read(databaseProvider).saveReaderPrefs(
titleId: widget.titleId,
readerMode: _mode.storageValue,
readingDirection: direction.storageValue,
);
ref.read(webDavSyncProvider).scheduleSync();
}
void _onWebtoonScroll() {
if (_restoringScroll || !_scrollController.hasClients || _pages.isEmpty) {
return;
}
// Metrics can change when images above the viewport finish loading.
// Re-anchor first so the same page stays under the viewport.
if (!_userScrolling) {
_reanchorWebtoonIfNeeded();
}
_updateWebtoonPageFromVisibility(updateAnchor: _userScrolling);
}
double? _pageContentY(int index) {
if (!_scrollController.hasClients ||
index < 0 ||
index >= _pageKeys.length) {
return null;
}
final listCtx = _webtoonListKey.currentContext;
final pageCtx = _pageKeys[index].currentContext;
if (listCtx == null || pageCtx == null) return null;
final listBox = listCtx.findRenderObject() as RenderBox?;
final pageBox = pageCtx.findRenderObject() as RenderBox?;
if (listBox == null || pageBox == null || !listBox.hasSize || !pageBox.hasSize) {
return null;
}
final listTop = listBox.localToGlobal(Offset.zero).dy;
final pageTop = pageBox.localToGlobal(Offset.zero).dy;
return _scrollController.offset + (pageTop - listTop);
}
void _captureWebtoonAnchor() {
_anchorContentY = _pageContentY(_pageIndex);
}
/// When placeholders expand above the current page, keep that page glued in place.
void _reanchorWebtoonIfNeeded() {
if (_restoringScroll ||
!_scrollController.hasClients ||
_anchorContentY == null ||
_pages.isEmpty) {
return;
}
final currentY = _pageContentY(_pageIndex);
if (currentY == null) return;
final delta = currentY - _anchorContentY!;
if (delta.abs() < 1.0) return;
_restoringScroll = true;
final pos = _scrollController.position;
final next = (pos.pixels + delta).clamp(0.0, pos.maxScrollExtent);
_scrollController.jumpTo(next);
_restoringScroll = false;
_anchorContentY = _pageContentY(_pageIndex) ?? (_anchorContentY! + delta);
}
/// Pick the page with the largest visible overlap in the viewport.
/// Avoids ratio-of-maxScrollExtent, which jumps as images finish loading.
void _updateWebtoonPageFromVisibility({bool updateAnchor = true}) {
if (!mounted || _pages.isEmpty) return;
final listCtx = _webtoonListKey.currentContext;
if (listCtx == null) return;
final listBox = listCtx.findRenderObject() as RenderBox?;
if (listBox == null || !listBox.hasSize) return;
final listTop = listBox.localToGlobal(Offset.zero).dy;
final viewportTop = listTop;
final viewportBottom = listTop + listBox.size.height;
var bestIndex = _pageIndex;
var bestVisible = 0.0;
var anyBuilt = false;
for (var i = 0; i < _pageKeys.length; i++) {
final ctx = _pageKeys[i].currentContext;
if (ctx == null) continue;
final box = ctx.findRenderObject() as RenderBox?;
if (box == null || !box.hasSize) continue;
anyBuilt = true;
final top = box.localToGlobal(Offset.zero).dy;
final bottom = top + box.size.height;
final visibleTop = math.max(top, viewportTop);
final visibleBottom = math.min(bottom, viewportBottom);
final visible = math.max(0.0, visibleBottom - visibleTop);
if (visible > bestVisible) {
bestVisible = visible;
bestIndex = i;
}
}
if (!anyBuilt) return;
final pos = _scrollController.position;
final atEnd = pos.maxScrollExtent > 0 &&
pos.pixels >= pos.maxScrollExtent - 24;
if (bestIndex != _pageIndex || atEnd != _viewingEndPanel) {
setState(() {
_pageIndex = bestIndex;
_viewingEndPanel = atEnd;
});
_scheduleSave();
}
if (updateAnchor) _captureWebtoonAnchor();
}
void _restoreWebtoonScroll() {
if (!_scrollController.hasClients || _pages.isEmpty) return;
if (_pageIndex <= 0) {
_captureWebtoonAnchor();
return;
}
if (_pageIndex >= _pageKeys.length) return;
final key = _pageKeys[_pageIndex];
final ctx = key.currentContext;
if (ctx == null) {
// ListView.builder has not mounted this index yet — nudge toward it.
if (_restoreAttempts < 16) {
_restoreAttempts++;
final pos = _scrollController.position;
// Prefer measured average height of built pages; fall back to placeholder.
var sampleH = 0.0;
var sampleN = 0;
for (final k in _pageKeys) {
final c = k.currentContext;
if (c == null) continue;
final box = c.findRenderObject() as RenderBox?;
if (box == null || !box.hasSize) continue;
sampleH += box.size.height;
sampleN++;
}
final estH = sampleN > 0 ? sampleH / sampleN : 240.0;
final target = (_pageIndex * estH).clamp(0.0, pos.maxScrollExtent);
_restoringScroll = true;
_scrollController.jumpTo(target);
_restoringScroll = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _mode == ReaderMode.webtoon) _restoreWebtoonScroll();
});
}
return;
}
_restoringScroll = true;
_restoreAttempts = 0;
Scrollable.ensureVisible(
ctx,
alignment: 0.0,
duration: Duration.zero,
).whenComplete(() {
_restoringScroll = false;
if (mounted) {
_captureWebtoonAnchor();
_updateWebtoonPageFromVisibility(updateAnchor: false);
}
});
}
void _revealEndPanel() {
if (_pages.isEmpty) return;
setState(() {
_pageIndex = _pages.length - 1;
_viewingEndPanel = true;
});
_scheduleSave();
if (_mode == ReaderMode.paged) {
if (_pageController.hasClients) {
_pageController.animateToPage(
_pages.length,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
);
}
} else if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final db = ref.read(databaseProvider);
final title = await db.getTitle(widget.titleId);
final chapter = await db.getChapter(widget.chapterId);
if (title == null || chapter == null) {
throw StateError('Chapter not found');
}
final chapters = await db.listChapters(widget.titleId);
chapters.sort((a, b) {
final na = double.tryParse(a.chapterNumber ?? '') ?? 0;
final nb = double.tryParse(b.chapterNumber ?? '') ?? 0;
return na.compareTo(nb);
});
List<String> pages = const [];
var offline = false;
if (chapter.status == 'downloaded') {
final store = ref.read(cbzStoreProvider);
pages = await store.pagesFromCacheOrCbz(
cacheDir: chapter.cacheDir,
cbzPath: chapter.cbzPath,
comicTitle: title.title,
chapterKey: chapter.chapterKey,
);
offline = pages.isNotEmpty;
if (offline && chapter.cacheDir == null) {
final cacheDir = await store.resolveCacheDir(
comicTitle: title.title,
chapterKey: chapter.chapterKey,
);
await (db.update(db.chapters)..where((c) => c.id.equals(chapter.id)))
.write(ChaptersCompanion(cacheDir: Value(cacheDir)));
}
}
if (!offline) {
final source =
ref.read(sourceRegistryProvider).get(SourceId.values.byName(title.source));
pages = await source.fetchPageUrls(chapter.url);
}
final globalDir = ref.read(readingDirectionProvider);
final mode = widget.initialMode ??
SourceReaderDefaults.resolveMode(title.source, title.readerMode);
final direction = SourceReaderDefaults.resolveDirection(
title.source,
title.readingDirection,
globalFallback: globalDir,
);
final pageIndex =
chapter.lastPage.clamp(0, pages.isEmpty ? 0 : pages.length - 1);
if (!mounted) return;
setState(() {
_title = title;
_chapter = chapter;
_allChapters = chapters;
_pages = pages;
_pageKeys = List<GlobalKey>.generate(pages.length, (_) => GlobalKey());
_offline = offline;
_mode = mode;
_direction = direction;
_pageIndex = pageIndex;
_viewingEndPanel = false;
_restoreAttempts = 0;
_anchorContentY = null;
_loading = false;
});
unawaited(_saveProgress());
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (_mode == ReaderMode.paged && _pageIndex > 0) {
if (_pageController.hasClients) {
_pageController.jumpToPage(_pageIndex);
}
} else if (_mode == ReaderMode.webtoon && _pageIndex > 0) {
_restoreWebtoonScroll();
} else if (_mode == ReaderMode.webtoon) {
_captureWebtoonAnchor();
}
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_loading = false;
});
}
}
void _toggleUi() {
setState(() => _uiVisible = !_uiVisible);
if (_uiVisible) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
}
}
/// Reading-order previous page (respects LTR/RTL for which edge, not chapter).
void _readingPrevPage() {
if (_viewingEndPanel) {
setState(() => _viewingEndPanel = false);
if (_mode == ReaderMode.paged && _pageController.hasClients) {
_pageController.animateToPage(
_pages.length - 1,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
return;
}
if (_pageIndex <= 0) return;
if (_mode == ReaderMode.paged && _pageController.hasClients) {
_pageController.previousPage(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
}
/// Reading-order next page; on last image reveals end panel.
void _readingNextPage() {
if (_viewingEndPanel) return;
if (_pages.isEmpty) return;
if (_pageIndex >= _pages.length - 1) {
_revealEndPanel();
return;
}
if (_mode == ReaderMode.paged && _pageController.hasClients) {
_pageController.nextPage(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
}
Future<void> _goChapter(int delta) async {
final chapter = _chapter;
if (chapter == null || _allChapters.isEmpty) return;
final idx = _allChapters.indexWhere((c) => c.id == chapter.id);
if (idx < 0) return;
final next = idx + delta;
if (next < 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('First chapter')),
);
return;
}
if (next >= _allChapters.length) {
_revealEndPanel();
return;
}
await _flushProgress();
if (!mounted) return;
context.pushReplacement(
'/read/${widget.titleId}/${_allChapters[next].id}',
);
}
Future<void> _openNextChapter() async {
final next = _nextChapter;
if (next == null) return;
await _flushProgress();
if (!mounted) return;
context.pushReplacement('/read/${widget.titleId}/${next.id}');
}
Future<void> _backToSeries() async {
final nav = Navigator.of(context);
final titleId = widget.titleId;
final canPop = nav.canPop();
await _flushProgress();
if (!mounted) return;
// Prefer pop so the existing TitlePage can reload via its push().then.
if (canPop) {
nav.pop();
} else {
context.go('/title/$titleId');
}
}
Future<void> _goLibrary() => _leaveReader(() => context.go('/library'));
Future<void> _goHome() => _leaveReader(() => context.go('/catalog'));
double _pageWidth(BuildContext context) {
final screenW = MediaQuery.sizeOf(context).width;
return math.max(1.0, screenW * _zoomScale);
}
Widget _pageImage(
String src, {
required bool offline,
required double pageWidth,
}) {
if (offline) {
return Image.file(
File(src),
fit: BoxFit.fitWidth,
width: pageWidth,
filterQuality: FilterQuality.medium,
gaplessPlayback: true,
);
}
final cache = ref.watch(imageCacheManagerProvider);
final title = _title;
Map<String, String> headers = const {};
if (title != null) {
final sid = SourceId.tryParse(title.source);
if (sid != null) {
final registry = ref.watch(sourceRegistryProvider);
if (registry.isEnabled(sid)) {
headers = registry.get(sid).imageHeaders;
}
}
}
return CachedNetworkImage(
imageUrl: src,
cacheManager: cache,
httpHeaders: headers,
fit: BoxFit.fitWidth,
width: pageWidth,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => SizedBox(
width: pageWidth,
height: 240,
child: const Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
errorWidget: (context, url, error) => SizedBox(
width: pageWidth,
height: 120,
child: const Center(child: Icon(Icons.broken_image_outlined)),
),
);
}
Widget _endOfChapterPanel() {
final next = _nextChapter;
return Material(
color: Colors.black,
child: SizedBox(
width: double.infinity,
height: _kEndPanelHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
child: next != null
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'End of ${_chapter?.label ?? 'chapter'}',
style: const TextStyle(color: Colors.white70, fontSize: 14),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _openNextChapter,
icon: const Icon(Icons.skip_next),
label: Text('Next chapter · ${next.label}'),
),
],
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'You’ve read the last chapter',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
_title?.title ?? '',
style: const TextStyle(color: Colors.white70, fontSize: 14),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 20),
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
FilledButton(
onPressed: _backToSeries,
child: const Text('Back to series'),
),
OutlinedButton(
onPressed: _goLibrary,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white54),
),
child: const Text('Library'),
),
OutlinedButton(
onPressed: _goHome,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white54),
),
child: const Text('Home'),
),
],
),
],
),
),
),
);
}
Widget _webtoon() {
final pageWidth = _pageWidth(context);
final list = NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollStartNotification &&
notification.dragDetails != null) {
_userScrolling = true;
} else if (notification is ScrollEndNotification) {
_userScrolling = false;
_updateWebtoonPageFromVisibility(updateAnchor: true);
} else if (notification is UserScrollNotification) {
// Keep flag in sync with user-driven scroll activity.
_userScrolling = notification.direction != ScrollDirection.idle;
if (!_userScrolling) _captureWebtoonAnchor();
} else if (notification is ScrollMetricsNotification && !_userScrolling) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_userScrolling) _reanchorWebtoonIfNeeded();
});
}
return false;
},
child: ListView.builder(
controller: _scrollController,
padding: EdgeInsets.zero,
cacheExtent: 2000,
itemCount: _pages.isEmpty ? 0 : _pages.length + 1,
itemBuilder: (context, i) {
if (i >= _pages.length) {
return _endOfChapterPanel();
}
return KeyedSubtree(
key: _pageKeys[i],
child: Align(
alignment: Alignment.topCenter,
child: _pageImage(
_pages[i],
offline: _offline,
pageWidth: pageWidth,
),
),
);
},
),
);
final wrapped = GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _toggleUi,
child: SizedBox.expand(
key: _webtoonListKey,
child: list,
),
);
if (_zoomScale <= 1.0) {
return wrapped;
}
return LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
controller: _hScrollController,
scrollDirection: Axis.horizontal,
child: SizedBox(
width: pageWidth,
height: constraints.maxHeight,
child: wrapped,
),
);
},
);
}
Widget _paged() {
final pageWidth = _pageWidth(context);
final rtl = _direction == ReadingDirection.rtl;
return Stack(
children: [
PageView.builder(
controller: _pageController,
reverse: rtl,
itemCount: _pages.isEmpty ? 0 : _pages.length + 1,
onPageChanged: (i) {
if (i >= _pages.length) {
setState(() {
_pageIndex = _pages.length - 1;
_viewingEndPanel = true;
});
} else {
setState(() {
_pageIndex = i;
_viewingEndPanel = false;
});
}
_scheduleSave();
},
itemBuilder: (context, i) {
if (i >= _pages.length) {
return _endOfChapterPanel();
}
return InteractiveViewer(
minScale: 1,
maxScale: 4,
child: Center(
child: _pageImage(
_pages[i],
offline: _offline,
pageWidth: pageWidth,
),
),
);
},
),
// Edge tap zones (screen %). Hidden on end panel so buttons stay tappable.
if (!_viewingEndPanel)
Positioned.fill(
child: Row(
children: [
Expanded(
flex: 35,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: rtl ? _readingNextPage : _readingPrevPage,
),
),
Expanded(
flex: 30,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: _toggleUi,
),
),
Expanded(
flex: 35,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: rtl ? _readingPrevPage : _readingNextPage,
),
),
],
),
),
],
);
}
List<Widget> _zoomControls(BuildContext context) {
final atMin = _zoomScale <= ReaderZoom.min + 0.001;
final atMax = _zoomScale >= ReaderZoom.max - 0.001;
return [
IconButton(
tooltip: 'Zoom out',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
onPressed: atMin ? null : _zoomOut,
icon: Icon(
Icons.remove,
color: atMin ? Colors.white38 : Colors.white,
),
),
Text(
ReaderZoom.percentLabel(_zoomScale),
style: const TextStyle(color: Colors.white, fontSize: 12),
),
IconButton(
tooltip: 'Zoom in',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
onPressed: atMax ? null : _zoomIn,
icon: Icon(
Icons.add,
color: atMax ? Colors.white38 : Colors.white,
),
),
];
}
@override
Widget build(BuildContext context) {
final direction = _direction;
final rtl = direction == ReadingDirection.rtl;
final completed =
_pages.isNotEmpty && _pageIndex >= _pages.length - 1;
final pageLabel = _pages.isEmpty
? '0/0'
: '${_pageIndex + 1}/${_pages.length}';
// Chevron actions follow reading direction (match edge taps).
final onChevronLeft = rtl ? _readingNextPage : _readingPrevPage;
final onChevronRight = rtl ? _readingPrevPage : _readingNextPage;
final canChevronLeft = rtl
? !_viewingEndPanel && _pages.isNotEmpty
: _viewingEndPanel || _pageIndex > 0;
final canChevronRight = rtl
? _viewingEndPanel || _pageIndex > 0
: !_viewingEndPanel && _pages.isNotEmpty;
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final nav = Navigator.of(context);
await _flushProgress();
if (!mounted) return;
// Force pop after flush; canPop is false so system back lands here.
nav.pop(result);
},
child: Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
if (_loading)
const Center(child: CircularProgressIndicator())
else if (_error != null)
Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(_error!, style: const TextStyle(color: Colors.white)),
),
)
else
_mode == ReaderMode.webtoon ? _webtoon() : _paged(),
if (_uiVisible)
SafeArea(
child: Column(
children: [
Material(
color: Colors.black54,
child: ListTile(
textColor: Colors.white,
iconColor: Colors.white,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).maybePop(),
),
title: Text(
_chapter?.label ?? 'Reader',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${_title?.title ?? ''} · ${_offline ? 'Offline' : 'Online'} · '
'$pageLabel'
'${completed || _viewingEndPanel ? ' · Done' : ''}'
'${_viewingEndPanel ? ' · End' : ''}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
..._zoomControls(context),
IconButton(
tooltip: (_title?.inLibrary ?? false)
? 'Remove from Library'
: 'Add to Library',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
onPressed: _title == null
? null
: () async {
final t = _title!;
final next = !t.inLibrary;
await ref
.read(databaseProvider)
.setInLibrary(t.id, next);
ref.read(webDavSyncProvider).scheduleSync();
final refreshed = await ref
.read(databaseProvider)
.getTitle(t.id);
if (!mounted) return;
setState(() => _title = refreshed);
},
icon: Icon(
(_title?.inLibrary ?? false)
? Icons.star
: Icons.star_border,
color: Colors.white,
),
),
IconButton(
tooltip: 'Vertical (webtoon)',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
onPressed: () => _setMode(ReaderMode.webtoon),
icon: Icon(
Icons.view_day,
color: _mode == ReaderMode.webtoon
? Theme.of(context).colorScheme.primary
: Colors.white,
),
),
IconButton(
tooltip: 'Horizontal (paged)',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
onPressed: () => _setMode(ReaderMode.paged),
icon: Icon(
Icons.auto_stories,
color: _mode == ReaderMode.paged
? Theme.of(context).colorScheme.primary
: Colors.white,
),
),
IconButton(
tooltip: 'Left → Right',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
onPressed: () =>
_setDirection(ReadingDirection.ltr),
icon: Icon(
Icons.format_textdirection_l_to_r,
color: _direction == ReadingDirection.ltr
? Theme.of(context).colorScheme.primary
: Colors.white,
),
),
IconButton(
tooltip: 'Right → Left',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
onPressed: () =>
_setDirection(ReadingDirection.rtl),
icon: Icon(
Icons.format_textdirection_r_to_l,
color: _direction == ReadingDirection.rtl
? Theme.of(context).colorScheme.primary
: Colors.white,
),
),
],
),
),
),
const Spacer(),
Material(
color: Colors.black54,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
color: Colors.white,
onPressed: () => _goChapter(-1),
icon: const Icon(Icons.skip_previous),
tooltip: 'Previous chapter',
),
if (_mode == ReaderMode.paged) ...[
IconButton(
color: Colors.white,
onPressed: canChevronLeft ? onChevronLeft : null,
icon: const Icon(Icons.chevron_left),
tooltip: rtl ? 'Next page' : 'Previous page',
),
IconButton(
color: Colors.white,
onPressed: canChevronRight ? onChevronRight : null,
icon: const Icon(Icons.chevron_right),
tooltip: rtl ? 'Previous page' : 'Next page',
),
],
IconButton(
color: Colors.white,
onPressed: _isLastChapter
? _revealEndPanel
: () => _goChapter(1),
icon: const Icon(Icons.skip_next),
tooltip: 'Next chapter',
),
],
),
),
],
),
),
],
),
),
);
}
}