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:
2026-07-22 13:42:25 +02:00
parent 6c87674652
commit 06d806d9d0
4 changed files with 799 additions and 14 deletions

View File

@ -23,7 +23,7 @@
"agentId": "10ee045b-1c41-479e-ba03-dceed9edd495", "agentId": "10ee045b-1c41-479e-ba03-dceed9edd495",
"name": "DevBackend", "name": "DevBackend",
"mdPath": "agents/devbackend.md", "mdPath": "agents/devbackend.md",
"profileId": "d2603c4e-1ee5-51a3-8b61-99a9f03eec50", "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"templateId": "b92be0a9-d8c6-4373-bac4-032950d62dc1", "templateId": "b92be0a9-d8c6-4373-bac4-032950d62dc1",
"synchronized": true, "synchronized": true,
"syncedTemplateVersion": 1 "syncedTemplateVersion": 1
@ -32,7 +32,7 @@
"agentId": "9933c93a-b8a1-4164-a3bb-7063fdad747d", "agentId": "9933c93a-b8a1-4164-a3bb-7063fdad747d",
"name": "DevFrontend", "name": "DevFrontend",
"mdPath": "agents/devfrontend.md", "mdPath": "agents/devfrontend.md",
"profileId": "d2603c4e-1ee5-51a3-8b61-99a9f03eec50", "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"templateId": "c42c2c2c-b0ae-4d7d-9041-35e0647e9175", "templateId": "c42c2c2c-b0ae-4d7d-9041-35e0647e9175",
"synchronized": true, "synchronized": true,
"syncedTemplateVersion": 1 "syncedTemplateVersion": 1
@ -41,7 +41,7 @@
"agentId": "f3408f5d-469c-4f64-9485-d8b218f3ff26", "agentId": "f3408f5d-469c-4f64-9485-d8b218f3ff26",
"name": "UX", "name": "UX",
"mdPath": "agents/ux.md", "mdPath": "agents/ux.md",
"profileId": "d2603c4e-1ee5-51a3-8b61-99a9f03eec50", "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"templateId": "6d577198-b737-4ff5-aa93-ee20ae4d29ec", "templateId": "6d577198-b737-4ff5-aa93-ee20ae4d29ec",
"synchronized": true, "synchronized": true,
"syncedTemplateVersion": 1 "syncedTemplateVersion": 1
@ -50,7 +50,7 @@
"agentId": "f8f40941-ecf7-4830-b9de-8818a099f448", "agentId": "f8f40941-ecf7-4830-b9de-8818a099f448",
"name": "Architect", "name": "Architect",
"mdPath": "agents/architect.md", "mdPath": "agents/architect.md",
"profileId": "d2603c4e-1ee5-51a3-8b61-99a9f03eec50", "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"templateId": "5a167cad-565a-4058-8efb-8144b433944e", "templateId": "5a167cad-565a-4058-8efb-8144b433944e",
"synchronized": true, "synchronized": true,
"syncedTemplateVersion": 1 "syncedTemplateVersion": 1
@ -59,7 +59,7 @@
"agentId": "7efa512f-3b3a-47b5-ade0-a2dd13073055", "agentId": "7efa512f-3b3a-47b5-ade0-a2dd13073055",
"name": "QA", "name": "QA",
"mdPath": "agents/qa.md", "mdPath": "agents/qa.md",
"profileId": "d2603c4e-1ee5-51a3-8b61-99a9f03eec50", "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"templateId": "2629157e-21c6-4c4c-93ef-10dde69480b0", "templateId": "2629157e-21c6-4c4c-93ef-10dde69480b0",
"synchronized": true, "synchronized": true,
"syncedTemplateVersion": 1 "syncedTemplateVersion": 1
@ -68,14 +68,14 @@
"agentId": "a5da242f-e5f4-49b5-a29f-e0d4b7b49169", "agentId": "a5da242f-e5f4-49b5-a29f-e0d4b7b49169",
"name": "Commercial", "name": "Commercial",
"mdPath": "agents/commercial.md", "mdPath": "agents/commercial.md",
"profileId": "d2603c4e-1ee5-51a3-8b61-99a9f03eec50", "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"synchronized": false "synchronized": false
}, },
{ {
"agentId": "aa1ae4f2-c78e-4481-8f96-64f77391d09a", "agentId": "aa1ae4f2-c78e-4481-8f96-64f77391d09a",
"name": "Coach", "name": "Coach",
"mdPath": "agents/coach.md", "mdPath": "agents/coach.md",
"profileId": "d2603c4e-1ee5-51a3-8b61-99a9f03eec50", "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"synchronized": false "synchronized": false
} }
] ]

View File

