worldhopper/lib/screens/reader/reader_screen.dart
Felipe M. 7d5c854dc9 feat: i18n (#2)
Reviewed-on: #2
Co-authored-by: Felipe M. <me@fmartingr.com>
Co-committed-by: Felipe M. <me@fmartingr.com>
2026-02-12 22:41:22 +01:00

1073 lines
38 KiB
Dart

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:cached_network_image/cached_network_image.dart';
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';
import 'package:worldhopper/l10n/app_localizations.dart';
import 'package:worldhopper/helpers/l10n_helpers.dart';
import 'package:worldhopper/models/enhanced_metadata.dart';
import 'package:worldhopper/models/opds_entry.dart';
import 'package:worldhopper/models/opds_stream_link.dart';
import 'package:worldhopper/models/reading_progress.dart';
import 'package:worldhopper/providers/cover_page_provider.dart';
import 'package:worldhopper/providers/page_dimensions_provider.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/filter_quality_provider.dart';
import 'package:worldhopper/providers/precache_pages_provider.dart';
import 'package:worldhopper/providers/reading_mode_provider.dart';
import 'package:worldhopper/providers/eink_mode_provider.dart';
import 'package:worldhopper/providers/two_page_mode_provider.dart';
import 'package:worldhopper/screens/reader/page_grouping.dart';
import 'package:worldhopper/screens/reader/two_page_reader.dart';
import 'package:worldhopper/services/auth_service.dart';
import 'package:uuid/uuid.dart';
import 'package:worldhopper/widgets/next_in_series_overlay.dart';
/// Main reader screen for page-by-page reading
class ReaderScreen extends ConsumerStatefulWidget {
final OPDSEntry entry;
final String serverId;
final int initialPage;
final String? feedUrl;
const ReaderScreen({
super.key,
required this.entry,
required this.serverId,
this.initialPage = 0,
this.feedUrl,
});
@override
ConsumerState<ReaderScreen> createState() => _ReaderScreenState();
}
class _ReaderScreenState extends ConsumerState<ReaderScreen> {
late PageController _pageController;
late int _currentPage;
bool _showControls = true;
bool _isInitialized = false;
bool _reachedEndThisSession = false;
ScrollController? _scrollController;
ReadingMode? _previousMode;
// Two-page spread state
PageController? _twoPageController;
List<PageSpread> _spreads = [];
bool _wasTwoPage = false;
// E-ink swipe tracking
double? _einkSwipeStartX;
@override
void initState() {
super.initState();
_currentPage = widget.initialPage;
_pageController = PageController(initialPage: widget.initialPage);
// Check if already on the last page (e.g. resuming a completed chapter)
final pageCount = widget.entry.streamLink?.pageCount ?? 0;
if (pageCount > 0 && widget.initialPage >= pageCount - 1) {
_reachedEndThisSession = true;
}
// Enter fullscreen immersive mode
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
// Keep screen awake while reading
WakelockPlus.enable();
}
@override
void dispose() {
// If the user finished the chapter, remove from recently read;
// otherwise save progress one final time before disposing.
if (_reachedEndThisSession) {
_deleteCompletedProgress();
} else {
_saveProgress(_currentPage);
}
_pageController.dispose();
_twoPageController?.dispose();
_scrollController?.dispose();
// Restore system UI
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
// Allow screen to sleep again
WakelockPlus.disable();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
if (!widget.entry.hasStreamLink) {
return Scaffold(
appBar: AppBar(
title: Text(l10n.readerError),
),
body: Center(
child: Text(l10n.readerNoStreamLink),
),
);
}
final streamLink = widget.entry.streamLink!;
final serverAsync = ref.watch(serverProvider(widget.serverId));
// Initialize or update reading progress
if (!_isInitialized) {
_isInitialized = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_initializeProgress();
});
}
return PopScope(
canPop: true,
onPopInvokedWithResult: (bool didPop, Object? result) {
if (didPop) {
if (_reachedEndThisSession) {
_deleteCompletedProgress();
} else {
_saveProgress(_currentPage);
}
}
},
child: Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
// Page viewer
serverAsync.when(
data: (server) {
if (server == null) {
return Center(
child: Text(
l10n.readerServerNotFound,
style: const TextStyle(color: Colors.white),
),
);
}
final globalMode = ref.watch(readingModeNotifierProvider);
final seriesOverride = widget.feedUrl != null
? ref.watch(
seriesReadingModeNotifierProvider(widget.feedUrl!))
: null;
final readingMode = seriesOverride ?? globalMode;
// Determine if two-page mode should activate
final globalTwoPage = ref.watch(twoPageModeNotifierProvider);
final seriesToPageOverride = widget.feedUrl != null
? ref.watch(
seriesToPageModeNotifierProvider(widget.feedUrl!))
: null;
final twoPageMode = seriesToPageOverride ?? globalTwoPage;
final screenWidth = MediaQuery.of(context).size.width;
final isPaged = readingMode != ReadingMode.verticalContinuous;
final useTwoPage = isPaged &&
twoPageMode == TwoPageMode.auto &&
screenWidth >= 768;
// Handle mode switching mid-read
if (_previousMode != null && _previousMode != readingMode) {
if (readingMode == ReadingMode.verticalContinuous) {
// Switching to vertical: create new scroll controller
_scrollController?.dispose();
_scrollController = null;
} else {
// Switching to paged: jump to current page after frame
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_pageController.hasClients) {
_pageController.jumpToPage(_currentPage);
}
});
}
}
_previousMode = readingMode;
// Handle transition between single-page and two-page modes
if (_wasTwoPage && !useTwoPage) {
// Transitioning from two-page to single-page
_wasTwoPage = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_pageController.hasClients) {
_pageController.jumpToPage(_currentPage);
}
});
} else if (!_wasTwoPage && useTwoPage) {
// Transitioning from single-page to two-page
_wasTwoPage = true;
}
if (useTwoPage) {
final isRtl = readingMode == ReadingMode.rtl;
final globalCover = ref.watch(coverPageNotifierProvider);
final seriesCoverOverride = widget.feedUrl != null
? ref.watch(
seriesCoverPageNotifierProvider(widget.feedUrl!))
: null;
final firstPageIsCover = seriesCoverOverride ?? globalCover;
return _buildTwoPageReader(streamLink, server,
rtl: isRtl, firstPageIsCover: firstPageIsCover);
}
return switch (readingMode) {
ReadingMode.ltr =>
_buildPagedReader(streamLink, server, reverse: false),
ReadingMode.rtl =>
_buildPagedReader(streamLink, server, reverse: true),
ReadingMode.verticalContinuous =>
_buildVerticalReader(streamLink, server),
};
},
loading: () => const Center(
child: CircularProgressIndicator(color: Colors.white),
),
error: (error, stack) => Center(
child: Text(
l10n.readerErrorLoadingServer(error.toString()),
style: const TextStyle(color: Colors.white),
),
),
),
// Top controls
if (_showControls)
Positioned(
top: 0,
left: 0,
right: 0,
child: _buildTopBar(context, streamLink),
),
// Bottom controls
if (_showControls)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: _buildBottomBar(context, streamLink),
),
// Next in series overlay
NextInSeriesOverlay(
serverId: widget.serverId,
feedUrl: widget.feedUrl,
entry: widget.entry,
isVisible: _reachedEndThisSession,
einkMode: ref.watch(einkModeNotifierProvider),
onBeforeNavigate: _deleteCompletedProgress,
),
],
),
),
);
}
Widget _buildPagedReader(OPDSStreamLink streamLink, dynamic server,
{required bool reverse}) {
final filterQuality = ref.watch(filterQualityNotifierProvider);
final einkMode = ref.watch(einkModeNotifierProvider);
Widget gallery = GestureDetector(
onTap: () {
setState(() {
_showControls = !_showControls;
});
},
child: PhotoViewGallery.builder(
scrollPhysics: einkMode
? const NeverScrollableScrollPhysics()
: const BouncingScrollPhysics(),
reverse: reverse,
builder: (context, index) {
return PhotoViewGalleryPageOptions(
imageProvider: CachedNetworkImageProvider(
streamLink.getPageUrl(index),
headers: AuthService().getAuthHeaders(server),
),
filterQuality: filterQuality,
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 3,
heroAttributes: PhotoViewHeroAttributes(
tag: 'page-$index',
),
);
},
itemCount: streamLink.pageCount,
loadingBuilder: (context, event) => Center(
child: CircularProgressIndicator(
value: event == null
? 0
: event.cumulativeBytesLoaded / (event.expectedTotalBytes ?? 1),
color: Colors.white,
),
),
backgroundDecoration: const BoxDecoration(
color: Colors.black,
),
pageController: _pageController,
onPageChanged: (index) {
setState(() {
_currentPage = index;
if (index >= streamLink.pageCount - 1) {
_reachedEndThisSession = true;
}
});
_saveProgress(index);
_precacheUpcomingPages(index);
},
),
);
if (einkMode) {
gallery = _wrapWithEinkSwipe(
child: gallery,
controller: _pageController,
itemCount: streamLink.pageCount,
reverse: reverse,
);
}
return gallery;
}
Widget _buildTwoPageReader(OPDSStreamLink streamLink, dynamic server,
{required bool rtl, required bool firstPageIsCover}) {
final filterQuality = ref.watch(filterQualityNotifierProvider);
final dimensions =
ref.watch(pageDimensionsNotifierProvider(widget.entry.id));
// Recompute spreads whenever dimensions change
_spreads = computePageSpreads(
pageCount: streamLink.pageCount,
dimensions: dimensions,
rtl: rtl,
firstPageIsCover: firstPageIsCover,
);
// Find the spread index for the current page
final currentSpreadIndex = spreadIndexForPage(_spreads, _currentPage);
// Create or update the two-page controller
if (_twoPageController == null || !_twoPageController!.hasClients) {
_twoPageController?.dispose();
_twoPageController = PageController(initialPage: currentSpreadIndex);
}
final authHeaders = AuthService().getAuthHeaders(server);
final einkMode = ref.watch(einkModeNotifierProvider);
Widget reader = TwoPageReader(
spreads: _spreads,
pageController: _twoPageController!,
getPageUrl: (index) => streamLink.getPageUrl(index),
headers: authHeaders,
filterQuality: filterQuality,
reverse: rtl,
einkMode: einkMode,
onTap: () {
setState(() {
_showControls = !_showControls;
});
},
onPageChanged: (spreadIndex) {
if (spreadIndex < 0 || spreadIndex >= _spreads.length) return;
final primaryPage = primaryPageForSpread(_spreads, spreadIndex);
final lastPage = lastPageInSpread(_spreads, spreadIndex);
setState(() {
_currentPage = primaryPage;
if (lastPage >= streamLink.pageCount - 1) {
_reachedEndThisSession = true;
}
});
_saveProgress(primaryPage);
_precacheUpcomingPages(lastPage);
},
);
if (einkMode) {
reader = _wrapWithEinkSwipe(
child: reader,
controller: _twoPageController!,
itemCount: _spreads.length,
reverse: rtl,
);
}
return reader;
}
Widget _buildVerticalReader(OPDSStreamLink streamLink, dynamic server) {
final filterQuality = ref.watch(filterQualityNotifierProvider);
// Lazily initialize scroll controller
_scrollController ??= ScrollController();
return GestureDetector(
onTap: () {
setState(() {
_showControls = !_showControls;
});
},
child: NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollUpdateNotification &&
notification.metrics.maxScrollExtent > 0) {
final scrollOffset = notification.metrics.pixels;
final maxExtent = notification.metrics.maxScrollExtent;
final estimatedPage =
(scrollOffset / maxExtent * (streamLink.pageCount - 1)).round();
final clampedPage =
estimatedPage.clamp(0, streamLink.pageCount - 1);
if (clampedPage != _currentPage) {
setState(() {
_currentPage = clampedPage;
if (clampedPage >= streamLink.pageCount - 1) {
_reachedEndThisSession = true;
}
});
_saveProgress(clampedPage);
}
}
return false;
},
child: ListView.builder(
controller: _scrollController,
itemCount: streamLink.pageCount,
itemBuilder: (context, index) {
return InteractiveViewer(
minScale: 1.0,
maxScale: 3.0,
child: CachedNetworkImage(
imageUrl: streamLink.getPageUrl(index),
httpHeaders: AuthService().getAuthHeaders(server),
filterQuality: filterQuality,
width: double.infinity,
fit: BoxFit.fitWidth,
placeholder: (context, url) => const SizedBox(
height: 400,
child: Center(
child: CircularProgressIndicator(color: Colors.white),
),
),
errorWidget: (context, url, error) => const SizedBox(
height: 400,
child: Center(
child: Icon(Icons.error, color: Colors.white),
),
),
),
);
},
),
),
);
}
/// Wraps [child] with a [Listener] that detects horizontal swipes and calls
/// [jumpToPage] on [controller], giving instant page turns with no animation.
Widget _wrapWithEinkSwipe({
required Widget child,
required PageController controller,
required int itemCount,
required bool reverse,
}) {
return Listener(
onPointerDown: (e) => _einkSwipeStartX = e.position.dx,
onPointerUp: (e) {
if (_einkSwipeStartX == null) return;
final dx = e.position.dx - _einkSwipeStartX!;
_einkSwipeStartX = null;
if (dx.abs() < 50) return; // below swipe threshold
// Swipe left (dx < 0) → next page, swipe right (dx > 0) → previous
final direction = dx < 0 ? 1 : -1;
final effectiveDirection = reverse ? -direction : direction;
final currentIdx = controller.page?.round() ?? 0;
final target = currentIdx + effectiveDirection;
if (target >= 0 && target < itemCount) {
controller.jumpToPage(target);
}
},
onPointerCancel: (_) => _einkSwipeStartX = null,
child: child,
);
}
Widget _buildTopBar(BuildContext context, OPDSStreamLink streamLink) {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.7),
Colors.transparent,
],
),
),
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top,
left: 8,
right: 8,
bottom: 16,
),
child: Consumer(
builder: (context, ref, _) {
final l10n = AppLocalizations.of(context);
// 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: () => Navigator.of(context).pop(),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.entry.title,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (widget.entry.authors.isNotEmpty)
Text(
widget.entry.authors.join(', '),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 12,
),
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,
),
),
// Settings menu
_buildSettingsMenu(ref),
],
);
},
),
);
}
void _setReadingMode(WidgetRef ref, ReadingMode mode) {
if (widget.feedUrl != null) {
ref
.read(seriesReadingModeNotifierProvider(widget.feedUrl!).notifier)
.setReadingMode(mode);
} else {
ref.read(readingModeNotifierProvider.notifier).setReadingMode(mode);
}
}
void _setTwoPageMode(WidgetRef ref, TwoPageMode mode) {
if (widget.feedUrl != null) {
ref
.read(seriesToPageModeNotifierProvider(widget.feedUrl!).notifier)
.setTwoPageMode(mode);
} else {
ref.read(twoPageModeNotifierProvider.notifier).setTwoPageMode(mode);
}
}
void _setCoverPage(WidgetRef ref, bool value) {
if (widget.feedUrl != null) {
ref
.read(seriesCoverPageNotifierProvider(widget.feedUrl!).notifier)
.setCoverPage(value);
} else {
ref.read(coverPageNotifierProvider.notifier).setCoverPage(value);
}
}
Widget _buildSettingsMenu(WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final globalMode = ref.watch(readingModeNotifierProvider);
final seriesOverride = widget.feedUrl != null
? ref.watch(seriesReadingModeNotifierProvider(widget.feedUrl!))
: null;
final readingMode = seriesOverride ?? globalMode;
final modeShortLabel = readingModeShortLabel(l10n, readingMode);
final globalTwoPage = ref.watch(twoPageModeNotifierProvider);
final seriesToPageOverride = widget.feedUrl != null
? ref.watch(seriesToPageModeNotifierProvider(widget.feedUrl!))
: null;
final twoPageMode = seriesToPageOverride ?? globalTwoPage;
final isPaged = readingMode != ReadingMode.verticalContinuous;
final globalCover = ref.watch(coverPageNotifierProvider);
final seriesCoverOverride = widget.feedUrl != null
? ref.watch(seriesCoverPageNotifierProvider(widget.feedUrl!))
: null;
final firstPageIsCover = seriesCoverOverride ?? globalCover;
return MenuAnchor(
builder: (context, controller, child) {
return IconButton(
icon: const Icon(Icons.settings, color: Colors.white),
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
);
},
menuChildren: [
SubmenuButton(
leadingIcon: const Icon(Icons.chrome_reader_mode_outlined),
trailingIcon: const Icon(Icons.chevron_right, size: 18),
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(Icons.arrow_forward),
trailingIcon: readingMode == ReadingMode.ltr
? const Icon(Icons.check, size: 18)
: null,
onPressed: () => _setReadingMode(ref, ReadingMode.ltr),
child: Text(l10n.readingModeLtr),
),
MenuItemButton(
leadingIcon: const Icon(Icons.arrow_back),
trailingIcon: readingMode == ReadingMode.rtl
? const Icon(Icons.check, size: 18)
: null,
onPressed: () => _setReadingMode(ref, ReadingMode.rtl),
child: Text(l10n.readingModeRtl),
),
MenuItemButton(
leadingIcon: const Icon(Icons.swap_vert),
trailingIcon: readingMode == ReadingMode.verticalContinuous
? const Icon(Icons.check, size: 18)
: null,
onPressed: () =>
_setReadingMode(ref, ReadingMode.verticalContinuous),
child: Text(l10n.readingModeVertical),
),
],
child: Text(l10n.readerReadingDirectionWithMode(modeShortLabel)),
),
if (isPaged)
MenuItemButton(
leadingIcon: const Icon(Icons.auto_stories),
trailingIcon: twoPageMode == TwoPageMode.auto
? const Icon(Icons.check, size: 18)
: null,
onPressed: () {
final newMode = twoPageMode == TwoPageMode.auto
? TwoPageMode.off
: TwoPageMode.auto;
_setTwoPageMode(ref, newMode);
},
child: Text(l10n.readersTwoPageSpread),
),
if (isPaged)
MenuItemButton(
leadingIcon: const Icon(Icons.looks_one),
trailingIcon:
firstPageIsCover ? const Icon(Icons.check, size: 18) : null,
onPressed: () => _setCoverPage(ref, !firstPageIsCover),
child: Text(l10n.readersFirstPageIsCover),
),
],
);
}
Widget _buildBottomBar(BuildContext context, OPDSStreamLink streamLink) {
final l10n = AppLocalizations.of(context);
final einkMode = ref.watch(einkModeNotifierProvider);
final progress = (_currentPage + 1) / streamLink.pageCount;
final globalMode = ref.watch(readingModeNotifierProvider);
final seriesOverride = widget.feedUrl != null
? ref.watch(seriesReadingModeNotifierProvider(widget.feedUrl!))
: null;
final readingMode = seriesOverride ?? globalMode;
final isVertical = readingMode == ReadingMode.verticalContinuous;
final isRtl = readingMode == ReadingMode.rtl;
// Determine if we're in two-page mode
final globalTwoPage = ref.watch(twoPageModeNotifierProvider);
final seriesToPageOverride = widget.feedUrl != null
? ref.watch(seriesToPageModeNotifierProvider(widget.feedUrl!))
: null;
final twoPageMode = seriesToPageOverride ?? globalTwoPage;
final screenWidth = MediaQuery.of(context).size.width;
final isPaged = readingMode != ReadingMode.verticalContinuous;
final isTwoPage = isPaged &&
twoPageMode == TwoPageMode.auto &&
screenWidth >= 768 &&
_spreads.isNotEmpty;
// Build page indicator text
String pageIndicator;
if (isTwoPage) {
final spreadIdx = spreadIndexForPage(_spreads, _currentPage);
final spread = _spreads[spreadIdx];
if (spread.isPair) {
final lo = spread.primaryPage + 1;
final hi = spread.lastPage + 1;
pageIndicator = l10n.readerPagesOf(lo, hi, streamLink.pageCount);
} else {
pageIndicator =
l10n.readerPageOf(_currentPage + 1, streamLink.pageCount);
}
} else {
pageIndicator = l10n.readerPageOf(_currentPage + 1, streamLink.pageCount);
}
// Select active controller for navigation buttons
final activeController = isTwoPage && _twoPageController != null
? _twoPageController!
: _pageController;
final totalItems = isTwoPage ? _spreads.length : streamLink.pageCount;
final currentIdx =
isTwoPage ? spreadIndexForPage(_spreads, _currentPage) : _currentPage;
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withValues(alpha: 0.7),
Colors.transparent,
],
),
),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + 8,
left: 16,
right: 16,
top: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Page indicator
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
pageIndicator,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
Text(
'${(progress * 100).toInt()}%',
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
// Progress bar
LinearProgressIndicator(
value: progress,
backgroundColor: Colors.white.withValues(alpha: 0.3),
valueColor: const AlwaysStoppedAnimation<Color>(Colors.white),
minHeight: 4,
),
// Navigation buttons (hidden in vertical mode)
if (!isVertical) ...[
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: isRtl
? [
IconButton(
icon: const Icon(Icons.last_page, color: Colors.white),
onPressed: currentIdx < totalItems - 1
? () => activeController.jumpToPage(totalItems - 1)
: null,
iconSize: 28,
),
IconButton(
icon: const Icon(Icons.chevron_right,
color: Colors.white),
onPressed: currentIdx > 0
? () => einkMode
? activeController.jumpToPage(currentIdx - 1)
: activeController.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
)
: null,
iconSize: 32,
),
IconButton(
icon:
const Icon(Icons.chevron_left, color: Colors.white),
onPressed: currentIdx < totalItems - 1
? () => einkMode
? activeController.jumpToPage(currentIdx + 1)
: activeController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
)
: null,
iconSize: 32,
),
IconButton(
icon: const Icon(Icons.first_page, color: Colors.white),
onPressed: currentIdx > 0
? () => activeController.jumpToPage(0)
: null,
iconSize: 28,
),
]
: [
IconButton(
icon: const Icon(Icons.first_page, color: Colors.white),
onPressed: currentIdx > 0
? () => activeController.jumpToPage(0)
: null,
iconSize: 28,
),
IconButton(
icon:
const Icon(Icons.chevron_left, color: Colors.white),
onPressed: currentIdx > 0
? () => einkMode
? activeController.jumpToPage(currentIdx - 1)
: activeController.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
)
: null,
iconSize: 32,
),
IconButton(
icon: const Icon(Icons.chevron_right,
color: Colors.white),
onPressed: currentIdx < totalItems - 1
? () => einkMode
? activeController.jumpToPage(currentIdx + 1)
: activeController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
)
: null,
iconSize: 32,
),
IconButton(
icon: const Icon(Icons.last_page, color: Colors.white),
onPressed: currentIdx < totalItems - 1
? () => activeController.jumpToPage(totalItems - 1)
: null,
iconSize: 28,
),
],
),
],
],
),
);
}
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);
}
}
void _initializeProgress() async {
// Cache the publication entry
final cachedPub =
await ref.read(publicationCacheProvider.notifier).cachePublication(
serverId: widget.serverId,
entry: widget.entry,
metadata: const EnhancedMetadata.empty(),
downloadImages: true,
);
final progressKey = ProgressKey(
publicationId: widget.entry.id,
serverId: widget.serverId,
);
final existingProgress =
await ref.read(readingProgressProvider(progressKey).future);
if (existingProgress == null) {
// Create new progress
final progress = ReadingProgress(
id: const Uuid().v4(),
publicationId: widget.entry.id,
serverId: widget.serverId,
currentPage: _currentPage,
totalPages: widget.entry.streamLink!.pageCount,
lastReadAt: DateTime.now(),
publicationCacheId: cachedPub.id,
seriesFeedUrl: widget.feedUrl,
);
await ref
.read(readingProgressNotifierProvider.notifier)
.saveProgress(progress);
// Invalidate to refresh library
ref.invalidate(recentlyReadProvider);
} else if (existingProgress.publicationCacheId == null ||
(widget.feedUrl != null && existingProgress.seriesFeedUrl == null)) {
// Update existing progress with cache ID or feed URL if missing
final updatedProgress = existingProgress.copyWith(
publicationCacheId: cachedPub.id,
lastReadAt: DateTime.now(),
seriesFeedUrl: widget.feedUrl ?? existingProgress.seriesFeedUrl,
);
await ref
.read(readingProgressNotifierProvider.notifier)
.saveProgress(updatedProgress);
// Invalidate to refresh library
ref.invalidate(recentlyReadProvider);
}
// Pre-cache upcoming pages from the starting page
_precacheUpcomingPages(_currentPage);
}
void _saveProgress(int page) async {
final progressKey = ProgressKey(
publicationId: widget.entry.id,
serverId: widget.serverId,
);
final existingProgress =
await ref.read(readingProgressProvider(progressKey).future);
// Get or cache the publication
final cachedPub = await ref.read(
cachedPublicationProvider(widget.serverId, widget.entry.id).future);
if (existingProgress != null) {
// Update existing progress with cache ID
final updatedProgress = existingProgress.copyWith(
currentPage: page,
lastReadAt: DateTime.now(),
publicationCacheId:
cachedPub?.id ?? existingProgress.publicationCacheId,
seriesFeedUrl: widget.feedUrl ?? existingProgress.seriesFeedUrl,
);
await ref
.read(readingProgressNotifierProvider.notifier)
.saveProgress(updatedProgress);
} else {
// Create new progress
final progress = ReadingProgress(
id: const Uuid().v4(),
publicationId: widget.entry.id,
serverId: widget.serverId,
currentPage: page,
totalPages: widget.entry.streamLink!.pageCount,
lastReadAt: DateTime.now(),
publicationCacheId: cachedPub?.id,
seriesFeedUrl: widget.feedUrl,
);
await ref
.read(readingProgressNotifierProvider.notifier)
.saveProgress(progress);
}
// Invalidate progress provider to refresh UI
ref.invalidate(readingProgressProvider(progressKey));
ref.invalidate(recentlyReadProvider);
}
void _precacheUpcomingPages(int currentPage) {
if (!mounted) return;
final count = ref.read(precachePagesNotifierProvider);
if (count == 0) return;
final streamLink = widget.entry.streamLink;
if (streamLink == null) return;
final serverAsync = ref.read(serverProvider(widget.serverId));
final server = serverAsync.valueOrNull;
if (server == null) return;
final authHeaders = AuthService().getAuthHeaders(server);
final totalPages = streamLink.pageCount;
final dimNotifier =
ref.read(pageDimensionsNotifierProvider(widget.entry.id).notifier);
for (var i = currentPage + 1;
i <= currentPage + count && i < totalPages;
i++) {
final imageProvider = CachedNetworkImageProvider(
streamLink.getPageUrl(i),
headers: authHeaders,
);
precacheImage(imageProvider, context).catchError((_) {});
// Also resolve dimensions as the image loads
dimNotifier.resolvePage(i, imageProvider);
}
// Resolve current page dimensions too
if (currentPage >= 0 && currentPage < totalPages) {
final currentProvider = CachedNetworkImageProvider(
streamLink.getPageUrl(currentPage),
headers: authHeaders,
);
dimNotifier.resolvePage(currentPage, currentProvider);
}
}
}