merge(main): édition ponctuelle des séries en séance (ticket #23)

Fusionne feature/#23-edition-ponctuelle — analyze propre, 33/33 tests
verts, build APK debug validé. Clôture le lot navigation libre /
programme simplifié (tickets #20 à #23).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 00:38:03 +02:00
5 changed files with 448 additions and 13 deletions

View File

@ -1,6 +1,6 @@
--- ---
issueRef: "#23" issueRef: "#23"
version: 3 version: 5
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784325855260 updatedAt: 1784327862613
--- ---

View File

@ -2,7 +2,7 @@
id: "5b61b1a0-6bed-4d51-912f-cedf27f2b1a2" id: "5b61b1a0-6bed-4d51-912f-cedf27f2b1a2"
number: 23 number: 23
title: "[DevFrontend] Exécution : édition ponctuelle d'une série passée ou terminée" title: "[DevFrontend] Exécution : édition ponctuelle d'une série passée ou terminée"
status: "open" status: "closed"
priority: "high" priority: "high"
sprint: null sprint: null
links: [{"target":"#9","kind":"relatesTo"},{"target":"#21","kind":"dependsOn"},{"target":"#22","kind":"dependsOn"}] links: [{"target":"#9","kind":"relatesTo"},{"target":"#21","kind":"dependsOn"},{"target":"#22","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"} createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1784325846325 createdAt: 1784325846325
updatedAt: 1784325855260 updatedAt: 1784327862613
version: 3 version: 5
--- ---
Depuis le plan de séance, permettre de taper une série Terminée ou Passée pour ouvrir une bottom sheet "Modifier la série" (mêmes champs que l'exécution active) et sauvegarder via upsertSetResultAtPosition, sans déplacer le curseur de progression ni affecter les repos/temps total. Gestion du repos actif pendant l'édition (bandeau compact persistant). Cf. mémoire "gametime-ux-execution-nav-and-program-simplification" point 1 pour le détail complet des cas limites. Depuis le plan de séance, permettre de taper une série Terminée ou Passée pour ouvrir une bottom sheet "Modifier la série" (mêmes champs que l'exécution active) et sauvegarder via upsertSetResultAtPosition, sans déplacer le curseur de progression ni affecter les repos/temps total. Gestion du repos actif pendant l'édition (bandeau compact persistant). Cf. mémoire "gametime-ux-execution-nav-and-program-simplification" point 1 pour le détail complet des cas limites.

View File

@ -255,13 +255,13 @@
"issueRef": "#23", "issueRef": "#23",
"path": "23", "path": "23",
"title": "[DevFrontend] Exécution : édition ponctuelle d'une série passée ou terminée", "title": "[DevFrontend] Exécution : édition ponctuelle d'une série passée ou terminée",
"status": "open", "status": "closed",
"priority": "high", "priority": "high",
"sprint": null, "sprint": null,
"assignedAgentIds": [ "assignedAgentIds": [
"9933c93a-b8a1-4164-a3bb-7063fdad747d" "9933c93a-b8a1-4164-a3bb-7063fdad747d"
], ],
"updatedAt": 1784325855260 "updatedAt": 1784327862613
} }
] ]
} }

View File

