worldhopper/lib/screens/settings/settings_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

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,
);
}
}