import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.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/download_provider.dart'; import 'package:worldhopper/providers/eink_mode_provider.dart'; import 'package:worldhopper/models/enhanced_metadata.dart'; import 'package:worldhopper/models/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/providers/server_software_provider.dart'; import 'package:worldhopper/providers/sync_progress_provider.dart'; import 'package:worldhopper/models/server.dart'; import 'package:worldhopper/models/publication.dart'; import 'package:worldhopper/services/server_software/server_software_service.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 Entry entry; final String serverId; final String? feedUrl; final String? localFilePath; const EpubReaderScreen({ super.key, required this.entry, required this.serverId, this.feedUrl, this.localFilePath, }); @override ConsumerState createState() => _EpubReaderScreenState(); } class _EpubReaderScreenState extends ConsumerState { static const int _minFontSize = 8; static const int _maxFontSize = 32; static const int _defaultFontSize = 15; static const String _fontSizePrefsKey = 'epub_font_size'; EpubController? _epubController; bool _isLoading = true; double _loadingProgress = 0.0; String? _loadingStatus; String? _errorMessage; File? _epubFile; String? _initialCfi; double? _initialServerProgress; // When restoring from server progress we suppress the first save (which // would otherwise overwrite the server-reported position with 0% before // toProgressPercentage navigates). bool _suppressInitialSave = false; 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) Timer? _serverSyncDebounceTimer; // Debounces EPUB progress reports to Kavita double? _pendingServerSyncProgress; static const _serverSyncDebounce = Duration(seconds: 3); int _fontSize = _defaultFontSize; // Loaded from SharedPreferences in _loadEpub bool _reachedEndThisSession = 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 @override void initState() { super.initState(); // Create a new controller for each instance to avoid state retention _epubController = EpubController(); WidgetsBinding.instance.addPostFrameCallback((_) { _loadEpub(); }); // Keep screen awake while reading WakelockPlus.enable(); // Enter immersive mode to hide system bars (matching image reader behavior) SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); } 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 for local downloaded file first File? file; if (widget.localFilePath != null) { final localFile = File(widget.localFilePath!); if (await localFile.exists()) { setState(() { _loadingStatus = l10n.epubLoadingFromCache; _loadingProgress = 0.8; }); file = localFile; } } // If no local file, check downloaded chapters repository if (file == null) { final chaptersRepo = ref.read( downloadedChaptersRepositoryProvider, ); final downloadedChapter = await chaptersRepo.getByEntryId( widget.serverId, widget.entry.id, ); if (downloadedChapter != null && downloadedChapter.isComplete && downloadedChapter.filePath != null) { final downloadedFile = File(downloadedChapter.filePath!); if (await downloadedFile.exists()) { setState(() { _loadingStatus = l10n.epubLoadingFromCache; _loadingProgress = 0.8; }); file = downloadedFile; } } } // Fall back to network download via server software dispatch if (file == null) { final software = ref.read(serverSoftwareServiceProvider).getImplementation(server); file = await software.downloadEpub( server, widget.entry, onProgress: (progress) { if (!mounted) return; final l10n = AppLocalizations.of(context); setState(() { _loadingProgress = progress * 0.8; _loadingStatus = l10n.epubDownloading((progress * 100).toInt()); }); }, ); } if (!mounted) return; final l10nAfter = AppLocalizations.of(context); setState(() { _loadingProgress = 0.85; _loadingStatus = l10nAfter.epubProcessing; }); // Cache the publication (skip image downloads when offline) final isOnline = ref.read(connectivityStateProvider).valueOrNull ?? false; await ref.read(publicationCacheProvider.notifier).cachePublication( serverId: widget.serverId, entry: widget.entry, metadata: const EnhancedMetadata.empty(), downloadImages: isOnline, ); // 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'); } } // If no local CFI, fall back to server-reported progress (Kavita's // pagesRead/pages). We navigate by percentage once the viewer loads, // since Kavita's page integers don't translate to CFI positions. final serverProgress = widget.entry.serverProgress; final initialServerProgress = (cfi == null && serverProgress != null && serverProgress > 0) ? serverProgress : null; // Load saved font size before rendering the viewer final prefs = await SharedPreferences.getInstance(); final savedFontSize = prefs.getInt(_fontSizePrefsKey); if (savedFontSize != null) { _fontSize = savedFontSize.clamp(_minFontSize, _maxFontSize); } // Bind to a non-null local so the closure inside setState can use it // (Dart does not promote nullable locals captured by closures) final resolvedFile = file; if (!mounted) return; final l10nFinal = AppLocalizations.of(context); setState(() { _loadingProgress = 1.0; _loadingStatus = l10nFinal.epubOpeningReader; _epubFile = resolvedFile; _initialCfi = cfi; _initialServerProgress = initialServerProgress; _suppressInitialSave = initialServerProgress != null; // Generate a unique key to force widget recreation and reset viewport _viewerKey = 'epub-${widget.entry.id}-${resolvedFile.path}-${DateTime.now().millisecondsSinceEpoch}'; _isLoading = false; }); } catch (e) { if (!mounted) return; final l10n = AppLocalizations.of(context); setState(() { _errorMessage = l10n.epubErrorLoading(e.toString()); _isLoading = false; }); } } /// Handle page navigation - save progress with CFI void _handleRelocated(EpubLocation location) { _lastLocation = location; if (location.progress >= 0.99 && !_reachedEndThisSession) { setState(() { _reachedEndThisSession = true; }); } // While restoring from server progress, skip saves until our target // percentage has been applied. The library emits an initial relocate at // progress=0 before onLocationLoaded fires, which would otherwise clobber // the server-reported position in local DB. if (_suppressInitialSave) { final pending = _initialServerProgress; if (pending != null && (location.progress - pending).abs() < 0.02) { _suppressInitialSave = false; } else { return; } } _saveProgress(location); _scheduleServerSync(location.progress); } /// Debounce EPUB progress reports so we do not spam Kavita on every micro /// relocation. The latest progress value is flushed on dispose. void _scheduleServerSync(double progress) { _pendingServerSyncProgress = progress; _serverSyncDebounceTimer?.cancel(); _serverSyncDebounceTimer = Timer(_serverSyncDebounce, _flushServerSync); } void _flushServerSync() { final progress = _pendingServerSyncProgress; if (progress == null) return; _pendingServerSyncProgress = null; _serverSyncDebounceTimer?.cancel(); _serverSyncDebounceTimer = null; _reportEpubProgressToServer(progress); } /// Fire-and-forget EPUB progress report to the server. void _reportEpubProgressToServer(double progress) { if (!ref.read(syncProgressNotifierProvider)) return; final isOnline = ref.read(connectivityStateProvider).valueOrNull ?? false; if (!isOnline) return; final server = ref.read(serverProvider(widget.serverId)).valueOrNull; if (server == null) return; final software = ref.read(serverSoftwareServiceProvider).getImplementation(server); software .reportEpubProgress(server, widget.entry, progress) .catchError((e) => debugPrint('reportEpubProgressToServer: $e')); } /// Handle chapters loaded void _handleChaptersLoaded(List chapters) { setState(() { _chapters = chapters; }); } /// Mark the entry as read on the server via the appropriate implementation. void _markAsReadOnServer() async { debugPrint( 'markAsRead[epub]: called for entry=${widget.entry.id} server=${widget.serverId}'); final isOnline = ref.read(connectivityStateProvider).valueOrNull ?? false; if (!isOnline) { debugPrint('markAsRead[epub]: skipped — offline'); return; } try { final server = await ref.read(serverProvider(widget.serverId).future); if (server == null) return; final software = ref.read(serverSoftwareServiceProvider).getImplementation(server); await software.markAsRead(server, widget.entry); } catch (e) { debugPrint('markAsRead[epub]: error: $e'); } } /// Dispose-safe version: all provider refs passed in as arguments. Future _markAsReadDetached({ required bool isOnline, required Future serverFuture, required ServerSoftwareService softwareService, }) async { debugPrint( 'markAsRead[epub]: called for entry=${widget.entry.id} server=${widget.serverId}'); if (!isOnline) { debugPrint('markAsRead[epub]: skipped — offline'); return; } try { final server = await serverFuture; if (server == null) return; final software = softwareService.getImplementation(server); await software.markAsRead(server, widget.entry); } catch (e) { debugPrint('markAsRead[epub]: error: $e'); } } Future _deleteCompletedProgress() async { _markAsReadOnServer(); await ref .read(readingProgressNotifierProvider.notifier) .deleteProgress(widget.entry.id, widget.serverId); if (mounted) { ref.invalidate(recentlyReadProvider); ref.invalidate(inProgressCountProvider); ref.invalidate(completedCountProvider); } } /// Dispose-safe version: all provider refs passed in as arguments. /// Wrapped in Future() to defer past widget tree finalization. void _deleteCompletedProgressDetached({ required bool isOnline, required Future serverFuture, required ServerSoftwareService softwareService, required ReadingProgressNotifier progressNotifier, }) { Future(() async { try { await _markAsReadDetached( isOnline: isOnline, serverFuture: serverFuture, softwareService: softwareService, ); await progressNotifier.deleteProgress(widget.entry.id, widget.serverId); } catch (e, stackTrace) { debugPrint( 'Failed to delete completed progress on dispose: $e\n$stackTrace'); } }); } /// Save reading progress with CFI-based location Future _saveProgress(EpubLocation location) async { final progressKey = ProgressKey( publicationId: widget.entry.id, serverId: widget.serverId, ); final existingProgress = await ref.read(readingProgressProvider(progressKey).future); // 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 ref.read( cachedPublicationProvider(widget.serverId, widget.entry.id).future); final progress = existingProgress?.copyWith( currentPage: currentPage, totalPages: totalPages, epubLocation: locationJson, lastReadAt: DateTime.now(), publicationCacheId: cachedPub?.id ?? existingProgress.publicationCacheId, seriesFeedUrl: widget.feedUrl ?? existingProgress.seriesFeedUrl, ) ?? 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, ); await ref .read(readingProgressNotifierProvider.notifier) .saveProgress(progress); ref.invalidate(readingProgressProvider(progressKey)); ref.invalidate(recentlyReadProvider); } /// Dispose-safe version: all provider refs passed in as arguments. /// Wrapped in Future() to defer past widget tree finalization. void _saveProgressDetached( EpubLocation location, { required Future progressFuture, required Future cachedPubFuture, required ReadingProgressNotifier progressNotifier, }) { Future(() async { try { final existingProgress = await progressFuture; final cachedPub = await cachedPubFuture; final percentage = location.progress; final currentPage = (percentage * 100).round(); const totalPages = 100; final locationJson = jsonEncode({ 'startCfi': location.startCfi, 'endCfi': location.endCfi, 'progress': location.progress, }); final progress = existingProgress?.copyWith( currentPage: currentPage, totalPages: totalPages, epubLocation: locationJson, lastReadAt: DateTime.now(), publicationCacheId: cachedPub?.id ?? existingProgress.publicationCacheId, seriesFeedUrl: widget.feedUrl ?? existingProgress.seriesFeedUrl, ) ?? 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, ); await progressNotifier.saveProgress(progress); } catch (e, stackTrace) { debugPrint('Failed to save progress on dispose: $e\n$stackTrace'); } }); } /// 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, fontSize: _fontSize, ); } @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) { if (_reachedEndThisSession) { _deleteCompletedProgress(); } else if (_lastLocation != null) { _saveProgress(_lastLocation!); } } }, child: Scaffold( body: Listener( behavior: HitTestBehavior.translucent, onPointerDown: (event) { // Record initial touch position and time _pointerDownPosition = event.position; _pointerDownTime = DateTime.now(); _isSwipe = false; }, onPointerMove: (event) { // Track movement to detect swipes if (_pointerDownPosition != null) { final delta = (event.position - _pointerDownPosition!).distance; // If movement exceeds threshold, mark as swipe (not a tap) if (delta > _swipeThreshold) { _isSwipe = true; } } }, onPointerUp: (event) { // Only toggle on tap (not swipe) if (_pointerDownTime != null && _pointerDownPosition != null) { final duration = DateTime.now().difference(_pointerDownTime!); // Check if this was a tap (minimal movement, quick release) if (!_isSwipe && duration < _tapMaxDuration) { // Apply existing position check for app bar exclusion if (_showAppBar) { final appBarHeight = MediaQuery.of(context).padding.top + 56; if (event.position.dy < appBarHeight) { return; // Tap on app bar, don't toggle } } _toggleAppBarVisibility(); } } // Reset tracking state _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"}'), // Include spread in key to force recreation epubController: _epubController!, epubSource: EpubSource.fromFile(_epubFile!), initialCfi: _initialCfi, displaySettings: _buildDisplaySettings(orientation), onEpubLoaded: () { debugPrint('EPUB loaded successfully'); }, onLocationLoaded: () { // Locations generated — progress-based navigation is // now available. Apply server-reported progress if we // have no local CFI to restore to. final pct = _initialServerProgress; if (pct != null) { debugPrint( 'Restoring EPUB to server progress: ${(pct * 100).toStringAsFixed(1)}%'); _epubController?.toProgressPercentage(pct); } }, onChaptersLoaded: _handleChaptersLoaded, onRelocated: _handleRelocated, onTextSelected: (selection) { // Future: Handle text selection for highlights/notes 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), isDownloadedContent: widget.localFilePath != null, 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), ), ], ), ); } /// Show settings bottom sheet with font size controls void _showSettingsSheet(BuildContext context) { final l10n = AppLocalizations.of(context); void applySize(int newSize, StateSetter setSheetState) { _fontSize = newSize.clamp(_minFontSize, _maxFontSize); _epubController?.setFontSize(fontSize: _fontSize.toDouble()); setSheetState(() {}); // Persist asynchronously — no provider needed SharedPreferences.getInstance().then((prefs) { prefs.setInt(_fontSizePrefsKey, _fontSize); }); } showModalBottomSheet( context: context, builder: (sheetContext) { return StatefulBuilder( builder: (context, setSheetState) { return Padding( padding: const EdgeInsets.all(24.0), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.epubFontSize, style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ IconButton( icon: const Icon(Icons.remove), onPressed: _fontSize > _minFontSize ? () => applySize(_fontSize - 1, setSheetState) : null, ), Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Text( '$_fontSize', style: Theme.of(context).textTheme.headlineSmall, ), ), IconButton( icon: const Icon(Icons.add), onPressed: _fontSize < _maxFontSize ? () => applySize(_fontSize + 1, setSheetState) : null, ), ], ), const SizedBox(height: 8), Center( child: TextButton( onPressed: _fontSize != _defaultFontSize ? () => applySize(_defaultFontSize, setSheetState) : null, child: Text(l10n.epubFontSizeReset), ), ), ], ), ); }, ); }, ); } /// 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())); } } /// 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, ), // Settings button IconButton( icon: const Icon(Icons.settings), color: Colors.white, onPressed: () { _cancelAutoHideTimer(); _showSettingsSheet(context); }, tooltip: l10n.epubSettings, ), ], ); }, ), ), ), ); } // Provider refs captured in deactivate() for use in dispose(), // because Riverpod invalidates ref before dispose() runs. bool _capturedIsOnline = false; Future? _capturedServerFuture; ServerSoftwareService? _capturedSoftwareService; ReadingProgressNotifier? _capturedProgressNotifier; Future? _capturedProgressFuture; Future? _capturedCachedPubFuture; @override void deactivate() { // Capture all provider references while ref is still valid. _capturedIsOnline = ref.read(connectivityStateProvider).valueOrNull ?? false; _capturedServerFuture = ref.read(serverProvider(widget.serverId).future); _capturedSoftwareService = ref.read(serverSoftwareServiceProvider); _capturedProgressNotifier = ref.read(readingProgressNotifierProvider.notifier); _capturedProgressFuture = ref.read(readingProgressProvider(ProgressKey( publicationId: widget.entry.id, serverId: widget.serverId, )).future); _capturedCachedPubFuture = ref.read( cachedPublicationProvider(widget.serverId, widget.entry.id).future); super.deactivate(); } @override void dispose() { // Cancel timers _appBarAutoHideTimer?.cancel(); // Flush any pending EPUB server sync so the last position is not lost. _flushServerSync(); // If the user finished the chapter, remove from recently read; // otherwise save progress one final time before disposing. if (_reachedEndThisSession) { if (_capturedProgressNotifier != null && _capturedServerFuture != null && _capturedSoftwareService != null) { _deleteCompletedProgressDetached( isOnline: _capturedIsOnline, serverFuture: _capturedServerFuture!, softwareService: _capturedSoftwareService!, progressNotifier: _capturedProgressNotifier!, ); } } else if (_lastLocation != null) { if (_capturedProgressNotifier != null && _capturedProgressFuture != null && _capturedCachedPubFuture != null) { _saveProgressDetached( _lastLocation!, progressFuture: _capturedProgressFuture!, cachedPubFuture: _capturedCachedPubFuture!, progressNotifier: _capturedProgressNotifier!, ); } } // 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(); } }