import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:flutter_epub_viewer/flutter_epub_viewer.dart'; import 'package:go_router/go_router.dart'; import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/helpers/snackbar_helper.dart'; import 'package:worldhopper/providers/eink_mode_provider.dart'; import 'package:worldhopper/models/enhanced_metadata.dart'; import 'package:worldhopper/models/opds_entry.dart'; import 'package:worldhopper/models/reading_progress.dart'; import 'package:worldhopper/providers/publication_cache_provider.dart'; import 'package:worldhopper/providers/reading_progress_provider.dart'; import 'package:worldhopper/providers/server_provider.dart'; import 'package:worldhopper/providers/connectivity_provider.dart'; import 'package:worldhopper/services/epub_download_service.dart'; import 'package:worldhopper/services/koreader_sync_service.dart'; import 'package:worldhopper/providers/koreader_sync_provider.dart'; import 'package:uuid/uuid.dart'; import 'package:worldhopper/widgets/next_in_series_overlay.dart'; /// EPUB reader screen using flutter_epub_viewer with Epub.js integration /// Provides integrated reading experience with CFI-based position tracking class EpubReaderScreen extends ConsumerStatefulWidget { final OPDSEntry entry; final String serverId; final String? feedUrl; const EpubReaderScreen({ super.key, required this.entry, required this.serverId, this.feedUrl, }); @override ConsumerState createState() => _EpubReaderScreenState(); } class _EpubReaderScreenState extends ConsumerState { EpubController? _epubController; bool _isLoading = true; double _loadingProgress = 0.0; String? _loadingStatus; String? _errorMessage; File? _epubFile; String? _initialCfi; List? _chapters; String? _viewerKey; // Unique key to force widget recreation EpubLocation? _lastLocation; // Track last location for saving on dispose bool _showAppBar = false; // Hidden by default for immersive reading Timer? _appBarAutoHideTimer; // Auto-hide timer (5 seconds) bool _reachedEndThisSession = false; // KOReader sync state String? _koreaderDocumentHash; KoreaderSyncService? _koreaderSyncService; bool _hasSavedOnExit = false; bool _koreaderAuthFailed = false; // Gesture tracking for tap vs swipe detection Offset? _pointerDownPosition; DateTime? _pointerDownTime; bool _isSwipe = false; // Thresholds static const double _swipeThreshold = 15.0; // pixels - movement beyond this = swipe static const Duration _tapMaxDuration = Duration(milliseconds: 500); // taps complete quickly bool _didStartLoad = false; @override void initState() { super.initState(); // Create a new controller for each instance to avoid state retention _epubController = EpubController(); // Keep screen awake while reading WakelockPlus.enable(); // Enter immersive mode to hide system bars (matching image reader behavior) SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); } @override void didChangeDependencies() { super.didChangeDependencies(); // _loadEpub uses AppLocalizations.of(context) which requires inherited // widgets to be available. initState() runs before they are ready, so // we trigger the load here instead (guarded to run only once). if (!_didStartLoad) { _didStartLoad = true; _loadEpub(); } } Future _loadEpub() async { try { final l10n = AppLocalizations.of(context); setState(() { _isLoading = true; _loadingProgress = 0.0; _loadingStatus = l10n.epubPreparing; _errorMessage = null; }); final server = await ref.read(serverProvider(widget.serverId).future); if (server == null) { setState(() { _errorMessage = l10n.readerServerNotFound; _isLoading = false; }); return; } // Check if file is cached first final epubService = EpubDownloadService(); final isCached = await epubService.isEpubCached(widget.entry); if (isCached) { setState(() { _loadingStatus = l10n.epubLoadingFromCache; _loadingProgress = 0.5; }); } // Download or get cached EPUB file with progress tracking final file = await epubService.downloadEpub( widget.entry, server, onProgress: (progress) { if (!mounted) return; final l10n = AppLocalizations.of(context); setState(() { _loadingProgress = progress * 0.8; // Reserve 0.8-1.0 for processing _loadingStatus = isCached ? l10n.epubLoadingFromCache : l10n.epubDownloading((progress * 100).toInt()); }); }, ); if (!mounted) return; final l10nAfter = AppLocalizations.of(context); setState(() { _loadingProgress = 0.85; _loadingStatus = l10nAfter.epubProcessing; }); // Cache the publication await ref.read(publicationCacheProvider.notifier).cachePublication( serverId: widget.serverId, entry: widget.entry, metadata: const EnhancedMetadata.empty(), downloadImages: true, ); // Load saved progress to get initial CFI location final progressKey = ProgressKey( publicationId: widget.entry.id, serverId: widget.serverId, ); final savedProgress = await ref.read(readingProgressProvider(progressKey).future); // Extract CFI from saved progress if available String? cfi; if (savedProgress?.epubLocation != null && savedProgress!.epubLocation!.isNotEmpty) { try { final locationData = jsonDecode(savedProgress.epubLocation!); cfi = locationData['startCfi'] as String?; } catch (e) { debugPrint('Error parsing saved CFI: $e'); } } // Always compute the document hash for storage (used by KOReader sync // and shown in developer-mode raw data). _koreaderDocumentHash = await computeDocumentHash( entryId: widget.entry.id, epubFile: file, ); // KOReader sync: pull remote progress. // Only restore from remote when there is NO local progress (i.e. // cross-device resume). When local progress exists we always trust it // because we are the ones who wrote it; the remote timestamp will // often be slightly later (server-side) and would otherwise always // win, overwriting the perfectly valid local CFI. if (server.koreaderSyncEnabled) { try { final syncService = ref.read(koreaderSyncServiceProvider(server)); if (syncService != null) { _koreaderSyncService = syncService; // Only pull remote progress when we have no local position if (cfi == null) { final remoteProgress = await pullKoreaderProgress( syncService: syncService, documentHash: _koreaderDocumentHash!, ); if (remoteProgress != null && remoteProgress.progress.isNotEmpty && _isValidEpubCfi(remoteProgress.progress)) { cfi = remoteProgress.progress; debugPrint('KOReader sync: restoring remote position ' '(${remoteProgress.percentage * 100}%)'); } else if (remoteProgress != null && remoteProgress.progress.isNotEmpty) { debugPrint('KOReader sync: ignoring non-CFI progress string: ' '${remoteProgress.progress}'); } } } } on KoreaderSyncAuthException { _koreaderAuthFailed = true; if (mounted) { final l10nSync = AppLocalizations.of(context); context.showWarningSnackBar(l10nSync.koreaderSyncAuthFailed); } } catch (e) { debugPrint('KOReader sync pull failed: $e'); } } if (!mounted) return; final l10nFinal = AppLocalizations.of(context); setState(() { _loadingProgress = 1.0; _loadingStatus = l10nFinal.epubOpeningReader; _epubFile = file; _initialCfi = cfi; // Generate a unique key to force widget recreation and reset viewport _viewerKey = 'epub-${widget.entry.id}-${file.path}-${DateTime.now().millisecondsSinceEpoch}'; _isLoading = false; }); } catch (e) { if (!mounted) return; final l10n = AppLocalizations.of(context); setState(() { _errorMessage = l10n.epubErrorLoading(e.toString()); _isLoading = false; }); } } bool _hasReceivedNonZeroProgress = false; /// Handle page navigation - save progress with CFI void _handleRelocated(EpubLocation location) { _lastLocation = location; // epub.js fires onRelocated immediately when restoring a saved position, // but reports progress as 0.0 before it finishes calculating the actual // value. Skip saving/pushing until we get a real progress value to avoid // overwriting the stored position with 0%. if (location.progress > 0.0) { _hasReceivedNonZeroProgress = true; } if (!_hasReceivedNonZeroProgress) { return; } if (location.progress >= 0.99 && !_reachedEndThisSession) { setState(() { _reachedEndThisSession = true; }); } // Save progress asynchronously (fire and forget, but we'll save on dispose too) _saveProgress(location); } /// Handle chapters loaded void _handleChaptersLoaded(List chapters) { setState(() { _chapters = chapters; }); } Future _deleteCompletedProgress() async { await ref .read(readingProgressNotifierProvider.notifier) .deleteProgress(widget.entry.id, widget.serverId); if (mounted) { ref.invalidate(recentlyReadProvider); ref.invalidate(inProgressCountProvider); ref.invalidate(completedCountProvider); } } /// Save reading progress with CFI-based location Future _saveProgress(EpubLocation location) async { final progressKey = ProgressKey( publicationId: widget.entry.id, serverId: widget.serverId, ); // Capture all ref-dependent values synchronously before any await. // This is critical when called from dispose(), where ref becomes // invalid after super.dispose(). final existingProgressFuture = ref.read(readingProgressProvider(progressKey).future); final notifier = ref.read(readingProgressNotifierProvider.notifier); final cachedPubFuture = ref.read( cachedPublicationProvider(widget.serverId, widget.entry.id).future); final existingProgress = await existingProgressFuture; // Calculate current/total pages from location percentage final percentage = location.progress; final currentPage = (percentage * 100).round(); const totalPages = 100; debugPrint( 'EPUB Progress - Location: ${location.progress * 100}%, Page: $currentPage/$totalPages, CFI: ${location.startCfi}'); // Store full location data as JSON final locationJson = jsonEncode({ 'startCfi': location.startCfi, 'endCfi': location.endCfi, 'progress': location.progress, }); // Get cached publication final cachedPub = await cachedPubFuture; final progress = existingProgress?.copyWith( currentPage: currentPage, totalPages: totalPages, epubLocation: locationJson, lastReadAt: DateTime.now(), publicationCacheId: cachedPub?.id ?? existingProgress.publicationCacheId, seriesFeedUrl: widget.feedUrl ?? existingProgress.seriesFeedUrl, syncHash: _koreaderDocumentHash ?? existingProgress.syncHash, ) ?? ReadingProgress( id: const Uuid().v4(), publicationId: widget.entry.id, serverId: widget.serverId, currentPage: currentPage, totalPages: totalPages, epubLocation: locationJson, lastReadAt: DateTime.now(), publicationCacheId: cachedPub?.id, seriesFeedUrl: widget.feedUrl, syncHash: _koreaderDocumentHash, ); await notifier.saveProgress(progress); // Only invalidate providers if the widget is still mounted. // During dispose(), ref is no longer valid for invalidation. if (mounted) { ref.invalidate(readingProgressProvider(progressKey)); ref.invalidate(recentlyReadProvider); } // Push to KOReader sync if enabled if (_koreaderSyncService != null && _koreaderDocumentHash != null) { // Round to 5 decimal places to match KOReader's precision final syncPercentage = double.parse(percentage.toStringAsFixed(5)); debugPrint( 'KOReader sync: pushing progress $syncPercentage (${location.startCfi})'); try { final pushed = await pushKoreaderProgress( syncService: _koreaderSyncService!, documentHash: _koreaderDocumentHash!, percentage: syncPercentage, progress: location.startCfi, ); debugPrint('KOReader sync: push result=$pushed'); } on KoreaderSyncAuthException { if (!_koreaderAuthFailed && mounted) { _koreaderAuthFailed = true; final l10n = AppLocalizations.of(context); context.showWarningSnackBar(l10n.koreaderSyncAuthFailed); } } } else { debugPrint( 'KOReader sync: skipping push (service=${_koreaderSyncService != null}, hash=${_koreaderDocumentHash != null})'); } } /// Build display settings based on orientation EpubDisplaySettings _buildDisplaySettings(Orientation orientation) { debugPrint( '_buildDisplaySettings: orientation=$orientation, width=${MediaQuery.of(context).size.width}'); final brightness = Theme.of(context).brightness; final isDarkMode = brightness == Brightness.dark; final colorScheme = Theme.of(context).colorScheme; final epubTheme = EpubTheme.custom( backgroundDecoration: BoxDecoration( color: isDarkMode ? colorScheme.surface : Colors.white, ), foregroundColor: colorScheme.onSurface, customCss: { 'body': ''' color: ${_colorToHex(colorScheme.onSurface)} !important; line-height: 1.6 !important; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important; ''', 'p': ''' color: ${_colorToHex(colorScheme.onSurface)} !important; line-height: 1.6 !important; ''', 'h1, h2, h3, h4, h5, h6': ''' color: ${_colorToHex(colorScheme.onSurface)} !important; ''', 'a': ''' color: ${_colorToHex(colorScheme.primary)} !important; ''', 'span, div': ''' color: ${_colorToHex(colorScheme.onSurface)} !important; ''', }, ); // Determine spread based on available width, not orientation final screenWidth = MediaQuery.of(context).size.width; const minWidthForDualPage = 768.0; final spread = screenWidth >= minWidthForDualPage ? EpubSpread.always : EpubSpread.none; debugPrint( '_buildDisplaySettings: returning spread=$spread (width=$screenWidth)'); return EpubDisplaySettings( flow: EpubFlow.paginated, snap: true, useSnapAnimationAndroid: false, theme: epubTheme, spread: spread, ); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); if (_isLoading) { return Scaffold( backgroundColor: Colors.black, appBar: AppBar( title: Text(widget.entry.title), backgroundColor: Colors.black, foregroundColor: Colors.white, ), body: Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 48), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CircularProgressIndicator( value: _loadingProgress > 0 ? _loadingProgress : null, color: Colors.white, ), const SizedBox(height: 24), LinearProgressIndicator( value: _loadingProgress > 0 ? _loadingProgress : null, backgroundColor: Colors.white24, valueColor: const AlwaysStoppedAnimation(Colors.white), minHeight: 4, borderRadius: BorderRadius.circular(2), ), const SizedBox(height: 16), Text( _loadingStatus ?? l10n.epubPreparing, style: const TextStyle( color: Colors.white, fontSize: 16, ), ), if (_loadingProgress > 0 && _loadingProgress < 1.0) Padding( padding: const EdgeInsets.only(top: 8), child: Text( '${(_loadingProgress * 100).toInt()}%', style: TextStyle( color: Colors.white.withValues(alpha: 0.7), fontSize: 14, ), ), ), ], ), ), ), ); } if (_errorMessage != null) { return Scaffold( appBar: AppBar(), body: Center( child: Padding( padding: const EdgeInsets.all(24.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( _errorMessage!, style: const TextStyle(color: Colors.red), textAlign: TextAlign.center, ), const SizedBox(height: 16), ElevatedButton( onPressed: () => Navigator.pop(context), child: Text(l10n.epubGoBack), ), ], ), ), ), ); } // Render integrated EPUB viewer if (_epubController == null || _epubFile == null || _viewerKey == null) { return const Scaffold( body: Center(child: CircularProgressIndicator()), ); } return PopScope( canPop: true, onPopInvokedWithResult: (bool didPop, Object? result) { if (didPop && !_hasSavedOnExit) { _hasSavedOnExit = true; if (_reachedEndThisSession) { _deleteCompletedProgress(); } else if (_lastLocation != null) { _saveProgress(_lastLocation!); } } }, child: Scaffold( body: Listener( behavior: HitTestBehavior.translucent, onPointerDown: (event) { _pointerDownPosition = event.position; _pointerDownTime = DateTime.now(); _isSwipe = false; }, onPointerMove: (event) { if (_pointerDownPosition != null) { final delta = (event.position - _pointerDownPosition!).distance; if (delta > _swipeThreshold) { _isSwipe = true; } } }, onPointerUp: (event) { if (_pointerDownTime != null && _pointerDownPosition != null) { final duration = DateTime.now().difference(_pointerDownTime!); if (!_isSwipe && duration < _tapMaxDuration) { if (_showAppBar) { final appBarHeight = MediaQuery.of(context).padding.top + 56; if (event.position.dy < appBarHeight) { return; // Tap on app bar, don't toggle } } _toggleAppBarVisibility(); } } _pointerDownPosition = null; _pointerDownTime = null; _isSwipe = false; }, child: Stack( children: [ OrientationBuilder( builder: (context, orientation) { // Calculate spread mode to include in key for widget recreation final screenWidth = MediaQuery.of(context).size.width; const minWidthForDualPage = 768.0; final shouldUseDualPage = screenWidth >= minWidthForDualPage; return SafeArea( child: EpubViewer( key: ValueKey( '$_viewerKey-${shouldUseDualPage ? "dual" : "single"}'), epubController: _epubController!, epubSource: EpubSource.fromFile(_epubFile!), initialCfi: _initialCfi, displaySettings: _buildDisplaySettings(orientation), onEpubLoaded: () { debugPrint('EPUB loaded successfully'); _disableEpubJsTapNavigation(); }, onChaptersLoaded: _handleChaptersLoaded, onRelocated: _handleRelocated, onTextSelected: (selection) { debugPrint('Selected: ${selection.selectedText}'); }, ), ); }, ), // Conditional custom app bar overlay if (_showAppBar) _buildCustomAppBar(), // Next in series overlay NextInSeriesOverlay( serverId: widget.serverId, feedUrl: widget.feedUrl, entry: widget.entry, isVisible: _reachedEndThisSession, einkMode: ref.watch(einkModeNotifierProvider), onBeforeNavigate: _deleteCompletedProgress, ), ], ), ), ), ); } /// Show chapter list dialog void _showChapterList(BuildContext context) { if (_chapters == null || _chapters!.isEmpty) return; showModalBottomSheet( context: context, builder: (context) => ListView.builder( itemCount: _chapters!.length, itemBuilder: (context, index) { final chapter = _chapters![index]; return ListTile( title: Text(chapter.title), onTap: () { Navigator.pop(context); _epubController?.display(cfi: chapter.href); _hideAppBarOnNavigation(); }, ); }, ), ); } /// Show search dialog void _showSearchDialog(BuildContext context) { final l10n = AppLocalizations.of(context); String searchQuery = ''; showDialog( context: context, builder: (context) => AlertDialog( title: Text(l10n.epubSearch), content: TextField( autofocus: true, decoration: InputDecoration( hintText: l10n.epubSearchHint, ), onChanged: (value) => searchQuery = value, onSubmitted: (value) { if (value.isNotEmpty) { Navigator.pop(context); _performSearch(value); } }, ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(l10n.commonCancel), ), TextButton( onPressed: () { if (searchQuery.isNotEmpty) { Navigator.pop(context); _performSearch(searchQuery); } }, child: Text(l10n.epubSearch), ), ], ), ); } /// Perform search and show results Future _performSearch(String query) async { if (_epubController == null) return; try { final results = await _epubController!.search(query: query); if (!mounted) return; final l10n = AppLocalizations.of(context); if (results.isEmpty) { context.showInfoSnackBar(l10n.epubNoResults); return; } // Show search results in bottom sheet showModalBottomSheet( context: context, builder: (context) => Column( children: [ Padding( padding: const EdgeInsets.all(16.0), child: Text( l10n.epubSearchResults(results.length, query), style: Theme.of(context).textTheme.titleMedium, ), ), Expanded( child: ListView.builder( itemCount: results.length, itemBuilder: (context, index) { final result = results[index]; return ListTile( title: Text( result.excerpt, maxLines: 2, overflow: TextOverflow.ellipsis, ), onTap: () { Navigator.pop(context); _epubController?.display(cfi: result.cfi); _hideAppBarOnNavigation(); }, ); }, ), ), ], ), ); } catch (e) { if (!mounted) return; final l10n = AppLocalizations.of(context); context.showErrorSnackBar(l10n.epubSearchFailed(e.toString())); } } /// Disable epub.js tap-to-navigate so that taps only toggle the app bar. /// Swipe gestures continue to work for page turning. /// /// The custom swipe handler uses touch events (touchstart/touchmove/touchend) /// to detect swipes and calls rendition.next()/prev() from the touchend handler. /// epub.js built-in navigation uses click events on content documents. /// /// Since taps generate: touchstart → touchend → click /// and swipes generate: touchstart → touchmove → touchend (no click), /// blocking click events in the content documents disables tap navigation /// while keeping swipe navigation intact. void _disableEpubJsTapNavigation() { _epubController?.webViewController?.evaluateJavascript(source: ''' (function() { function blockClicks(doc) { doc.addEventListener('click', function(e) { if (e.target.closest && e.target.closest('a')) return; e.stopPropagation(); }, true); } // Hook into new content loads (each chapter/spine item) rendition.hooks.content.register(function(contents) { if (contents.document) { blockClicks(contents.document); } }); // Apply to already-loaded views var views = rendition.views(); if (views && views.forEach) { views.forEach(function(view) { if (view.document) blockClicks(view.document); }); } })(); '''); } /// Check whether a progress string looks like a valid EPUB CFI. /// KOReader devices may store XPointer-based progress strings (e.g. /// "/body/DocFragment[20]/body/p[22]") which epub.js cannot parse. /// We only accept strings that start with "epubcfi(". bool _isValidEpubCfi(String value) { return value.trimLeft().startsWith('epubcfi('); } /// Helper method to convert Flutter Color to CSS hex string String _colorToHex(Color color) { return '#${color.toARGB32().toRadixString(16).substring(2).toUpperCase()}'; } /// Toggle app bar visibility void _toggleAppBarVisibility() { setState(() { _showAppBar = !_showAppBar; }); if (_showAppBar) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); _startAutoHideTimer(); } else { SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); _cancelAutoHideTimer(); } } /// Start auto-hide timer void _startAutoHideTimer() { _cancelAutoHideTimer(); _appBarAutoHideTimer = Timer(const Duration(seconds: 5), () { if (mounted) { setState(() { _showAppBar = false; }); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); } }); } /// Cancel auto-hide timer void _cancelAutoHideTimer() { _appBarAutoHideTimer?.cancel(); _appBarAutoHideTimer = null; } /// Hide app bar when navigating void _hideAppBarOnNavigation() { _cancelAutoHideTimer(); setState(() { _showAppBar = false; }); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); } /// Build custom app bar overlay Widget _buildCustomAppBar() { final l10n = AppLocalizations.of(context); final topPadding = MediaQuery.of(context).padding.top; return Positioned( top: 0, left: 0, right: 0, child: AnimatedOpacity( opacity: _showAppBar ? 1.0 : 0.0, duration: ref.watch(einkModeNotifierProvider) ? Duration.zero : const Duration(milliseconds: 200), child: Container( padding: EdgeInsets.only( top: topPadding, left: 4, right: 4, ), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.black.withValues(alpha: 0.7), Colors.black.withValues(alpha: 0.0), ], stops: const [0.0, 1.0], ), ), child: Consumer( builder: (context, ref, _) { // Watch connectivity state final connectivityState = ref.watch(connectivityStateProvider); // Check if offline (default to online while loading) final isOffline = connectivityState.whenOrNull( data: (isOnline) => !isOnline, ) ?? false; return Row( children: [ IconButton( icon: const Icon(Icons.arrow_back), color: Colors.white, onPressed: () => context.pop(), tooltip: l10n.epubBack, ), Expanded( child: Text( widget.entry.title, style: const TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.w500, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), // Offline indicator if (isOffline) Padding( padding: const EdgeInsets.only(right: 8), child: Icon( Icons.cloud_off, color: Colors.redAccent, semanticLabel: l10n.offlineSemanticLabel, ), ), // Chapter navigation button if (_chapters != null && _chapters!.isNotEmpty) IconButton( icon: const Icon(Icons.list), color: Colors.white, onPressed: () { _cancelAutoHideTimer(); _showChapterList(context); }, tooltip: l10n.epubChapters, ), // Search button IconButton( icon: const Icon(Icons.search), color: Colors.white, onPressed: () { _cancelAutoHideTimer(); _showSearchDialog(context); }, tooltip: l10n.epubSearch, ), ], ); }, ), ), ), ); } @override void dispose() { // Cancel timers _appBarAutoHideTimer?.cancel(); // Save progress one final time if not already saved by onPopInvokedWithResult. // The guard flag prevents double saves when both onPop and dispose fire. if (!_hasSavedOnExit) { _hasSavedOnExit = true; if (_reachedEndThisSession) { _deleteCompletedProgress(); } else if (_lastLocation != null) { _saveProgress(_lastLocation!); } } // Allow screen to sleep again WakelockPlus.disable(); // Restore system UI bars when leaving the reader SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); // Clean up WebView resources to prevent OOM crashes // The EpubController holds a reference to the InAppWebViewController // which needs to be properly disposed _epubController?.webViewController?.dispose(); _epubController = null; super.dispose(); } }