worldhopper/lib/screens/reader/epub_reader_screen.dart
Felipe M. b5debe2117
All checks were successful
Release / build (push) Successful in 9m40s
feat: sync EPUB reading progress to Kavita with debounced updates
2026-04-17 12:09:44 +02:00

1199 lines
41 KiB
Dart

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<EpubReaderScreen> createState() => _EpubReaderScreenState();
}
class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
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<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)
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<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 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<EpubChapter> 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<void> _markAsReadDetached({
required bool isOnline,
required Future<Server?> 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<void> _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<Server?> 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<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);
}
/// 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<ReadingProgress?> progressFuture,
required Future<Publication?> 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<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) {
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<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()));
}
}
/// 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<Server?>? _capturedServerFuture;
ServerSoftwareService? _capturedSoftwareService;
ReadingProgressNotifier? _capturedProgressNotifier;
Future<ReadingProgress?>? _capturedProgressFuture;
Future<Publication?>? _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();
}
}