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/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 Entry 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 _hasServerProgress => entry.hasServerProgress; Widget _buildProgressCard( BuildContext context, WidgetRef ref, AsyncValue progressAsync, ) { final l10n = AppLocalizations.of(context); return progressAsync.when( data: (progress) { if (progress == null) { if (_hasServerProgress) { final percentage = entry.serverProgress!; final label = entry.isEpub ? '${(percentage * 100).toInt()}%' : l10n.publicationPageOfTotal( entry.serverCurrentPage! + 1, entry.serverTotalPages!, ); return _buildProgressCardContent( context, percentage: percentage, label: label, ); } 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(), ); } 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 hasServer = _hasServerProgress; // Two-button "continue from page X" resume is only meaningful for // image streams — EPUB page indices from the server do not map to // CFI positions in the EPUB reader. final showPageResume = progress == null && hasServer && entry.isImageStream; final isStarted = (progress?.isStarted ?? false) || (progress == null && hasServer && entry.isEpub); final isCompleted = progress?.isCompleted ?? false; return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (showPageResume) ...[ 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); } } }