import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/config/constants.dart'; import 'package:worldhopper/helpers/responsive_helper.dart'; import 'package:worldhopper/helpers/snackbar_helper.dart'; import 'package:worldhopper/models/downloaded_series.dart'; import 'package:worldhopper/models/feed.dart'; import 'package:worldhopper/models/entry.dart'; import 'package:worldhopper/providers/feed_provider.dart'; import 'package:worldhopper/providers/server_provider.dart'; import 'package:worldhopper/providers/connectivity_provider.dart'; import 'package:worldhopper/services/url_helper.dart'; import 'package:worldhopper/providers/download_provider.dart'; import 'package:worldhopper/services/series_download_service.dart'; import 'package:worldhopper/widgets/download_badge.dart'; import 'package:worldhopper/widgets/publication_card.dart'; import 'package:worldhopper/widgets/worldhopper_app_bar.dart'; /// Generic screen for displaying any OPDS feed class FeedScreen extends ConsumerStatefulWidget { final String serverId; final String feedUrl; final String feedTitle; const FeedScreen({ super.key, required this.serverId, required this.feedUrl, required this.feedTitle, }); @override ConsumerState createState() => _FeedScreenState(); } class _FeedScreenState extends ConsumerState { List _allEntries = []; String? _nextPageUrl; bool _isLoadingMore = false; bool _initialized = false; bool _isAcquisitionFeed = false; final ScrollController _scrollController = ScrollController(); @override void initState() { super.initState(); _scrollController.addListener(_onScroll); } @override void dispose() { _scrollController.dispose(); super.dispose(); } void _onScroll() { if (_isLoadingMore || _nextPageUrl == null) return; // Load more when user scrolls to 80% of the content final maxScroll = _scrollController.position.maxScrollExtent; final currentScroll = _scrollController.position.pixels; final threshold = maxScroll * 0.8; if (currentScroll >= threshold) { _loadMoreEntries(); } } @override Widget build(BuildContext context) { final feedRequest = FeedRequest( serverId: widget.serverId, url: widget.feedUrl, forceRefresh: false, ); final feedAsync = ref.watch(feedProvider(feedRequest)); // Watch download status for this feed final downloadStatusAsync = ref.watch(seriesDownloadStatusProvider( SeriesDownloadKey(serverId: widget.serverId, feedUrl: widget.feedUrl), )); // Listen to download progress for completion/error feedback ref.listen>(activeDownloadProgressProvider, (previous, next) { next.whenData((progress) { if (progress.status == DownloadStatus.complete || progress.status == DownloadStatus.partial || progress.status == DownloadStatus.failed) { if (!mounted) return; final l10n = AppLocalizations.of(context); switch (progress.status) { case DownloadStatus.complete: context.showInfoSnackBar( '${l10n.downloadComplete}: ${progress.seriesTitle}', ); case DownloadStatus.partial: context.showErrorSnackBar( '${l10n.downloadFailed}: ${progress.seriesTitle} ' '(${progress.completedChapters}/${progress.totalChapters})', ); case DownloadStatus.failed: context.showErrorSnackBar( '${l10n.downloadFailed}: ${progress.seriesTitle}', ); default: break; } } }); }); return Scaffold( appBar: WorldhopperAppBar( title: Text(widget.feedTitle), actions: _isAcquisitionFeed ? [_buildDownloadAction(context, ref, downloadStatusAsync)] : null, ), body: feedAsync.when( data: (feed) => _buildFeedContent(context, ref, feed), loading: () => const Center(child: CircularProgressIndicator()), error: (error, stack) => _buildErrorState(context, ref, error), ), ); } Widget _buildFeedContent(BuildContext context, WidgetRef ref, Feed feed) { final l10n = AppLocalizations.of(context); // Initialize entries only once when first loaded if (!_initialized) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && !_initialized) { setState(() { _allEntries = List.from(feed.entries); _nextPageUrl = feed.nextLink?.href; _isAcquisitionFeed = feed.isAcquisitionFeed; _initialized = true; }); } }); } return RefreshIndicator( onRefresh: () async { // Clear the repository cache for this specific feed final repository = ref.read(feedRepositoryProvider); final server = await ref.read(serverProvider(widget.serverId).future); if (server != null) { final resolvedUrl = UrlHelper.resolveUrl(server.url, widget.feedUrl); repository.clearFeedCache(widget.serverId, resolvedUrl); } // Reset pagination state setState(() { _allEntries = []; _nextPageUrl = null; _initialized = false; }); // Invalidate the provider to force refetch final refreshRequest = FeedRequest( serverId: widget.serverId, url: widget.feedUrl, forceRefresh: false, ); ref.invalidate(feedProvider(refreshRequest)); // Wait for the new data to load await ref.read(feedProvider(refreshRequest).future); }, child: _allEntries.isEmpty ? _buildEmptyState(context, l10n) : LayoutBuilder( builder: (context, constraints) { return SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), controller: _scrollController, child: ConstrainedBox( constraints: BoxConstraints( minHeight: constraints.maxHeight, ), child: GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), padding: const EdgeInsets.all(16), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: gridColumnCount(constraints.maxWidth), childAspectRatio: 0.65, crossAxisSpacing: 12, mainAxisSpacing: 12, ), itemCount: _allEntries.length + (_isLoadingMore ? 1 : 0), itemBuilder: (context, index) { // Show loading indicator at the end if (index == _allEntries.length) { return Center( child: Padding( padding: const EdgeInsets.all(16), child: CircularProgressIndicator( color: Theme.of(context).colorScheme.primary, ), ), ); } final entry = _allEntries[index]; return PublicationCard( entry: entry, serverId: widget.serverId, badge: _isAcquisitionFeed && !entry.isNavigation ? DownloadBadge( serverId: widget.serverId, entryId: entry.id, ) : null, onTap: () async { if (entry.isNavigation) { // Navigate to nested feed final subsectionLink = entry.links.firstWhere( (link) => link.rel == 'subsection' || link.rel.contains('navigation') || link.rel == 'self', orElse: () => entry.links.first, ); context.push( '/browse/${widget.serverId}/feed', extra: { 'feedUrl': subsectionLink.href, 'feedTitle': entry.title, }, ); } else { // Check connectivity before navigating to publication detail final connectivityState = ref.read(connectivityStateProvider); final isOnline = connectivityState.whenOrNull( data: (online) => online, ) ?? true; // If offline, check content availability and show warning if (!isOnline) { final checker = ref.read(offlineContentCheckerProvider); final isAvailable = await checker .isContentAvailableOffline(entry); if (!isAvailable) { if (entry.isEpub) { // Warn for uncached EPUBs if (!context.mounted) return; context.showInfoSnackBar( l10n.browserOfflineEpubNotCached, duration: const Duration(seconds: 3), ); } else if (entry.hasStreamLink) { // Warn for OPDS-PS streams if (!context.mounted) return; context.showInfoSnackBar( l10n.browserOfflinePagesWarning, duration: const Duration(seconds: 3), ); } } } // Navigate to publication detail if (!context.mounted) return; context.push( AppConstants.routePublicationDetail, extra: { 'entry': entry, 'serverId': widget.serverId, 'feedUrl': widget.feedUrl, }, ); } }, ); }, ), ), ); }, ), ); } Future _loadMoreEntries() async { if (_isLoadingMore || _nextPageUrl == null) return; setState(() { _isLoadingMore = true; }); try { final repository = ref.read(feedRepositoryProvider); final nextFeed = await repository.fetchFeed( widget.serverId, _nextPageUrl!, ); setState(() { _allEntries.addAll(nextFeed.entries); _nextPageUrl = nextFeed.nextLink?.href; _isLoadingMore = false; }); } catch (e) { setState(() { _isLoadingMore = false; }); if (mounted) { final l10n = AppLocalizations.of(context); context.showErrorSnackBar(l10n.browserFailedToLoadMore(e.toString())); } } } Widget _buildEmptyState(BuildContext context, AppLocalizations l10n) { return LayoutBuilder( builder: (context, constraints) { return SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), child: ConstrainedBox( constraints: BoxConstraints( minHeight: constraints.maxHeight, ), child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.folder_open, size: 80, color: Theme.of(context) .colorScheme .secondary .withValues(alpha: 0.5), ), const SizedBox(height: 24), Text( l10n.browserNoContent, style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: 8), Text( l10n.browserCollectionEmpty, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context) .colorScheme .onSurface .withValues(alpha: 0.7), ), ), ], ), ), ), ); }, ); } Widget _buildDownloadAction( BuildContext context, WidgetRef ref, AsyncValue downloadStatusAsync, ) { final l10n = AppLocalizations.of(context); return downloadStatusAsync.when( data: (series) { if (series == null) { // Not downloaded - show download button return IconButton( icon: const Icon(Icons.download), tooltip: l10n.downloadAll, onPressed: () => _confirmAndStartDownload(context, ref), ); } if (series.isDownloading) { // Downloading - show progress with cancel return IconButton( icon: SizedBox( width: 24, height: 24, child: CircularProgressIndicator( value: series.progress > 0 ? series.progress : null, strokeWidth: 2, ), ), tooltip: l10n.downloadCancel, onPressed: () { ref .read(downloadNotifierProvider.notifier) .cancelDownload(series.id); }, ); } // Terminal state — differentiate complete vs partial/failed final (IconData icon, Color color) = switch (series.status) { DownloadStatus.complete => (Icons.download_done, Colors.green), DownloadStatus.failed => (Icons.error_outline, Colors.red), _ => (Icons.download_done, Colors.orange), }; return IconButton( icon: Icon(icon, color: color), tooltip: l10n.downloadDelete, onPressed: () => _confirmDeleteDownload(context, ref, series), ); }, loading: () => const SizedBox( width: 48, child: Center( child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ), ), ), error: (_, __) => IconButton( icon: const Icon(Icons.download), tooltip: l10n.downloadAll, onPressed: () => _confirmAndStartDownload(context, ref), ), ); } Future _confirmAndStartDownload( BuildContext context, WidgetRef ref) async { final l10n = AppLocalizations.of(context); // Count downloadable entries final downloadableCount = _allEntries.where((e) => e.isEpub || e.isImageStream).length; if (downloadableCount == 0) return; final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: Text(l10n.downloadConfirmTitle), content: Text(l10n.downloadConfirmMessage(downloadableCount)), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: Text(l10n.commonCancel), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: Text(l10n.downloadConfirmStart), ), ], ), ); if (confirmed != true || !context.mounted) return; // Get the server final server = await ref.read(serverProvider(widget.serverId).future); if (server == null) return; // Get the current feed for pagination info final feedRequest = FeedRequest( serverId: widget.serverId, url: widget.feedUrl, forceRefresh: false, ); final feed = await ref.read(feedProvider(feedRequest).future); // Get a cover URL from the first entry with a cover final coverEntry = _allEntries.firstWhere( (e) => e.thumbnailUrl != null || e.coverUrl != null, orElse: () => _allEntries.first, ); if (!context.mounted) return; context.showInfoSnackBar(l10n.downloadStarting); ref.read(downloadNotifierProvider.notifier).startSeriesDownload( server: server, feedUrl: widget.feedUrl, feedTitle: widget.feedTitle, initialFeed: feed, coverUrl: coverEntry.coverUrl, thumbnailUrl: coverEntry.thumbnailUrl, ); // Invalidate download status to update UI ref.invalidate(seriesDownloadStatusProvider( SeriesDownloadKey(serverId: widget.serverId, feedUrl: widget.feedUrl), )); } Future _confirmDeleteDownload( BuildContext context, WidgetRef ref, DownloadedSeries series, ) async { final l10n = AppLocalizations.of(context); final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: Text(l10n.downloadDeleteSeries), content: Text(l10n.downloadDeleteSeriesConfirmation(series.title)), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: Text(l10n.commonCancel), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, ), child: Text(l10n.commonDelete), ), ], ), ); if (confirmed == true) { await ref.read(downloadNotifierProvider.notifier).deleteSeries(series.id); if (context.mounted) { ref.invalidate(seriesDownloadStatusProvider( SeriesDownloadKey(serverId: widget.serverId, feedUrl: widget.feedUrl), )); } } } Widget _buildErrorState(BuildContext context, WidgetRef ref, Object error) { final l10n = AppLocalizations.of(context); return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.error_outline, size: 64, color: Colors.red), const SizedBox(height: 24), Text( l10n.browserFailedToLoadFeed, style: Theme.of(context).textTheme.titleLarge, textAlign: TextAlign.center, ), const SizedBox(height: 12), Text( error.toString(), style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center, ), const SizedBox(height: 24), ElevatedButton.icon( onPressed: () { final refreshRequest = FeedRequest( serverId: widget.serverId, url: widget.feedUrl, forceRefresh: false, ); ref.invalidate(feedProvider(refreshRequest)); }, icon: const Icon(Icons.refresh), label: Text(l10n.browserRetry), ), ], ), ), ); } }