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/config/constants.dart'; import 'package:worldhopper/models/opds_entry.dart'; import 'package:worldhopper/providers/reading_progress_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( // extendBodyBehindAppBar: true, 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) { // 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.withOpacity(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: 'Offline', ), ), ] : 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.withOpacity(0.3), ), ); } return CachedNetworkImage( imageUrl: imageUrl, // fit: BoxFit.cover, 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) { 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('${entry.streamLink!.pageCount} pages'), ), 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, ) { 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: 'Page ${lastRead + 1} of $pageCount', ); } return const SizedBox.shrink(); } return _buildProgressCardContent( context, percentage: progress.progressPercentage, label: entry.isEpub ? 'Last read: ${_formatDateTime(progress.lastReadAt)}' : 'Page ${progress.currentPage + 1} of ${progress.totalPages}', ); }, loading: () => const SizedBox.shrink(), error: (_, __) => const SizedBox.shrink(), ); } Widget _buildProgressCardContent( BuildContext context, { required double percentage, required String label, }) { 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( 'Reading Progress', 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, ) { // 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( 'This publication cannot be read in the app', 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( 'Continue reading from page ${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: const Text('Start reading'), ), ] else ...[ FilledButton.icon( onPressed: () => _startReading(context, ref, progress), icon: Icon(isStarted ? Icons.play_arrow : Icons.play_circle), label: Text( isStarted ? (isCompleted ? 'Read Again' : 'Continue Reading') : 'Start Reading', ), 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: const Text('Start from Beginning'), ), ], ], ], ), ); }, 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(DateTime dateTime) { final now = DateTime.now(); final difference = now.difference(dateTime); if (difference.inDays == 0) { if (difference.inHours == 0) { if (difference.inMinutes == 0) { return 'Just now'; } return '${difference.inMinutes} min ago'; } return '${difference.inHours} hour${difference.inHours > 1 ? 's' : ''} ago'; } else if (difference.inDays == 1) { return 'Yesterday'; } else if (difference.inDays < 7) { return '${difference.inDays} days ago'; } else { return _formatDate(dateTime); } } String _stripHtml(String html) { return html.replaceAll(RegExp(r'<[^>]*>'), '').trim(); } }