--- issueRef: "#84" 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 readExportSnapshot(); Future hasOpenActiveWorkoutSession(); Future hasAnyUserData(); Future applyImportSnapshot({ required LocalDataExportSnapshot snapshot, required LocalBackupImportMode mode, required DateTime importedAt, }); } abstract interface class LocalBackupMediaStore { Future> readEmbeddableFiles( List assets, ); Future> restoreEmbeddedFiles( List files, ); } ``` Use cases : ```dart final class DataExportUseCase { Future exportAll(); } final class DataImportUseCase { Future preview(Uint8List bytes); Future 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. ## Vérification Main (commits 77b2718, f7d32a3, 889f07c, d2b7d59) `flutter test --no-pub test/presentation/profile_screen_test.dart` : 16/16 verts. Suite complète `flutter test --no-pub` : 238 tests, 1 seul échec préexistant déjà documenté (FK media, sans rapport avec #84). `dart analyze` propre. Aucune régression. **Lot frontend F1/F2 : GO.** Étape suivante : QA pour validation finale. # QA — Validation finale #84 ## Méthode Relecture indépendante du cadrage UX et Architect vs implémentation (`lib/application/use_cases.dart`, `lib/presentation/profile_screen.dart`), relecture de la couverture de test (`test/application/use_cases_test.dart`, `test/infrastructure/drift_repositories_test.dart`, `test/presentation/profile_screen_test.dart`), puis ré-exécution réelle des commandes. ## Conformité cadrage - **Format `.gametime` v1** : envelope, `kind`, `formatVersion`/`minSupportedFormatVersion`, `counts`, `data`, `mediaFiles` — conforme au cadrage Architect (`LocalBackupCodec.encode/decode`, `lib/application/use_cases.dart:53-145`). - **Validation de compatibilité** : `invalidFile` (kind/racine), `incompatibleFormat` (version absente/<1), `newerVersion` (version future), `corrupted` (data malformé) — tous les cas du cadrage sont bien distingués et testés. - **Blocage séance active** : vérifié avant toute mutation (`importFrom`, ligne 197), pour `merge` et `replaceAll` indifféremment — conforme. - **Transaction tout-ou-rien** : erreur générique pendant `applyImportSnapshot` → `importFailedNoMutation`, rollback confirmé par test dédié (round-trip avec ressource cassée → aucune insertion partielle, `drift_repositories_test.dart` ~ligne 673-732). - **Fusionner (LWW)** : nouvelle ressource insérée, ressource plus récente dans le fichier remplace le local, local plus récent ignoré — couvert par test round-trip dédié. - **Remplacer tout** : purge logique (soft-delete + change_log tombstone) + import intégral, compte/tokens/sync préservés — couvert par test dédié (`local backup replaceAll soft deletes absent data and imports file`). - **UX Profil** : tous les libellés exacts du cadrage sont présents mot pour mot dans `profile_screen.dart` (`Sauvegarde locale`, `Exporter mes données`, `Préparation de l'export...`, `Lecture de la sauvegarde...`, `Importer cette sauvegarde ?`, `Fusionner`, `Remplacer tout`, doubles confirmations, tous les messages d'erreur typés, message compte connecté). Cas app locale vide correctement simplifié (`_confirmSimpleImport` avec bouton unique `Importer`). - **Aucun libellé à éviter** (`Dump`, `Backup DB`, etc.) n'apparaît dans le code. ## Couverture de test — vérifiée par lecture directe - Round-trip export→import Fusionner et Remplacer tout via le vrai codec : présents et couvrent insertion, préservation, tags, historique complet. - Détection fichier invalide / corrompu / version incompatible / version future : couverte côté `use_cases_test.dart` (codec) et côté widget (`profile_screen_test.dart` : message dédié affiché). - Blocage séance active : couvert côté repository ET côté widget. - Comptage médias manquants/corrompus : couvert (`missingMediaCount` reflète un échec de checksum, régression testée explicitement suite au fix DevBackend). - Double confirmation destructive Remplacer tout : couverte côté widget, y compris annulation de la seconde confirmation. - Annulation sélection fichier silencieuse : couverte. ## Ré-exécution des commandes (QA, indépendante) ``` $ flutter test --no-pub 00:08 +238 -1: Some tests failed. Failing tests: test/infrastructure/drift_repositories_test.dart: exercise repository round-trips exercise option combinations stopwatch score with stopwatch scored steps ``` 238 tests exécutés, 1 seul échec — identique à celui documenté par DevBackend/Main (contrainte FOREIGN KEY sur media assets de fixture, préexistant, sans rapport avec #84). Aucune régression détectée. ``` $ dart analyze 24 issues found. (uniquement des `info`, deprecations Flutter préexistantes + 2 `use_build_context_synchronously` déjà présents ailleurs dans profile_screen.dart, hors code #84) ``` Propre : aucun `warning`/`error`. ## Écarts non bloquants (confirmés par QA) 1. **Pas de message dédié « Espace insuffisant »** : le cadrage UX rend ce message conditionnel (« Si l'erreur vient d'un manque d'espace disque et que l'app peut le savoir »). L'implémentation retombe sur le message générique `Export impossible pour le moment.` dans tous les cas d'erreur d'export. Acceptable au MVP : le cadrage n'impose pas cette distinction, seulement l'autorise. 2. **État intermédiaire d'import non distingué en deux phases** : `_importBusy` réutilise le même indicateur/libellé (`Lecture de la sauvegarde...`) pour la phase de lecture/preview ET pour la phase d'application (merge/replace). Le cadrage ne demande explicitement ce libellé que pour la validation initiale ; il n'y a pas d'exigence UX d'un libellé distinct pendant l'application. Acceptable au MVP, cohérence UX conservée (bouton désactivé dans les deux cas, pas d'action concurrente possible). Aucun autre écart trouvé entre cadrage UX/Architect et implémentation. ## Verdict **GO.** Implémentation conforme au cadrage UX et Architect, couverture de test adéquate sur les invariants critiques (LWW, transaction tout-ou-rien, blocage séance active, validation de version, comptage médias), suite complète verte à l'exception de l'échec préexistant hors périmètre, `dart analyze` propre. Les 2 écarts relevés par DevFrontend sont non bloquants et n'appellent pas de correction avant clôture. Étape suivante : Git peut clôturer le ticket #84.