import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/config/constants.dart'; import 'package:worldhopper/models/opds_entry.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/models/reading_progress.dart'; import 'package:uuid/uuid.dart'; /// Screen displaying publication details class PublicationDetailScreen extends ConsumerWidget { final OPDSEntry entry; final String serverId; final String? feedUrl; const PublicationDetailScreen({ super.key, required this.entry, required this.serverId, this.feedUrl, }); @override Widget build(BuildContext context, WidgetRef ref) { final progressKey = ProgressKey( publicationId: entry.id, serverId: serverId, ); final progressAsync = ref.watch(readingProgressProvider(progressKey)); return ColoredBox( color: Theme.of(context).colorScheme.surface, child: SafeArea( bottom: false, child: Scaffold( appBar: _buildAppBar(context, ref), body: ListView( padding: EdgeInsets.zero, children: [ SizedBox( height: 300, width: double.infinity, child: _buildCoverImage(context), ), const SizedBox(height: 12), _buildMetadata(context), _buildProgressCard(context, ref, progressAsync), const SizedBox(height: 12), _buildActionButtons(context, ref, progressAsync), ], ), ), ), ); } PreferredSizeWidget _buildAppBar(BuildContext context, WidgetRef 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 AppBar( backgroundColor: Theme.of(context).colorScheme.surface.withValues(alpha: 0.75), title: Text(entry.title), actions: isOffline ? [ Padding( padding: const EdgeInsets.only(right: 8), child: Icon( Icons.cloud_off, color: Theme.of(context).colorScheme.error, semanticLabel: l10n.offlineSemanticLabel, ), ), ] : null, ); } Widget _buildCoverImage(BuildContext context) { final imageUrl = entry.coverUrl ?? entry.thumbnailUrl; if (imageUrl == null) { return Container( color: Theme.of(context).colorScheme.surfaceContainerHighest, child: Icon( Icons.book, size: 100, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), ), ); } return CachedNetworkImage( imageUrl: imageUrl, placeholder: (context, url) => Container( color: Theme.of(context).colorScheme.surfaceContainerHighest, child: const Center(child: CircularProgressIndicator()), ), errorWidget: (context, url, error) => Container( color: Theme.of(context).colorScheme.surfaceContainerHighest, child: Icon( Icons.broken_image, size: 100, color: Theme.of(context).colorScheme.error, ), ), ); } Widget _buildMetadata(BuildContext context) { final l10n = AppLocalizations.of(context); return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Wrap( spacing: 12, runSpacing: 8, alignment: WrapAlignment.center, children: [ if (entry.hasStreamLink) Chip( avatar: const Icon(Icons.auto_stories, size: 18), label: Text(l10n.publicationPages(entry.streamLink!.pageCount)), ), if (entry.categories.isNotEmpty) ...entry.categories.map( (category) => Chip( label: Text(category), ), ), if (entry.published != null) Chip( avatar: const Icon(Icons.calendar_today, size: 18), label: Text(_formatDate(entry.published!)), ), ], ), ); } bool get _hasOpdsProgress => entry.streamLink?.lastRead != null && entry.streamLink!.lastRead! > 0; Widget _buildProgressCard( BuildContext context, WidgetRef ref, AsyncValue progressAsync, ) { final l10n = AppLocalizations.of(context); return progressAsync.when( data: (progress) { if (progress == null) { // Fallback: show OPDS-PS server-side progress if available if (_hasOpdsProgress) { final lastRead = entry.streamLink!.lastRead!; final pageCount = entry.streamLink!.pageCount; final percentage = (lastRead + 1) / pageCount; return _buildProgressCardContent( context, percentage: percentage, label: l10n.publicationPageOfTotal(lastRead + 1, pageCount), ); } // Show KOReader sync info for EPUBs when sync is enabled if (entry.isEpub) { return _buildKoreaderSyncInfo(context, ref); } return const SizedBox.shrink(); } return _buildProgressCardContent( context, percentage: progress.progressPercentage, label: entry.isEpub ? l10n.publicationLastRead( _formatDateTime(context, progress.lastReadAt)) : l10n.publicationPageOfTotal( progress.currentPage + 1, progress.totalPages), ); }, loading: () => const SizedBox.shrink(), error: (_, __) => const SizedBox.shrink(), ); } /// Show an informational panel when KOReader sync is enabled, /// telling the user that reading position will be restored automatically. Widget _buildKoreaderSyncInfo(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final serverAsync = ref.watch(serverProvider(serverId)); return serverAsync.when( data: (server) { if (server == null || !server.koreaderSyncEnabled) { return const SizedBox.shrink(); } return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Card( color: Theme.of(context).colorScheme.surfaceContainerHighest, child: Padding( padding: const EdgeInsets.all(16), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( Icons.sync, color: Theme.of(context).colorScheme.primary, size: 20, ), const SizedBox(width: 12), Expanded( child: Text( l10n.koreaderSyncResumeInfo, style: Theme.of(context).textTheme.bodySmall, ), ), ], ), ), ), ); }, loading: () => const SizedBox.shrink(), error: (_, __) => const SizedBox.shrink(), ); } Widget _buildProgressCardContent( BuildContext context, { required double percentage, required String label, }) { final l10n = AppLocalizations.of(context); return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( l10n.publicationReadingProgress, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), Text( '${(percentage * 100).toInt()}%', style: Theme.of(context).textTheme.titleMedium?.copyWith( color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold, ), ), ], ), const SizedBox(height: 12), LinearProgressIndicator( value: percentage, minHeight: 8, borderRadius: BorderRadius.circular(4), ), const SizedBox(height: 8), Text( label, style: Theme.of(context).textTheme.bodySmall, ), ], ), ), ), ); } Widget _buildActionButtons( BuildContext context, WidgetRef ref, AsyncValue progressAsync, ) { final l10n = AppLocalizations.of(context); // Check if entry can be read (either as stream or EPUB) final canRead = entry.hasStreamLink || entry.isEpub; if (!canRead) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Card( color: Theme.of(context).colorScheme.errorContainer, child: Padding( padding: const EdgeInsets.all(16), child: Row( children: [ Icon( Icons.info_outline, color: Theme.of(context).colorScheme.onErrorContainer, ), const SizedBox(width: 12), Expanded( child: Text( l10n.publicationCannotRead, style: TextStyle( color: Theme.of(context).colorScheme.onErrorContainer, ), ), ), ], ), ), ), ); } return progressAsync.when( data: (progress) { final isStarted = progress != null && progress.isStarted; final isCompleted = progress != null && progress.isCompleted; // No local progress but OPDS-PS has server-side progress final showOpdsResume = progress == null && _hasOpdsProgress; return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (showOpdsResume) ...[ FilledButton.icon( onPressed: () => _navigateToReader( context, ref, entry.streamLink!.lastRead!, ), icon: const Icon(Icons.play_arrow), label: Text( l10n.publicationContinueFromPage( entry.streamLink!.lastRead! + 1), ), style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), ), ), const SizedBox(height: 12), OutlinedButton.icon( onPressed: () => _navigateToReader(context, ref, 0), icon: const Icon(Icons.restart_alt), label: Text(l10n.publicationStartReading), ), ] else ...[ FilledButton.icon( onPressed: () => _startReading(context, ref, progress), icon: Icon(isStarted ? Icons.play_arrow : Icons.play_circle), label: Text( isStarted ? (isCompleted ? l10n.publicationReadAgain : l10n.publicationContinueReading) : l10n.publicationStartReading, ), style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), ), ), if (isStarted && !isCompleted) ...[ const SizedBox(height: 12), OutlinedButton.icon( onPressed: () => _startFromBeginning(context, ref), icon: const Icon(Icons.restart_alt), label: Text(l10n.publicationStartFromBeginning), ), ], ], ], ), ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (_, __) => const SizedBox.shrink(), ); } void _navigateToReader(BuildContext context, WidgetRef ref, int initialPage) { if (entry.isEpub) { context.push( AppConstants.routeEpubReader, extra: { 'entry': entry, 'serverId': serverId, 'feedUrl': feedUrl, }, ); } else if (entry.hasStreamLink) { context.push( AppConstants.routeReader, extra: { 'entry': entry, 'serverId': serverId, 'initialPage': initialPage, 'feedUrl': feedUrl, }, ); } } void _startReading( BuildContext context, WidgetRef ref, ReadingProgress? progress, ) { // Route to appropriate reader based on content type if (entry.isEpub) { // Navigate to EPUB reader (CosmosEpub handles position restoration) context.push( AppConstants.routeEpubReader, extra: { 'entry': entry, 'serverId': serverId, 'feedUrl': feedUrl, }, ); } else if (entry.hasStreamLink) { // Navigate to image reader final initialPage = progress?.currentPage ?? entry.streamLink?.lastRead ?? 0; context.push( AppConstants.routeReader, extra: { 'entry': entry, 'serverId': serverId, 'initialPage': initialPage, 'feedUrl': feedUrl, }, ); } } void _startFromBeginning(BuildContext context, WidgetRef ref) { // Reset progress based on content type if (entry.isEpub) { final progress = ReadingProgress( id: const Uuid().v4(), publicationId: entry.id, serverId: serverId, currentPage: 0, totalPages: 100, // Percentage-based for EPUB epubLocation: null, // Clear location to start from beginning lastReadAt: DateTime.now(), seriesFeedUrl: feedUrl, ); ref.read(readingProgressNotifierProvider.notifier).saveProgress(progress); // Navigate to EPUB reader from beginning context.push( AppConstants.routeEpubReader, extra: { 'entry': entry, 'serverId': serverId, 'feedUrl': feedUrl, }, ); } else if (entry.hasStreamLink) { final progress = ReadingProgress( id: const Uuid().v4(), publicationId: entry.id, serverId: serverId, currentPage: 0, totalPages: entry.streamLink!.pageCount, lastReadAt: DateTime.now(), seriesFeedUrl: feedUrl, ); ref.read(readingProgressNotifierProvider.notifier).saveProgress(progress); // Navigate to image reader from beginning context.push( AppConstants.routeReader, extra: { 'entry': entry, 'serverId': serverId, 'initialPage': 0, 'feedUrl': feedUrl, }, ); } } String _formatDate(DateTime date) { return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; } String _formatDateTime(BuildContext context, DateTime dateTime) { final l10n = AppLocalizations.of(context); final now = DateTime.now(); final difference = now.difference(dateTime); if (difference.inDays == 0) { if (difference.inHours == 0) { if (difference.inMinutes == 0) { return l10n.timeJustNow; } return l10n.timeMinAgo(difference.inMinutes); } return l10n.timeHoursAgoLong(difference.inHours); } else if (difference.inDays == 1) { return l10n.timeYesterday; } else if (difference.inDays < 7) { return l10n.timeDaysAgoLong(difference.inDays); } else { return _formatDate(dateTime); } } }