worldhopper/lib/screens/reader/epub_reader_screen.dart
Felipe M. b3466fd359
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
fix: remove completed chapters from reccently read automatically
2026-02-11 10:17:19 +01:00

778 lines
25 KiB
Dart

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';
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<EpubReaderScreen> createState() => _EpubReaderScreenState();
}
class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
EpubController? _epubController;
bool _isLoading = true;
double _loadingProgress = 0.0;
String _loadingStatus = 'Preparing...';
String? _errorMessage;
File? _epubFile;
String? _initialCfi;
List<EpubChapter>? _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;
// 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<void> _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;
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<EpubChapter> chapters) {
setState(() {
_chapters = chapters;
});
}
Future<void> _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<void> _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);
}
/// 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<Color>(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) {
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 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(),
// Next in series overlay
NextInSeriesOverlay(
serverId: widget.serverId,
feedUrl: widget.feedUrl,
entry: widget.entry,
isVisible: _reachedEndThisSession,
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) {
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<void> _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();
// If the user finished the chapter, remove from recently read;
// otherwise save progress one final time before disposing.
if (_reachedEndThisSession) {
_deleteCompletedProgress();
} else 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();
}
}