mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-31 08:17:22 +02:00
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.
327 lines
11 KiB
Dart
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)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|