worldhopper/lib/screens/library/downloaded_series_detail_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

198 lines
5.8 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/models/downloaded_chapter.dart';
import 'package:worldhopper/models/downloaded_series.dart';
import 'package:worldhopper/providers/download_provider.dart';
import 'package:worldhopper/widgets/downloaded_chapter_card.dart';
import 'package:worldhopper/widgets/worldhopper_app_bar.dart';
/// Shows all chapters in a downloaded series
class DownloadedSeriesDetailScreen extends ConsumerWidget {
final String seriesId;
const DownloadedSeriesDetailScreen({
super.key,
required this.seriesId,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final seriesAsync = ref.watch(downloadedSeriesListProvider);
final chaptersAsync = ref.watch(downloadedChaptersProvider(seriesId));
// Find the series from the list
final series = seriesAsync.whenOrNull(
data: (list) {
for (final s in list) {
if (s.id == seriesId) return s;
}
return null;
},
);
final hasFailedChapters = chaptersAsync.whenOrNull(
data: (chapters) =>
chapters.any((c) => c.status == DownloadStatus.failed),
) ??
false;
return Scaffold(
appBar: WorldhopperAppBar(
title: Text(series?.title ?? l10n.downloadedSeriesDetailTitle),
actions: [
if (hasFailedChapters && series != null)
IconButton(
icon: const Icon(Icons.refresh),
tooltip: l10n.downloadRetryAll,
onPressed: () => _retryAllFailed(context, ref, series),
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => _confirmDeleteSeries(context, ref, series),
),
],
),
body: chaptersAsync.when(
data: (chapters) => _buildChaptersList(context, ref, chapters, series),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Text(l10n.libraryErrorLoading),
),
),
);
}
Widget _buildChaptersList(
BuildContext context,
WidgetRef ref,
List<DownloadedChapter> chapters,
DownloadedSeries? series,
) {
if (chapters.isEmpty) {
return const Center(child: Text('No chapters'));
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: chapters.length,
itemBuilder: (context, index) {
final chapter = chapters[index];
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: DownloadedChapterCard(
chapter: chapter,
onTap: () => _openChapter(context, ref, chapter),
onRetry: chapter.status == DownloadStatus.failed && series != null
? () => _retryChapter(context, ref, series, chapter)
: null,
),
);
},
);
}
void _openChapter(
BuildContext context,
WidgetRef ref,
DownloadedChapter chapter,
) {
final entry = chapter.toEntry();
if (chapter.contentType == ChapterContentType.epub) {
context.push(AppConstants.routeEpubReader, extra: {
'entry': entry,
'serverId': chapter.serverId,
'localFilePath': chapter.filePath,
});
} else {
context.push(AppConstants.routeReader, extra: {
'entry': entry,
'serverId': chapter.serverId,
'initialPage': 0,
'localContentPath': chapter.filePath,
});
}
}
Future<void> _retryChapter(
BuildContext context,
WidgetRef ref,
DownloadedSeries series,
DownloadedChapter chapter,
) async {
final server = await ref.read(serverByIdProvider(series.serverId).future);
if (server == null) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Server not found')),
);
}
return;
}
await ref
.read(downloadNotifierProvider.notifier)
.retryChapter(server: server, chapter: chapter);
}
Future<void> _retryAllFailed(
BuildContext context,
WidgetRef ref,
DownloadedSeries series,
) async {
final server = await ref.read(serverByIdProvider(series.serverId).future);
if (server == null) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Server not found')),
);
}
return;
}
await ref
.read(downloadNotifierProvider.notifier)
.retryFailedChapters(server: server, seriesId: series.id);
}
Future<void> _confirmDeleteSeries(
BuildContext context,
WidgetRef ref,
DownloadedSeries? series,
) async {
if (series == null) return;
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 && context.mounted) {
await ref.read(downloadNotifierProvider.notifier).deleteSeries(series.id);
if (context.mounted) {
context.pop();
}
}
}
}