worldhopper/lib/screens/browse/feed_screen.dart

353 lines
12 KiB
Dart

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';
/// 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<FeedScreen> createState() => _FeedScreenState();
}
class _FeedScreenState extends ConsumerState<FeedScreen> {
List<OPDSEntry> _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 feedRequest = FeedRequest(
serverId: widget.serverId,
url: widget.feedUrl,
forceRefresh: false,
);
final feedAsync = ref.watch(opdsFeedProvider(feedRequest));
return Scaffold(
appBar: WorldhopperAppBar(
title: Text(widget.feedTitle),
),
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 specific feed
final repository = ref.read(opdsRepositoryProvider);
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(opdsFeedProvider(refreshRequest));
// Wait for the new data to load
await ref.read(opdsFeedProvider(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 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;
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,
'feedUrl': widget.feedUrl,
},
);
}
},
);
},
),
),
);
},
),
);
}
Future<void> _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.folder_open,
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 collection 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 Feed',
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(opdsFeedProvider(refreshRequest));
},
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
),
);
}
}