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.
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.
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.
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.
-`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.
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.
- 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.
- 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.
-`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.
- 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`).
- 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.
-`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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.