From 889f07cd1daad38a62db4ed597f0d1b0d8c0cd9b Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 22 Jul 2026 13:54:35 +0200 Subject: [PATCH] feat(profile): add local backup export/import UI (#84 F1/F2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F1: "Sauvegarde locale" panel in Profil (both signed-out/signed-in states, positioned per UX cadrage), "Exporter mes données" calling DataExportUseCase.exportAll() then handing the file to the system share sheet, with the exact UX success/error snackbars. - F2: "Importer des données" picks a file, previews it, then confirms with the exact UX dialogs: simplified single-choice when there is no local data yet, Fusionner/Remplacer tout (with a destructive second confirmation) otherwise. All typed LocalBackupValidationError cases are mapped to their dedicated dialog copy, including the active workout session block. --- lib/presentation/home_screen.dart | 2 + lib/presentation/profile_screen.dart | 453 ++++++++++++++++++++++++++- 2 files changed, 454 insertions(+), 1 deletion(-) diff --git a/lib/presentation/home_screen.dart b/lib/presentation/home_screen.dart index 715f2e0..6a54c27 100644 --- a/lib/presentation/home_screen.dart +++ b/lib/presentation/home_screen.dart @@ -227,6 +227,8 @@ final class _HomeScreenState extends State with RouteAware { authUseCases: widget.bootstrap.authUseCases, syncUseCases: widget.bootstrap.syncUseCases, shareUseCases: widget.bootstrap.shareUseCases, + dataExportUseCase: widget.bootstrap.dataExportUseCase, + dataImportUseCase: widget.bootstrap.dataImportUseCase, ), ), ), diff --git a/lib/presentation/profile_screen.dart b/lib/presentation/profile_screen.dart index 9c3ddc3..5774707 100644 --- a/lib/presentation/profile_screen.dart +++ b/lib/presentation/profile_screen.dart @@ -1,6 +1,9 @@ import 'dart:async'; +import 'dart:typed_data'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:share_plus/share_plus.dart'; import '../application/application.dart'; import '../domain/domain.dart'; @@ -12,12 +15,20 @@ final class ProfileScreen extends StatefulWidget { required this.authUseCases, required this.syncUseCases, required this.shareUseCases, + required this.dataExportUseCase, + required this.dataImportUseCase, + this.backupFileExporter = const SharePlusLocalBackupFileExporter(), + this.backupFilePicker = const SystemLocalBackupFilePicker(), super.key, }); final AuthUseCases authUseCases; final SyncUseCases syncUseCases; final ShareUseCases shareUseCases; + final DataExportUseCase dataExportUseCase; + final DataImportUseCase dataImportUseCase; + final LocalBackupFileExporter backupFileExporter; + final LocalBackupFilePicker backupFilePicker; @override State createState() => _ProfileScreenState(); @@ -47,6 +58,10 @@ final class _ProfileScreenState extends State { return _SignedOutProfile( onRegister: () => _openRegister(context), onLogin: () => _openLogin(context), + dataExportUseCase: widget.dataExportUseCase, + dataImportUseCase: widget.dataImportUseCase, + backupFileExporter: widget.backupFileExporter, + backupFilePicker: widget.backupFilePicker, ); } return _SignedInProfile( @@ -54,6 +69,10 @@ final class _ProfileScreenState extends State { syncUseCases: widget.syncUseCases, onLogout: () => _confirmLogout(context), onShares: () => _openReceivedShares(context), + dataExportUseCase: widget.dataExportUseCase, + dataImportUseCase: widget.dataImportUseCase, + backupFileExporter: widget.backupFileExporter, + backupFilePicker: widget.backupFilePicker, ); }, ), @@ -144,10 +163,21 @@ final class _ProfileScreenState extends State { } final class _SignedOutProfile extends StatelessWidget { - const _SignedOutProfile({required this.onRegister, required this.onLogin}); + const _SignedOutProfile({ + required this.onRegister, + required this.onLogin, + required this.dataExportUseCase, + required this.dataImportUseCase, + required this.backupFileExporter, + required this.backupFilePicker, + }); final VoidCallback onRegister; final VoidCallback onLogin; + final DataExportUseCase dataExportUseCase; + final DataImportUseCase dataImportUseCase; + final LocalBackupFileExporter backupFileExporter; + final LocalBackupFilePicker backupFilePicker; @override Widget build(BuildContext context) { @@ -199,6 +229,14 @@ final class _SignedOutProfile extends StatelessWidget { ), ), const SizedBox(height: 16), + LocalBackupPanel( + dataExportUseCase: dataExportUseCase, + dataImportUseCase: dataImportUseCase, + isAccountConnected: false, + fileExporter: backupFileExporter, + filePicker: backupFilePicker, + ), + const SizedBox(height: 16), CourtBlazerAccentPanel( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -223,12 +261,20 @@ final class _SignedInProfile extends StatelessWidget { required this.syncUseCases, required this.onLogout, required this.onShares, + required this.dataExportUseCase, + required this.dataImportUseCase, + required this.backupFileExporter, + required this.backupFilePicker, }); final UserAccountSession session; final SyncUseCases syncUseCases; final VoidCallback onLogout; final VoidCallback onShares; + final DataExportUseCase dataExportUseCase; + final DataImportUseCase dataImportUseCase; + final LocalBackupFileExporter backupFileExporter; + final LocalBackupFilePicker backupFilePicker; @override Widget build(BuildContext context) { @@ -268,6 +314,14 @@ final class _SignedInProfile extends StatelessWidget { const SizedBox(height: 16), SyncStatusPanel(syncUseCases: syncUseCases), const SizedBox(height: 12), + LocalBackupPanel( + dataExportUseCase: dataExportUseCase, + dataImportUseCase: dataImportUseCase, + isAccountConnected: true, + fileExporter: backupFileExporter, + filePicker: backupFilePicker, + ), + const SizedBox(height: 12), CourtBlazerAccentPanel( child: Material( type: MaterialType.transparency, @@ -420,6 +474,403 @@ _SyncStatusView _syncStatusView(SyncMetadataSnapshot? metadata, bool syncing) { ); } +abstract interface class LocalBackupFileExporter { + Future exportFile({ + required String fileName, + required Uint8List bytes, + }); +} + +final class SharePlusLocalBackupFileExporter implements LocalBackupFileExporter { + const SharePlusLocalBackupFileExporter(); + + @override + Future exportFile({ + required String fileName, + required Uint8List bytes, + }) async { + final result = await SharePlus.instance.share( + ShareParams( + files: [ + XFile.fromData( + bytes, + name: fileName, + mimeType: 'application/octet-stream', + ), + ], + ), + ); + return result.status; + } +} + +final class LocalBackupPickedFile { + const LocalBackupPickedFile({required this.fileName, required this.bytes}); + + final String fileName; + final Uint8List bytes; +} + +abstract interface class LocalBackupFilePicker { + Future pickFile(); +} + +final class SystemLocalBackupFilePicker implements LocalBackupFilePicker { + const SystemLocalBackupFilePicker(); + + @override + Future pickFile() async { + final result = await FilePicker.pickFiles(withData: true); + if (result == null || result.files.isEmpty) { + return null; + } + final file = result.files.single; + final bytes = file.bytes; + if (bytes == null) { + return null; + } + return LocalBackupPickedFile(fileName: file.name, bytes: bytes); + } +} + +enum _ImportChoice { cancel, merge, replaceAll } + +final class LocalBackupPanel extends StatefulWidget { + const LocalBackupPanel({ + required this.dataExportUseCase, + required this.dataImportUseCase, + required this.isAccountConnected, + this.fileExporter = const SharePlusLocalBackupFileExporter(), + this.filePicker = const SystemLocalBackupFilePicker(), + super.key, + }); + + final DataExportUseCase dataExportUseCase; + final DataImportUseCase dataImportUseCase; + final bool isAccountConnected; + final LocalBackupFileExporter fileExporter; + final LocalBackupFilePicker filePicker; + + @override + State createState() => _LocalBackupPanelState(); +} + +final class _LocalBackupPanelState extends State { + var _exporting = false; + var _importBusy = false; + + @override + Widget build(BuildContext context) { + return CourtBlazerAccentPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sauvegarde locale', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + const Text( + 'Exporte un fichier de sauvegarde ou importe une sauvegarde ' + 'GameTime depuis cet appareil.', + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _exporting ? null : _export, + icon: const Icon(Icons.ios_share), + label: Text( + _exporting + ? 'Préparation de l’export...' + : 'Exporter mes données', + ), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: _importBusy ? null : _startImport, + icon: const Icon(Icons.file_download_outlined), + label: Text( + _importBusy + ? 'Lecture de la sauvegarde...' + : 'Importer des données', + ), + ), + ], + ), + ); + } + + Future _export() async { + setState(() => _exporting = true); + try { + final document = await widget.dataExportUseCase.exportAll(); + final status = await widget.fileExporter.exportFile( + fileName: document.fileName, + bytes: document.bytes, + ); + if (!mounted) return; + final message = switch (status) { + ShareResultStatus.success => 'Sauvegarde exportée.', + ShareResultStatus.dismissed => null, + ShareResultStatus.unavailable => + 'Export prêt. Choisis où enregistrer le fichier.', + }; + if (message != null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + } on Object { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Export impossible pour le moment.')), + ); + } finally { + if (mounted) setState(() => _exporting = false); + } + } + + Future _startImport() async { + final picked = await widget.filePicker.pickFile(); + if (picked == null || !mounted) return; + + setState(() => _importBusy = true); + final LocalBackupPreview preview; + try { + preview = await widget.dataImportUseCase.preview(picked.bytes); + } on LocalBackupException catch (error) { + if (mounted) setState(() => _importBusy = false); + if (!mounted) return; + await _showBackupErrorDialog(error.error); + return; + } + final hasLocalData = await widget.dataImportUseCase.hasAnyLocalData(); + if (mounted) setState(() => _importBusy = false); + if (!mounted) return; + + final mode = hasLocalData + ? await _resolveModeWithChoice(preview) + : await _confirmSimpleImport(preview); + if (mode == null || !mounted) return; + + setState(() => _importBusy = true); + try { + await widget.dataImportUseCase.importFrom(picked.bytes, mode: mode); + if (!mounted) return; + final message = mode == LocalBackupImportMode.replaceAll + ? 'Sauvegarde restaurée.' + : 'Données importées.'; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } on LocalBackupException catch (error) { + if (!mounted) return; + await _showBackupErrorDialog(error.error); + } finally { + if (mounted) setState(() => _importBusy = false); + } + } + + Future _resolveModeWithChoice( + LocalBackupPreview preview, + ) async { + final choice = await _confirmFullImport(preview); + if (choice == _ImportChoice.merge) { + return LocalBackupImportMode.merge; + } + if (choice == _ImportChoice.replaceAll) { + if (!mounted) return null; + final confirmedReplace = await _confirmReplaceAll(); + return confirmedReplace ? LocalBackupImportMode.replaceAll : null; + } + return null; + } + + Future _confirmSimpleImport( + LocalBackupPreview preview, + ) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Importer cette sauvegarde ?'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: _previewSummaryLines(preview, showChoiceHint: false), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Annuler'), + ), + FilledButton( + onPressed: () => + Navigator.of(context).pop(LocalBackupImportMode.merge), + child: const Text('Importer'), + ), + ], + ), + ); + } + + Future<_ImportChoice?> _confirmFullImport(LocalBackupPreview preview) { + return showDialog<_ImportChoice>( + context: context, + builder: (context) => AlertDialog( + title: const Text('Importer cette sauvegarde ?'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ..._previewSummaryLines(preview, showChoiceHint: true), + const SizedBox(height: 12), + const Text( + 'Fusionner ajoute ce qui manque et conserve tes données ' + 'actuelles.', + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(_ImportChoice.cancel), + child: const Text('Annuler'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(_ImportChoice.merge), + child: const Text('Fusionner'), + ), + TextButton( + onPressed: () => + Navigator.of(context).pop(_ImportChoice.replaceAll), + child: const Text('Remplacer tout'), + ), + ], + ), + ); + } + + Future _confirmReplaceAll() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Remplacer toutes les données locales ?'), + content: const Text( + 'Tes exercices, programmes, séances et historiques actuels seront ' + 'remplacés par ceux de cette sauvegarde. Cette action ne peut pas ' + 'être annulée.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Annuler'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Remplacer tout'), + ), + ], + ), + ); + return confirmed == true; + } + + List _previewSummaryLines( + LocalBackupPreview preview, { + required bool showChoiceHint, + }) { + final counts = preview.counts; + final lines = [ + const Text('Cette sauvegarde contient :'), + Text('${counts.exercises} exercices'), + Text('${counts.programs} programmes'), + Text('${counts.workoutTemplates} séances'), + Text('${counts.workoutHistories} historiques'), + ]; + if (preview.hasEmbeddedMedia) { + lines.add(const SizedBox(height: 8)); + lines.add(const Text('Médias inclus')); + } + if (preview.missingMediaCount > 0) { + lines.add(const SizedBox(height: 8)); + lines.add(const Text('Les médias absents seront ignorés.')); + } + if (preview.hasNameDuplicates) { + lines.add(const SizedBox(height: 8)); + lines.add( + const Text( + 'Des éléments portent déjà le même nom. En fusion, ils seront ' + 'conservés séparément si GameTime ne peut pas reconnaître qu’il ' + 's’agit du même élément.', + ), + ); + } + if (showChoiceHint) { + lines.add(const SizedBox(height: 12)); + lines.add(const Text('Choisis comment l’ajouter à tes données locales.')); + } + if (widget.isAccountConnected) { + lines.add(const SizedBox(height: 8)); + lines.add( + const Text( + 'Les données importées resteront locales et seront ' + 'synchronisées selon tes réglages habituels.', + ), + ); + } + return lines; + } + + Future _showBackupErrorDialog(LocalBackupValidationError error) { + final (title, message) = _backupErrorText(error); + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } +} + +(String, String) _backupErrorText(LocalBackupValidationError error) { + return switch (error) { + LocalBackupValidationError.invalidFile => ( + 'Fichier invalide', + 'Ce fichier n’est pas une sauvegarde GameTime valide.', + ), + LocalBackupValidationError.incompatibleFormat => ( + 'Format incompatible', + 'Cette sauvegarde ne peut pas être importée par cette version de ' + 'GameTime.', + ), + LocalBackupValidationError.newerVersion => ( + 'Mets GameTime à jour', + 'Cette sauvegarde vient d’une version plus récente de GameTime.', + ), + LocalBackupValidationError.corrupted => ( + 'Sauvegarde illisible', + 'Le fichier semble incomplet ou endommagé.', + ), + LocalBackupValidationError.activeWorkoutInProgress => ( + 'Séance en cours', + 'Termine ou sauvegarde ta séance avant d’importer des données.', + ), + LocalBackupValidationError.importFailedNoMutation => ( + 'Import impossible', + 'Aucune donnée n’a été modifiée.', + ), + }; +} + final class LoginScreen extends StatefulWidget { const LoginScreen({ required this.authUseCases,