@ -323,10 +323,12 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
builder: (context) => FractionallySizedBox( builder: (context) => FractionallySizedBox(
heightFactor: 1, heightFactor: 1,
child: _WorkoutPlanSheet( child: _WorkoutPlanSheet(
sessionId: _session.metadata.id,
plan: _plan, plan: _plan,
currentPosition: _position, currentPosition: _position,
setStates: setStates, setStates: setStates,
activeRest: activeRest, activeRest: activeRest,
activeUseCases: widget.activeUseCases,
), ),
), ),
); );
@ -609,15 +611,19 @@ final class SetMeasureInput extends StatelessWidget {
final class _WorkoutPlanSheet extends StatefulWidget { final class _WorkoutPlanSheet extends StatefulWidget {
const _WorkoutPlanSheet({ const _WorkoutPlanSheet({
required this.sessionId,
required this.plan, required this.plan,
required this.currentPosition, required this.currentPosition,
required this.setStates, required this.setStates,
required this.activeUseCases,
this.activeRest, this.activeRest,
}); });
final String sessionId;
final WorkoutExecutionPlan plan; final WorkoutExecutionPlan plan;
final ExecutionPosition currentPosition; final ExecutionPosition currentPosition;
final List<SetResultPositionState> setStates; final List<SetResultPositionState> setStates;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveRestState? activeRest; final ActiveRestState? activeRest;
@override @override
@ -627,10 +633,12 @@ final class _WorkoutPlanSheet extends StatefulWidget {
final class _WorkoutPlanSheetState extends State<_WorkoutPlanSheet> { final class _WorkoutPlanSheetState extends State<_WorkoutPlanSheet> {
Timer? _timer; Timer? _timer;
late int _remainingRestSeconds; late int _remainingRestSeconds;
late List<SetResultPositionState> _setStates;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_setStates = widget.setStates;
_remainingRestSeconds = _remainingSecondsForRest(widget.activeRest); _remainingRestSeconds = _remainingSecondsForRest(widget.activeRest);
if (widget.activeRest != null && _remainingRestSeconds > 0) { if (widget.activeRest != null && _remainingRestSeconds > 0) {
_timer = Timer.periodic(const Duration(seconds: 1), (_) { _timer = Timer.periodic(const Duration(seconds: 1), (_) {
@ -653,7 +661,7 @@ final class _WorkoutPlanSheetState extends State<_WorkoutPlanSheet> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final statesByPosition = { final statesByPosition = {
for (final state in widget.setStates) _stateKey(state): state, for (final state in _setStates) _stateKey(state): state,
}; };
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@ -715,12 +723,20 @@ final class _WorkoutPlanSheetState extends State<_WorkoutPlanSheet> {
setIndex: setIndex, setIndex: setIndex,
), ),
currentPosition: widget.currentPosition, currentPosition: widget.currentPosition,
exercise: widget
.plan
.programs[programIndex]
.exercises[exerciseIndex],
state: state:
statesByPosition[_positionKey( statesByPosition[_positionKey(
programIndex, programIndex,
exerciseIndex, exerciseIndex,
setIndex, setIndex,
)], )],
onUpdated: _refreshAfterEdit,
activeUseCases: widget.activeUseCases,
sessionId: widget.sessionId,
activeRest: widget.activeRest,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
@ -729,6 +745,17 @@ final class _WorkoutPlanSheetState extends State<_WorkoutPlanSheet> {
), ),
); );
} }
Future<void> _refreshAfterEdit() async {
final updated = await widget.activeUseCases.listSetResults(
widget.sessionId,
);
if (!mounted) return;
setState(() => _setStates = updated);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Série mise à jour')));
}
} }
final class _RestPlanBanner extends StatelessWidget { final class _RestPlanBanner extends StatelessWidget {
@ -752,17 +779,30 @@ final class _WorkoutPlanSetTile extends StatelessWidget {
const _WorkoutPlanSetTile({ const _WorkoutPlanSetTile({
required this.position, required this.position,
required this.currentPosition, required this.currentPosition,
required this.exercise,
required this.onUpdated,
required this.activeUseCases,
required this.sessionId,
this.activeRest,
this.state, this.state,
}); });
final ExecutionPosition position; final ExecutionPosition position;
final ExecutionPosition currentPosition; final ExecutionPosition currentPosition;
final ExecutionExercise exercise;
final SetResultPositionState? state; final SetResultPositionState? state;
final Future<void> Function() onUpdated;
final ActiveWorkoutSessionUseCases activeUseCases;
final String sessionId;
final ActiveRestState? activeRest;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final planState = _planSetState(position, currentPosition, state); final planState = _planSetState(position, currentPosition, state);
final tappable = planState != _PlanSetStatus.todo; final tappable = planState != _PlanSetStatus.todo;
final VoidCallback? onTap = tappable
? () => unawaited(_handleTap(context, planState))
: null;
final tokens = courtBlazerTokensOf(context); final tokens = courtBlazerTokensOf(context);
return Card( return Card(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
@ -778,11 +818,273 @@ final class _WorkoutPlanSetTile extends StatelessWidget {
enabled: tappable, enabled: tappable,
title: Text('Série ${position.setIndex + 1}'), title: Text('Série ${position.setIndex + 1}'),
subtitle: Text(_planSetSubtitle(planState, state?.result)), subtitle: Text(_planSetSubtitle(planState, state?.result)),
trailing: _PlanStatusBadge( trailing: _PlanStatusBadge(status: planState, onTap: onTap),
status: planState, onTap: onTap,
onTap: tappable ? () => Navigator.of(context).pop() : null, ),
);
}
Future<void> _handleTap(BuildContext context, _PlanSetStatus status) async {
if (status == _PlanSetStatus.current) {
Navigator.of(context).pop();
return;
}
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => _EditSetResultSheet(
sessionId: sessionId,
position: position,
exercise: exercise,
state: state,
status: status,
activeUseCases: activeUseCases,
activeRest: activeRest,
onSaved: onUpdated,
),
);
}
}
final class _EditSetResultSheet extends StatefulWidget {
const _EditSetResultSheet({
required this.sessionId,
required this.position,
required this.exercise,
required this.state,
required this.status,
required this.activeUseCases,
required this.onSaved,
this.activeRest,
});
final String sessionId;
final ExecutionPosition position;
final ExecutionExercise exercise;
final SetResultPositionState? state;
final _PlanSetStatus status;
final ActiveWorkoutSessionUseCases activeUseCases;
final Future<void> Function() onSaved;
final ActiveRestState? activeRest;
@override
State<_EditSetResultSheet> createState() => _EditSetResultSheetState();
}
final class _EditSetResultSheetState extends State<_EditSetResultSheet> {
final _timeController = TextEditingController();
final _repsController = TextEditingController();
final _scoreController = TextEditingController();
Timer? _timer;
late int _remainingRestSeconds;
@override
void initState() {
super.initState();
final result = widget.state?.result;
final actualTimeMs = result?.actualTimeMs;
if (actualTimeMs != null) {
_timeController.text = (actualTimeMs / 1000).round().toString();
}
final actualReps = result?.actualReps;
if (actualReps != null) {
_repsController.text = actualReps.toString();
}
final actualScore = result?.actualScore;
if (actualScore != null) {
_scoreController.text = _formatScore(actualScore);
}
_remainingRestSeconds = _remainingSecondsForRest(widget.activeRest);
if (widget.activeRest != null && _remainingRestSeconds > 0) {
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() {
_remainingRestSeconds = (_remainingRestSeconds - 1)
.clamp(0, 9999)
.toInt();
});
});
}
}
@override
void dispose() {
_timer?.cancel();
_timeController.dispose();
_repsController.dispose();
_scoreController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isCompleted = widget.status == _PlanSetStatus.completed;
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
),
child: ListView(
shrinkWrap: true,
children: [
Row(
children: [
Expanded(
child: Text(
'Modifier la série',
style: Theme.of(context).textTheme.titleLarge,
),
),
IconButton(
tooltip: 'Annuler',
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 8),
Text(
'${widget.exercise.name} · '
'Série ${widget.position.setIndex + 1}/${widget.exercise.setsCount}',
),
const SizedBox(height: 8),
Text(
'État actuel : ${isCompleted ? 'Terminée' : 'Passée'}',
style: Theme.of(context).textTheme.bodySmall,
),
if (widget.activeRest != null) ...[
const SizedBox(height: 12),
_RestPlanBanner(remainingSeconds: _remainingRestSeconds),
],
const SizedBox(height: 24),
if (widget.exercise.timeEnabled) ...[
TextField(
controller: _timeController,
decoration: const InputDecoration(labelText: 'Temps réalisé (s)'),
keyboardType: TextInputType.number,
),
const SizedBox(height: 12),
],
if (widget.exercise.repsEnabled) ...[
TextField(
controller: _repsController,
decoration: const InputDecoration(labelText: 'Répétitions'),
keyboardType: TextInputType.number,
),
const SizedBox(height: 12),
],
if (widget.exercise.scoreEnabled) ...[
TextField(
controller: _scoreController,
decoration: InputDecoration(
labelText: widget.exercise.scoreUnit == null
? 'Score'
: 'Score (${widget.exercise.scoreUnit})',
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 12),
],
const SizedBox(height: 12),
FilledButton(
onPressed: _saveCompleted,
child: Text(
isCompleted
? 'Enregistrer les corrections'
: 'Enregistrer le résultat',
),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: _markSkipped,
child: const Text('Marquer comme passée'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
],
),
);
}
Future<void> _saveCompleted() async {
if (!_hasAnyValue()) {
if (widget.status == _PlanSetStatus.completed) {
final confirmed = await _confirmMarkSkipped();
if (confirmed != true) return;
}
await _saveSkipped();
return;
}
await widget.activeUseCases.upsertSetResultAtPosition(
sessionId: widget.sessionId,
programIndex: widget.position.programIndex,
exerciseIndex: widget.position.exerciseIndex,
setIndex: widget.position.setIndex,
status: SetResultStatus.completed,
actualTimeMs: widget.exercise.timeEnabled
? _secondsToMilliseconds(_timeController.text)
: null,
actualReps: widget.exercise.repsEnabled
? int.tryParse(_repsController.text.trim())
: null,
actualScore: widget.exercise.scoreEnabled
? double.tryParse(_scoreController.text.trim())
: null,
scoreUnitSnapshot: widget.exercise.scoreUnit,
);
await _finishSave();
}
Future<void> _markSkipped() async {
await _saveSkipped();
}
Future<void> _saveSkipped() async {
await widget.activeUseCases.upsertSetResultAtPosition(
sessionId: widget.sessionId,
programIndex: widget.position.programIndex,
exerciseIndex: widget.position.exerciseIndex,
setIndex: widget.position.setIndex,
status: SetResultStatus.skipped,
);
await _finishSave();
}
Future<void> _finishSave() async {
await widget.onSaved();
if (!mounted) return;
Navigator.of(context).pop();
}
bool _hasAnyValue() {
return _timeController.text.trim().isNotEmpty ||
_repsController.text.trim().isNotEmpty ||
_scoreController.text.trim().isNotEmpty;
}
Future<bool?> _confirmMarkSkipped() {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer le résultat de cette série ?'),
content: const Text(
'La série restera dans le plan comme passée, sans résultat saisi.',
), ),
onTap: tappable ? () => Navigator.of(context).pop() : null, actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Marquer comme passée'),
),
],
), ),
); );
} }
@ -872,6 +1174,11 @@ String _formatScore(double value) {
: value.toString(); : value.toString();
} }
int? _secondsToMilliseconds(String rawValue) {
final seconds = int.tryParse(rawValue.trim());
return seconds == null ? null : seconds * 1000;
}
int _remainingSecondsForRest(ActiveRestState? rest) { int _remainingSecondsForRest(ActiveRestState? rest) {
if (rest == null) return 0; if (rest == null) return 0;
final elapsedSeconds = DateTime.now() final elapsedSeconds = DateTime.now()

View File

@ -280,6 +280,123 @@ void main() {
expect(find.text('Plan de séance'), findsNothing); expect(find.text('Plan de séance'), findsNothing);
}); });
testWidgets(
'modifier une série passée la termine sans changer la position courante',
(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: 2,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 4,
restSeconds: 0,
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.tap(find.byTooltip('Voir le plan'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ListTile, 'Série 2'));
await tester.pumpAndSettle();
expect(find.text('Modifier la série'), findsOneWidget);
await tester.enterText(_textFieldWithLabel('Répétitions'), '12');
await tester.ensureVisible(find.text('Enregistrer le résultat'));
await tester.tap(find.text('Enregistrer le résultat'));
await tester.pumpAndSettle();
expect(repository.session?.currentSetIndex, 2);
expect(repository.results.single.status, SetResultStatus.completed);
expect(repository.results.single.actualReps, 12);
expect(find.text('Série mise à jour'), findsWidgets);
expect(find.text('12 reps'), findsOneWidget);
await tester.tap(find.byTooltip('Fermer'));
await tester.pumpAndSettle();
expect(
find.text('Programme 1/1 · Exercice 1/1 · Série 3/4'),
findsOneWidget,
);
},
);
testWidgets(
'toucher la série courante dans le plan revient à lécran principal',
(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,
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.tap(find.byTooltip('Voir le plan'));
await tester.pumpAndSettle();
await tester.tap(find.text('En cours'));
await tester.pumpAndSettle();
expect(find.text('Plan de séance'), findsNothing);
expect(find.text('Modifier la série'), findsNothing);
expect(
find.text('Programme 1/1 · Exercice 1/1 · Série 2/3'),
findsOneWidget,
);
},
);
}
Finder _textFieldWithLabel(String label) {
return find.byWidgetPredicate(
(widget) => widget is TextField && widget.decoration?.labelText == label,
description: 'TextField with label "$label"',
);
} }
ActiveWorkoutSessionUseCases _activeUseCases( ActiveWorkoutSessionUseCases _activeUseCases(
@ -437,7 +554,18 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override @override
Future<void> saveSetResult(ActiveSetResult result) async { Future<void> saveSetResult(ActiveSetResult result) async {
results.add(result); final index = results.indexWhere(
(saved) =>
saved.activeWorkoutSessionId == result.activeWorkoutSessionId &&
saved.programIndex == result.programIndex &&
saved.exerciseIndex == result.exerciseIndex &&
saved.setIndex == result.setIndex,
);
if (index == -1) {
results.add(result);
} else {
results[index] = result;
}
} }
} }