merge(main): correctif série non terminable (ticket #29)
Fusionne feature/#29-serie-non-terminable — analyze propre, 42/42 tests verts, build APK debug validé. Ticket laissé en statut QA : validation manuelle sur APK par l'utilisateur avant clôture. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1,6 +1,11 @@
|
||||
---
|
||||
issueRef: "#29"
|
||||
version: 5
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1784329025027
|
||||
version: 7
|
||||
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||
updatedAt: 1784358750450
|
||||
---
|
||||
Correctif appliqué : l'écran d'exécution ne lisait pas correctement `scoreInputModeSnapshot` depuis le snapshot de séance avant de transmettre le résultat à `recordCurrentSetResult`, ce qui empêchait l'enregistrement du score manuel quand Temps+Répétitions+Score étaient actifs simultanément — la série ne pouvait donc jamais être validée. Corrigé : lecture du mode depuis le snapshot, et ajout d'un SnackBar d'erreur explicite si l'enregistrement échoue malgré tout (pour éviter un "rien ne se passe" silencieux à l'avenir).
|
||||
|
||||
Vérifié : analyze propre, 42/42 tests verts (dont un nouveau test qui reproduit exactement le scénario rapporté : série 2/3, Temps+Répétitions+Score actifs, valeurs différentes des cibles, "Terminer la série" fait bien avancer à 3/3), build APK debug réussi.
|
||||
|
||||
À tester sur le téléphone : reproduire le scénario exact (exercice à 3 mesures, série en cours avec valeurs différentes des cibles, "Terminer la série").
|
||||
@ -2,15 +2,15 @@
|
||||
id: "89da284b-871c-4e5b-9bfa-ab16f386ba8f"
|
||||
number: 29
|
||||
title: "[Bug] Serie qu'on ne peut pas terminer"
|
||||
status: "open"
|
||||
status: "qa"
|
||||
priority: "critical"
|
||||
sprint: "abc4f969-b169-45f7-988c-daeeab762201"
|
||||
links: []
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||
createdAt: 1784328426827
|
||||
updatedAt: 1784329025027
|
||||
version: 5
|
||||
updatedAt: 1784358750450
|
||||
version: 7
|
||||
---
|
||||
Dans un programme j'ai un exercice qui se compose de 3 series, une mesure a suivre de temps, de répétition et un score. J'ai mis le temps cible à 30, le nombre de répétition à 5. Une fois dans ma séance, je suis sur la série 2/3, temps indiqué à 30 s, j'ai mis Répétitions sur 3 et score sur 5, mais quand je clique sur temriner la série, rien ne se passe
|
||||
@ -327,11 +327,11 @@
|
||||
"issueRef": "#29",
|
||||
"path": "29",
|
||||
"title": "[Bug] Serie qu'on ne peut pas terminer",
|
||||
"status": "open",
|
||||
"status": "qa",
|
||||
"priority": "critical",
|
||||
"sprint": "abc4f969-b169-45f7-988c-daeeab762201",
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1784329025027
|
||||
"updatedAt": 1784358750450
|
||||
},
|
||||
{
|
||||
"issueRef": "#30",
|
||||
|
||||
@ -354,48 +354,60 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
}
|
||||
|
||||
Future<void> _finishSet({required bool skipped}) async {
|
||||
final score = double.tryParse(_scoreController.text.trim());
|
||||
final actualTimeMs = DateTime.now()
|
||||
.toUtc()
|
||||
.difference(_seriesStartedAt)
|
||||
.inMilliseconds;
|
||||
await widget.activeUseCases.recordCurrentSetResult(
|
||||
sessionId: _session.metadata.id,
|
||||
programSnapshotId: _plan.programAt(_position).id,
|
||||
exerciseSnapshotId: _exercise.id,
|
||||
programIndex: _position.programIndex,
|
||||
exerciseIndex: _position.exerciseIndex,
|
||||
setIndex: _position.setIndex,
|
||||
actualReps: skipped ? null : (_exercise.repsEnabled ? _reps : null),
|
||||
actualScore: skipped ? null : (_exercise.scoreEnabled ? score : null),
|
||||
scoreUnitSnapshot: _exercise.scoreUnit,
|
||||
actualTimeMs: skipped
|
||||
? null
|
||||
: (_exercise.timeEnabled ? actualTimeMs : null),
|
||||
);
|
||||
|
||||
final next = _plan.nextPosition(_position);
|
||||
if (next == null) {
|
||||
await _complete();
|
||||
return;
|
||||
}
|
||||
final shouldRest = _plan.shouldShowRestAfter(_position);
|
||||
if (shouldRest) {
|
||||
final rest = await widget.activeUseCases.startRestAfterSet(
|
||||
try {
|
||||
final score = double.tryParse(_scoreController.text.trim());
|
||||
final actualTimeMs = DateTime.now()
|
||||
.toUtc()
|
||||
.difference(_seriesStartedAt)
|
||||
.inMilliseconds;
|
||||
await widget.activeUseCases.recordCurrentSetResult(
|
||||
sessionId: _session.metadata.id,
|
||||
afterProgramIndex: _position.programIndex,
|
||||
afterExerciseIndex: _position.exerciseIndex,
|
||||
afterSetIndex: _position.setIndex,
|
||||
plannedRestSeconds: _exercise.restSeconds,
|
||||
programSnapshotId: _plan.programAt(_position).id,
|
||||
exerciseSnapshotId: _exercise.id,
|
||||
programIndex: _position.programIndex,
|
||||
exerciseIndex: _position.exerciseIndex,
|
||||
setIndex: _position.setIndex,
|
||||
actualReps: skipped ? null : (_exercise.repsEnabled ? _reps : null),
|
||||
actualScore: skipped
|
||||
? null
|
||||
: (_exercise.manualScoreEnabled ? score : null),
|
||||
scoreInputModeSnapshot: _exercise.scoreInputMode,
|
||||
scoreLabelSnapshot: _exercise.scoreLabel,
|
||||
scoreUnitSnapshot: _exercise.scoreUnit,
|
||||
actualTimeMs: skipped
|
||||
? null
|
||||
: (_exercise.timeEnabled ? actualTimeMs : null),
|
||||
);
|
||||
|
||||
final next = _plan.nextPosition(_position);
|
||||
if (next == null) {
|
||||
await _complete();
|
||||
return;
|
||||
}
|
||||
final shouldRest = _plan.shouldShowRestAfter(_position);
|
||||
if (shouldRest) {
|
||||
final rest = await widget.activeUseCases.startRestAfterSet(
|
||||
sessionId: _session.metadata.id,
|
||||
afterProgramIndex: _position.programIndex,
|
||||
afterExerciseIndex: _position.exerciseIndex,
|
||||
afterSetIndex: _position.setIndex,
|
||||
plannedRestSeconds: _exercise.restSeconds,
|
||||
);
|
||||
if (!mounted) return;
|
||||
_activeRestStateId = rest.metadata.id;
|
||||
_remainingRestSeconds = _exercise.restSeconds;
|
||||
_startRestTicker(next);
|
||||
setState(() => _mode = WorkoutExecutionMode.rest);
|
||||
return;
|
||||
}
|
||||
await _moveTo(next);
|
||||
} on Exception catch (error) {
|
||||
if (!mounted) return;
|
||||
_activeRestStateId = rest.metadata.id;
|
||||
_remainingRestSeconds = _exercise.restSeconds;
|
||||
_startRestTicker(next);
|
||||
setState(() => _mode = WorkoutExecutionMode.rest);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Impossible de terminer la série : $error')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await _moveTo(next);
|
||||
}
|
||||
|
||||
void _startRestTicker(ExecutionPosition next) {
|
||||
@ -1344,6 +1356,8 @@ final class ExecutionExercise {
|
||||
required this.repsEnabled,
|
||||
required this.scoreEnabled,
|
||||
required this.restSeconds,
|
||||
this.scoreInputMode = ScoreInputMode.manual,
|
||||
this.scoreLabel,
|
||||
this.targetTimeSeconds,
|
||||
this.targetReps,
|
||||
this.targetScore,
|
||||
@ -1364,6 +1378,10 @@ final class ExecutionExercise {
|
||||
repsEnabled: exercise['repsEnabled'] as bool,
|
||||
scoreEnabled: exercise['scoreEnabled'] as bool,
|
||||
restSeconds: exercise['restSecondsOverride'] as int? ?? 0,
|
||||
scoreInputMode: _scoreInputModeFromSnapshot(
|
||||
exercise['scoreInputModeSnapshot'],
|
||||
),
|
||||
scoreLabel: exercise['scoreLabelSnapshot'] as String?,
|
||||
targetTimeSeconds:
|
||||
(override?['targetTimeSecondsOverride'] as int?) ??
|
||||
(exercise['targetTimeSeconds'] as int?),
|
||||
@ -1384,10 +1402,23 @@ final class ExecutionExercise {
|
||||
final bool repsEnabled;
|
||||
final bool scoreEnabled;
|
||||
final int restSeconds;
|
||||
final ScoreInputMode scoreInputMode;
|
||||
final String? scoreLabel;
|
||||
final int? targetTimeSeconds;
|
||||
final int? targetReps;
|
||||
final double? targetScore;
|
||||
final String? scoreUnit;
|
||||
|
||||
bool get manualScoreEnabled {
|
||||
return scoreEnabled && scoreInputMode == ScoreInputMode.manual;
|
||||
}
|
||||
}
|
||||
|
||||
ScoreInputMode _scoreInputModeFromSnapshot(Object? value) {
|
||||
return switch (value) {
|
||||
'stopwatch' => ScoreInputMode.stopwatch,
|
||||
_ => ScoreInputMode.manual,
|
||||
};
|
||||
}
|
||||
|
||||
final class ExecutionPosition {
|
||||
|
||||
@ -218,6 +218,74 @@ void main() {
|
||||
expect(repository.results.single.actualTimeMs, greaterThan(0));
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'terminer la série 2 avec temps répétitions et score manuel avance',
|
||||
(tester) async {
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||||
final repository = _FakeActiveSessionRepository();
|
||||
final session = ActiveWorkoutSession(
|
||||
metadata: _metadata('session-1'),
|
||||
sourceWorkoutTemplateId: 'template-1',
|
||||
status: ActiveWorkoutStatus.running,
|
||||
startedAt: DateTime.utc(2026, 7, 17, 12),
|
||||
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
|
||||
elapsedActiveMs: 0,
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 1,
|
||||
resolvedTemplateSnapshotJson: _sessionSnapshot(
|
||||
setsCount: 3,
|
||||
restSeconds: 0,
|
||||
targetTimeSeconds: 30,
|
||||
targetReps: 5,
|
||||
targetScore: 10,
|
||||
scoreUnit: 'points',
|
||||
),
|
||||
);
|
||||
repository.session = session;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: WorkoutExecutionScreen(
|
||||
initialSession: session,
|
||||
activeUseCases: _activeUseCases(repository, clock),
|
||||
closeUseCase: _closeUseCase(repository, clock),
|
||||
historyUseCases: _historyUseCases(clock),
|
||||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
find.text('Programme 1/1 · Exercice 1/1 · Série 2/3'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('30 s'), findsOneWidget);
|
||||
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pump();
|
||||
}
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Score (points)'),
|
||||
'5',
|
||||
);
|
||||
await tester.ensureVisible(find.text('Terminer la série'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Terminer la série'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repository.results.single.setIndex, 1);
|
||||
expect(repository.results.single.actualReps, 3);
|
||||
expect(repository.results.single.actualScore, 5);
|
||||
expect(repository.session?.currentSetIndex, 2);
|
||||
expect(
|
||||
find.text('Programme 1/1 · Exercice 1/1 · Série 3/3'),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('la flèche de retour met la séance active en pause', (
|
||||
tester,
|
||||
) async {
|
||||
@ -483,7 +551,15 @@ WorkoutTemplateUseCases _workoutTemplateUseCases() {
|
||||
);
|
||||
}
|
||||
|
||||
String _sessionSnapshot({int setsCount = 3, int restSeconds = 0}) {
|
||||
String _sessionSnapshot({
|
||||
int setsCount = 3,
|
||||
int restSeconds = 0,
|
||||
int targetTimeSeconds = 45,
|
||||
int targetReps = 10,
|
||||
double targetScore = 80,
|
||||
String scoreUnit = 'kg',
|
||||
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
|
||||
}) {
|
||||
return jsonEncode({
|
||||
'name': 'Séance jambes',
|
||||
'programs': [
|
||||
@ -499,10 +575,12 @@ String _sessionSnapshot({int setsCount = 3, int restSeconds = 0}) {
|
||||
'timeEnabled': true,
|
||||
'repsEnabled': true,
|
||||
'scoreEnabled': true,
|
||||
'targetTimeSeconds': 45,
|
||||
'targetReps': 10,
|
||||
'targetScore': 80,
|
||||
'scoreUnitSnapshot': 'kg',
|
||||
'targetTimeSeconds': targetTimeSeconds,
|
||||
'targetReps': targetReps,
|
||||
'targetScore': targetScore,
|
||||
'scoreInputModeSnapshot': scoreInputMode.name,
|
||||
'scoreLabelSnapshot': 'Score',
|
||||
'scoreUnitSnapshot': scoreUnit,
|
||||
'restSecondsOverride': restSeconds,
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user