chore(tickets): met à jour le carnet du ticket #84 (rapport backend B1/B2/B3)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -1,6 +1,791 @@
|
||||
---
|
||||
issueRef: "#84"
|
||||
version: 1
|
||||
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||
updatedAt: 1784650724409
|
||||
version: 4
|
||||
updatedBy: {"kind":"agent","role":"DevBackend"}
|
||||
updatedAt: 1784720508935
|
||||
---
|
||||
# UX — Export/import local des données
|
||||
|
||||
## Contexte lu
|
||||
|
||||
Ticket #84 : GameTime est offline-first ; un utilisateur qui n’active jamais le compte/sync doit pouvoir sauvegarder et restaurer ses données localement via un fichier.
|
||||
|
||||
Mémos pris en compte :
|
||||
|
||||
- `gametime-online-layer-philosophy` : le compte est optionnel, aucune action locale ne dépend du serveur, les données locales restent la base fiable de l’app.
|
||||
- `gametime-ux-online-client` : les fonctions compte/sync/partage vivent dans `Profil`, avec des messages neutres et non intrusifs.
|
||||
- `gametime-product-scope` : données à préserver : exercices, programmes, séances-modèles, historique, et idéalement médias locaux liés aux exercices.
|
||||
- `gametime-visual-identity` : panneaux Court Blazer plats, libellés directs, pas de wizard lourd.
|
||||
|
||||
État réel observé :
|
||||
|
||||
- écran existant : `lib/presentation/profile_screen.dart` ;
|
||||
- `ProfileScreen` affiche déjà `Profil` ;
|
||||
- état déconnecté : panneaux `Compte optionnel`, `Données locales`, `Partages` ;
|
||||
- état connecté : carte profil, `Se déconnecter`, `Synchronisation`, `Partages reçus`.
|
||||
|
||||
## Décision générale
|
||||
|
||||
Ajouter une section **Sauvegarde locale** dans l’écran `Profil`, visible dans les deux états connecté/déconnecté.
|
||||
|
||||
Ne pas créer d’entrée d’accueil dédiée, ni écran de réglages séparé au MVP. L’export/import est une fonction de gestion du profil/appareil, pas un parcours quotidien.
|
||||
|
||||
## Placement dans Profil
|
||||
|
||||
### État déconnecté
|
||||
|
||||
Ordre cible :
|
||||
|
||||
```text
|
||||
Compte optionnel
|
||||
Données locales
|
||||
Sauvegarde locale
|
||||
Partages
|
||||
```
|
||||
|
||||
La section `Sauvegarde locale` vient juste après `Données locales`, car elle prolonge directement le message : les données sont sur cet appareil et peuvent être sauvegardées sans compte.
|
||||
|
||||
### État connecté
|
||||
|
||||
Ordre cible :
|
||||
|
||||
```text
|
||||
Carte profil
|
||||
Se déconnecter
|
||||
Synchronisation
|
||||
Sauvegarde locale
|
||||
Partages reçus
|
||||
```
|
||||
|
||||
Raison : la sync reste visible pour les comptes connectés, mais l’export local doit rester accessible comme alternative indépendante.
|
||||
|
||||
## Section `Sauvegarde locale`
|
||||
|
||||
Panneau Court Blazer, même composant que les sections Profil actuelles.
|
||||
|
||||
Contenu :
|
||||
|
||||
```text
|
||||
Sauvegarde locale
|
||||
Exporte un fichier de sauvegarde ou importe une sauvegarde GameTime depuis cet appareil.
|
||||
|
||||
[Exporter mes données]
|
||||
[Importer des données]
|
||||
```
|
||||
|
||||
Boutons :
|
||||
|
||||
- `Exporter mes données` : bouton rempli ou `OutlinedButton.icon` selon densité de l’écran ; icône recommandée `Icons.file_upload_outlined` ou `Icons.ios_share` si l’export passe par la feuille système.
|
||||
- `Importer des données` : bouton secondaire/outlined ; icône `Icons.file_download_outlined`.
|
||||
|
||||
Important : ne pas présenter l’export local comme inférieur à la sync. Texte neutre, pas de culpabilisation du mode sans compte.
|
||||
|
||||
## Export
|
||||
|
||||
### Déclenchement
|
||||
|
||||
Tap sur :
|
||||
|
||||
```text
|
||||
Exporter mes données
|
||||
```
|
||||
|
||||
État pendant préparation :
|
||||
|
||||
```text
|
||||
Préparation de l’export...
|
||||
```
|
||||
|
||||
Le bouton est désactivé pendant la génération du fichier.
|
||||
|
||||
### Résultat attendu
|
||||
|
||||
Une fois le fichier créé, ouvrir le sélecteur système de partage/enregistrement.
|
||||
|
||||
Nom de fichier recommandé côté UX :
|
||||
|
||||
```text
|
||||
gametime-sauvegarde-YYYY-MM-DD.gametime
|
||||
```
|
||||
|
||||
Le format exact est à cadrer par Architect ; l’extension peut changer si nécessaire, mais le nom doit rester reconnaissable pour l’utilisateur.
|
||||
|
||||
### Message après succès
|
||||
|
||||
Si le système confirme que le fichier a été remis au sélecteur :
|
||||
|
||||
```text
|
||||
Export prêt. Choisis où enregistrer le fichier.
|
||||
```
|
||||
|
||||
Si l’API permet de savoir que le fichier a été enregistré/partagé :
|
||||
|
||||
```text
|
||||
Sauvegarde exportée.
|
||||
```
|
||||
|
||||
Si l’utilisateur annule la feuille système : pas de message d’erreur. Éventuellement :
|
||||
|
||||
```text
|
||||
Export annulé.
|
||||
```
|
||||
|
||||
mais ce n’est pas nécessaire.
|
||||
|
||||
### Erreur export
|
||||
|
||||
Snackbar :
|
||||
|
||||
```text
|
||||
Export impossible pour le moment.
|
||||
```
|
||||
|
||||
Si l’erreur vient d’un manque d’espace disque et que l’app peut le savoir :
|
||||
|
||||
```text
|
||||
Espace insuffisant pour créer la sauvegarde.
|
||||
```
|
||||
|
||||
Ne pas afficher de stack trace ni de message technique.
|
||||
|
||||
## Import
|
||||
|
||||
### Déclenchement
|
||||
|
||||
Tap sur :
|
||||
|
||||
```text
|
||||
Importer des données
|
||||
```
|
||||
|
||||
Ouvrir le sélecteur système de fichier.
|
||||
|
||||
Si l’utilisateur annule la sélection : retour silencieux au profil, pas de snackbar.
|
||||
|
||||
### Validation du fichier
|
||||
|
||||
Après sélection, l’app valide le fichier avant de proposer l’import.
|
||||
|
||||
États pendant validation :
|
||||
|
||||
```text
|
||||
Lecture de la sauvegarde...
|
||||
```
|
||||
|
||||
Si le fichier est valide, afficher une confirmation. Pas d’import silencieux.
|
||||
|
||||
### Confirmation d’import
|
||||
|
||||
Dialog ou bottom sheet courte. Recommandation : dialog si deux actions destructives/fortes doivent être comparées ; bottom sheet acceptable si le contenu tient sans scroll excessif.
|
||||
|
||||
Titre :
|
||||
|
||||
```text
|
||||
Importer cette sauvegarde ?
|
||||
```
|
||||
|
||||
Contenu :
|
||||
|
||||
```text
|
||||
Cette sauvegarde contient :
|
||||
12 exercices
|
||||
4 programmes
|
||||
3 séances
|
||||
18 historiques
|
||||
|
||||
Choisis comment l’ajouter à tes données locales.
|
||||
```
|
||||
|
||||
Si la sauvegarde contient des médias exportés :
|
||||
|
||||
```text
|
||||
Médias inclus
|
||||
```
|
||||
|
||||
Si les médias ne sont pas inclus ou partiellement indisponibles :
|
||||
|
||||
```text
|
||||
Les médias absents seront ignorés.
|
||||
```
|
||||
|
||||
Actions :
|
||||
|
||||
```text
|
||||
[Annuler]
|
||||
[Fusionner]
|
||||
[Remplacer tout]
|
||||
```
|
||||
|
||||
### Choix recommandé : Fusionner
|
||||
|
||||
`Fusionner` est l’action recommandée et la moins risquée.
|
||||
|
||||
Texte d’aide sous le choix ou dans le dialog :
|
||||
|
||||
```text
|
||||
Fusionner ajoute ce qui manque et conserve tes données actuelles.
|
||||
```
|
||||
|
||||
Comportement UX attendu :
|
||||
|
||||
- les données locales existantes restent disponibles ;
|
||||
- les éléments reconnus comme identiques sont mis à jour selon la règle définie par Architect ;
|
||||
- les éléments nouveaux sont ajoutés ;
|
||||
- les doublons de nom non reconnus comme identiques ne bloquent pas l’import.
|
||||
|
||||
Si des doublons de nom sont détectés avant import :
|
||||
|
||||
```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.
|
||||
```
|
||||
|
||||
Pas de résolution item par item au MVP.
|
||||
|
||||
Après fusion réussie :
|
||||
|
||||
```text
|
||||
Données importées.
|
||||
```
|
||||
|
||||
Puis rester sur `Profil`. Ne pas naviguer automatiquement vers une liste.
|
||||
|
||||
### Choix destructif : Remplacer tout
|
||||
|
||||
`Remplacer tout` restaure le fichier comme source principale. C’est une action destructive.
|
||||
|
||||
Premier dialog : action visible mais destructive.
|
||||
|
||||
Si l’utilisateur tape `Remplacer tout`, afficher une seconde confirmation courte :
|
||||
|
||||
Titre :
|
||||
|
||||
```text
|
||||
Remplacer toutes les données locales ?
|
||||
```
|
||||
|
||||
Contenu :
|
||||
|
||||
```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 :
|
||||
|
||||
```text
|
||||
[Annuler]
|
||||
[Remplacer tout]
|
||||
```
|
||||
|
||||
Après succès :
|
||||
|
||||
```text
|
||||
Sauvegarde restaurée.
|
||||
```
|
||||
|
||||
Puis rester sur `Profil` et laisser l’utilisateur retourner vers les listes.
|
||||
|
||||
### Cas où l’app locale est vide
|
||||
|
||||
Si aucune donnée utilisateur locale n’existe, simplifier la confirmation :
|
||||
|
||||
```text
|
||||
Importer cette sauvegarde ?
|
||||
Cette sauvegarde contient :
|
||||
...
|
||||
|
||||
[Annuler]
|
||||
[Importer]
|
||||
```
|
||||
|
||||
Message succès :
|
||||
|
||||
```text
|
||||
Données importées.
|
||||
```
|
||||
|
||||
## États d’erreur import
|
||||
|
||||
### Fichier invalide
|
||||
|
||||
```text
|
||||
Fichier invalide
|
||||
Ce fichier n’est pas une sauvegarde GameTime valide.
|
||||
|
||||
[OK]
|
||||
```
|
||||
|
||||
### Format incompatible
|
||||
|
||||
```text
|
||||
Format incompatible
|
||||
Cette sauvegarde ne peut pas être importée par cette version de GameTime.
|
||||
|
||||
[OK]
|
||||
```
|
||||
|
||||
### Sauvegarde créée par une version plus récente
|
||||
|
||||
```text
|
||||
Mets GameTime à jour
|
||||
Cette sauvegarde vient d’une version plus récente de GameTime.
|
||||
|
||||
[OK]
|
||||
```
|
||||
|
||||
### Fichier incomplet ou corrompu
|
||||
|
||||
```text
|
||||
Sauvegarde illisible
|
||||
Le fichier semble incomplet ou endommagé.
|
||||
|
||||
[OK]
|
||||
```
|
||||
|
||||
### Import échoué après confirmation
|
||||
|
||||
Si l’architecture garantit une transaction tout-ou-rien, message :
|
||||
|
||||
```text
|
||||
Import impossible
|
||||
Aucune donnée n’a été modifiée.
|
||||
|
||||
[OK]
|
||||
```
|
||||
|
||||
Si l’architecture ne peut pas garantir le tout-ou-rien, Architect doit le signaler avant implémentation UX ; le MVP ne doit pas laisser l’utilisateur dans un état partiellement importé sans message clair.
|
||||
|
||||
## États particuliers
|
||||
|
||||
### Import pendant une séance en cours
|
||||
|
||||
Si une séance active est ouverte/sauvegardée pour reprise, l’import doit être bloqué ou demandé explicitement selon décision Architect. Recommandation UX MVP : bloquer l’import tant qu’une séance est en cours.
|
||||
|
||||
Message :
|
||||
|
||||
```text
|
||||
Séance en cours
|
||||
Termine ou sauvegarde ta séance avant d’importer des données.
|
||||
|
||||
[OK]
|
||||
```
|
||||
|
||||
Raison : remplacer/fusionner pendant une séance active peut rendre l’état d’exécution difficile à comprendre.
|
||||
|
||||
### Compte connecté
|
||||
|
||||
L’import/export local reste disponible connecté.
|
||||
|
||||
Si l’import modifie des données qui seront ensuite synchronisées, afficher dans la confirmation :
|
||||
|
||||
```text
|
||||
Les données importées resteront locales et seront synchronisées selon tes réglages habituels.
|
||||
```
|
||||
|
||||
Ne pas lancer de popup réseau. Si une sync est nécessaire, elle se fera en arrière-plan selon la logique existante.
|
||||
|
||||
### Hors ligne
|
||||
|
||||
Aucun état spécial. L’export/import local doit fonctionner hors ligne.
|
||||
|
||||
## Libellés à utiliser
|
||||
|
||||
- `Sauvegarde locale`
|
||||
- `Exporter mes données`
|
||||
- `Importer des données`
|
||||
- `Préparation de l’export...`
|
||||
- `Lecture de la sauvegarde...`
|
||||
- `Importer cette sauvegarde ?`
|
||||
- `Fusionner`
|
||||
- `Remplacer tout`
|
||||
- `Données importées.`
|
||||
- `Sauvegarde restaurée.`
|
||||
- `Export impossible pour le moment.`
|
||||
- `Ce fichier n’est pas une sauvegarde GameTime valide.`
|
||||
|
||||
## Libellés à éviter
|
||||
|
||||
- `Dump`
|
||||
- `Backup DB`
|
||||
- `Importer JSON`
|
||||
- `Migration échouée`
|
||||
- `Conflit de clé primaire`
|
||||
- `Écraser la base`
|
||||
- `Rollback`
|
||||
|
||||
## Exigences de données pour Architect
|
||||
|
||||
Architect doit cadrer avant implémentation :
|
||||
|
||||
- format d’export stable et versionné ;
|
||||
- extension/nom de fichier final ;
|
||||
- contenu exact : exercices, programmes, séances-modèles, historique, tags si présents, médias associés si possible ;
|
||||
- validation rapide du fichier avant confirmation utilisateur ;
|
||||
- résumé pré-import : nombre d’exercices, programmes, séances, historiques, médias si inclus ;
|
||||
- règle de fusion pour identifiants identiques ;
|
||||
- règle de doublons par nom quand les identifiants diffèrent ;
|
||||
- comportement connecté/sync après import ;
|
||||
- blocage ou traitement des séances actives pendant import ;
|
||||
- garantie transactionnelle tout-ou-rien, surtout pour `Remplacer tout` ;
|
||||
- compatibilité entre versions d’app : plus ancienne, actuelle, plus récente.
|
||||
|
||||
## Étape suivante
|
||||
|
||||
Architect doit cadrer le format d’export et les garanties de cohérence/conflits avant DevBackend/DevFrontend.
|
||||
|
||||
# Architect — Cadrage #84
|
||||
|
||||
## Décision format de fichier
|
||||
|
||||
Format retenu : **JSON UTF-8 structuré**, extension utilisateur `.gametime`, nom `gametime-sauvegarde-YYYY-MM-DD.gametime`.
|
||||
|
||||
Le fichier n’est pas un dump SQLite. Il est un document applicatif versionné, indépendant de Drift et stable entre migrations internes.
|
||||
|
||||
Envelope v1 :
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "gametime.localBackup",
|
||||
"formatVersion": 1,
|
||||
"minSupportedFormatVersion": 1,
|
||||
"exportedAt": "2026-07-22T10:15:00Z",
|
||||
"appSchemaVersion": 19,
|
||||
"originDeviceId": "local-device",
|
||||
"counts": {
|
||||
"exercises": 12,
|
||||
"programs": 4,
|
||||
"workoutTemplates": 3,
|
||||
"workoutHistories": 18,
|
||||
"mediaAssets": 5,
|
||||
"embeddedMediaFiles": 4
|
||||
},
|
||||
"data": {
|
||||
"mediaAssets": [],
|
||||
"exercises": [],
|
||||
"programs": [],
|
||||
"workoutTemplates": [],
|
||||
"workoutHistories": []
|
||||
},
|
||||
"mediaFiles": []
|
||||
}
|
||||
```
|
||||
|
||||
Validation de compatibilité :
|
||||
|
||||
- `kind != gametime.localBackup` -> fichier invalide.
|
||||
- JSON non parsable / racine non objet -> fichier invalide ou corrompu selon l’erreur.
|
||||
- `formatVersion < 1` ou champ absent -> format incompatible.
|
||||
- `minSupportedFormatVersion > 1` ou `formatVersion > 1` -> sauvegarde d’une version plus récente : demander mise à jour.
|
||||
- `data` absent ou une collection attendue non-liste -> sauvegarde illisible/corrompue.
|
||||
|
||||
## Entités incluses
|
||||
|
||||
Inclure uniquement les données métier locales nécessaires à une restauration utilisateur :
|
||||
|
||||
- `MediaAsset` metadata, parce que les exercices/programmes peuvent référencer des médias.
|
||||
- `Exercise`, avec images multiples, vidéo, steps, catégorie, tags, archivedAt.
|
||||
- `Program`, avec tous ses `ProgramExercise` snapshots : mesures, cibles, repos, steps snapshotés, réglage `autoStartNextTimedStep`.
|
||||
- `WorkoutTemplate`, avec `WorkoutTemplateProgram` snapshots et `WorkoutTemplateExerciseOverride`.
|
||||
- `WorkoutHistory`, avec **résultats complets** : `WorkoutHistorySetResult` et `WorkoutHistoryStepResult`.
|
||||
|
||||
Ne pas inclure au MVP :
|
||||
|
||||
- sessions actives / reprise (`ActiveWorkoutSession`, timers, rest states, step progress) ; import bloqué s’il existe une séance ouverte.
|
||||
- compte connecté, tokens, profil online.
|
||||
- curseur sync, remote mappings, inbox de partage, pending share actions.
|
||||
- seed metadata interne.
|
||||
- change_log en tant que tel.
|
||||
|
||||
Exporter les entités actives non supprimées (`deletedAt == null`) via les repositories `listActive()`. Les tombstones internes de sync ne font pas partie d’une sauvegarde utilisateur.
|
||||
|
||||
## Réutilisation des payloads sync existants
|
||||
|
||||
Réutiliser les payloads existants pour éviter deux formats concurrents quand ils sont complets :
|
||||
|
||||
- `_exercisePayload` est réutilisable : il inclut metadata, médias référencés, steps, catégorie, tags.
|
||||
- `_programPayload` est réutilisable : il inclut metadata, tags et `ProgramExercise.toSnapshotJson()`.
|
||||
- `_workoutTemplatePayload` est réutilisable pour l’en-tête + programmes + overrides.
|
||||
- `_mediaAssetPayload` est réutilisable pour les métadonnées média.
|
||||
|
||||
Ne pas réutiliser `_workoutHistoryPayload` tel quel pour le fichier local : aujourd’hui `lib/infrastructure/local/drift_repositories.dart:4156-4167` exporte seulement l’en-tête d’historique (`historySnapshotJson`, dates, total, completed), sans `results` ni `stepResults`. Or le ticket demande l’historique complet. Pour #84, créer un payload local dédié ou étendre le helper privé en ajoutant :
|
||||
|
||||
```json
|
||||
"results": [ ...WorkoutHistorySetResult... ],
|
||||
"stepResults": [ ...WorkoutHistoryStepResult... ]
|
||||
```
|
||||
|
||||
Point connexe : `lib/infrastructure/local/drift_repositories.dart:491-546` applique les payloads remote pour exercices/programmes/séances/médias, mais retourne `false` pour `workoutHistory`. L’import local ne doit donc pas dépendre du chemin `LocalSyncChangeRepository.applyRemoteItem` tant que ce cas n’est pas implémenté.
|
||||
|
||||
## Médias
|
||||
|
||||
MVP recommandé : supporter les médias quand le fichier local est lisible, mais ne pas faire échouer l’export si un fichier média manque.
|
||||
|
||||
Dans `mediaAssets`, conserver la metadata (`id`, `kind`, `localUri`, dimensions, checksum, etc.). Dans `mediaFiles`, ajouter les fichiers effectivement embarqués en base64 :
|
||||
|
||||
```json
|
||||
{
|
||||
"mediaAssetId": "...",
|
||||
"role": "original",
|
||||
"fileName": "media-asset-id.jpg",
|
||||
"mimeType": "image/jpeg",
|
||||
"sizeBytes": 123456,
|
||||
"sha256": "...",
|
||||
"base64": "..."
|
||||
}
|
||||
```
|
||||
|
||||
À l’import : si un blob existe et son checksum correspond, recréer un fichier géré localement et réécrire `MediaAsset.localUri` vers ce nouveau fichier. Si le blob manque ou est corrompu, importer les données en ignorant ce média et remonter `missingMediaCount` au résumé/résultat pour l’UI (`Les médias absents seront ignorés.`).
|
||||
|
||||
Si DevBackend juge le base64 trop coûteux pour les vidéos au MVP, alternative acceptable : limiter l’embarquement aux images et laisser les vidéos en metadata manquante. Cette limite doit être explicite dans le résultat d’export.
|
||||
|
||||
## Ports et use cases
|
||||
|
||||
La logique vit en `application`; Drift/fichiers sont des adapters. La présentation choisit un fichier et affiche les confirmations, mais ne connaît pas les règles de fusion/remplacement.
|
||||
|
||||
DTO application :
|
||||
|
||||
```dart
|
||||
enum LocalBackupImportMode { merge, replaceAll }
|
||||
|
||||
enum LocalBackupValidationError {
|
||||
invalidFile,
|
||||
incompatibleFormat,
|
||||
newerVersion,
|
||||
corrupted,
|
||||
}
|
||||
|
||||
final class LocalBackupDocument {
|
||||
const LocalBackupDocument({required this.fileName, required this.bytes});
|
||||
final String fileName;
|
||||
final Uint8List bytes;
|
||||
}
|
||||
|
||||
final class LocalBackupPreview {
|
||||
const LocalBackupPreview({
|
||||
required this.exportedAt,
|
||||
required this.counts,
|
||||
required this.hasEmbeddedMedia,
|
||||
required this.missingMediaCount,
|
||||
required this.hasNameDuplicates,
|
||||
});
|
||||
}
|
||||
|
||||
final class LocalBackupImportResult {
|
||||
const LocalBackupImportResult({
|
||||
required this.insertedCount,
|
||||
required this.updatedCount,
|
||||
required this.ignoredOlderCount,
|
||||
required this.deletedByReplaceCount,
|
||||
required this.missingMediaCount,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Ports application :
|
||||
|
||||
```dart
|
||||
abstract interface class LocalDataBackupRepository {
|
||||
Future<LocalDataExportSnapshot> readExportSnapshot();
|
||||
Future<bool> hasOpenActiveWorkoutSession();
|
||||
Future<bool> hasAnyUserData();
|
||||
Future<LocalBackupImportResult> applyImportSnapshot({
|
||||
required LocalDataExportSnapshot snapshot,
|
||||
required LocalBackupImportMode mode,
|
||||
required DateTime importedAt,
|
||||
});
|
||||
}
|
||||
|
||||
abstract interface class LocalBackupMediaStore {
|
||||
Future<List<EmbeddedBackupMediaFile>> readEmbeddableFiles(
|
||||
List<MediaAsset> assets,
|
||||
);
|
||||
Future<Map<String, RestoredMediaFile>> restoreEmbeddedFiles(
|
||||
List<EmbeddedBackupMediaFile> files,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Use cases :
|
||||
|
||||
```dart
|
||||
final class DataExportUseCase {
|
||||
Future<LocalBackupDocument> exportAll();
|
||||
}
|
||||
|
||||
final class DataImportUseCase {
|
||||
Future<LocalBackupPreview> preview(Uint8List bytes);
|
||||
Future<LocalBackupImportResult> importFrom(
|
||||
Uint8List bytes, {
|
||||
required LocalBackupImportMode mode,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Le codec JSON peut rester une classe application pure (`LocalBackupCodec`) testable sans Drift. L’adapter Drift (`DriftLocalDataBackupRepository`) est responsable des transactions et de la persistance.
|
||||
|
||||
## Stratégie Fusionner
|
||||
|
||||
Doublon métier = même type de ressource + même ID stable local. Les doublons de nom avec IDs différents ne sont **pas** fusionnés et ne bloquent pas l’import.
|
||||
|
||||
Règle MVP : LWW cohérent avec la sync serveur.
|
||||
|
||||
- Si l’ID n’existe pas localement : insérer la ressource importée.
|
||||
- Si l’ID existe localement et que `backup.metadata.updatedAt` est strictement plus récent que `local.metadata.updatedAt` : remplacer l’agrégat local par celui du fichier.
|
||||
- Si l’ID existe et que le local est plus récent ou égal : ignorer la ressource du fichier.
|
||||
|
||||
Pour les agrégats à enfants :
|
||||
|
||||
- `Program` importé/remplacé = remplacer sa composition `ProgramExercise` par celle du fichier.
|
||||
- `WorkoutTemplate` importé/remplacé = remplacer programmes intégrés + overrides par ceux du fichier.
|
||||
- `WorkoutHistory` importé/remplacé = remplacer entête + `WorkoutHistorySetResult` + `WorkoutHistoryStepResult`.
|
||||
- `Exercise` importé/remplacé = remplacer l’entité, images associées et steps.
|
||||
|
||||
Après application d’un item importé, le changement devient une mutation locale : marquer l’agrégat et ses enfants importés comme `syncState = dirty`, `updatedAt = importedAt` ou au minimum écrire un `change_log` d’insert/update à `importedAt`. Cela garantit qu’un utilisateur connecté pousse ensuite l’import selon les réglages sync habituels. La comparaison LWW se fait avant cette mutation avec les `updatedAt` du fichier.
|
||||
|
||||
## Stratégie Remplacer tout
|
||||
|
||||
`Remplacer tout` doit être tout-ou-rien dans une transaction Drift.
|
||||
|
||||
Règle fonctionnelle : après succès, les listes utilisateur reflètent uniquement le fichier importé. Techniquement, ne pas faire un `DELETE` brutal des tables syncables si un compte/sync existe, sinon les ressources serveur absentes du fichier risquent de revenir au prochain pull.
|
||||
|
||||
Stratégie retenue : **purge logique des données métier + import intégral**.
|
||||
|
||||
Dans une transaction :
|
||||
|
||||
1. Vérifier qu’aucune séance active ouverte n’existe, sinon bloquer avant mutation.
|
||||
2. Soft-delete/masquer toutes les ressources métier locales absentes du fichier : `Exercise`, `Program`, `WorkoutTemplate`, `WorkoutHistory`, `MediaAsset` et leurs tables enfants. Les active-session tables peuvent être vidées/soft-deleted car elles ne sont pas exportées et l’import est bloqué en cas de séance ouverte.
|
||||
3. Écrire le `change_log` des suppressions avec `importedAt`, `syncState = deleted`, `localRevision + 1`.
|
||||
4. Importer toutes les ressources du fichier, en marquant les ressources restaurées comme mutations locales (`dirty`, change_log insert/update à `importedAt`).
|
||||
5. Ne pas supprimer `online_account_sessions`, tokens, `sync_metadata`, `remote_resource_mappings`, share inbox/pending actions. Ces tables relèvent du compte/sync, pas de la sauvegarde métier.
|
||||
|
||||
Conséquence sync : si l’utilisateur est connecté, la prochaine sync poussera les suppressions/restaurations locales. Ne pas reset le curseur serveur ; ne pas vider les mappings. Reset le curseur ferait courir le risque de réimporter d’anciennes ressources serveur que l’utilisateur vient de remplacer.
|
||||
|
||||
Cas sans compte : les tombstones internes sont invisibles et sans impact UX. Une optimisation future pourra compacter les tombstones, hors MVP.
|
||||
|
||||
## Séance active
|
||||
|
||||
Importer est bloqué si `ActiveSessionRepository.findOpen()` retourne une séance ouverte (`running`, `paused`, `savedExit` selon le modèle actuel). Cela vaut pour `merge` et `replaceAll` afin d’éviter des références incohérentes entre séance en cours, exercices/programmes restaurés et historique.
|
||||
|
||||
Exporter pendant une séance ouverte est autorisé mais n’exporte pas la séance active non terminée. Si UX veut éviter l’ambiguïté, le frontend peut afficher un texte secondaire plus tard ; pas bloquant MVP.
|
||||
|
||||
## Transactions et erreurs
|
||||
|
||||
L’import doit garantir : succès complet ou aucune donnée modifiée. L’adapter Drift applique `merge` et `replaceAll` dans `database.transaction`.
|
||||
|
||||
Erreurs typées application :
|
||||
|
||||
- `invalidFile` : mauvais kind/racine non objet.
|
||||
- `incompatibleFormat` : version trop ancienne/format absent.
|
||||
- `newerVersion` : fichier créé par format futur.
|
||||
- `corrupted` : JSON cassé, collection malformée, checksum média invalide bloquant si le média est requis.
|
||||
- `activeWorkoutInProgress` : séance ouverte.
|
||||
- `importFailedNoMutation` : erreur pendant transaction, rollback garanti.
|
||||
|
||||
## Lots backend/frontend
|
||||
|
||||
### B1 — Export local complet
|
||||
|
||||
- Ajouter DTO `LocalBackup*`, codec JSON v1 et `DataExportUseCase`.
|
||||
- Ajouter `LocalDataBackupRepository.readExportSnapshot()` côté application + `DriftLocalDataBackupRepository`.
|
||||
- Réutiliser payloads sync pour `mediaAsset`, `exercise`, `program`, `workoutTemplate`.
|
||||
- Ajouter payload local complet pour `WorkoutHistory` avec `results` et `stepResults`.
|
||||
- Ajouter port/adaptateur média pour embarquer les fichiers lisibles en base64, ou documenter explicitement la limite si médias non inclus en B1.
|
||||
- Tests codec/export : envelope, version, counts, historique avec résultats séries/étapes, tags, export hors ligne.
|
||||
|
||||
### B2 — Import Fusionner
|
||||
|
||||
- Ajouter `DataImportUseCase.preview` + validation fichier.
|
||||
- Implémenter `merge` transactionnel.
|
||||
- Règle LWW par type + ID stable ; noms dupliqués autorisés.
|
||||
- Restaurer médias embarqués quand disponibles, ignorer les médias absents avec compteur.
|
||||
- Marquer les ressources importées comme mutations locales syncables (`dirty` + change_log).
|
||||
- Bloquer si séance active ouverte.
|
||||
- Tests : fichier invalide, version future, preview counts, insertion nouvelle, update si backup plus récent, ignore si local plus récent, historique complet restauré, rollback sur erreur.
|
||||
|
||||
### B3 — Import Remplacer tout
|
||||
|
||||
- Implémenter purge logique transactionnelle des données métier.
|
||||
- Préserver compte/tokens/sync metadata/mappings/share inbox.
|
||||
- Soft-delete les ressources absentes du fichier avec change_log pour éviter la résurrection serveur.
|
||||
- Importer intégralement le fichier comme état métier visible.
|
||||
- Tests : remplacement masque les anciennes données, restaure toutes les collections, écrit tombstones/changelog, préserve session compte/sync, rollback sur erreur.
|
||||
|
||||
### F1 — UI Profil Export
|
||||
|
||||
- Ajouter section `Sauvegarde locale` dans `profile_screen.dart` aux emplacements UX.
|
||||
- Bouton `Exporter mes données`, état `Préparation de l’export...`.
|
||||
- Appeler `DataExportUseCase.exportAll()`.
|
||||
- Remettre le fichier `.gametime` au partage/enregistrement système.
|
||||
- Snackbars UX exactes.
|
||||
- Tests widget : section visible connecté/déconnecté, bouton désactivé pendant export, erreur export.
|
||||
|
||||
### F2 — UI Import
|
||||
|
||||
- Bouton `Importer des données`, sélecteur fichier.
|
||||
- Lire fichier, appeler `preview`, afficher confirmation avec counts + médias.
|
||||
- `Fusionner` action par défaut ; `Remplacer tout` avec double confirmation.
|
||||
- Gérer annulation silencieuse.
|
||||
- Mapper erreurs typées vers les messages UX.
|
||||
- Message connecté : `Les données importées resteront locales et seront synchronisées selon tes réglages habituels.`
|
||||
- Tests widget : preview valide, fusion succès, replace double confirmation, fichier invalide, version future, séance active bloquée.
|
||||
|
||||
## Étape suivante
|
||||
|
||||
Git doit créer la branche #84. Ensuite DevBackend prend B1 -> B2 -> B3 ; DevFrontend démarre F1 après B1 et F2 après B2/B3.
|
||||
|
||||
# DevBackend — État final B1/B2/B3 #84
|
||||
|
||||
## Constat de reprise
|
||||
|
||||
À la reprise, `feature/export-import-local` contenait déjà 3 commits d'une session précédente :
|
||||
`c3d5273` (core export/import), `4384797` (tests), `8d6e5b4` (vérification checksum média). Contrairement à ce qui était annoncé, il n'y avait **aucune modification non commitée** sur `use_cases_test.dart` / `drift_repositories_test.dart` — ces fichiers étaient déjà commités. Vérification faite via `git status` et lecture directe du code : **B1, B2 et B3 étaient déjà fonctionnellement implémentés**, pas seulement B1.
|
||||
|
||||
## État réel par lot
|
||||
|
||||
### B1 — Export local complet : FAIT
|
||||
- `LocalBackupCodec`, `DataExportUseCase`, `LocalDataBackupRepository.readExportSnapshot`, `DriftLocalDataBackupRepository` : présents (`lib/application/use_cases.dart`, `lib/application/ports.dart`, `lib/infrastructure/local/drift_repositories.dart`).
|
||||
- Payload `WorkoutHistory` local dédié avec `results`/`stepResults` : présent (`_localWorkoutHistoryPayload`).
|
||||
- Médias embarqués en base64 avec sha256 : présent (`lib/infrastructure/local/local_media_storage.dart`).
|
||||
|
||||
### B2 — Import Fusionner : FAIT (complété)
|
||||
- `preview`, validation fichier, LWW par type+ID stable, marquage `dirty`+change_log, blocage séance active : déjà implémentés (`applyImportSnapshot`, `_applyBackupResources`).
|
||||
- **Bug corrigé** : `missingMediaCount` du résultat d'import comparait les assets restaurés à la liste des fichiers *déclarés* dans le backup (`snapshot.mediaFiles`), donc un média dont le checksum sha256 échouait à la restauration n'était pas compté comme manquant. Corrigé pour comparer aux fichiers **effectivement restaurés** (`lib/application/use_cases.dart`, fonction `_missingRestoredMediaCount`).
|
||||
|
||||
### B3 — Import Remplacer tout : FAIT
|
||||
- Purge logique transactionnelle (soft-delete + change_log) préservant compte/tokens/sync metadata : déjà implémentée (`_softDeleteResourcesAbsentFromBackup`, `_softDeleteMainRowsAbsent`).
|
||||
- Transaction Drift englobant merge/replace : confirmée atomique par un nouveau test de rollback.
|
||||
|
||||
## Tests ajoutés
|
||||
|
||||
`test/application/use_cases_test.dart` :
|
||||
- rejet `invalidFile` (mauvais `kind`) et `incompatibleFormat` (formatVersion absent).
|
||||
- rejet `corrupted` (collection `data` malformée).
|
||||
- `importFailedNoMutation` : erreur générique du repository correctement enveloppée.
|
||||
- `missingMediaCount` reflète bien un échec de checksum (test qui aurait échoué avant le fix).
|
||||
|
||||
`test/infrastructure/drift_repositories_test.dart` :
|
||||
- round-trip complet **Fusionner** via le vrai codec (export DB A -> encode -> decode -> import DB B fraîche), vérifie insertion, préservation des données existantes, tags et historique complet.
|
||||
- round-trip complet **Remplacer tout** via le vrai codec, vérifie purge logique (soft-delete + tombstone change_log) et import intégral.
|
||||
- rollback transactionnel : une ressource dont le payload provoque une exception pendant l'import laisse la base strictement inchangée (aucune insertion partielle).
|
||||
|
||||
Les tests round-trip fusion/remplacement et le blocage séance active existaient déjà partiellement (au niveau snapshot littéral) ; l'ajout couvre le chemin `codec.encode` → `codec.decode` de bout en bout, absent avant.
|
||||
|
||||
## Fichiers touchés
|
||||
|
||||
- `lib/application/use_cases.dart` : fix `missingMediaCount`.
|
||||
- `test/application/use_cases_test.dart` : 5 tests ajoutés.
|
||||
- `test/infrastructure/drift_repositories_test.dart` : 3 tests ajoutés.
|
||||
|
||||
Commits : `6d94562` (fix), `d90c7ad` (tests application), `6c87674` (tests infrastructure).
|
||||
|
||||
## Résultat des vérifications
|
||||
|
||||
- `dart analyze` sur le périmètre backend touché : aucun problème. `dart analyze` global : uniquement des `info` préexistants (deprecations Flutter, `use_build_context_synchronously`) sur des fichiers de présentation hors périmètre.
|
||||
- `flutter test` complet : tous les tests passent sauf **1 échec préexistant et non lié**, confirmé présent avant toute modification de cette session (`git stash` + run) : `exercise repository round-trips exercise option combinations stopwatch score with stopwatch scored steps` (violation de contrainte FOREIGN KEY sur des media assets de fixture, indépendant du backup). Signalé ici pour visibilité mais **hors périmètre de ce ticket**.
|
||||
|
||||
## Écart remonté (non bloquant)
|
||||
|
||||
Le bug `missingMediaCount` ci-dessus n'était pas un écart de cadrage Architect mais une divergence d'implémentation par rapport au cadrage (`missingMediaCount` doit refléter les médias réellement absents/corrompus). Corrigé directement, pas d'arbitrage nécessaire.
|
||||
|
||||
## Étape suivante
|
||||
|
||||
B1/B2/B3 backend sont complets et testés. DevFrontend peut enchaîner F1 (déjà possible depuis B1) et F2 (import) sur la base des use cases `DataExportUseCase`/`DataImportUseCase` stabilisés.
|
||||
Reference in New Issue
Block a user