All checks were successful
ci/woodpecker/tag/release Pipeline was successful
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>
307 lines
11 KiB
Dart
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|