1199 lines
41 KiB
Dart
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();
|
|
}
|
|
}
|