From a93647841398a8207c456c82aa6b0067435df3f2 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 18 Jul 2026 00:30:41 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(ui):=20navigation=20libre=20dans=20le?= =?UTF-8?q?=20plan=20de=20s=C3=A9ance=20(ticket=20#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permet de circuler librement entre les exercices/séries du plan de séance sur workout_execution_screen.dart, au lieu d'un déroulé strictement linéaire. flutter analyze propre, 31/31 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 --- .../workout_execution_screen.dart | 323 +++++++++++++++++- .../workout_execution_screen_test.dart | 63 ++++ 2 files changed, 385 insertions(+), 1 deletion(-) diff --git a/lib/presentation/workout_execution_screen.dart b/lib/presentation/workout_execution_screen.dart index f386915..702b46d 100644 --- a/lib/presentation/workout_execution_screen.dart +++ b/lib/presentation/workout_execution_screen.dart @@ -81,7 +81,14 @@ final class _WorkoutExecutionScreenState extends State { child: Scaffold( appBar: AppBar( title: Text(_plan.name), - actions: [TextButton(onPressed: _pause, child: const Text('Pause'))], + actions: [ + IconButton( + tooltip: 'Voir le plan', + onPressed: _openWorkoutPlan, + icon: const Icon(Icons.list_alt), + ), + TextButton(onPressed: _pause, child: const Text('Pause')), + ], ), body: switch (_mode) { WorkoutExecutionMode.active => _buildActive(), @@ -301,6 +308,30 @@ final class _WorkoutExecutionScreenState extends State { Navigator.of(context).popUntil((route) => route.isFirst); } + Future _openWorkoutPlan() async { + final setStates = await widget.activeUseCases.listSetResults( + _session.metadata.id, + ); + final activeRest = await widget.activeUseCases.findActiveRest( + sessionId: _session.metadata.id, + ); + if (!mounted) return; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => FractionallySizedBox( + heightFactor: 1, + child: _WorkoutPlanSheet( + plan: _plan, + currentPosition: _position, + setStates: setStates, + activeRest: activeRest, + ), + ), + ); + } + Future _finishSet({required bool skipped}) async { final score = double.tryParse(_scoreController.text.trim()); final actualTimeMs = DateTime.now() @@ -576,6 +607,296 @@ final class SetMeasureInput extends StatelessWidget { } } +final class _WorkoutPlanSheet extends StatefulWidget { + const _WorkoutPlanSheet({ + required this.plan, + required this.currentPosition, + required this.setStates, + this.activeRest, + }); + + final WorkoutExecutionPlan plan; + final ExecutionPosition currentPosition; + final List setStates; + final ActiveRestState? activeRest; + + @override + State<_WorkoutPlanSheet> createState() => _WorkoutPlanSheetState(); +} + +final class _WorkoutPlanSheetState extends State<_WorkoutPlanSheet> { + Timer? _timer; + late int _remainingRestSeconds; + + @override + void initState() { + super.initState(); + _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(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final statesByPosition = { + for (final state in widget.setStates) _stateKey(state): state, + }; + return Scaffold( + appBar: AppBar( + title: const Text('Plan de séance'), + leading: IconButton( + tooltip: 'Fermer', + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + const Text('Touchez une série pour voir ses valeurs.'), + if (widget.activeRest != null) ...[ + const SizedBox(height: 12), + _RestPlanBanner(remainingSeconds: _remainingRestSeconds), + ], + const SizedBox(height: 24), + for ( + var programIndex = 0; + programIndex < widget.plan.programs.length; + programIndex++ + ) ...[ + Text( + widget.plan.programs[programIndex].name, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + for ( + var exerciseIndex = 0; + exerciseIndex < + widget.plan.programs[programIndex].exercises.length; + exerciseIndex++ + ) ...[ + Text( + widget + .plan + .programs[programIndex] + .exercises[exerciseIndex] + .name, + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 4), + for ( + var setIndex = 0; + setIndex < + widget + .plan + .programs[programIndex] + .exercises[exerciseIndex] + .setsCount; + setIndex++ + ) + _WorkoutPlanSetTile( + position: ExecutionPosition( + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ), + currentPosition: widget.currentPosition, + state: + statesByPosition[_positionKey( + programIndex, + exerciseIndex, + setIndex, + )], + ), + const SizedBox(height: 16), + ], + ], + ], + ), + ); + } +} + +final class _RestPlanBanner extends StatelessWidget { + const _RestPlanBanner({required this.remainingSeconds}); + + final int remainingSeconds; + + @override + Widget build(BuildContext context) { + final label = remainingSeconds <= 0 + ? 'Repos terminé · Reprendre' + : 'Repos en cours · ${_formatDuration(Duration(seconds: remainingSeconds))}'; + return CourtBlazerAccentPanel( + padding: const EdgeInsets.all(12), + child: Text(label), + ); + } +} + +final class _WorkoutPlanSetTile extends StatelessWidget { + const _WorkoutPlanSetTile({ + required this.position, + required this.currentPosition, + this.state, + }); + + final ExecutionPosition position; + final ExecutionPosition currentPosition; + final SetResultPositionState? state; + + @override + Widget build(BuildContext context) { + final planState = _planSetState(position, currentPosition, state); + final tappable = planState != _PlanSetStatus.todo; + final tokens = courtBlazerTokensOf(context); + return Card( + margin: const EdgeInsets.only(bottom: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + side: BorderSide( + color: planState == _PlanSetStatus.current + ? Theme.of(context).colorScheme.primary + : tokens.border, + ), + ), + child: ListTile( + enabled: tappable, + title: Text('Série ${position.setIndex + 1}'), + subtitle: Text(_planSetSubtitle(planState, state?.result)), + trailing: _PlanStatusBadge( + status: planState, + onTap: tappable ? () => Navigator.of(context).pop() : null, + ), + onTap: tappable ? () => Navigator.of(context).pop() : null, + ), + ); + } +} + +final class _PlanStatusBadge extends StatelessWidget { + const _PlanStatusBadge({required this.status, this.onTap}); + + final _PlanSetStatus status; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final tokens = courtBlazerTokensOf(context); + final label = switch (status) { + _PlanSetStatus.todo => 'À faire', + _PlanSetStatus.current => 'En cours', + _PlanSetStatus.completed => 'Terminée', + _PlanSetStatus.passed => 'Passée', + }; + final color = switch (status) { + _PlanSetStatus.completed => tokens.success, + _PlanSetStatus.current => Theme.of(context).colorScheme.primary, + _ => tokens.border, + }; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Chip( + label: Text(label), + backgroundColor: color.withAlpha(35), + side: BorderSide(color: color), + ), + ); + } +} + +enum _PlanSetStatus { todo, current, completed, passed } + +_PlanSetStatus _planSetState( + ExecutionPosition position, + ExecutionPosition currentPosition, + SetResultPositionState? state, +) { + if (_comparePosition(position, currentPosition) == 0) { + return _PlanSetStatus.current; + } + if (state?.status == SetPositionStatus.completed) { + return _PlanSetStatus.completed; + } + if (state?.status == SetPositionStatus.skipped || + _comparePosition(position, currentPosition) < 0) { + return _PlanSetStatus.passed; + } + return _PlanSetStatus.todo; +} + +String _planSetSubtitle(_PlanSetStatus status, ActiveSetResult? result) { + if (status == _PlanSetStatus.completed && result != null) { + return _setResultSummary(result); + } + if (status == _PlanSetStatus.passed) { + return 'Aucun résultat'; + } + return ''; +} + +String _setResultSummary(ActiveSetResult result) { + final parts = []; + if (result.actualTimeMs != null) { + parts.add(_formatDuration(Duration(milliseconds: result.actualTimeMs!))); + } + if (result.actualReps != null) { + parts.add('${result.actualReps} reps'); + } + if (result.actualScore != null) { + final value = _formatScore(result.actualScore!); + final unit = result.scoreUnitSnapshot?.trim(); + parts.add(unit == null || unit.isEmpty ? 'Score $value' : '$value $unit'); + } + return parts.isEmpty ? 'Aucun résultat' : parts.join(' · '); +} + +String _formatScore(double value) { + return value == value.roundToDouble() + ? value.round().toString() + : value.toString(); +} + +int _remainingSecondsForRest(ActiveRestState? rest) { + if (rest == null) return 0; + final elapsedSeconds = DateTime.now() + .toUtc() + .difference(rest.startedAt.toUtc()) + .inSeconds; + return (rest.adjustedRestSeconds - elapsedSeconds).clamp(0, 9999).toInt(); +} + +String _stateKey(SetResultPositionState state) { + return _positionKey(state.programIndex, state.exerciseIndex, state.setIndex); +} + +String _positionKey(int programIndex, int exerciseIndex, int setIndex) { + return '$programIndex:$exerciseIndex:$setIndex'; +} + +int _comparePosition(ExecutionPosition left, ExecutionPosition right) { + final program = left.programIndex.compareTo(right.programIndex); + if (program != 0) return program; + final exercise = left.exerciseIndex.compareTo(right.exerciseIndex); + if (exercise != 0) return exercise; + return left.setIndex.compareTo(right.setIndex); +} + final class WorkoutExecutionPlan { const WorkoutExecutionPlan({required this.name, required this.programs}); diff --git a/test/presentation/workout_execution_screen_test.dart b/test/presentation/workout_execution_screen_test.dart index 413211d..3f46219 100644 --- a/test/presentation/workout_execution_screen_test.dart +++ b/test/presentation/workout_execution_screen_test.dart @@ -217,6 +217,69 @@ void main() { expect(repository.results.single.actualTimeMs, greaterThan(0)); }); + + testWidgets('le plan de séance affiche les états des séries', (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; + repository.results.add( + ActiveSetResult( + metadata: _metadata('result-1'), + activeWorkoutSessionId: 'session-1', + programSnapshotId: 'template-program-1', + exerciseSnapshotId: 'exercise-1', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + actualReps: 10, + actualScore: 7, + scoreUnitSnapshot: 'paniers', + ), + ); + + 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(); + + expect(find.text('Plan de séance'), findsOneWidget); + expect(find.text('Terminée'), findsOneWidget); + expect(find.text('10 reps · 7 paniers'), findsOneWidget); + expect(find.text('Passée'), findsOneWidget); + expect(find.text('Aucun résultat'), findsOneWidget); + expect(find.text('En cours'), findsOneWidget); + expect(find.text('À faire'), findsOneWidget); + + await tester.tap(find.text('En cours')); + await tester.pumpAndSettle(); + + expect(find.text('Plan de séance'), findsNothing); + }); } ActiveWorkoutSessionUseCases _activeUseCases( From 0f9974d6dab32cec453461a759c24066cac39158 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 18 Jul 2026 00:30:44 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs(ideai):=20met=20=C3=A0=20jour=20le=20t?= =?UTF-8?q?icket=20#22=20apr=C3=A8s=20la=20navigation=20libre=20en=20s?= =?UTF-8?q?=C3=A9ance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .ideai/tickets/22/carnet.md | 4 ++-- .ideai/tickets/22/issue.md | 6 +++--- .ideai/tickets/index.json | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.ideai/tickets/22/carnet.md b/.ideai/tickets/22/carnet.md index 1a7d309..b4f2bf2 100644 --- a/.ideai/tickets/22/carnet.md +++ b/.ideai/tickets/22/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#22" -version: 2 +version: 4 updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784325851266 +updatedAt: 1784327426815 --- diff --git a/.ideai/tickets/22/issue.md b/.ideai/tickets/22/issue.md index 5810c59..925b716 100644 --- a/.ideai/tickets/22/issue.md +++ b/.ideai/tickets/22/issue.md @@ -2,7 +2,7 @@ id: "a9ed002f-5e6c-4658-9a1b-ee772ce623ea" number: 22 title: "[DevFrontend] Exécution : plan de séance consultable" -status: "open" +status: "closed" priority: "high" sprint: null links: [{"target":"#9","kind":"relatesTo"},{"target":"#21","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: 1784325843738 -updatedAt: 1784325851266 -version: 2 +updatedAt: 1784327426815 +version: 4 --- Ajouter une action "Voir le plan" sur l'écran d'exécution (lib/presentation/workout_execution_screen.dart) ouvrant une bottom sheet plein écran "Plan de séance" listant tous les programmes/exercices/séries avec leur état (À faire / En cours / Terminée avec résumé / Passée). Dépend du use case listSetResults du ticket backend dédié. Cf. mémoire "gametime-ux-execution-nav-and-program-simplification" point 1. \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 4ab4a6a..1aa948c 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -243,13 +243,13 @@ "issueRef": "#22", "path": "22", "title": "[DevFrontend] Exécution : plan de séance consultable", - "status": "open", + "status": "closed", "priority": "high", "sprint": null, "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], - "updatedAt": 1784325851266 + "updatedAt": 1784327426815 }, { "issueRef": "#23",