worldhopper/lib/screens/library/library_screen.dart
Felipe M. 30df288552
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
feat: add offline series download with local reading support
Enable downloading entire series for offline reading. Includes database
tables and migration for downloaded series/chapters, download orchestration
with progress tracking, per-chapter EPUB and image stream downloading,
a Downloaded section in the library, and offline-first reader support.

Key changes:
- DB migration v11 with downloaded_series and downloaded_chapters tables
- Series download service with pagination, cancellation, and progress stream
- Feed screen download button with status indicators and completion feedback
- Library screen Downloaded section with series cards and detail screen
- Reader pre-caching using local files (including two-page spread mode)
- Next-in-series navigation resolves from downloaded chapters when offline
- Skip image downloads and progress sync when device is offline
- Settings screen shows download storage usage with clear option

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:40:15 +01:00

307 lines
11 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/models/downloaded_series.dart';
import 'package:worldhopper/providers/download_provider.dart';
import 'package:worldhopper/providers/reading_progress_provider.dart';
import 'package:worldhopper/providers/server_provider.dart';
import 'package:worldhopper/widgets/downloaded_series_card.dart';
import 'package:worldhopper/widgets/worldhopper_app_bar.dart';
import 'package:worldhopper/widgets/recently_read_card.dart';
/// Screen displaying the user's reading history and library
class LibraryScreen extends ConsumerWidget {
const LibraryScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final recentlyReadAsync = ref.watch(recentlyReadProvider);
final downloadedSeriesAsync = ref.watch(downloadedSeriesListProvider);
return Scaffold(
appBar: WorldhopperAppBar(
title: Text(l10n.libraryTitle),
),
body: RefreshIndicator(
onRefresh: () async {
ref.invalidate(recentlyReadProvider);
ref.invalidate(downloadedSeriesListProvider);
},
child: recentlyReadAsync.when(
data: (recentlyRead) {
final downloadedSeries = downloadedSeriesAsync.valueOrNull ?? [];
if (recentlyRead.isEmpty && downloadedSeries.isEmpty) {
return _buildEmptyState(context, ref, l10n);
}
return LayoutBuilder(
builder: (context, constraints) {
return CustomScrollView(
slivers: [
// Recently read section
if (recentlyRead.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding:
const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 8.0),
child: Text(
l10n.libraryRecentlyRead,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
sliver: SliverGrid(
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount:
gridColumnCount(constraints.maxWidth),
childAspectRatio: 0.7,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
final progress = recentlyRead[index];
return RecentlyReadCard(progress: progress);
},
childCount: recentlyRead.length,
),
),
),
],
// Downloaded section
if (downloadedSeries.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding:
const EdgeInsets.fromLTRB(16.0, 24.0, 16.0, 8.0),
child: Text(
l10n.libraryDownloaded,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
sliver: SliverGrid(
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount:
gridColumnCount(constraints.maxWidth),
childAspectRatio: 0.7,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
final series = downloadedSeries[index];
return DownloadedSeriesCard(
series: series,
onTap: () {
context.push(
AppConstants.routeDownloadedSeriesDetail,
extra: {'seriesId': series.id},
);
},
onLongPress: () =>
_confirmDeleteSeries(context, ref, series),
);
},
childCount: downloadedSeries.length,
),
),
),
],
const SliverToBoxAdapter(
child: SizedBox(height: 16),
),
],
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => _buildErrorState(context, l10n, error),
),
),
);
}
Future<void> _confirmDeleteSeries(
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);
}
}
Widget _buildEmptyState(
BuildContext context, WidgetRef ref, AppLocalizations l10n) {
final serversAsync = ref.watch(serverListProvider);
return serversAsync.when(
data: (servers) {
// Case 1: No servers configured
if (servers.isEmpty) {
return _buildNoServersState(context, l10n);
}
// Case 2: Servers exist but no reading history
return _buildNoHistoryState(context, l10n);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => _buildNoHistoryState(context, l10n), // Fallback
);
}
Widget _buildNoServersState(BuildContext context, AppLocalizations l10n) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.dns_outlined,
size: 80,
color: Theme.of(context).colorScheme.outline,
),
const SizedBox(height: 24),
Text(
l10n.libraryNoServers,
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
l10n.libraryNoServersSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () => context.go(AppConstants.routeServerList),
icon: const Icon(Icons.add),
label: Text(l10n.libraryAddServer),
),
],
),
),
);
}
Widget _buildNoHistoryState(BuildContext context, AppLocalizations l10n) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.auto_stories_outlined,
size: 80,
color: Theme.of(context).colorScheme.outline,
),
const SizedBox(height: 24),
Text(
l10n.libraryNoHistory,
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
l10n.libraryNoHistorySubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () => context.go(AppConstants.routeServerList),
icon: const Icon(Icons.dns),
label: Text(l10n.libraryBrowseServers),
),
],
),
),
);
}
Widget _buildErrorState(
BuildContext context, AppLocalizations l10n, Object error) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 80,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 24),
Text(
l10n.libraryErrorLoading,
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
error.toString(),
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
);
}
}