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:
@ -227,6 +227,8 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
|
||||
authUseCases: widget.bootstrap.authUseCases,
|
||||
syncUseCases: widget.bootstrap.syncUseCases,
|
||||
shareUseCases: widget.bootstrap.shareUseCases,
|
||||
dataExportUseCase: widget.bootstrap.dataExportUseCase,
|
||||
dataImportUseCase: widget.bootstrap.dataImportUseCase,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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<ProfileScreen> createState() => _ProfileScreenState();
|
||||
@ -47,6 +58,10 @@ final class _ProfileScreenState extends State<ProfileScreen> {
|
||||
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<ProfileScreen> {
|
||||
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<ProfileScreen> {
|
||||
}
|
||||
|
||||
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<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 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<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 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<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 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,
|
||||
|
||||
Reference in New Issue
Block a user