Files
GameTime/.ideai/tickets/84/carnet.md
2026-07-22 14:01:07 +02:00

35 KiB
Raw Blame History

issueRef, version, updatedBy, updatedAt
issueRef version updatedBy updatedAt
#84 4
kind role
agent DevBackend
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 :

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 :

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 :

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 :

Exporter mes données

État pendant préparation :

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 :

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 :

Export prêt. Choisis où enregistrer le fichier.

Si lAPI permet de savoir que le fichier a été enregistré/partagé :

Sauvegarde exportée.

Si lutilisateur annule la feuille système : pas de message derreur. Éventuellement :

Export annulé.

mais ce nest pas nécessaire.

Erreur export

Snackbar :

Export impossible pour le moment.

Si lerreur vient dun manque despace disque et que lapp peut le savoir :

Espace insuffisant pour créer la sauvegarde.

Ne pas afficher de stack trace ni de message technique.

Import

Déclenchement

Tap sur :

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 :

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 :

Importer cette sauvegarde ?

Contenu :

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 :

Médias inclus

Si les médias ne sont pas inclus ou partiellement indisponibles :

Les médias absents seront ignorés.

Actions :

[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 :

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 :

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 :

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 :

Remplacer toutes les données locales ?

Contenu :

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 :

[Annuler]
[Remplacer tout]

Après succès :

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 :

Importer cette sauvegarde ?
Cette sauvegarde contient :
...

[Annuler]
[Importer]

Message succès :

Données importées.

États derreur import

Fichier invalide

Fichier invalide
Ce fichier nest pas une sauvegarde GameTime valide.

[OK]

Format incompatible

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

Mets GameTime à jour
Cette sauvegarde vient dune version plus récente de GameTime.

[OK]

Fichier incomplet ou corrompu

Sauvegarde illisible
Le fichier semble incomplet ou endommagé.

[OK]

Import échoué après confirmation

Si larchitecture garantit une transaction tout-ou-rien, message :

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 :

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 :

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 :

{
  "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 :

"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 :

{
  "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 :

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 :

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 :

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.encodecodec.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 applyImportSnapshotimportFailedNoMutation, 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.