worldhopper/lib/screens/reader/epub_reader_screen.dart
Felipe M. bb579719f9
All checks were successful
ci/woodpecker/pr/pr Pipeline was successful
fix: prevent double-save on exit, surface KOReader sync auth failures
- Add guard flag to prevent both onPopInvokedWithResult and dispose()
  from saving progress (double DB writes + double sync pushes)
- Capture ref-dependent values synchronously in _saveProgress before
  any await to prevent accessing disposed ref after super.dispose()
- Guard ref.invalidate() calls with mounted check for dispose safety
- Add KoreaderSyncAuthException so 401 errors in getProgress and
  updateProgress propagate instead of being silently swallowed
- Show a warning snackbar (once per session) when auth fails so the
  user knows sync credentials need updating

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 00:12:06 +01:00

981 lines
33 KiB
Dart

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<EpubReaderScreen> createState() => _EpubReaderScreenState();
}
class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
EpubController? _epubController;
bool _isLoading = true;
double _loadingProgress = 0.0;
String? _loadingStatus;
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;
// 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<void> _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<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,
);
// 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<Color>(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<void> _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();
}
}