- 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.
1115 lines
38 KiB
Dart
1115 lines
38 KiB
Dart
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',
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|