merge(main): configuration du score chronométré (ticket #26)

Fusionne feature/#26-score-chrono-config — analyze propre, 41/41
tests verts, build APK debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 09:08:27 +02:00
16 changed files with 432 additions and 54 deletions

View File

@ -9,3 +9,4 @@
- [gametime-architecture-set-editing](gametime-architecture-set-editing.md) — memory note gametime-architecture-set-editing
- [gametime-ux-score-chrono](gametime-ux-score-chrono.md) — memory note gametime-ux-score-chrono
- [gametime-architecture-score-chrono](gametime-architecture-score-chrono.md) — memory note gametime-architecture-score-chrono
- [gametime-resume-plan-2026-07-18](gametime-resume-plan-2026-07-18.md) — memory note gametime-resume-plan-2026-07-18

View File

@ -0,0 +1,48 @@
---
name: gametime-resume-plan-2026-07-18
description: memory note gametime-resume-plan-2026-07-18
metadata:
type: project
---
# GameTime — Plan de reprise (consigné suite à interruption pour limite de tokens, 2026-07-18)
## Instruction utilisateur explicite
1. Attendre le reset de tokens (~3h du matin) avant de reprendre.
2. Terminer ce qui était en cours (voir "État exact au moment de l'interruption" ci-dessous).
3. Enchaîner ensuite sur TOUS les tickets restants, en commençant par ceux du sprint **"Bug resolution"**.
4. Puis les autres tickets par ordre de priorité, en tenant compte des dépendances — l'utilisateur laisse Main juger de l'ordre exact, il ne sera pas devant l'écran.
5. **Règle importante pour les bugs** : ne PAS passer les tickets de bug en `closed` une fois corrigés — les passer en statut `QA`. L'utilisateur testera lui-même avec l'APK et les clôturera à la main.
6. Une fois absolument tout terminé, rebuild l'APK final (`flutter build apk --debug`), pour que l'utilisateur ait un seul APK à jour avec tous les tickets faits.
## État exact au moment de l'interruption
Ticket **#26** ([DevFrontend] Configuration du Score chrono — exercice + programme), branche `feature/#26-score-chrono-config`, en cours de débogage d'un dernier test qui échoue de façon persistante après plusieurs itérations :
Test : `test/presentation/exercise_library_screen_test.dart` — "basculer en chrono intégré masque l'unité et affiche le badge" (ligne ~129).
Échec : `expect(find.bySemanticsLabel('Score chrono'), findsOneWidget)` ne trouve rien, alors que :
- Le widget `MeasureBadge` (lib/presentation/exercise_library_screen.dart ~ligne 655-684) a bien la logique correcte : si `measure == WorkoutMeasure.score && scoreInputMode == ScoreInputMode.stopwatch`, `label = 'Score chrono'`, rendu via `Semantics(label: label, child: Chip(...))`.
- Le site d'appel dans `ExerciseListTile` (~ligne 246) passe bien `scoreInputMode: exercise.scoreInputMode` à `MeasureBadge`.
- Le test vérifie bien `saved.scoreInputMode == ScoreInputMode.stopwatch` à la ligne 120 (ça passe), puis reconstruit `ExerciseListTile(exercise: saved)` dans un nouveau `pumpWidget` (lignes 122-127), `pumpAndSettle()`, puis cherche le badge (ligne 129) — et ne le trouve pas.
**Hypothèse non encore vérifiée à l'interruption** : `exercise.availableMeasures` (domain/entities.dart ligne 166) dépend de `hasScoreMeasure` — vérifier si `saved.hasScoreMeasure` est bien `true` après la sauvegarde du formulaire en mode "Chrono intégré" (il est possible que le formulaire ne coche/persiste pas `hasScoreMeasure=true` correctement quand on passe directement en mode chrono sans que le switch "Score" ait été committé avant le changement de mode radio — à vérifier dans `exercise_library_screen.dart`, la logique de sauvegarde du formulaire ExerciseFormScreen, autour de la construction de l'objet Exercise final avant `save()`). C'est la piste la plus probable à vérifier en premier à la reprise, avant de relancer un nouvel aller-retour avec DevFrontend.
## Chaîne de tickets score chrono (ticket parent #18)
- #25 [DevBackend] Domain et migration — **terminé et mergé** (tag v1.5.0-debug + ce qui suit).
- #26 [DevFrontend] Configuration Score chrono — **en cours**, cf. ci-dessus. Branche `feature/#26-score-chrono-config` non mergée.
- #27 [DevFrontend] Chrono score intégré dans l'exécution — dépend de #25, #26. Pas commencé.
- #28 [DevFrontend] Affichage Score chrono dans plan/historique — dépend de #27. Pas commencé.
## État des branches/tags au moment de l'interruption
- `main`/`develop` à jour au tag `v1.5.0-debug` (inclut tickets #1-23, #12, #31).
- Branche `feature/#26-score-chrono-config` en cours, non mergée, contient le travail du ticket #26 (avec le test encore rouge).
## Tickets connus restants après #26/#27/#28
- Tout ticket dans le sprint "Bug resolution" (sprintId `abc4f969-b169-45f7-988c-daeeab762201`) — vérifier `idea_ticket_list` pour la liste à jour, le sprint pourrait contenir de nouveaux tickets créés pendant l'interruption/l'absence de l'utilisateur.
- Vérifier aussi `idea_ticket_list` pour tout ticket créé par l'utilisateur pendant l'absence (comme le ticket #31 et #18 créés en cours de route précédemment) — ne pas supposer que la liste est figée.
## Environnement de build (rappel)
Voir mémoire "gametime-dev-environment" pour les commandes exactes (ANDROID_HOME, PATH, flutter analyze/test/build). Toujours vérifier soi-même (Main) via Bash après chaque implémentation d'agent, les sandbox des agents n'ont pas accès réseau.
## Process à respecter pour chaque ticket restant
Même cycle que jusqu'ici : Git crée la branche → agent (DevBackend/DevFrontend selon le domaine) implémente → Main vérifie réellement (pub get si besoin, build_runner si schéma Drift touché, analyze, test, build apk) → relais des échecs réels bruts à l'agent si besoin → Git merge dans main + develop + nettoyage branche + tag incrémental (vX.Y.0-debug). Consulter UX/Architect en amont si un ticket touche à la conception produit ou au modèle de données de façon non triviale (comme cela a été fait pour #18#25/#26/#27/#28).
Pour les tickets de type [Bug] : à la fin de l'implémentation et de la vérification technique (analyze/test/build OK), passer le statut à `QA` (pas `closed`) et laisser un message clair dans le carnet du ticket résumant ce qui a été corrigé, pour que l'utilisateur puisse tester sur l'APK final.

View File

@ -1,6 +1,6 @@
---
issueRef: "#26"
version: 2
version: 4
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784328255774
updatedAt: 1784358479742
---

View File

@ -2,7 +2,7 @@
id: "37a022d8-b152-48c9-a4d1-72528e2d63af"
number: 26
title: "[DevFrontend] Configuration du Score chrono (exercice + programme)"
status: "open"
status: "closed"
priority: "high"
sprint: null
links: [{"target":"#18","kind":"relatesTo"},{"target":"#25","kind":"dependsOn"}]
@ -10,7 +10,7 @@ agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}
createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1784328244539
updatedAt: 1784328255774
version: 2
updatedAt: 1784358479742
version: 4
---
Dans la création/édition d'exercice (lib/presentation/exercise_library_screen.dart), si Score est activé, ajouter le sous-choix "Mode de saisie" : Saisie libre (actuel) vs Chrono intégré (label par défaut "Temps réalisé", pas d'unité libre). Badge résumé "Score chrono" pour ce mode. Dans l'écran de personnalisation d'exercice en programme (lib/presentation/program_screen.dart), pour un exercice en Score chrono : champ "Objectif de chrono" optionnel au lieu de "cible score". Avertissement si Temps ET Score chrono actifs simultanément. Cf. mémoire "gametime-ux-score-chrono" pour le détail complet.

View File

@ -1,6 +1,6 @@
---
issueRef: "#35"
version: 3
version: 5
updatedBy: {"kind":"user"}
updatedAt: 1784329588125
updatedAt: 1784329838645
---

View File

@ -1,7 +1,7 @@
---
id: "3f1a583a-320b-43c3-9993-52d249c9c430"
number: 35
title: "Proposer de emttre plsuieurs images pour un exercice"
title: "Proposer de mettre plsuieurs images pour un exercice"
status: "open"
priority: "low"
sprint: null
@ -10,7 +10,7 @@ agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1784329532159
updatedAt: 1784329588125
version: 3
updatedAt: 1784329838645
version: 5
---
J'iamerais que lors de la création d'un exercice, ils oit possible d'associer jusqu'a 5 images
J'aimerais que lors de la création d'un exercice, ils oit possible d'associer jusqu'a 5 images

View File

@ -0,0 +1,6 @@
---
issueRef: "#37"
version: 3
updatedBy: {"kind":"user"}
updatedAt: 1784330063749
---

View File

@ -0,0 +1,16 @@
---
id: "d472ee06-f4f1-4796-9797-b5d8a65000f3"
number: 37
title: "Pouvoir supprimer un programme, une séance ou un exercice"
status: "open"
priority: "high"
sprint: null
links: []
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1784329898629
updatedAt: 1784330063749
version: 3
---
Il faut que l'utilisateur puisse supprimer un exercice qu'il a créé, un programme qu'il a créé ou une séance qu'il a créé. Si on supprime une exerccie, alors il est retiré des programmes dans lequel il se trouve. Si on supprime un programme alors il est supprimé des séances dans lequel il se trouve. Pour l'historique, il faut cependant que les datas soient persistantes, même si un exercice a été supprimé par exemple, il faut quand meme quie l'on puisse garder les données d'affichées. La séance ne pourra par contre pas être relancée, il faudra donc afficher un message qui l'explique.

View File

@ -0,0 +1,6 @@
---
issueRef: "#38"
version: 3
updatedBy: {"kind":"user"}
updatedAt: 1784330204091
---

View File

@ -0,0 +1,16 @@
---
id: "51e8769d-7b2b-4b4d-8de1-dc784881e575"
number: 38
title: "Proposer de mettre une image pour l'icone de l'exercice"
status: "open"
priority: "medium"
sprint: null
links: []
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1784330169746
updatedAt: 1784330204091
version: 3
---
J'aimerais qu'en plus des images que l'on peut ajouter, qu'il soit possible de mettre une image comme icone de l'exercice

View File

@ -1,3 +1,3 @@
{
"nextNumber": 37
"nextNumber": 39
}

View File

@ -291,13 +291,13 @@
"issueRef": "#26",
"path": "26",
"title": "[DevFrontend] Configuration du Score chrono (exercice + programme)",
"status": "open",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [
"9933c93a-b8a1-4164-a3bb-7063fdad747d"
],
"updatedAt": 1784328255774
"updatedAt": 1784358479742
},
{
"issueRef": "#27",
@ -392,14 +392,14 @@
{
"issueRef": "#35",
"path": "35",
"title": "Proposer de emttre plsuieurs images pour un exercice",
"title": "Proposer de mettre plsuieurs images pour un exercice",
"status": "open",
"priority": "low",
"sprint": null,
"assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae"
],
"updatedAt": 1784329588125
"updatedAt": 1784329838645
},
{
"issueRef": "#36",
@ -412,6 +412,30 @@
"57695b92-24d0-4876-837c-76116e70a6ae"
],
"updatedAt": 1784329810425
},
{
"issueRef": "#37",
"path": "37",
"title": "Pouvoir supprimer un programme, une séance ou un exercice",
"status": "open",
"priority": "high",
"sprint": null,
"assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae"
],
"updatedAt": 1784330063749
},
{
"issueRef": "#38",
"path": "38",
"title": "Proposer de mettre une image pour l'icone de l'exercice",
"status": "open",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae"
],
"updatedAt": 1784330204091
}
]
}