@ -1,6 +1,791 @@
--- ---
issueRef: "#84" issueRef: "#84"
version: 1 version: 4
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"agent","role":"DevBackend"}
updatedAt: 1784650724409 updatedAt: 1784720508935
--- ---
# UX — Export/import local des données
## Contexte lu
Ticket #84 : GameTime est offline-first ; un utilisateur qui nactive 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 lapp.
- `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 dentrée daccueil dédiée, ni écran de réglages séparé au MVP. Lexport/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 lexport 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 lexport passe par la feuille système.
- `Importer des données` : bouton secondaire/outlined ; icône `Icons.file_download_outlined`.
Important : ne pas présenter lexport 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 lexport...
```
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 ; lextension peut changer si nécessaire, mais le nom doit rester reconnaissable pour lutilisateur.
### 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 lAPI permet de savoir que le fichier a été enregistré/partagé :
```text
Sauvegarde exportée.
```
Si lutilisateur annule la feuille système : pas de message derreur. Éventuellement :
```text
Export annulé.
```
mais ce nest pas nécessaire.
### Erreur export
Snackbar :
```text
Export impossible pour le moment.
```
Si lerreur vient dun manque despace disque et que lapp 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 lutilisateur annule la sélection : retour silencieux au profil, pas de snackbar.
### Validation du fichier
Après sélection, lapp valide le fichier avant de proposer limport.
États pendant validation :
```text
Lecture de la sauvegarde...
```
Si le fichier est valide, afficher une confirmation. Pas dimport silencieux.
### Confirmation dimport
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 lajouter à 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 laction recommandée et la moins risquée.
Texte daide 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 limport.
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 quil sagit 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. Cest une action destructive.
Premier dialog : action visible mais destructive.
Si lutilisateur 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 lutilisateur retourner vers les listes.
### Cas où lapp locale est vide
Si aucune donnée utilisateur locale nexiste, simplifier la confirmation :
```text
Importer cette sauvegarde ?
Cette sauvegarde contient :
...
[Annuler]
[Importer]
```
Message succès :
```text
Données importées.
```
## États derreur import
### Fichier invalide
```text
Fichier invalide
Ce fichier nest 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 dune 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 larchitecture garantit une transaction tout-ou-rien, message :
```text
Import impossible
Aucune donnée na été modifiée.
[OK]
```
Si larchitecture ne peut pas garantir le tout-ou-rien, Architect doit le signaler avant implémentation UX ; le MVP ne doit pas laisser lutilisateur 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, limport doit être bloqué ou demandé explicitement selon décision Architect. Recommandation UX MVP : bloquer limport tant quune séance est en cours.
Message :
```text
Séance en cours
Termine ou sauvegarde ta séance avant dimporter des données.
[OK]
```
Raison : remplacer/fusionner pendant une séance active peut rendre létat dexécution difficile à comprendre.
### Compte connecté
Limport/export local reste disponible connecté.
Si limport 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. Lexport/import local doit fonctionner hors ligne.
## Libellés à utiliser
- `Sauvegarde locale`
- `Exporter mes données`
- `Importer des données`
- `Préparation de lexport...`
- `Lecture de la sauvegarde...`
- `Importer cette sauvegarde ?`
- `Fusionner`
- `Remplacer tout`
- `Données importées.`
- `Sauvegarde restaurée.`
- `Export impossible pour le moment.`
- `Ce fichier nest 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 dexport 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 dexercices, 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 dapp : plus ancienne, actuelle, plus récente.
## Étape suivante
Architect doit cadrer le format dexport 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 nest 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 lerreur.
- `formatVersion < 1` ou champ absent -> format incompatible.
- `minSupportedFormatVersion > 1` ou `formatVersion > 1` -> sauvegarde dune 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é sil 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 dune 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 len-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 : aujourdhui `lib/infrastructure/local/drift_repositories.dart:4156-4167` exporte seulement len-tête dhistorique (`historySnapshotJson`, dates, total, completed), sans `results` ni `stepResults`. Or le ticket demande lhistorique 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`. Limport local ne doit donc pas dépendre du chemin `LocalSyncChangeRepository.applyRemoteItem` tant que ce cas nest pas implémenté.
## Médias
MVP recommandé : supporter les médias quand le fichier local est lisible, mais ne pas faire échouer lexport 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": "..."
}
```
À limport : 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 lUI (`Les médias absents seront ignorés.`).
Si DevBackend juge le base64 trop coûteux pour les vidéos au MVP, alternative acceptable : limiter lembarquement aux images et laisser les vidéos en metadata manquante. Cette limite doit être explicite dans le résultat dexport.
## 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. Ladapter 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 limport.
Règle MVP : LWW cohérent avec la sync serveur.
- Si lID nexiste pas localement : insérer la ressource importée.
- Si lID existe localement et que `backup.metadata.updatedAt` est strictement plus récent que `local.metadata.updatedAt` : remplacer lagrégat local par celui du fichier.
- Si lID 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 lentité, images associées et steps.
Après application dun item importé, le changement devient une mutation locale : marquer lagrégat et ses enfants importés comme `syncState = dirty`, `updatedAt = importedAt` ou au minimum écrire un `change_log` dinsert/update à `importedAt`. Cela garantit quun utilisateur connecté pousse ensuite limport 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 quaucune séance active ouverte nexiste, 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 limport 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 lutilisateur 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 danciennes ressources serveur que lutilisateur 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 nexporte pas la séance active non terminée. Si UX veut éviter lambiguïté, le frontend peut afficher un texte secondaire plus tard ; pas bloquant MVP.
## Transactions et erreurs
Limport doit garantir : succès complet ou aucune donnée modifiée. Ladapter 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 lexport...`.
- 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.

View File

@ -8,10 +8,10 @@ sprint: null
links: [] links: []
agentRefs: [] agentRefs: []
createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
createdAt: 1784650724409 createdAt: 1784650724409
updatedAt: 1784650724409 updatedAt: 1784718798540
version: 1 version: 3
--- ---
Constat Commercial : la différenciation offline-first de GameTime doit rester crédible même sans compte ni serveur. Un utilisateur qui n'active jamais la couche online doit pouvoir sauvegarder/restaurer ses données autrement. Constat Commercial : la différenciation offline-first de GameTime doit rester crédible même sans compte ni serveur. Un utilisateur qui n'active jamais la couche online doit pouvoir sauvegarder/restaurer ses données autrement.

View File

@ -951,7 +951,7 @@
"priority": "medium", "priority": "medium",
"sprint": null, "sprint": null,
"assignedAgentIds": [], "assignedAgentIds": [],
"updatedAt": 1784650724409 "updatedAt": 1784718798540
}, },
{ {
"issueRef": "#85", "issueRef": "#85",