import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.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/config/constants.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:uuid/uuid.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; const EpubReaderScreen({ super.key, required this.entry, required this.serverId, }); @override ConsumerState createState() => _EpubReaderScreenState(); } class _EpubReaderScreenState extends ConsumerState { EpubController? _epubController; bool _isLoading = true; double _loadingProgress = 0.0; String _loadingStatus = 'Preparing...'; 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) // 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(); _loadEpub(); // Keep screen awake while reading WakelockPlus.enable(); } Future _loadEpub() async { try { setState(() { _isLoading = true; _loadingProgress = 0.0; _loadingStatus = 'Preparing...'; _errorMessage = null; }); final server = await ref.read(serverProvider(widget.serverId).future); if (server == null) { setState(() { _errorMessage = 'Server not found'; _isLoading = false; }); return; } // Check if file is cached first final epubService = EpubDownloadService(); final isCached = await epubService.isEpubCached(widget.entry); if (isCached) { setState(() { _loadingStatus = 'Loading from cache...'; _loadingProgress = 0.5; }); } // Download or get cached EPUB file with progress tracking final file = await epubService.downloadEpub( widget.entry, server, onProgress: (progress) { setState(() { _loadingProgress = progress * 0.8; // Reserve 0.8-1.0 for processing _loadingStatus = isCached ? 'Loading from cache...' : 'Downloading... ${(progress * 100).toInt()}%'; }); }, ); setState(() { _loadingProgress = 0.85; _loadingStatus = 'Processing EPUB...'; }); // 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'); } } setState(() { _loadingProgress = 1.0; _loadingStatus = 'Opening reader...'; _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) { setState(() { _errorMessage = 'Error loading EPUB: $e'; _isLoading = false; }); } } /// Handle page navigation - save progress with CFI void _handleRelocated(EpubLocation location) { _lastLocation = location; // 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; }); } /// 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, ) ?? ReadingProgress( id: const Uuid().v4(), publicationId: widget.entry.id, serverId: widget.serverId, currentPage: currentPage, totalPages: totalPages, epubLocation: locationJson, lastReadAt: DateTime.now(), publicationCacheId: cachedPub?.id, ); await ref .read(readingProgressNotifierProvider.notifier) .saveProgress(progress); ref.invalidate(readingProgressProvider(progressKey)); ref.invalidate(recentlyReadProvider); } /// 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) { 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, 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.withOpacity(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: const Text('Go Back'), ), ], ), ), ), ); } // Render integrated EPUB viewer if (_epubController == null || _epubFile == null || _viewerKey == null) { return const Scaffold( body: Center(child: CircularProgressIndicator()), ); } return PopScope( canPop: true, onPopInvoked: (bool didPop) { if (didPop) { // Save progress one final time when popping 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 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'); }, 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(), ], ), ), ), ); } /// 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) { String searchQuery = ''; showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Search'), content: TextField( autofocus: true, decoration: const InputDecoration( hintText: 'Enter search term...', ), onChanged: (value) => searchQuery = value, onSubmitted: (value) { if (value.isNotEmpty) { Navigator.pop(context); _performSearch(value); } }, ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), TextButton( onPressed: () { if (searchQuery.isNotEmpty) { Navigator.pop(context); _performSearch(searchQuery); } }, child: const Text('Search'), ), ], ), ); } /// 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; if (results.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('No results found')), ); return; } // Show search results in bottom sheet showModalBottomSheet( context: context, builder: (context) => Column( children: [ Padding( padding: const EdgeInsets.all(16.0), child: Text( '${results.length} results for "$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; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Search failed: $e')), ); } } /// Helper method to convert Flutter Color to CSS hex string String _colorToHex(Color color) { return '#${color.value.toRadixString(16).substring(2).toUpperCase()}'; } /// Toggle app bar visibility void _toggleAppBarVisibility() { setState(() { _showAppBar = !_showAppBar; }); if (_showAppBar) { _startAutoHideTimer(); } else { _cancelAutoHideTimer(); } } /// Start auto-hide timer void _startAutoHideTimer() { _cancelAutoHideTimer(); _appBarAutoHideTimer = Timer(const Duration(seconds: 5), () { if (mounted) { setState(() { _showAppBar = false; }); } }); } /// Cancel auto-hide timer void _cancelAutoHideTimer() { _appBarAutoHideTimer?.cancel(); _appBarAutoHideTimer = null; } /// Hide app bar when navigating void _hideAppBarOnNavigation() { _cancelAutoHideTimer(); setState(() { _showAppBar = false; }); } /// Build custom app bar overlay Widget _buildCustomAppBar() { final topPadding = MediaQuery.of(context).padding.top; return Positioned( top: 0, left: 0, right: 0, child: AnimatedOpacity( opacity: _showAppBar ? 1.0 : 0.0, duration: 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.withOpacity(0.7), Colors.black.withOpacity(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: 'Back', ), 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) const Padding( padding: EdgeInsets.only(right: 8), child: Icon( Icons.cloud_off, color: Colors.redAccent, semanticLabel: 'Offline', ), ), // Chapter navigation button if (_chapters != null && _chapters!.isNotEmpty) IconButton( icon: const Icon(Icons.list), color: Colors.white, onPressed: () { _cancelAutoHideTimer(); _showChapterList(context); }, tooltip: 'Chapters', ), // Search button IconButton( icon: const Icon(Icons.search), color: Colors.white, onPressed: () { _cancelAutoHideTimer(); _showSearchDialog(context); }, tooltip: 'Search', ), ], ); }, ), ), ), ); } @override void dispose() { // Cancel timers _appBarAutoHideTimer?.cancel(); // Save progress one final time before disposing if (_lastLocation != null) { _saveProgress(_lastLocation!); } // Allow screen to sleep again WakelockPlus.disable(); // 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(); } }