worldhopper/lib/screens/reader/reader_screen.dart
Felipe M. 58a562abe8
feat: refactor server software abstraction with Kavita REST API integration
Rename all OPDS-prefixed models to generic names (Server, Feed, Entry,
Link, StreamLink), expand the ServerSoftware interface to cover all
server interactions, and implement a full Kavita REST API client that
replaces the OPDS delegation.

- Rename OPDS* models to generic names across ~60 files
- Add database migrations 13 (opds_id → entry_id) and 14 (unify credentials)
- Create OPDSServerSoftware wrapping existing OPDS services
- Create KavitaApiClient for direct Kavita REST API calls
- Create KavitaFeedMapper to convert Kavita JSON to Feed/Entry models
- Rewrite KavitaServerSoftware to use native API (no OPDS delegation)
- Unify server credentials (remove softwareUsername/softwarePassword)
- Simplify add/edit server UI to single auth section
- Add test connection button to server add/edit screens
- Add progress indicator to PublicationCard using local and server data
- Eliminate softwareType branching in reader screens
- Add EntryProgress and fetchEntryProgress to ServerSoftware interface
- Fix Entry.acquisitionLink crash on empty links
- Add 59 new tests covering models, services, and providers
2026-04-06 17:46:48 +02:00

1344 lines
47 KiB
Dart

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: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/entry.dart';
import 'package:worldhopper/models/server.dart';
import 'package:worldhopper/models/publication.dart';
import 'package:worldhopper/models/stream_link.dart';
import 'package:worldhopper/models/reading_progress.dart';
import 'package:worldhopper/providers/sync_progress_provider.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/server_software_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:worldhopper/services/server_software/server_software_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 Entry entry;
final String serverId;
final int initialPage;
final String? feedUrl;
final String? localContentPath;
const ReaderScreen({
super.key,
required this.entry,
required this.serverId,
this.initialPage = 0,
this.feedUrl,
this.localContentPath,
});
@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;
bool _needsVerticalScrollToPage = false;
ReadingMode? _previousMode;
// Two-page spread state
PageController? _twoPageController;
List<PageSpread> _spreads = [];
bool _wasTwoPage = false;
// E-ink swipe tracking
double? _einkSwipeStartX;
// Cached file extension for local content (avoids probing 5 extensions per page)
String? _localFileExtension;
bool _localExtensionResolved = false;
@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();
}
// Provider refs captured in deactivate() for use in dispose().
// Riverpod invalidates `ref` after deactivate() completes, so reading
// providers in dispose() throws. We snapshot the values we need here.
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() {
// If the user finished the chapter, remove from recently read;
// otherwise save progress one final time before disposing.
// Guard against deactivate() not having been called (captured fields null).
if (_reachedEndThisSession) {
if (_capturedProgressNotifier != null &&
_capturedServerFuture != null &&
_capturedSoftwareService != null) {
_deleteCompletedProgressDetached(
isOnline: _capturedIsOnline,
serverFuture: _capturedServerFuture!,
softwareService: _capturedSoftwareService!,
progressNotifier: _capturedProgressNotifier!,
);
}
} else {
if (_capturedProgressNotifier != null &&
_capturedProgressFuture != null &&
_capturedCachedPubFuture != null) {
_saveProgressDetached(
_currentPage,
progressFuture: _capturedProgressFuture!,
cachedPubFuture: _capturedCachedPubFuture!,
progressNotifier: _capturedProgressNotifier!,
);
}
}
_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),
isDownloadedContent: widget.localContentPath != null,
onBeforeNavigate: _deleteCompletedProgress,
),
],
),
),
);
}
Widget _buildPagedReader(StreamLink 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: _getPageImageProvider(index, streamLink, 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);
_reportPageToServer(index);
_precacheUpcomingPages(index);
},
),
);
if (einkMode) {
gallery = _wrapWithEinkSwipe(
child: gallery,
controller: _pageController,
itemCount: streamLink.pageCount,
reverse: reverse,
);
}
return gallery;
}
Widget _buildTwoPageReader(StreamLink 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 einkMode = ref.watch(einkModeNotifierProvider);
Widget reader = TwoPageReader(
spreads: _spreads,
pageController: _twoPageController!,
getImageProvider: (index) =>
_getPageImageProvider(index, streamLink, server),
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);
_reportPageToServer(lastPage);
_precacheUpcomingPages(lastPage);
},
);
if (einkMode) {
reader = _wrapWithEinkSwipe(
child: reader,
controller: _twoPageController!,
itemCount: _spreads.length,
reverse: rtl,
);
}
return reader;
}
Widget _buildVerticalReader(StreamLink streamLink, dynamic server) {
final filterQuality = ref.watch(filterQualityNotifierProvider);
// Lazily initialize scroll controller
if (_scrollController == null) {
_scrollController = ScrollController();
if (_currentPage > 0) {
_needsVerticalScrollToPage = true;
}
}
// Restore scroll position to the saved page on resume
if (_needsVerticalScrollToPage) {
_needsVerticalScrollToPage = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController != null &&
_scrollController!.hasClients &&
_scrollController!.position.maxScrollExtent > 0) {
final pageCount = streamLink.pageCount;
if (pageCount <= 1) return;
final targetOffset = (_currentPage / (pageCount - 1)) *
_scrollController!.position.maxScrollExtent;
_scrollController!.jumpTo(
targetOffset.clamp(
0.0, _scrollController!.position.maxScrollExtent),
);
}
});
}
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);
_reportPageToServer(clampedPage);
}
}
return false;
},
child: ListView.builder(
controller: _scrollController,
itemCount: streamLink.pageCount,
itemBuilder: (context, index) {
return InteractiveViewer(
minScale: 1.0,
maxScale: 3.0,
child: _buildPageImage(index, streamLink, server, filterQuality),
);
},
),
),
);
}
/// 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, StreamLink 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, StreamLink 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 {
_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 counterpart of [_deleteCompletedProgress].
/// 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');
}
});
}
void _initializeProgress() async {
// Cache the publication entry (skip image downloads when offline)
final isOnline = ref.read(connectivityStateProvider).valueOrNull ?? false;
final cachedPub =
await ref.read(publicationCacheProvider.notifier).cachePublication(
serverId: widget.serverId,
entry: widget.entry,
metadata: const EnhancedMetadata.empty(),
downloadImages: isOnline,
);
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);
}
/// Dispose-safe version: all provider refs passed in as arguments.
/// Wrapped in Future() to defer past widget tree finalization.
void _saveProgressDetached(
int page, {
required Future<ReadingProgress?> progressFuture,
required Future<Publication?> cachedPubFuture,
required ReadingProgressNotifier progressNotifier,
}) {
Future(() async {
try {
final existingProgress = await progressFuture;
final cachedPub = await cachedPubFuture;
if (existingProgress != null) {
final updatedProgress = existingProgress.copyWith(
currentPage: page,
lastReadAt: DateTime.now(),
publicationCacheId:
cachedPub?.id ?? existingProgress.publicationCacheId,
seriesFeedUrl: widget.feedUrl ?? existingProgress.seriesFeedUrl,
);
await progressNotifier.saveProgress(updatedProgress);
} else {
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 progressNotifier.saveProgress(progress);
}
} catch (e, stackTrace) {
debugPrint('Failed to save progress on dispose: $e\n$stackTrace');
}
});
}
/// Fire-and-forget page progress report so the server tracks reading.
void _reportPageToServer(int page) {
if (!ref.read(syncProgressNotifierProvider)) return;
if (!widget.entry.hasStreamLink) 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
.reportPageProgress(server, widget.entry.streamLink!, page)
.catchError((e) => debugPrint('reportPageToServer: $e'));
}
/// Mark the entry as read on the server via the appropriate implementation.
void _markAsReadOnServer() async {
debugPrint(
'markAsRead: called for entry=${widget.entry.id} server=${widget.serverId}');
final isOnline = ref.read(connectivityStateProvider).valueOrNull ?? false;
if (!isOnline) {
debugPrint('markAsRead: 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: 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: called for entry=${widget.entry.id} server=${widget.serverId}');
if (!isOnline) {
debugPrint('markAsRead: 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: error: $e');
}
}
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;
// Server is only needed for network images; local content can precache without it
final serverAsync = ref.read(serverProvider(widget.serverId));
final server = serverAsync.valueOrNull;
if (server == null && widget.localContentPath == null) return;
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 = _getPageImageProvider(i, streamLink, server);
precacheImage(imageProvider, context).catchError((e) {
debugPrint('Failed to precache page $i: $e');
});
// Also resolve dimensions as the image loads
dimNotifier.resolvePage(i, imageProvider);
}
// Resolve current page dimensions too
if (currentPage >= 0 && currentPage < totalPages) {
final currentProvider =
_getPageImageProvider(currentPage, streamLink, server);
dimNotifier.resolvePage(currentPage, currentProvider);
}
}
/// Get the image provider for a page, using local files if available
ImageProvider _getPageImageProvider(
int pageIndex,
StreamLink streamLink,
dynamic server,
) {
if (widget.localContentPath != null) {
final localFile = _findLocalPageFile(pageIndex);
if (localFile != null) {
return FileImage(localFile);
}
}
return CachedNetworkImageProvider(
streamLink.getPageUrl(pageIndex),
headers: server != null ? AuthService().getAuthHeaders(server) : {},
);
}
/// Build a page image widget for vertical scroll mode
Widget _buildPageImage(
int pageIndex,
StreamLink streamLink,
dynamic server,
FilterQuality filterQuality,
) {
if (widget.localContentPath != null) {
final localFile = _findLocalPageFile(pageIndex);
if (localFile != null) {
return Image.file(
localFile,
filterQuality: filterQuality,
width: double.infinity,
fit: BoxFit.fitWidth,
errorBuilder: (_, __, ___) => const SizedBox(
height: 400,
child: Center(child: Icon(Icons.error, color: Colors.white)),
),
);
}
}
return CachedNetworkImage(
imageUrl: streamLink.getPageUrl(pageIndex),
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)),
),
);
}
/// Resolve the file extension used in the local content directory.
/// Probes once using page 0 and caches the result for all subsequent calls.
String? _resolveLocalExtension() {
if (_localExtensionResolved) return _localFileExtension;
_localExtensionResolved = true;
if (widget.localContentPath == null) return null;
for (final ext in ['.jpg', '.png', '.gif', '.webp', '.jpeg']) {
if (File('${widget.localContentPath}/0$ext').existsSync()) {
_localFileExtension = ext;
return ext;
}
}
return null;
}
/// Find a local page file using the cached extension
File? _findLocalPageFile(int pageIndex) {
if (widget.localContentPath == null) return null;
final ext = _resolveLocalExtension();
if (ext == null) return null;
final file = File('${widget.localContentPath}/$pageIndex$ext');
if (file.existsSync()) return file;
return null;
}
}