worldhopper/lib/screens/browse/feed_screen.dart
Felipe M. 58a562abe8
feat: refactor server software abstraction with Kavita REST API integration
Rename all OPDS-prefixed models to generic names (Server, Feed, Entry,
Link, StreamLink), expand the ServerSoftware interface to cover all
server interactions, and implement a full Kavita REST API client that
replaces the OPDS delegation.

- Rename OPDS* models to generic names across ~60 files
- Add database migrations 13 (opds_id → entry_id) and 14 (unify credentials)
- Create OPDSServerSoftware wrapping existing OPDS services
- Create KavitaApiClient for direct Kavita REST API calls
- Create KavitaFeedMapper to convert Kavita JSON to Feed/Entry models
- Rewrite KavitaServerSoftware to use native API (no OPDS delegation)
- Unify server credentials (remove softwareUsername/softwarePassword)
- Simplify add/edit server UI to single auth section
- Add test connection button to server add/edit screens
- Add progress indicator to PublicationCard using local and server data
- Eliminate softwareType branching in reader screens
- Add EntryProgress and fetchEntryProgress to ServerSoftware interface
- Fix Entry.acquisitionLink crash on empty links
- Add 59 new tests covering models, services, and providers
2026-04-06 17:46:48 +02:00

578 lines
20 KiB
Dart

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<FeedScreen> createState() => _FeedScreenState();
}
class _FeedScreenState extends ConsumerState<FeedScreen> {
List<Entry> _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<AsyncValue<DownloadProgress>>(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<void> _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<DownloadedSeries?> 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<void> _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<bool>(
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<void> _confirmDeleteDownload(
BuildContext context,
WidgetRef ref,
DownloadedSeries series,
) async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
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),
),
],
),
),
);
}
}