Files
SpotiFLAC-Mobile/lib/screens/settings/backup_restore_page.dart
T
zarzet b12502c8f6 refactor(settings): replace 18 inline collapsing headers with SettingsSliverAppBar
Every settings-style page carried the same 40-line SliverAppBar block;
titles, custom leadings (discard-confirm back handlers), and action
buttons are preserved through the widget's slots.
2026-07-11 16:36:08 +07:00

327 lines
11 KiB
Dart

import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus, XFile;
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/backup_service.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart';
class BackupRestorePage extends ConsumerStatefulWidget {
const BackupRestorePage({super.key});
@override
ConsumerState<BackupRestorePage> createState() => _BackupRestorePageState();
}
class _BackupRestorePageState extends ConsumerState<BackupRestorePage> {
static final _log = AppLogger('BackupRestorePage');
bool _isExporting = false;
bool _isImporting = false;
bool _includeSecrets = false;
bool get _isBusy => _isExporting || _isImporting;
Future<void> _createBackup() async {
if (_isBusy) return;
setState(() => _isExporting = true);
final l10n = context.l10n;
final messenger = ScaffoldMessenger.of(context);
try {
final settings = ref.read(settingsProvider).toJson();
final history = await HistoryDatabase.instance.getAll();
final collectionsNotifier = ref.read(
libraryCollectionsProvider.notifier,
);
final collections = await collectionsNotifier.exportCollections();
final covers = await collectionsNotifier.exportPlaylistCovers();
final extensions = await ref
.read(extensionProvider.notifier)
.exportBackup(includeSecrets: _includeSecrets);
final envelope = BackupService.buildEnvelope(
settings: settings,
history: history,
collections: collections,
playlistCovers: covers,
extensions: extensions,
);
final file = await BackupService.writeBackupFile(envelope);
messenger.showSnackBar(SnackBar(content: Text(l10n.backupCreated)));
await SharePlus.instance.share(
ShareParams(files: [XFile(file.path)], text: l10n.backupTitle),
);
} catch (e, stack) {
_log.e('Failed to create backup: $e', e, stack);
messenger.showSnackBar(
SnackBar(content: Text(l10n.backupCreateFailed)),
);
} finally {
if (mounted) setState(() => _isExporting = false);
}
}
Future<void> _restoreBackup() async {
if (_isBusy) return;
final l10n = context.l10n;
final messenger = ScaffoldMessenger.of(context);
String? content;
try {
final result = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: ['json', BackupService.fileExtension],
);
final path = result?.files.single.path;
if (path == null) return;
content = await File(path).readAsString();
} catch (e) {
_log.e('Failed to read backup file: $e');
messenger.showSnackBar(
SnackBar(content: Text(l10n.backupInvalidFile)),
);
return;
}
final bundle = BackupService.parse(content);
if (bundle == null) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.backupInvalidFile)),
);
return;
}
if (!mounted) return;
final confirmed = await _confirmRestore(bundle);
if (confirmed != true || !mounted) return;
setState(() => _isImporting = true);
try {
if (bundle.hasSettings) {
await ref
.read(settingsProvider.notifier)
.restoreFromBackup(bundle.settings!);
}
await ref
.read(downloadHistoryProvider.notifier)
.restoreFromBackup(bundle.history);
await ref
.read(libraryCollectionsProvider.notifier)
.restoreFromBackup(
bundle.collections,
coverImages: bundle.playlistCovers,
);
ExtensionRestoreResult? extResult;
if (bundle.hasExtensions) {
extResult = await ref
.read(extensionProvider.notifier)
.restoreFromBackup(bundle.extensions);
}
final message = StringBuffer(l10n.backupRestored)
..write('\n')
..write(l10n.backupRestoreRestartHint);
if (extResult != null && extResult.failed > 0) {
message
..write('\n')
..write(l10n.backupExtensionsRestoreFailed(extResult.failed));
}
messenger.showSnackBar(
SnackBar(
content: Text(message.toString()),
duration: const Duration(seconds: 6),
),
);
} catch (e, stack) {
_log.e('Failed to restore backup: $e', e, stack);
messenger.showSnackBar(
SnackBar(content: Text(l10n.backupRestoreFailed)),
);
} finally {
if (mounted) setState(() => _isImporting = false);
}
}
Future<bool?> _confirmRestore(BackupBundle bundle) {
final l10n = context.l10n;
return showDialog<bool>(
context: context,
builder: (dialogContext) {
final theme = Theme.of(dialogContext);
return AlertDialog(
title: Text(l10n.backupRestoreConfirmTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.backupRestoreConfirmMessage),
const SizedBox(height: 16),
Text(
l10n.backupContentsTitle,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
if (bundle.hasSettings)
_ContentRow(
icon: Icons.settings_outlined,
label: l10n.backupContentsSettings,
),
_ContentRow(
icon: Icons.history,
label: l10n.backupContentsHistory(bundle.historyCount),
),
_ContentRow(
icon: Icons.favorite_outline,
label: l10n.backupContentsLiked(bundle.likedCount),
),
_ContentRow(
icon: Icons.bookmark_outline,
label: l10n.backupContentsWishlist(bundle.wishlistCount),
),
_ContentRow(
icon: Icons.queue_music_outlined,
label: l10n.backupContentsPlaylists(bundle.playlistCount),
),
if (bundle.favoriteArtistCount > 0)
_ContentRow(
icon: Icons.person_outline,
label: l10n.backupContentsArtists(
bundle.favoriteArtistCount,
),
),
if (bundle.extensionCount > 0)
_ContentRow(
icon: Icons.extension_outlined,
label: l10n.backupContentsExtensions(
bundle.extensionCount,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(l10n.backupRestoreConfirmButton),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold(
body: CustomScrollView(
slivers: [
SettingsSliverAppBar(title: l10n.backupTitle),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: l10n.backupExportSectionTitle,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.vpn_key_outlined,
title: l10n.backupIncludeSecrets,
subtitle: l10n.backupIncludeSecretsDescription,
value: _includeSecrets,
onChanged: _isBusy
? null
: (value) => setState(() => _includeSecrets = value),
),
SettingsItem(
icon: Icons.ios_share,
title: l10n.backupExportButton,
subtitle: l10n.backupExportSectionDescription,
onTap: _isBusy ? null : _createBackup,
trailing: _isExporting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: null,
showDivider: false,
),
],
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: l10n.backupImportSectionTitle,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.settings_backup_restore,
title: l10n.backupImportButton,
subtitle: l10n.backupImportSectionDescription,
onTap: _isBusy ? null : _restoreBackup,
trailing: _isImporting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: null,
showDivider: false,
),
],
),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
),
);
}
}
class _ContentRow extends StatelessWidget {
final IconData icon;
final String label;
const _ContentRow({required this.icon, required this.label});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Icon(icon, size: 18, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 10),
Expanded(child: Text(label, style: theme.textTheme.bodyMedium)),
],
),
);
}
}