All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
778 lines
25 KiB
Dart
778 lines
25 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
|
import 'package:flutter_epub_viewer/flutter_epub_viewer.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:worldhopper/config/constants.dart';
|
|
import 'package:worldhopper/models/enhanced_metadata.dart';
|
|
import 'package:worldhopper/models/opds_entry.dart';
|
|
import 'package:worldhopper/models/reading_progress.dart';
|
|
import 'package:worldhopper/providers/publication_cache_provider.dart';
|
|
import 'package:worldhopper/providers/reading_progress_provider.dart';
|
|
import 'package:worldhopper/providers/server_provider.dart';
|
|
import 'package:worldhopper/providers/connectivity_provider.dart';
|
|
import 'package:worldhopper/services/epub_download_service.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
import 'package:worldhopper/widgets/next_in_series_overlay.dart';
|
|
|
|
/// EPUB reader screen using flutter_epub_viewer with Epub.js integration
|
|
/// Provides integrated reading experience with CFI-based position tracking
|
|
class EpubReaderScreen extends ConsumerStatefulWidget {
|
|
final OPDSEntry entry;
|
|
final String serverId;
|
|
final String? feedUrl;
|
|
|
|
const EpubReaderScreen({
|
|
super.key,
|
|
required this.entry,
|
|
required this.serverId,
|
|
this.feedUrl,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<EpubReaderScreen> createState() => _EpubReaderScreenState();
|
|
}
|
|
|
|
class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
|
EpubController? _epubController;
|
|
bool _isLoading = true;
|
|
double _loadingProgress = 0.0;
|
|
String _loadingStatus = 'Preparing...';
|
|
String? _errorMessage;
|
|
File? _epubFile;
|
|
String? _initialCfi;
|
|
List<EpubChapter>? _chapters;
|
|
String? _viewerKey; // Unique key to force widget recreation
|
|
EpubLocation? _lastLocation; // Track last location for saving on dispose
|
|
bool _showAppBar = false; // Hidden by default for immersive reading
|
|
Timer? _appBarAutoHideTimer; // Auto-hide timer (5 seconds)
|
|
|
|
bool _reachedEndThisSession = false;
|
|
|
|
// Gesture tracking for tap vs swipe detection
|
|
Offset? _pointerDownPosition;
|
|
DateTime? _pointerDownTime;
|
|
bool _isSwipe = false;
|
|
|
|
// Thresholds
|
|
static const double _swipeThreshold = 15.0; // pixels - movement beyond this = swipe
|
|
static const Duration _tapMaxDuration = Duration(milliseconds: 500); // taps complete quickly
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Create a new controller for each instance to avoid state retention
|
|
_epubController = EpubController();
|
|
_loadEpub();
|
|
|
|
// Keep screen awake while reading
|
|
WakelockPlus.enable();
|
|
}
|
|
|
|
Future<void> _loadEpub() async {
|
|
try {
|
|
setState(() {
|
|
_isLoading = true;
|
|
_loadingProgress = 0.0;
|
|
_loadingStatus = 'Preparing...';
|
|
_errorMessage = null;
|
|
});
|
|
|
|
final server = await ref.read(serverProvider(widget.serverId).future);
|
|
|
|
if (server == null) {
|
|
setState(() {
|
|
_errorMessage = 'Server not found';
|
|
_isLoading = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Check if file is cached first
|
|
final epubService = EpubDownloadService();
|
|
final isCached = await epubService.isEpubCached(widget.entry);
|
|
|
|
if (isCached) {
|
|
setState(() {
|
|
_loadingStatus = 'Loading from cache...';
|
|
_loadingProgress = 0.5;
|
|
});
|
|
}
|
|
|
|
// Download or get cached EPUB file with progress tracking
|
|
final file = await epubService.downloadEpub(
|
|
widget.entry,
|
|
server,
|
|
onProgress: (progress) {
|
|
setState(() {
|
|
_loadingProgress = progress * 0.8; // Reserve 0.8-1.0 for processing
|
|
_loadingStatus = isCached
|
|
? 'Loading from cache...'
|
|
: 'Downloading... ${(progress * 100).toInt()}%';
|
|
});
|
|
},
|
|
);
|
|
|
|
setState(() {
|
|
_loadingProgress = 0.85;
|
|
_loadingStatus = 'Processing EPUB...';
|
|
});
|
|
|
|
// Cache the publication
|
|
await ref.read(publicationCacheProvider.notifier).cachePublication(
|
|
serverId: widget.serverId,
|
|
entry: widget.entry,
|
|
metadata: const EnhancedMetadata.empty(),
|
|
downloadImages: true,
|
|
);
|
|
|
|
// Load saved progress to get initial CFI location
|
|
final progressKey = ProgressKey(
|
|
publicationId: widget.entry.id,
|
|
serverId: widget.serverId,
|
|
);
|
|
final savedProgress = await ref.read(readingProgressProvider(progressKey).future);
|
|
|
|
// Extract CFI from saved progress if available
|
|
String? cfi;
|
|
if (savedProgress?.epubLocation != null && savedProgress!.epubLocation!.isNotEmpty) {
|
|
try {
|
|
final locationData = jsonDecode(savedProgress.epubLocation!);
|
|
cfi = locationData['startCfi'] as String?;
|
|
} catch (e) {
|
|
debugPrint('Error parsing saved CFI: $e');
|
|
}
|
|
}
|
|
|
|
setState(() {
|
|
_loadingProgress = 1.0;
|
|
_loadingStatus = 'Opening reader...';
|
|
_epubFile = file;
|
|
_initialCfi = cfi;
|
|
// Generate a unique key to force widget recreation and reset viewport
|
|
_viewerKey = 'epub-${widget.entry.id}-${file.path}-${DateTime.now().millisecondsSinceEpoch}';
|
|
_isLoading = false;
|
|
});
|
|
} catch (e) {
|
|
setState(() {
|
|
_errorMessage = 'Error loading EPUB: $e';
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Handle page navigation - save progress with CFI
|
|
void _handleRelocated(EpubLocation location) {
|
|
_lastLocation = location;
|
|
if (location.progress >= 0.99 && !_reachedEndThisSession) {
|
|
setState(() {
|
|
_reachedEndThisSession = true;
|
|
});
|
|
}
|
|
// Save progress asynchronously (fire and forget, but we'll save on dispose too)
|
|
_saveProgress(location);
|
|
}
|
|
|
|
/// Handle chapters loaded
|
|
void _handleChaptersLoaded(List<EpubChapter> chapters) {
|
|
setState(() {
|
|
_chapters = chapters;
|
|
});
|
|
}
|
|
|
|
Future<void> _deleteCompletedProgress() async {
|
|
await ref.read(readingProgressNotifierProvider.notifier)
|
|
.deleteProgress(widget.entry.id, widget.serverId);
|
|
if (mounted) {
|
|
ref.invalidate(recentlyReadProvider);
|
|
ref.invalidate(inProgressCountProvider);
|
|
ref.invalidate(completedCountProvider);
|
|
}
|
|
}
|
|
|
|
/// Save reading progress with CFI-based location
|
|
Future<void> _saveProgress(EpubLocation location) async {
|
|
final progressKey = ProgressKey(
|
|
publicationId: widget.entry.id,
|
|
serverId: widget.serverId,
|
|
);
|
|
|
|
final existingProgress = await ref.read(
|
|
readingProgressProvider(progressKey).future
|
|
);
|
|
|
|
// Calculate current/total pages from location percentage
|
|
final percentage = location.progress;
|
|
final currentPage = (percentage * 100).round();
|
|
const totalPages = 100;
|
|
|
|
debugPrint('EPUB Progress - Location: ${location.progress * 100}%, Page: $currentPage/$totalPages, CFI: ${location.startCfi}');
|
|
|
|
// Store full location data as JSON
|
|
final locationJson = jsonEncode({
|
|
'startCfi': location.startCfi,
|
|
'endCfi': location.endCfi,
|
|
'progress': location.progress,
|
|
});
|
|
|
|
// Get cached publication
|
|
final cachedPub = await ref.read(cachedPublicationProvider(widget.serverId, widget.entry.id).future);
|
|
|
|
final progress = existingProgress?.copyWith(
|
|
currentPage: currentPage,
|
|
totalPages: totalPages,
|
|
epubLocation: locationJson,
|
|
lastReadAt: DateTime.now(),
|
|
publicationCacheId: cachedPub?.id ?? existingProgress.publicationCacheId,
|
|
seriesFeedUrl: widget.feedUrl ?? existingProgress.seriesFeedUrl,
|
|
) ?? ReadingProgress(
|
|
id: const Uuid().v4(),
|
|
publicationId: widget.entry.id,
|
|
serverId: widget.serverId,
|
|
currentPage: currentPage,
|
|
totalPages: totalPages,
|
|
epubLocation: locationJson,
|
|
lastReadAt: DateTime.now(),
|
|
publicationCacheId: cachedPub?.id,
|
|
seriesFeedUrl: widget.feedUrl,
|
|
);
|
|
|
|
await ref
|
|
.read(readingProgressNotifierProvider.notifier)
|
|
.saveProgress(progress);
|
|
|
|
ref.invalidate(readingProgressProvider(progressKey));
|
|
ref.invalidate(recentlyReadProvider);
|
|
}
|
|
|
|
/// Build display settings based on orientation
|
|
EpubDisplaySettings _buildDisplaySettings(Orientation orientation) {
|
|
debugPrint('🔧 _buildDisplaySettings: orientation=$orientation, width=${MediaQuery.of(context).size.width}');
|
|
|
|
final brightness = Theme.of(context).brightness;
|
|
final isDarkMode = brightness == Brightness.dark;
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
final epubTheme = EpubTheme.custom(
|
|
backgroundDecoration: BoxDecoration(
|
|
color: isDarkMode ? colorScheme.surface : Colors.white,
|
|
),
|
|
foregroundColor: colorScheme.onSurface,
|
|
customCss: {
|
|
'body': '''
|
|
color: ${_colorToHex(colorScheme.onSurface)} !important;
|
|
line-height: 1.6 !important;
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
|
|
''',
|
|
'p': '''
|
|
color: ${_colorToHex(colorScheme.onSurface)} !important;
|
|
line-height: 1.6 !important;
|
|
''',
|
|
'h1, h2, h3, h4, h5, h6': '''
|
|
color: ${_colorToHex(colorScheme.onSurface)} !important;
|
|
''',
|
|
'a': '''
|
|
color: ${_colorToHex(colorScheme.primary)} !important;
|
|
''',
|
|
'span, div': '''
|
|
color: ${_colorToHex(colorScheme.onSurface)} !important;
|
|
''',
|
|
},
|
|
);
|
|
|
|
// Determine spread based on available width, not orientation
|
|
final screenWidth = MediaQuery.of(context).size.width;
|
|
const minWidthForDualPage = 768.0;
|
|
final spread = screenWidth >= minWidthForDualPage
|
|
? EpubSpread.always
|
|
: EpubSpread.none;
|
|
|
|
debugPrint('🔧 _buildDisplaySettings: returning spread=$spread (width=$screenWidth)');
|
|
|
|
return EpubDisplaySettings(
|
|
flow: EpubFlow.paginated,
|
|
snap: true,
|
|
useSnapAnimationAndroid: false,
|
|
theme: epubTheme,
|
|
spread: spread,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (_isLoading) {
|
|
return Scaffold(
|
|
backgroundColor: Colors.black,
|
|
appBar: AppBar(
|
|
title: Text(widget.entry.title),
|
|
backgroundColor: Colors.black,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
body: Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 48),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
CircularProgressIndicator(
|
|
value: _loadingProgress > 0 ? _loadingProgress : null,
|
|
color: Colors.white,
|
|
),
|
|
const SizedBox(height: 24),
|
|
LinearProgressIndicator(
|
|
value: _loadingProgress > 0 ? _loadingProgress : null,
|
|
backgroundColor: Colors.white24,
|
|
valueColor: const AlwaysStoppedAnimation<Color>(Colors.white),
|
|
minHeight: 4,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
_loadingStatus,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
if (_loadingProgress > 0 && _loadingProgress < 1.0)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Text(
|
|
'${(_loadingProgress * 100).toInt()}%',
|
|
style: TextStyle(
|
|
color: Colors.white.withOpacity(0.7),
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_errorMessage != null) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
),
|
|
body: Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24.0),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
_errorMessage!,
|
|
style: const TextStyle(color: Colors.red),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 16),
|
|
ElevatedButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Go Back'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Render integrated EPUB viewer
|
|
if (_epubController == null || _epubFile == null || _viewerKey == null) {
|
|
return const Scaffold(
|
|
body: Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
|
|
return PopScope(
|
|
canPop: true,
|
|
onPopInvoked: (bool didPop) {
|
|
if (didPop) {
|
|
if (_reachedEndThisSession) {
|
|
_deleteCompletedProgress();
|
|
} else if (_lastLocation != null) {
|
|
_saveProgress(_lastLocation!);
|
|
}
|
|
}
|
|
},
|
|
child: Scaffold(
|
|
body: Listener(
|
|
behavior: HitTestBehavior.translucent,
|
|
onPointerDown: (event) {
|
|
// Record initial touch position and time
|
|
_pointerDownPosition = event.position;
|
|
_pointerDownTime = DateTime.now();
|
|
_isSwipe = false;
|
|
},
|
|
onPointerMove: (event) {
|
|
// Track movement to detect swipes
|
|
if (_pointerDownPosition != null) {
|
|
final delta = (event.position - _pointerDownPosition!).distance;
|
|
|
|
// If movement exceeds threshold, mark as swipe (not a tap)
|
|
if (delta > _swipeThreshold) {
|
|
_isSwipe = true;
|
|
}
|
|
}
|
|
},
|
|
onPointerUp: (event) {
|
|
// Only toggle on tap (not swipe)
|
|
if (_pointerDownTime != null && _pointerDownPosition != null) {
|
|
final duration = DateTime.now().difference(_pointerDownTime!);
|
|
|
|
// Check if this was a tap (minimal movement, quick release)
|
|
if (!_isSwipe && duration < _tapMaxDuration) {
|
|
// Apply existing position check for app bar exclusion
|
|
if (_showAppBar) {
|
|
final appBarHeight = MediaQuery.of(context).padding.top + 56;
|
|
if (event.position.dy < appBarHeight) {
|
|
return; // Tap on app bar, don't toggle
|
|
}
|
|
}
|
|
|
|
_toggleAppBarVisibility();
|
|
}
|
|
}
|
|
|
|
// Reset tracking state
|
|
_pointerDownPosition = null;
|
|
_pointerDownTime = null;
|
|
_isSwipe = false;
|
|
},
|
|
child: Stack(
|
|
children: [
|
|
OrientationBuilder(
|
|
builder: (context, orientation) {
|
|
// Calculate spread mode to include in key for widget recreation
|
|
final screenWidth = MediaQuery.of(context).size.width;
|
|
const minWidthForDualPage = 768.0;
|
|
final shouldUseDualPage = screenWidth >= minWidthForDualPage;
|
|
|
|
return EpubViewer(
|
|
key: ValueKey('$_viewerKey-${shouldUseDualPage ? "dual" : "single"}'), // Include spread in key to force recreation
|
|
epubController: _epubController!,
|
|
epubSource: EpubSource.fromFile(_epubFile!),
|
|
initialCfi: _initialCfi,
|
|
displaySettings: _buildDisplaySettings(orientation),
|
|
onEpubLoaded: () {
|
|
debugPrint('EPUB loaded successfully');
|
|
},
|
|
onChaptersLoaded: _handleChaptersLoaded,
|
|
onRelocated: _handleRelocated,
|
|
onTextSelected: (selection) {
|
|
// Future: Handle text selection for highlights/notes
|
|
debugPrint('Selected: ${selection.selectedText}');
|
|
},
|
|
);
|
|
},
|
|
),
|
|
// Conditional custom app bar overlay
|
|
if (_showAppBar) _buildCustomAppBar(),
|
|
// Next in series overlay
|
|
NextInSeriesOverlay(
|
|
serverId: widget.serverId,
|
|
feedUrl: widget.feedUrl,
|
|
entry: widget.entry,
|
|
isVisible: _reachedEndThisSession,
|
|
onBeforeNavigate: _deleteCompletedProgress,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Show chapter list dialog
|
|
void _showChapterList(BuildContext context) {
|
|
if (_chapters == null || _chapters!.isEmpty) return;
|
|
|
|
showModalBottomSheet(
|
|
context: context,
|
|
builder: (context) => ListView.builder(
|
|
itemCount: _chapters!.length,
|
|
itemBuilder: (context, index) {
|
|
final chapter = _chapters![index];
|
|
return ListTile(
|
|
title: Text(chapter.title),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_epubController?.display(cfi: chapter.href);
|
|
_hideAppBarOnNavigation();
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Show search dialog
|
|
void _showSearchDialog(BuildContext context) {
|
|
String searchQuery = '';
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Search'),
|
|
content: TextField(
|
|
autofocus: true,
|
|
decoration: const InputDecoration(
|
|
hintText: 'Enter search term...',
|
|
),
|
|
onChanged: (value) => searchQuery = value,
|
|
onSubmitted: (value) {
|
|
if (value.isNotEmpty) {
|
|
Navigator.pop(context);
|
|
_performSearch(value);
|
|
}
|
|
},
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
if (searchQuery.isNotEmpty) {
|
|
Navigator.pop(context);
|
|
_performSearch(searchQuery);
|
|
}
|
|
},
|
|
child: const Text('Search'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Perform search and show results
|
|
Future<void> _performSearch(String query) async {
|
|
if (_epubController == null) return;
|
|
try {
|
|
final results = await _epubController!.search(query: query);
|
|
|
|
if (!mounted) return;
|
|
|
|
if (results.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('No results found')),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Show search results in bottom sheet
|
|
showModalBottomSheet(
|
|
context: context,
|
|
builder: (context) => Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Text(
|
|
'${results.length} results for "$query"',
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: ListView.builder(
|
|
itemCount: results.length,
|
|
itemBuilder: (context, index) {
|
|
final result = results[index];
|
|
return ListTile(
|
|
title: Text(
|
|
result.excerpt,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_epubController?.display(cfi: result.cfi);
|
|
_hideAppBarOnNavigation();
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Search failed: $e')),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Helper method to convert Flutter Color to CSS hex string
|
|
String _colorToHex(Color color) {
|
|
return '#${color.value.toRadixString(16).substring(2).toUpperCase()}';
|
|
}
|
|
|
|
/// Toggle app bar visibility
|
|
void _toggleAppBarVisibility() {
|
|
setState(() {
|
|
_showAppBar = !_showAppBar;
|
|
});
|
|
|
|
if (_showAppBar) {
|
|
_startAutoHideTimer();
|
|
} else {
|
|
_cancelAutoHideTimer();
|
|
}
|
|
}
|
|
|
|
/// Start auto-hide timer
|
|
void _startAutoHideTimer() {
|
|
_cancelAutoHideTimer();
|
|
_appBarAutoHideTimer = Timer(const Duration(seconds: 5), () {
|
|
if (mounted) {
|
|
setState(() {
|
|
_showAppBar = false;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Cancel auto-hide timer
|
|
void _cancelAutoHideTimer() {
|
|
_appBarAutoHideTimer?.cancel();
|
|
_appBarAutoHideTimer = null;
|
|
}
|
|
|
|
/// Hide app bar when navigating
|
|
void _hideAppBarOnNavigation() {
|
|
_cancelAutoHideTimer();
|
|
setState(() {
|
|
_showAppBar = false;
|
|
});
|
|
}
|
|
|
|
/// Build custom app bar overlay
|
|
Widget _buildCustomAppBar() {
|
|
final topPadding = MediaQuery.of(context).padding.top;
|
|
|
|
return Positioned(
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
child: AnimatedOpacity(
|
|
opacity: _showAppBar ? 1.0 : 0.0,
|
|
duration: const Duration(milliseconds: 200),
|
|
child: Container(
|
|
padding: EdgeInsets.only(
|
|
top: topPadding,
|
|
left: 4,
|
|
right: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Colors.black.withOpacity(0.7),
|
|
Colors.black.withOpacity(0.0),
|
|
],
|
|
stops: const [0.0, 1.0],
|
|
),
|
|
),
|
|
child: Consumer(
|
|
builder: (context, ref, _) {
|
|
// Watch connectivity state
|
|
final connectivityState = ref.watch(connectivityStateProvider);
|
|
|
|
// Check if offline (default to online while loading)
|
|
final isOffline = connectivityState.whenOrNull(
|
|
data: (isOnline) => !isOnline,
|
|
) ??
|
|
false;
|
|
|
|
return Row(
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
color: Colors.white,
|
|
onPressed: () => context.pop(),
|
|
tooltip: 'Back',
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
widget.entry.title,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
// Offline indicator
|
|
if (isOffline)
|
|
const Padding(
|
|
padding: EdgeInsets.only(right: 8),
|
|
child: Icon(
|
|
Icons.cloud_off,
|
|
color: Colors.redAccent,
|
|
semanticLabel: 'Offline',
|
|
),
|
|
),
|
|
// Chapter navigation button
|
|
if (_chapters != null && _chapters!.isNotEmpty)
|
|
IconButton(
|
|
icon: const Icon(Icons.list),
|
|
color: Colors.white,
|
|
onPressed: () {
|
|
_cancelAutoHideTimer();
|
|
_showChapterList(context);
|
|
},
|
|
tooltip: 'Chapters',
|
|
),
|
|
// Search button
|
|
IconButton(
|
|
icon: const Icon(Icons.search),
|
|
color: Colors.white,
|
|
onPressed: () {
|
|
_cancelAutoHideTimer();
|
|
_showSearchDialog(context);
|
|
},
|
|
tooltip: 'Search',
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
// Cancel timers
|
|
_appBarAutoHideTimer?.cancel();
|
|
|
|
// If the user finished the chapter, remove from recently read;
|
|
// otherwise save progress one final time before disposing.
|
|
if (_reachedEndThisSession) {
|
|
_deleteCompletedProgress();
|
|
} else if (_lastLocation != null) {
|
|
_saveProgress(_lastLocation!);
|
|
}
|
|
|
|
// Allow screen to sleep again
|
|
WakelockPlus.disable();
|
|
|
|
// Clean up WebView resources to prevent OOM crashes
|
|
// The EpubController holds a reference to the InAppWebViewController
|
|
// which needs to be properly disposed
|
|
_epubController?.webViewController?.dispose();
|
|
_epubController = null;
|
|
super.dispose();
|
|
}
|
|
}
|