import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:worldhopper/config/constants.dart'; import 'package:worldhopper/models/opds_feed.dart'; import 'package:worldhopper/models/opds_entry.dart'; import 'package:worldhopper/providers/opds_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/widgets/publication_card.dart'; import 'package:worldhopper/widgets/worldhopper_app_bar.dart'; /// Screen for browsing an OPDS server's root feed class LibraryBrowserScreen extends ConsumerStatefulWidget { final String serverId; const LibraryBrowserScreen({ super.key, required this.serverId, }); @override ConsumerState createState() => _LibraryBrowserScreenState(); } class _LibraryBrowserScreenState extends ConsumerState { List _allEntries = []; String? _nextPageUrl; bool _isLoadingMore = false; bool _initialized = 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 serverAsync = ref.watch(serverProvider(widget.serverId)); final feedRequest = RootFeedRequest(serverId: widget.serverId); final feedAsync = ref.watch(opdsRootFeedProvider(feedRequest)); return Scaffold( appBar: WorldhopperAppBar( title: serverAsync.when( data: (server) => Text(server?.name ?? 'Library'), loading: () => const Text('Loading...'), error: (_, __) => const Text('Library'), ), ), 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, OPDSFeed feed) { // 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; _initialized = true; }); } }); } return RefreshIndicator( onRefresh: () async { // Clear the repository cache for this server's root feed final repository = ref.read(opdsRepositoryProvider); final server = await ref.read(serverProvider(widget.serverId).future); if (server != null) { final resolvedUrl = UrlHelper.resolveUrl(server.url, server.url); repository.clearFeedCache(widget.serverId, resolvedUrl); } // Reset pagination state setState(() { _allEntries = []; _nextPageUrl = null; _initialized = false; }); // Invalidate the provider to force refetch final refreshRequest = RootFeedRequest(serverId: widget.serverId); ref.invalidate(opdsRootFeedProvider(refreshRequest)); // Wait for the new data to load await ref.read(opdsRootFeedProvider(refreshRequest).future); }, child: _allEntries.isEmpty ? _buildEmptyState(context) : 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: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, 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, onTap: () async { if (entry.isNavigation) { // Navigate to subsection 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; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'This EPUB is not cached. You may not be able to read it offline.'), duration: Duration(seconds: 3), ), ); } else if (entry.hasStreamLink) { // Warn for OPDS-PS streams if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'You are offline. Pages may not load properly.'), duration: Duration(seconds: 3), ), ); } } } // Navigate to publication detail if (!context.mounted) return; context.push( AppConstants.routePublicationDetail, extra: { 'entry': entry, 'serverId': widget.serverId, }, ); } }, ); }, ), ), ); }, ), ); } Future _loadMoreEntries() async { if (_isLoadingMore || _nextPageUrl == null) return; setState(() { _isLoadingMore = true; }); try { final repository = ref.read(opdsRepositoryProvider); 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) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to load more: $e'), backgroundColor: Colors.red, ), ); } } } Widget _buildEmptyState(BuildContext context) { 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.library_books_outlined, size: 80, color: Theme.of(context).colorScheme.secondary.withOpacity(0.5), ), const SizedBox(height: 24), Text( 'No Content Available', style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: 8), Text( 'This library appears to be empty', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), ), ), ], ), ), ), ); }, ); } Widget _buildErrorState(BuildContext context, WidgetRef ref, Object error) { 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( 'Failed to Load Library', 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 = RootFeedRequest(serverId: widget.serverId); ref.invalidate(opdsRootFeedProvider(refreshRequest)); }, icon: const Icon(Icons.refresh), label: const Text('Retry'), ), ], ), ), ); } }