import 'dart:convert'; 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/app_sliver_header.dart'; class BackupRestorePage extends ConsumerStatefulWidget { const BackupRestorePage({super.key}); @override ConsumerState createState() => _BackupRestorePageState(); } class _BackupRestorePageState extends ConsumerState { static final _log = AppLogger('BackupRestorePage'); bool _isExporting = false; bool _isImporting = false; bool _includeSecrets = false; bool get _isBusy => _isExporting || _isImporting; Future _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 collectionsNotifier = ref.read(libraryCollectionsProvider.notifier); final collections = await collectionsNotifier.exportCollections(); final covers = await collectionsNotifier.exportPlaylistCoverFiles(); final extensions = await ref .read(extensionProvider.notifier) .exportBackup(includeSecrets: _includeSecrets); final file = await BackupService.writeBackupArchive( settings: settings, 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 _restoreBackup() async { if (_isBusy) return; final l10n = context.l10n; final messenger = ScaffoldMessenger.of(context); BackupBundle? bundle; try { final picked = await FilePicker.pickFile( type: FileType.custom, allowedExtensions: ['json', BackupService.fileExtension], ); 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!); } await ref .read(downloadHistoryProvider.notifier) .restoreFromBackupStream(bundle.streamHistory()); 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 _confirmRestore(BackupBundle bundle) { final l10n = context.l10n; return showDialog( 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: [ AppSliverHeader.page(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)), ], ), ); } }