diff --git a/.ideai/agents.json b/.ideai/agents.json index 5c5548a..3718998 100644 --- a/.ideai/agents.json +++ b/.ideai/agents.json @@ -23,7 +23,7 @@ "agentId": "10ee045b-1c41-479e-ba03-dceed9edd495", "name": "DevBackend", "mdPath": "agents/devbackend.md", - "profileId": "d2603c4e-1ee5-51a3-8b61-99a9f03eec50", + "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", "templateId": "b92be0a9-d8c6-4373-bac4-032950d62dc1", "synchronized": true, "syncedTemplateVersion": 1 @@ -32,7 +32,7 @@ "agentId": "9933c93a-b8a1-4164-a3bb-7063fdad747d", "name": "DevFrontend", "mdPath": "agents/devfrontend.md", - "profileId": "d2603c4e-1ee5-51a3-8b61-99a9f03eec50", + "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", "templateId": "c42c2c2c-b0ae-4d7d-9041-35e0647e9175", "synchronized": true, "syncedTemplateVersion": 1 @@ -41,7 +41,7 @@ "agentId": "f3408f5d-469c-4f64-9485-d8b218f3ff26", "name": "UX", "mdPath": "agents/ux.md", - "profileId": "d2603c4e-1ee5-51a3-8b61-99a9f03eec50", + "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", "templateId": "6d577198-b737-4ff5-aa93-ee20ae4d29ec", "synchronized": true, "syncedTemplateVersion": 1 @@ -50,7 +50,7 @@ "agentId": "f8f40941-ecf7-4830-b9de-8818a099f448", "name": "Architect", "mdPath": "agents/architect.md", - "profileId": "d2603c4e-1ee5-51a3-8b61-99a9f03eec50", + "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", "templateId": "5a167cad-565a-4058-8efb-8144b433944e", "synchronized": true, "syncedTemplateVersion": 1 @@ -59,7 +59,7 @@ "agentId": "7efa512f-3b3a-47b5-ade0-a2dd13073055", "name": "QA", "mdPath": "agents/qa.md", - "profileId": "d2603c4e-1ee5-51a3-8b61-99a9f03eec50", + "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", "templateId": "2629157e-21c6-4c4c-93ef-10dde69480b0", "synchronized": true, "syncedTemplateVersion": 1 @@ -68,14 +68,14 @@ "agentId": "a5da242f-e5f4-49b5-a29f-e0d4b7b49169", "name": "Commercial", "mdPath": "agents/commercial.md", - "profileId": "d2603c4e-1ee5-51a3-8b61-99a9f03eec50", + "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", "synchronized": false }, { "agentId": "aa1ae4f2-c78e-4481-8f96-64f77391d09a", "name": "Coach", "mdPath": "agents/coach.md", - "profileId": "d2603c4e-1ee5-51a3-8b61-99a9f03eec50", + "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", "synchronized": false } ] diff --git a/.ideai/tickets/84/carnet.md b/.ideai/tickets/84/carnet.md index 133cd49..e42fb0b 100644 --- a/.ideai/tickets/84/carnet.md +++ b/.ideai/tickets/84/carnet.md @@ -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 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. \ No newline at end of file diff --git a/.ideai/tickets/84/issue.md b/.ideai/tickets/84/issue.md index 97d99fc..8063073 100644 --- a/.ideai/tickets/84/issue.md +++ b/.ideai/tickets/84/issue.md @@ -8,10 +8,10 @@ sprint: null links: [] agentRefs: [] 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 -updatedAt: 1784650724409 -version: 1 +updatedAt: 1784718798540 +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. diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 86de179..1240141 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -951,7 +951,7 @@ "priority": "medium", "sprint": null, "assignedAgentIds": [], - "updatedAt": 1784650724409 + "updatedAt": 1784718798540 }, { "issueRef": "#85",