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; const PublicationDetailScreen({ super.key, required this.entry, required this.serverId, }); @override Widget build(BuildContext context, WidgetRef ref) { final progressKey = ProgressKey( publicationId: entry.id, serverId: serverId, ); final progressAsync = ref.watch(readingProgressProvider(progressKey)); return Scaffold( body: CustomScrollView( slivers: [ _buildAppBar(context, ref), SliverToBoxAdapter( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildHeader(context), const SizedBox(height: 24), _buildMetadata(context), const SizedBox(height: 24), if (entry.summary != null || entry.content != null) ...[ _buildDescription(context), const SizedBox(height: 24), ], _buildProgressCard(context, ref, progressAsync), const SizedBox(height: 24), _buildActionButtons(context, ref, progressAsync), const SizedBox(height: 32), ], ), ), ], ), ); } Widget _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 SliverAppBar( expandedHeight: 300, pinned: true, actions: isOffline ? [ Padding( padding: const EdgeInsets.only(right: 8), child: Icon( Icons.cloud_off, color: Theme.of(context).colorScheme.error, semanticLabel: 'Offline', ), ), ] : null, flexibleSpace: FlexibleSpaceBar( background: _buildCoverImage(context), ), ); } 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 _buildHeader(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( entry.title, style: Theme.of(context).textTheme.headlineMedium?.copyWith( fontWeight: FontWeight.bold, ), ), if (entry.authors.isNotEmpty) ...[ const SizedBox(height: 8), Text( entry.authors.join(', '), style: Theme.of(context).textTheme.titleMedium?.copyWith( color: Theme.of(context) .colorScheme .onSurface .withOpacity(0.7), ), ), ], ], ), ); } Widget _buildMetadata(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Wrap( spacing: 12, runSpacing: 8, 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!)), ), ], ), ); } Widget _buildDescription(BuildContext context) { final description = entry.content ?? entry.summary ?? ''; return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Description', style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: 12), Text( _stripHtml(description), style: Theme.of(context).textTheme.bodyMedium, ), ], ), ); } Widget _buildProgressCard( BuildContext context, WidgetRef ref, AsyncValue progressAsync, ) { return progressAsync.when( data: (progress) { if (progress == null) return const SizedBox.shrink(); 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( '${(progress.progressPercentage * 100).toInt()}%', style: Theme.of(context).textTheme.titleMedium?.copyWith( color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold, ), ), ], ), const SizedBox(height: 12), LinearProgressIndicator( value: progress.progressPercentage, minHeight: 8, borderRadius: BorderRadius.circular(4), ), const SizedBox(height: 8), Text( entry.isEpub ? 'Last read: ${_formatDateTime(progress.lastReadAt)}' : 'Page ${progress.currentPage + 1} of ${progress.totalPages}', style: Theme.of(context).textTheme.bodySmall, ), ], ), ), ), ); }, loading: () => const SizedBox.shrink(), error: (_, __) => const SizedBox.shrink(), ); } 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; return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ 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 _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, }, ); } else if (entry.hasStreamLink) { // Navigate to image reader final initialPage = progress?.currentPage ?? 0; context.push( AppConstants.routeReader, extra: { 'entry': entry, 'serverId': serverId, 'initialPage': initialPage, }, ); } } 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(), ); ref.read(readingProgressNotifierProvider.notifier).saveProgress(progress); // Navigate to EPUB reader from beginning context.push( AppConstants.routeEpubReader, extra: { 'entry': entry, 'serverId': serverId, }, ); } 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(), ); ref.read(readingProgressNotifierProvider.notifier).saveProgress(progress); // Navigate to image reader from beginning context.push( AppConstants.routeReader, extra: { 'entry': entry, 'serverId': serverId, 'initialPage': 0, }, ); } } 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(); } }