feat(settings): add backup and restore for settings, history and library

Add a Backup & Restore page that exports app settings, download history, liked tracks, wishlist, playlists (with cover images) and favorite artists into a single JSON file, and restores them on another device. Settings restore preserves device-specific storage location (SAF tree URI, download dir). Includes EN strings and ID translations.
This commit is contained in:
zarzet committed 2026-06-26 19:54:20 +07:00
1 parent f236d72a19
commit b2705cb2ae
25 files changed
+3068

No files matched your search

@@ -0,0 +1,374 @@
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/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/app_bar_layout.dart';
import 'package:spotiflac_android/utils/logger.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 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 envelope = BackupService.buildEnvelope(
settings: settings,
history: history,
collections: collections,
playlistCovers: covers,
);
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,
);
messenger.showSnackBar(
SnackBar(
content: Text('${l10n.backupRestored}\n${l10n.backupRestoreRestartHint}'),
duration: const Duration(seconds: 5),
),
);
} 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,
),
),
],
),
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 colorScheme = Theme.of(context).colorScheme;
final topPadding = normalizedHeaderTopPadding(context);
final l10n = context.l10n;
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
l10n.backupTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_ActionCard(
icon: Icons.backup_outlined,
title: l10n.backupExportSectionTitle,
description: l10n.backupExportSectionDescription,
buttonLabel: l10n.backupExportButton,
buttonIcon: Icons.ios_share,
isBusy: _isExporting,
onPressed: _isBusy ? null : _createBackup,
),
const SizedBox(height: 16),
_ActionCard(
icon: Icons.settings_backup_restore,
title: l10n.backupImportSectionTitle,
description: l10n.backupImportSectionDescription,
buttonLabel: l10n.backupImportButton,
buttonIcon: Icons.folder_open_outlined,
isBusy: _isImporting,
onPressed: _isBusy ? null : _restoreBackup,
),
],
),
),
),
],
),
);
}
}
class _ActionCard extends StatelessWidget {
final IconData icon;
final String title;
final String description;
final String buttonLabel;
final IconData buttonIcon;
final bool isBusy;
final VoidCallback? onPressed;
const _ActionCard({
required this.icon,
required this.title,
required this.description,
required this.buttonLabel,
required this.buttonIcon,
required this.isBusy,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 10),
Text(
description,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onPressed,
icon: isBusy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(buttonIcon),
label: Text(buttonLabel),
),
],
),
);
}
}
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)),
],
),
);
}
}
+8
View File
@@ -12,6 +12,7 @@ import 'package:spotiflac_android/screens/settings/library_settings_page.dart';
import 'package:spotiflac_android/screens/settings/app_settings_page.dart';
import 'package:spotiflac_android/screens/settings/about_page.dart';
import 'package:spotiflac_android/screens/settings/cache_management_page.dart';
import 'package:spotiflac_android/screens/settings/backup_restore_page.dart';
import 'package:spotiflac_android/screens/settings/donate_page.dart';
import 'package:spotiflac_android/screens/settings/log_screen.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
@@ -160,6 +161,13 @@ class SettingsTab extends ConsumerWidget {
onTap: () =>
_navigateTo(context, const AppSettingsPage()),
),
SettingsItem(
icon: Icons.settings_backup_restore,
title: l10n.settingsBackup,
subtitle: l10n.settingsBackupSubtitle,
onTap: () =>
_navigateTo(context, const BackupRestorePage()),
),
SettingsItem(
icon: Icons.article_outlined,
title: l10n.logTitle,