feat(profile): add local backup export/import UI (#84 F1/F2)

- 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.
This commit is contained in:
2026-07-22 13:54:35 +02:00
parent f7d32a34c2
commit 889f07cd1d
2 changed files with 454 additions and 1 deletions

View File

@ -227,6 +227,8 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
authUseCases: widget.bootstrap.authUseCases, authUseCases: widget.bootstrap.authUseCases,
syncUseCases: widget.bootstrap.syncUseCases, syncUseCases: widget.bootstrap.syncUseCases,
shareUseCases: widget.bootstrap.shareUseCases, shareUseCases: widget.bootstrap.shareUseCases,
dataExportUseCase: widget.bootstrap.dataExportUseCase,
dataImportUseCase: widget.bootstrap.dataImportUseCase,
), ),
), ),
), ),

View File

@ -1,6 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:share_plus/share_plus.dart';
import '../application/application.dart'; import '../application/application.dart';
import '../domain/domain.dart'; import '../domain/domain.dart';
@ -12,12 +15,20 @@ final class ProfileScreen extends StatefulWidget {
required this.authUseCases, required this.authUseCases,
required this.syncUseCases, required this.syncUseCases,
required this.shareUseCases, required this.shareUseCases,
required this.dataExportUseCase,
required this.dataImportUseCase,
this.backupFileExporter = const SharePlusLocalBackupFileExporter(),
this.backupFilePicker = const SystemLocalBackupFilePicker(),
super.key, super.key,
}); });
final AuthUseCases authUseCases; final AuthUseCases authUseCases;
final SyncUseCases syncUseCases; final SyncUseCases syncUseCases;
final ShareUseCases shareUseCases; final ShareUseCases shareUseCases;
final DataExportUseCase dataExportUseCase;
final DataImportUseCase dataImportUseCase;
final LocalBackupFileExporter backupFileExporter;
final LocalBackupFilePicker backupFilePicker;
@override @override
State<ProfileScreen> createState() => _ProfileScreenState(); State<ProfileScreen> createState() => _ProfileScreenState();
@ -47,6 +58,10 @@ final class _ProfileScreenState extends State<ProfileScreen> {
return _SignedOutProfile( return _SignedOutProfile(
onRegister: () => _openRegister(context), onRegister: () => _openRegister(context),
onLogin: () => _openLogin(context), onLogin: () => _openLogin(context),
dataExportUseCase: widget.dataExportUseCase,
dataImportUseCase: widget.dataImportUseCase,
backupFileExporter: widget.backupFileExporter,
backupFilePicker: widget.backupFilePicker,
); );
} }
return _SignedInProfile( return _SignedInProfile(
@ -54,6 +69,10 @@ final class _ProfileScreenState extends State<ProfileScreen> {
syncUseCases: widget.syncUseCases, syncUseCases: widget.syncUseCases,
onLogout: () => _confirmLogout(context), onLogout: () => _confirmLogout(context),
onShares: () => _openReceivedShares(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<ProfileScreen> {
} }
final class _SignedOutProfile extends StatelessWidget { 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 onRegister;
final VoidCallback onLogin; final VoidCallback onLogin;
final DataExportUseCase dataExportUseCase;
final DataImportUseCase dataImportUseCase;
final LocalBackupFileExporter backupFileExporter;
final LocalBackupFilePicker backupFilePicker;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -199,6 +229,14 @@ final class _SignedOutProfile extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
LocalBackupPanel(
dataExportUseCase: dataExportUseCase,
dataImportUseCase: dataImportUseCase,
isAccountConnected: false,
fileExporter: backupFileExporter,
filePicker: backupFilePicker,
),
const SizedBox(height: 16),
CourtBlazerAccentPanel( CourtBlazerAccentPanel(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -223,12 +261,20 @@ final class _SignedInProfile extends StatelessWidget {
required this.syncUseCases, required this.syncUseCases,
required this.onLogout, required this.onLogout,
required this.onShares, required this.onShares,
required this.dataExportUseCase,
required this.dataImportUseCase,
required this.backupFileExporter,
required this.backupFilePicker,
}); });
final UserAccountSession session; final UserAccountSession session;
final SyncUseCases syncUseCases; final SyncUseCases syncUseCases;
final VoidCallback onLogout; final VoidCallback onLogout;
final VoidCallback onShares; final VoidCallback onShares;
final DataExportUseCase dataExportUseCase;
final DataImportUseCase dataImportUseCase;
final LocalBackupFileExporter backupFileExporter;
final LocalBackupFilePicker backupFilePicker;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -268,6 +314,14 @@ final class _SignedInProfile extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
SyncStatusPanel(syncUseCases: syncUseCases), SyncStatusPanel(syncUseCases: syncUseCases),
const SizedBox(height: 12), const SizedBox(height: 12),
LocalBackupPanel(
dataExportUseCase: dataExportUseCase,
dataImportUseCase: dataImportUseCase,
isAccountConnected: true,
fileExporter: backupFileExporter,
filePicker: backupFilePicker,
),
const SizedBox(height: 12),
CourtBlazerAccentPanel( CourtBlazerAccentPanel(
child: Material( child: Material(
type: MaterialType.transparency, type: MaterialType.transparency,
@ -420,6 +474,403 @@ _SyncStatusView _syncStatusView(SyncMetadataSnapshot? metadata, bool syncing) {
); );
} }
abstract interface class LocalBackupFileExporter {
Future<ShareResultStatus> exportFile({
required String fileName,
required Uint8List bytes,
});
}
final class SharePlusLocalBackupFileExporter implements LocalBackupFileExporter {
const SharePlusLocalBackupFileExporter();
@override
Future<ShareResultStatus> 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<LocalBackupPickedFile?> pickFile();
}
final class SystemLocalBackupFilePicker implements LocalBackupFilePicker {
const SystemLocalBackupFilePicker();
@override
Future<LocalBackupPickedFile?> 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<LocalBackupPanel> createState() => _LocalBackupPanelState();
}
final class _LocalBackupPanelState extends State<LocalBackupPanel> {
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 lexport...'
: '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<void> _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<void> _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<LocalBackupImportMode?> _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<LocalBackupImportMode?> _confirmSimpleImport(
LocalBackupPreview preview,
) {
return showDialog<LocalBackupImportMode>(
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<bool> _confirmReplaceAll() async {
final confirmed = await showDialog<bool>(
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<Widget> _previewSummaryLines(
LocalBackupPreview preview, {
required bool showChoiceHint,
}) {
final counts = preview.counts;
final lines = <Widget>[
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 quil '
'sagit du même élément.',
),
);
}
if (showChoiceHint) {
lines.add(const SizedBox(height: 12));
lines.add(const Text('Choisis comment lajouter à 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<void> _showBackupErrorDialog(LocalBackupValidationError error) {
final (title, message) = _backupErrorText(error);
return showDialog<void>(
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 nest 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 dune 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 dimporter des données.',
),
LocalBackupValidationError.importFailedNoMutation => (
'Import impossible',
'Aucune donnée na été modifiée.',
),
};
}
final class LoginScreen extends StatefulWidget { final class LoginScreen extends StatefulWidget {
const LoginScreen({ const LoginScreen({
required this.authUseCases, required this.authUseCases,