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>
216 lines
7.4 KiB
Dart
216 lines
7.4 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:worldhopper/l10n/app_localizations.dart';
|
|
import 'package:worldhopper/helpers/snackbar_helper.dart';
|
|
import 'package:worldhopper/providers/download_provider.dart';
|
|
import 'package:worldhopper/screens/settings/about_screen.dart';
|
|
import 'package:worldhopper/screens/settings/advanced_settings_screen.dart';
|
|
import 'package:worldhopper/screens/settings/appearance_settings_screen.dart';
|
|
import 'package:worldhopper/screens/settings/language_settings_screen.dart';
|
|
import 'package:worldhopper/screens/settings/readers_settings_screen.dart';
|
|
import 'package:worldhopper/widgets/worldhopper_app_bar.dart';
|
|
|
|
/// Screen for app settings and configuration
|
|
class SettingsScreen extends ConsumerWidget {
|
|
const SettingsScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
return Scaffold(
|
|
appBar: WorldhopperAppBar(
|
|
title: Text(l10n.settingsTitle),
|
|
),
|
|
body: ListView(
|
|
children: [
|
|
const SizedBox(height: 8),
|
|
Card(
|
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Column(
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(Icons.palette_outlined),
|
|
title: Text(l10n.settingsAppearance),
|
|
subtitle: Text(l10n.settingsAppearanceSubtitle),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => const AppearanceSettingsScreen(),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
const Divider(height: 1),
|
|
ListTile(
|
|
leading: const Icon(Icons.language_outlined),
|
|
title: Text(l10n.settingsLanguage),
|
|
subtitle: Text(l10n.settingsLanguageSubtitle),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => const LanguageSettingsScreen(),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
const Divider(height: 1),
|
|
ListTile(
|
|
leading: const Icon(Icons.menu_book_outlined),
|
|
title: Text(l10n.settingsReaders),
|
|
subtitle: Text(l10n.settingsReadersSubtitle),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => const ReadersSettingsScreen(),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
const Divider(height: 1),
|
|
ListTile(
|
|
leading: const Icon(Icons.tune_outlined),
|
|
title: Text(l10n.settingsAdvanced),
|
|
subtitle: Text(l10n.settingsAdvancedSubtitle),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => const AdvancedSettingsScreen(),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
const Divider(height: 1),
|
|
ListTile(
|
|
leading: const Icon(Icons.info_outline),
|
|
title: Text(l10n.settingsAbout),
|
|
subtitle: Text(l10n.settingsAboutSubtitle),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => const AboutScreen(),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
_DownloadStorageCard(),
|
|
const SizedBox(height: 16),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DownloadStorageCard extends ConsumerWidget {
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
return Card(
|
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Column(
|
|
children: [
|
|
_DownloadStorageTile(
|
|
onTap: () => _confirmClearDownloads(context, ref),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _confirmClearDownloads(
|
|
BuildContext context, WidgetRef ref) async {
|
|
final l10n = AppLocalizations.of(context);
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(l10n.settingsClearAllDownloads),
|
|
content: Text(l10n.settingsClearAllDownloadsConfirmation),
|
|
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) {
|
|
final notifier = ref.read(downloadNotifierProvider.notifier);
|
|
final seriesRepo = ref.read(downloadedSeriesRepositoryProvider);
|
|
final allSeries = await seriesRepo.getAll();
|
|
for (final series in allSeries) {
|
|
await notifier.deleteSeries(series.id);
|
|
}
|
|
if (context.mounted) {
|
|
context.showInfoSnackBar(l10n.settingsDownloadsCleared);
|
|
}
|
|
}
|
|
}
|
|
|
|
static String _formatBytes(int bytes) {
|
|
if (bytes < 1024) return '$bytes B';
|
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
|
if (bytes < 1024 * 1024 * 1024) {
|
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
|
}
|
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
|
}
|
|
}
|
|
|
|
class _DownloadStorageTile extends ConsumerStatefulWidget {
|
|
final VoidCallback onTap;
|
|
|
|
const _DownloadStorageTile({required this.onTap});
|
|
|
|
@override
|
|
ConsumerState<_DownloadStorageTile> createState() =>
|
|
_DownloadStorageTileState();
|
|
}
|
|
|
|
class _DownloadStorageTileState extends ConsumerState<_DownloadStorageTile> {
|
|
Future<int>? _sizeFuture;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_sizeFuture =
|
|
ref.read(downloadStorageServiceProvider).getTotalDownloadSize();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
return ListTile(
|
|
leading: const Icon(Icons.download_outlined),
|
|
title: Text(l10n.settingsDownloadedContent),
|
|
subtitle: FutureBuilder<int>(
|
|
future: _sizeFuture,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.hasData) {
|
|
return Text(l10n.settingsDownloadSize(
|
|
_DownloadStorageCard._formatBytes(snapshot.data!)));
|
|
}
|
|
return Text(l10n.settingsDownloadedContentSubtitle);
|
|
},
|
|
),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: widget.onTap,
|
|
);
|
|
}
|
|
}
|