35 KiB
issueRef, version, updatedBy, updatedAt
| issueRef | version | updatedBy | updatedAt | ||||
|---|---|---|---|---|---|---|---|
| #84 | 4 |
|
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 dansProfil, 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; ProfileScreenaffiche 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 :
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 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 :
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 ouOutlinedButton.iconselon densité de l’écran ; icône recommandéeIcons.file_upload_outlinedouIcons.ios_sharesi l’export passe par la feuille système.Importer des données: bouton secondaire/outlined ; icôneIcons.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 :
Exporter mes données
État pendant préparation :
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 :
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 :
Export prêt. Choisis où enregistrer le fichier.
Si l’API permet de savoir que le fichier a été enregistré/partagé :
Sauvegarde exportée.
Si l’utilisateur annule la feuille système : pas de message d’erreur. Éventuellement :
Export annulé.
mais ce n’est pas nécessaire.
Erreur export
Snackbar :
Export impossible pour le moment.
Si l’erreur vient d’un manque d’espace disque et que l’app 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 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 :
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 :
Importer cette sauvegarde ?
Contenu :
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 :
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 l’action recommandée et la moins risquée.
Texte d’aide 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 l’import.
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 qu’il s’agit 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. C’est une action destructive.
Premier dialog : action visible mais destructive.
Si l’utilisateur 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 l’utilisateur retourner vers les listes.
Cas où l’app locale est vide
Si aucune donnée utilisateur locale n’existe, simplifier la confirmation :
Importer cette sauvegarde ?
Cette sauvegarde contient :
...
[Annuler]
[Importer]
Message succès :
Données importées.
États d’erreur import
Fichier invalide
Fichier invalide
Ce fichier n’est 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 d’une 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 l’architecture garantit une transaction tout-ou-rien, message :
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 :
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 :
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 localeExporter mes donnéesImporter des donnéesPréparation de l’export...Lecture de la sauvegarde...Importer cette sauvegarde ?FusionnerRemplacer toutDonnées importées.Sauvegarde restaurée.Export impossible pour le moment.Ce fichier n’est pas une sauvegarde GameTime valide.
Libellés à éviter
DumpBackup DBImporter JSONMigration échouéeConflit de clé primaireÉcraser la baseRollback
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 :
{
"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 < 1ou champ absent -> format incompatible.minSupportedFormatVersion > 1ouformatVersion > 1-> sauvegarde d’une version plus récente : demander mise à jour.dataabsent 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 :
MediaAssetmetadata, 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 sesProgramExercisesnapshots : mesures, cibles, repos, steps snapshotés, réglageautoStartNextTimedStep.WorkoutTemplate, avecWorkoutTemplateProgramsnapshots etWorkoutTemplateExerciseOverride.WorkoutHistory, avec résultats complets :WorkoutHistorySetResultetWorkoutHistoryStepResult.
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 :
_exercisePayloadest réutilisable : il inclut metadata, médias référencés, steps, catégorie, tags._programPayloadest réutilisable : il inclut metadata, tags etProgramExercise.toSnapshotJson()._workoutTemplatePayloadest réutilisable pour l’en-tête + programmes + overrides._mediaAssetPayloadest 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 :
"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 :
{
"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 :
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. 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.updatedAtest strictement plus récent quelocal.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 :
Programimporté/remplacé = remplacer sa compositionProgramExercisepar celle du fichier.WorkoutTemplateimporté/remplacé = remplacer programmes intégrés + overrides par ceux du fichier.WorkoutHistoryimporté/remplacé = remplacer entête +WorkoutHistorySetResult+WorkoutHistoryStepResult.Exerciseimporté/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 :
- Vérifier qu’aucune séance active ouverte n’existe, sinon bloquer avant mutation.
- Soft-delete/masquer toutes les ressources métier locales absentes du fichier :
Exercise,Program,WorkoutTemplate,WorkoutHistory,MediaAssetet 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. - Écrire le
change_logdes suppressions avecimportedAt,syncState = deleted,localRevision + 1. - Importer toutes les ressources du fichier, en marquant les ressources restaurées comme mutations locales (
dirty, change_log insert/update àimportedAt). - 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 etDataExportUseCase. - Ajouter
LocalDataBackupRepository.readExportSnapshot()côté application +DriftLocalDataBackupRepository. - Réutiliser payloads sync pour
mediaAsset,exercise,program,workoutTemplate. - Ajouter payload local complet pour
WorkoutHistoryavecresultsetstepResults. - 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
mergetransactionnel. - 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 localedansprofile_screen.dartaux emplacements UX. - Bouton
Exporter mes données, étatPréparation de l’export.... - Appeler
DataExportUseCase.exportAll(). - Remettre le fichier
.gametimeau 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. Fusionneraction par défaut ;Remplacer toutavec 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
WorkoutHistorylocal dédié avecresults/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, marquagedirty+change_log, blocage séance active : déjà implémentés (applyImportSnapshot,_applyBackupResources).- Bug corrigé :
missingMediaCountdu 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(mauvaiskind) etincompatibleFormat(formatVersion absent). - rejet
corrupted(collectiondatamalformée). importFailedNoMutation: erreur générique du repository correctement enveloppée.missingMediaCountreflè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: fixmissingMediaCount.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 analyzesur le périmètre backend touché : aucun problème.dart analyzeglobal : uniquement desinfopréexistants (deprecations Flutter,use_build_context_synchronously) sur des fichiers de présentation hors périmètre.flutter testcomplet : 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
.gametimev1 : 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), pourmergeetreplaceAllindiffé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é (_confirmSimpleImportavec bouton uniqueImporter). - 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 (
missingMediaCountreflè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)
- 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. - État intermédiaire d'import non distingué en deux phases :
_importBusyré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.