View File

@ -246,6 +246,7 @@ final class ExerciseListTile extends StatelessWidget {
(measure) => MeasureBadge(
measure: measure,
scoreUnit: exercise.scoreUnit,
scoreInputMode: exercise.scoreInputMode,
),
),
if (hasMedia)
@ -293,6 +294,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
var _hasTime = true;
var _hasReps = false;
var _hasScore = false;
var _scoreInputMode = ScoreInputMode.manual;
var _saving = false;
var _importingImage = false;
var _importingVideo = false;
@ -314,6 +316,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
_hasTime = exercise?.hasTimeMeasure ?? true;
_hasReps = exercise?.hasRepsMeasure ?? false;
_hasScore = exercise?.hasScoreMeasure ?? false;
_scoreInputMode = exercise?.scoreInputMode ?? ScoreInputMode.manual;
}
@override
@ -408,27 +411,63 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
),
if (_hasScore) ...[
const SizedBox(height: 12),
TextFormField(
controller: _scoreLabelController,
decoration: const InputDecoration(labelText: 'Score à saisir'),
validator: (value) {
if (!_hasScore) return null;
return value == null || value.trim().isEmpty
? 'Le libellé du score est obligatoire.'
: null;
Text(
'Mode de saisie',
style: Theme.of(context).textTheme.titleSmall,
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Saisie libre'),
value: ScoreInputMode.manual,
groupValue: _scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => _scoreInputMode = value);
},
),
const SizedBox(height: 12),
TextFormField(
controller: _scoreUnitController,
decoration: const InputDecoration(labelText: 'Unité'),
validator: (value) {
if (!_hasScore) return null;
return value == null || value.trim().isEmpty
? 'Lunité du score est obligatoire.'
: null;
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Chrono intégré'),
subtitle: const Text('Temps réalisé'),
value: ScoreInputMode.stopwatch,
groupValue: _scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => _scoreInputMode = value);
},
),
if (_scoreInputMode == ScoreInputMode.manual) ...[
const SizedBox(height: 12),
TextFormField(
controller: _scoreLabelController,
decoration: const InputDecoration(
labelText: 'Score à saisir',
),
validator: (value) {
if (!_hasScore ||
_scoreInputMode != ScoreInputMode.manual) {
return null;
}
return value == null || value.trim().isEmpty
? 'Le libellé du score est obligatoire.'
: null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _scoreUnitController,
decoration: const InputDecoration(labelText: 'Unité'),
validator: (value) {
if (!_hasScore ||
_scoreInputMode != ScoreInputMode.manual) {
return null;
}
return value == null || value.trim().isEmpty
? 'Lunité du score est obligatoire.'
: null;
},
),
],
],
if (_warning != null) ...[
const SizedBox(height: 16),
@ -537,6 +576,16 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
}
setState(() => _saving = true);
final scoreInputMode = _hasScore ? _scoreInputMode : ScoreInputMode.manual;
final scoreLabel = _hasScore
? switch (scoreInputMode) {
ScoreInputMode.manual => _scoreLabelController.text.trim(),
ScoreInputMode.stopwatch => 'Temps réalisé',
}
: null;
final scoreUnit = _hasScore && scoreInputMode == ScoreInputMode.manual
? _scoreUnitController.text.trim()
: null;
try {
if (exercise == null) {
await widget.exerciseUseCases.create(
@ -547,8 +596,9 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
hasTimeMeasure: _hasTime,
hasRepsMeasure: _hasReps,
hasScoreMeasure: _hasScore,
scoreLabel: _hasScore ? _scoreLabelController.text.trim() : null,
scoreUnit: _hasScore ? _scoreUnitController.text.trim() : null,
scoreInputMode: scoreInputMode,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
);
} else {
await widget.exerciseUseCases.update(
@ -560,12 +610,16 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
hasTimeMeasure: _hasTime,
hasRepsMeasure: _hasReps,
hasScoreMeasure: _hasScore,
scoreLabel: _hasScore ? _scoreLabelController.text.trim() : null,
scoreUnit: _hasScore ? _scoreUnitController.text.trim() : null,
scoreInputMode: scoreInputMode,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
);
}
if (mounted) {
Navigator.of(context).pop(true);
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.pop(true);
}
}
} on Exception catch (error) {
_showSnackBar(error.toString());
@ -579,7 +633,8 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
bool _measuresChanged(Exercise exercise) {
return exercise.hasTimeMeasure != _hasTime ||
exercise.hasRepsMeasure != _hasReps ||
exercise.hasScoreMeasure != _hasScore;
exercise.hasScoreMeasure != _hasScore ||
exercise.scoreInputMode != _scoreInputMode;
}
String? _optionalText(TextEditingController controller) {
@ -601,20 +656,32 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
}
final class MeasureBadge extends StatelessWidget {
const MeasureBadge({required this.measure, this.scoreUnit, super.key});
const MeasureBadge({
required this.measure,
this.scoreInputMode = ScoreInputMode.manual,
this.scoreUnit,
super.key,
});
final WorkoutMeasure measure;
final ScoreInputMode scoreInputMode;
final String? scoreUnit;
@override
Widget build(BuildContext context) {
final unit = scoreUnit?.trim();
final label =
measure == WorkoutMeasure.score && unit != null && unit.isNotEmpty
? '${measure.label} ($unit)'
: measure.label;
var label = measure.label;
if (measure == WorkoutMeasure.score &&
scoreInputMode == ScoreInputMode.stopwatch) {
label = 'Score chrono';
} else if (measure == WorkoutMeasure.score &&
unit != null &&
unit.isNotEmpty) {
label = '${measure.label} ($unit)';
}
return Semantics(
label: label,
excludeSemantics: true,
child: Chip(visualDensity: VisualDensity.compact, label: Text(label)),
);
}

View File

@ -539,6 +539,13 @@ final class _ProgramExerciseCustomizationScreenState
@override
Widget build(BuildContext context) {
final scoreUnit = _draft.scoreUnitSnapshot;
final hasStopwatchScore =
_draft.scoreInputModeSnapshot == ScoreInputMode.stopwatch;
final scoreTitle = hasStopwatchScore
? 'Score chrono'
: scoreUnit == null
? 'Score'
: 'Score ($scoreUnit)';
return Scaffold(
appBar: AppBar(title: const Text("Personnaliser l'exercice")),
bottomNavigationBar: SafeArea(
@ -603,10 +610,19 @@ final class _ProgramExerciseCustomizationScreenState
if (_draft.availableScoreSnapshot)
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(scoreUnit == null ? 'Score' : 'Score ($scoreUnit)'),
title: Text(scoreTitle),
value: _draft.enabledMeasures.contains(WorkoutMeasure.score),
onChanged: (value) => _toggle(WorkoutMeasure.score, value),
),
if (_draft.enabledMeasures.contains(WorkoutMeasure.time) &&
_draft.enabledMeasures.contains(WorkoutMeasure.score) &&
hasStopwatchScore) ...[
const SizedBox(height: 8),
const Text(
"Temps sert d'objectif de durée ; Score chrono enregistre "
'le temps réalisé.',
),
],
if (_measureError != null)
Padding(
padding: const EdgeInsets.only(top: 8),
@ -630,13 +646,31 @@ final class _ProgramExerciseCustomizationScreenState
onChanged: (value) => _draft.targetReps = value?.round(),
),
if (_draft.enabledMeasures.contains(WorkoutMeasure.score))
_NumberField(
label: scoreUnit == null
? 'Cible score'
: 'Cible score ($scoreUnit)',
initialValue: _draft.targetScore,
onChanged: (value) => _draft.targetScore = value,
),
if (hasStopwatchScore)
_NumberField(
label: 'Objectif de chrono',
helperText: 'Le résultat réel sera mesuré pendant la série.',
initialValue: _millisecondsToSeconds(
_draft.targetScoreTimeMs,
),
onChanged: (value) {
_draft.targetScore = null;
_draft.targetScoreTimeMs = value == null
? null
: (value * 1000).round();
},
)
else
_NumberField(
label: scoreUnit == null
? 'Cible score'
: 'Cible score ($scoreUnit)',
initialValue: _draft.targetScore,
onChanged: (value) {
_draft.targetScore = value;
_draft.targetScoreTimeMs = null;
},
),
const SizedBox(height: 24),
Text('Repos', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
@ -662,6 +696,10 @@ final class _ProgramExerciseCustomizationScreenState
_draft.enabledMeasures = {..._draft.enabledMeasures, measure};
} else {
_draft.enabledMeasures = {..._draft.enabledMeasures}..remove(measure);
if (measure == WorkoutMeasure.score) {
_draft.targetScore = null;
_draft.targetScoreTimeMs = null;
}
}
});
}
@ -731,11 +769,13 @@ final class _NumberField extends StatelessWidget {
required this.label,
required this.initialValue,
required this.onChanged,
this.helperText,
});
final String label;
final num? initialValue;
final ValueChanged<double?> onChanged;
final String? helperText;
@override
Widget build(BuildContext context) {
@ -743,7 +783,7 @@ final class _NumberField extends StatelessWidget {
padding: const EdgeInsets.only(top: 8),
child: TextFormField(
initialValue: initialValue?.toString(),
decoration: InputDecoration(labelText: label),
decoration: InputDecoration(labelText: label, helperText: helperText),
keyboardType: TextInputType.number,
onChanged: (value) => onChanged(double.tryParse(value)),
),
@ -764,6 +804,7 @@ final class _ProgramExerciseDraft {
required this.availableTimeSnapshot,
required this.availableRepsSnapshot,
required this.availableScoreSnapshot,
this.scoreInputModeSnapshot = ScoreInputMode.manual,
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
required this.setsCount,
@ -771,6 +812,7 @@ final class _ProgramExerciseDraft {
this.targetTimeSeconds,
this.targetReps,
this.targetScore,
this.targetScoreTimeMs,
this.restSecondsOverride,
});
@ -789,6 +831,7 @@ final class _ProgramExerciseDraft {
availableTimeSnapshot: exercise.hasTimeMeasure,
availableRepsSnapshot: exercise.hasRepsMeasure,
availableScoreSnapshot: exercise.hasScoreMeasure,
scoreInputModeSnapshot: exercise.scoreInputMode,
scoreLabelSnapshot: exercise.scoreLabel,
scoreUnitSnapshot: exercise.scoreUnit,
setsCount: 3,
@ -810,6 +853,7 @@ final class _ProgramExerciseDraft {
availableTimeSnapshot: exercise.availableTimeSnapshot,
availableRepsSnapshot: exercise.availableRepsSnapshot,
availableScoreSnapshot: exercise.availableScoreSnapshot,
scoreInputModeSnapshot: exercise.scoreInputModeSnapshot,
scoreLabelSnapshot: exercise.scoreLabelSnapshot,
scoreUnitSnapshot: exercise.scoreUnitSnapshot,
setsCount: exercise.setsCount,
@ -821,6 +865,7 @@ final class _ProgramExerciseDraft {
targetTimeSeconds: exercise.targetTimeSeconds,
targetReps: exercise.targetReps,
targetScore: exercise.targetScore,
targetScoreTimeMs: exercise.targetScoreTimeMs,
restSecondsOverride: exercise.restSecondsOverride,
);
}
@ -836,6 +881,7 @@ final class _ProgramExerciseDraft {
final bool availableTimeSnapshot;
final bool availableRepsSnapshot;
final bool availableScoreSnapshot;
final ScoreInputMode scoreInputModeSnapshot;
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
int setsCount;
@ -843,6 +889,7 @@ final class _ProgramExerciseDraft {
int? targetTimeSeconds;
int? targetReps;
double? targetScore;
int? targetScoreTimeMs;
int? restSecondsOverride;
_ProgramExerciseDraft copy() {
@ -858,6 +905,7 @@ final class _ProgramExerciseDraft {
availableTimeSnapshot: availableTimeSnapshot,
availableRepsSnapshot: availableRepsSnapshot,
availableScoreSnapshot: availableScoreSnapshot,
scoreInputModeSnapshot: scoreInputModeSnapshot,
scoreLabelSnapshot: scoreLabelSnapshot,
scoreUnitSnapshot: scoreUnitSnapshot,
setsCount: setsCount,
@ -865,11 +913,14 @@ final class _ProgramExerciseDraft {
targetTimeSeconds: targetTimeSeconds,
targetReps: targetReps,
targetScore: targetScore,
targetScoreTimeMs: targetScoreTimeMs,
restSecondsOverride: restSecondsOverride,
);
}
ProgramExerciseConfig toConfig() {
final scoreEnabled = enabledMeasures.contains(WorkoutMeasure.score);
final stopwatchScore = scoreInputModeSnapshot == ScoreInputMode.stopwatch;
return ProgramExerciseConfig(
existingMetadata: existingMetadata,
sourceExerciseId: sourceExerciseId,
@ -881,18 +932,26 @@ final class _ProgramExerciseDraft {
availableTimeSnapshot: availableTimeSnapshot,
availableRepsSnapshot: availableRepsSnapshot,
availableScoreSnapshot: availableScoreSnapshot,
scoreInputModeSnapshot: scoreInputModeSnapshot,
scoreLabelSnapshot: scoreLabelSnapshot,
scoreUnitSnapshot: scoreUnitSnapshot,
setsCount: setsCount,
enabledMeasures: enabledMeasures,
targetTimeSeconds: targetTimeSeconds,
targetReps: targetReps,
targetScore: targetScore,
targetScore: scoreEnabled && !stopwatchScore ? targetScore : null,
targetScoreTimeMs: scoreEnabled && stopwatchScore
? targetScoreTimeMs
: null,
restSecondsOverride: restSecondsOverride,
);
}
}
double? _millisecondsToSeconds(int? milliseconds) {
return milliseconds == null ? null : milliseconds / 1000;
}
String _programSummary(Program program) {
final exerciseCount = program.exercises.length;
final setsCount = program.exercises.fold<int>(
@ -907,6 +966,10 @@ String _exerciseSummary(_ProgramExerciseDraft draft, int defaultRestSeconds) {
final measures = draft.enabledMeasures
.map((measure) {
final unit = draft.scoreUnitSnapshot?.trim();
if (measure == WorkoutMeasure.score &&
draft.scoreInputModeSnapshot == ScoreInputMode.stopwatch) {
return 'Score chrono';
}
if (measure == WorkoutMeasure.score &&
unit != null &&
unit.isNotEmpty) {

View File

@ -66,6 +66,70 @@ void main() {
expect(find.text('Score (kg)'), findsOneWidget);
});
testWidgets('basculer en chrono intégré masque lunité et affiche le badge', (
tester,
) async {
final exerciseRepository = _FakeExerciseRepository();
await tester.pumpWidget(
MaterialApp(
home: ExerciseFormScreen(
exerciseUseCases: _exerciseUseCases(exerciseRepository),
mediaUseCases: _mediaUseCases(exerciseRepository),
),
),
);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Run');
await tester.dragUntilVisible(
find.text('Score'),
find.byType(ListView),
const Offset(0, -200),
);
await tester.tap(find.widgetWithText(SwitchListTile, 'Score'));
await tester.pump();
await tester.dragUntilVisible(
find.text('Unité'),
find.byType(ListView),
const Offset(0, -200),
);
expect(find.widgetWithText(TextFormField, 'Unité'), findsOneWidget);
await tester.dragUntilVisible(
find.text('Chrono intégré'),
find.byType(ListView),
const Offset(0, 200),
);
await tester.tap(find.text('Chrono intégré'));
await tester.pump();
await tester.dragUntilVisible(
find.text('Temps réalisé'),
find.byType(ListView),
const Offset(0, -200),
);
expect(find.text('Temps réalisé'), findsOneWidget);
expect(find.widgetWithText(TextFormField, 'Unité'), findsNothing);
await tester.ensureVisible(find.text('Enregistrer'));
await tester.tap(find.text('Enregistrer'));
await tester.pumpAndSettle();
final saved = exerciseRepository.saved.single;
expect(saved.scoreInputMode, ScoreInputMode.stopwatch);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(body: ExerciseListTile(exercise: saved)),
),
);
await tester.pumpAndSettle();
expect(find.text('Score chrono'), findsOneWidget);
expect(find.text('Score'), findsNothing);
});
testWidgets(
'sélectionner une image déclenche limport et confirme le fichier',
(tester) async {

View File

@ -102,6 +102,73 @@ void main() {
expect(find.widgetWithText(SwitchListTile, 'Score'), findsNothing);
},
);
testWidgets('affiche lobjectif de chrono pour un score en mode chrono', (
tester,
) async {
final exerciseRepository = _FakeExerciseRepository()
..exercises.add(
Exercise(
metadata: _metadata('exercise-1'),
name: 'Suicides',
hasTimeMeasure: true,
hasRepsMeasure: true,
hasScoreMeasure: true,
scoreInputMode: ScoreInputMode.stopwatch,
scoreLabel: 'Temps réalisé',
),
);
await tester.pumpWidget(
MaterialApp(
home: ProgramFormScreen(
programUseCases: _programUseCases(
_FakeProgramRepository(),
exerciseRepository,
),
exerciseUseCases: _exerciseUseCases(exerciseRepository),
),
),
);
await tester.tap(find.text('Ajouter un exercice'));
await tester.pumpAndSettle();
await tester.tap(find.text('Suicides'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip("Personnaliser l'exercice"));
await tester.pumpAndSettle();
expect(find.text('Score chrono'), findsOneWidget);
await tester.dragUntilVisible(
find.text(
"Temps sert d'objectif de durée ; Score chrono enregistre "
'le temps réalisé.',
),
find.byType(ListView),
const Offset(0, -200),
);
expect(
find.text(
"Temps sert d'objectif de durée ; Score chrono enregistre "
'le temps réalisé.',
),
findsOneWidget,
);
await tester.dragUntilVisible(
find.text('Objectif de chrono'),
find.byType(ListView),
const Offset(0, -200),
);
expect(
find.widgetWithText(TextFormField, 'Objectif de chrono'),
findsOneWidget,
);
expect(find.widgetWithText(TextFormField, 'Cible score'), findsNothing);
expect(
find.text('Le résultat réel sera mesuré pendant la série.'),
findsOneWidget,
);
});
}
ExerciseUseCases _exerciseUseCases(_FakeExerciseRepository repository) {