All checks were successful
ci/woodpecker/pr/pr Pipeline was successful
- 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>
981 lines
33 KiB
Dart
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();
|
|
}
|
|
}
|