Files
SpotiFLAC-Mobile/lib/screens/settings/backup_restore_page.dart
T
zarzet 63e1a9b9c8 feat(ui): add Mornye glass theme and adaptive music player
Add a selectable Mornye theme with shared glass controls, dialogs, sheets, notifications, and collection headers while retaining Material components.

Update player artwork, lyrics transitions and highlighting, synchronized rolling time labels, volume controls, and compact navigation. Unify metadata, Library, search, and extension surfaces; preserve restored artwork and report extension load failures.

Validation: full Flutter analyzer clean; 713 tests passed and 4 skipped; pinned Dart formatting and diff checks passed; signed ARM64 and ARM32 release APKs built and verified.
2026-09-19 12:10:10 +07:00

396 lines
14 KiB
Dart

import 'dart:convert';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.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/app_sliver_header.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 _includeSettings = true;
bool _includeHistory = true;
bool _includeCollections = true;
bool _includeExtensions = true;
bool get _isBusy => _isExporting || _isImporting;
bool get _hasSelection =>
_includeSettings ||
_includeHistory ||
_includeCollections ||
_includeExtensions;
Future<void> _createBackup() async {
if (_isBusy || !_hasSelection) return;
setState(() => _isExporting = true);
final l10n = context.l10n;
final messenger = ScaffoldMessenger.of(context);
try {
final settings = _includeSettings
? ref.read(settingsProvider).toJson()
: null;
var collections = <String, dynamic>{};
var covers = <String, Map<String, String>>{};
if (_includeCollections) {
final notifier = ref.read(libraryCollectionsProvider.notifier);
collections = await notifier.exportCollections();
covers = await notifier.exportPlaylistCoverFiles();
}
final extensions = _includeExtensions
? await ref
.read(extensionProvider.notifier)
.exportBackup(includeSecrets: _includeSecrets)
: <String, dynamic>{};
final file = await BackupService.writeBackupArchive(
settings: settings,
includeHistory: _includeHistory,
loadHistoryPage: (limit, offset) =>
HistoryDatabase.instance.getAll(limit: limit, offset: offset),
collections: collections,
playlistCoverFiles: covers,
extensions: extensions,
);
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);
BackupBundle? bundle;
try {
// Android resolves custom extensions to MIME types. It recognizes JSON
// but not SFLB, so mixing them hides our own backups. Validate contents
// with BackupService after selection instead of filtering by extension.
final picked = await FilePicker.pickFile(type: FileType.any);
if (picked == null) return;
final path = picked.path;
bundle = path != null
? await BackupService.parseFile(path)
: BackupService.parse(utf8.decode(await picked.readAsBytes()));
} catch (e) {
_log.e('Failed to read backup file: $e');
messenger.showSnackBar(SnackBar(content: Text(l10n.backupInvalidFile)));
return;
}
if (bundle == null) {
messenger.showSnackBar(SnackBar(content: Text(l10n.backupInvalidFile)));
return;
}
if (!mounted) {
await bundle.cleanup();
return;
}
final confirmed = await _confirmRestore(bundle);
if (confirmed != true || !mounted) {
await bundle.cleanup();
return;
}
setState(() => _isImporting = true);
try {
if (bundle.hasSettings) {
await ref
.read(settingsProvider.notifier)
.restoreFromBackup(bundle.settings!);
}
if (bundle.hasHistory) {
await ref
.read(downloadHistoryProvider.notifier)
.restoreFromBackupStream(bundle.streamHistory());
}
if (bundle.hasCollections) {
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 {
await bundle.cleanup();
if (mounted) setState(() => _isImporting = false);
}
}
Future<bool?> _confirmRestore(BackupBundle bundle) {
final l10n = context.l10n;
return showAppDialog<bool>(
context: context,
builder: (dialogContext) {
final theme = Theme.of(dialogContext);
return AppAlertDialog(
title: Text(l10n.backupRestoreConfirmTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.backupSelectedRestoreMessage),
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,
),
if (bundle.hasHistory)
_ContentRow(
icon: Icons.history,
label: l10n.backupContentsHistory(bundle.historyCount),
),
if (bundle.hasCollections)
_ContentRow(
icon: Icons.favorite_outline,
label: l10n.backupContentsLiked(bundle.likedCount),
),
if (bundle.hasCollections)
_ContentRow(
icon: Icons.bookmark_outline,
label: l10n.backupContentsWishlist(bundle.wishlistCount),
),
if (bundle.hasCollections)
_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: [
AppDialogAction(
isDefault: true,
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(l10n.dialogCancel),
),
AppDialogAction(
filled: true,
isDestructive: true,
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(l10n.backupRestoreConfirmButton),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold(
body: CustomScrollView(
slivers: [
AppSliverHeader.page(title: l10n.backupTitle),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: l10n.backupExportSectionTitle),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.settings_outlined,
title: l10n.backupSettingsOnly,
onTap: _isBusy
? null
: () => setState(() {
_includeSettings = true;
_includeHistory = false;
_includeCollections = false;
_includeExtensions = false;
_includeSecrets = false;
}),
),
SettingsSwitchItem(
icon: Icons.settings_outlined,
title: l10n.backupContentsSettings,
value: _includeSettings,
onChanged: _isBusy
? null
: (value) => setState(() => _includeSettings = value),
),
SettingsSwitchItem(
icon: Icons.history,
title: l10n.backupSelectHistory,
value: _includeHistory,
onChanged: _isBusy
? null
: (value) => setState(() => _includeHistory = value),
),
SettingsSwitchItem(
icon: Icons.library_music_outlined,
title: l10n.backupSelectCollections,
subtitle: l10n.backupSelectCollectionsDescription,
value: _includeCollections,
onChanged: _isBusy
? null
: (value) => setState(() => _includeCollections = value),
),
SettingsSwitchItem(
icon: Icons.extension_outlined,
title: l10n.backupSelectExtensions,
value: _includeExtensions,
onChanged: _isBusy
? null
: (value) => setState(() {
_includeExtensions = value;
if (!value) _includeSecrets = false;
}),
),
SettingsSwitchItem(
icon: Icons.vpn_key_outlined,
title: l10n.backupIncludeSecrets,
subtitle: l10n.backupIncludeSecretsDescription,
value: _includeSecrets,
onChanged: _isBusy || !_includeExtensions
? null
: (value) => setState(() => _includeSecrets = value),
),
SettingsItem(
icon: Icons.ios_share,
title: l10n.backupExportButton,
subtitle: _hasSelection
? l10n.backupSelectedExportDescription
: l10n.backupSelectAtLeastOne,
onTap: _isBusy || !_hasSelection ? 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)),
],
),
);
}
}