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 createState() => _ReaderPageState(); } class _ReaderPageState extends ConsumerState { ReaderMode _mode = ReaderMode.webtoon; ReadingDirection _direction = ReadingDirection.ltr; Title? _title; Chapter? _chapter; List _allChapters = const []; List _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 _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 _loadZoom() async { final scale = await ReaderZoom.load(); if (!mounted) return; setState(() => _zoomScale = scale); } Future _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 _flushProgress() async { _saveDebounce?.cancel(); _saveDebounce = null; await _saveProgress(); } Future _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 _leaveReader(void Function() navigate) async { await _flushProgress(); if (!mounted) return; navigate(); } Future _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 _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 _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 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.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 _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 _openNextChapter() async { final next = _nextChapter; if (next == null) return; await _flushProgress(); if (!mounted) return; context.pushReplacement('/read/${widget.titleId}/${next.id}'); } Future _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 _goLibrary() => _leaveReader(() => context.go('/library')); Future _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 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( 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 _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', ), ], ), ), ], ), ), ], ), ), ); } }