merge(main): navigation libre dans le plan de séance (ticket #22)
Fusionne feature/#22-plan-de-seance — analyze propre, 31/31 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
issueRef: "#22"
|
issueRef: "#22"
|
||||||
version: 2
|
version: 4
|
||||||
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||||
updatedAt: 1784325851266
|
updatedAt: 1784327426815
|
||||||
---
|
---
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
id: "a9ed002f-5e6c-4658-9a1b-ee772ce623ea"
|
id: "a9ed002f-5e6c-4658-9a1b-ee772ce623ea"
|
||||||
number: 22
|
number: 22
|
||||||
title: "[DevFrontend] Exécution : plan de séance consultable"
|
title: "[DevFrontend] Exécution : plan de séance consultable"
|
||||||
status: "open"
|
status: "closed"
|
||||||
priority: "high"
|
priority: "high"
|
||||||
sprint: null
|
sprint: null
|
||||||
links: [{"target":"#9","kind":"relatesTo"},{"target":"#21","kind":"dependsOn"}]
|
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"}
|
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: 1784325843738
|
createdAt: 1784325843738
|
||||||
updatedAt: 1784325851266
|
updatedAt: 1784327426815
|
||||||
version: 2
|
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.
|
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.
|
||||||
@ -243,13 +243,13 @@
|
|||||||
"issueRef": "#22",
|
"issueRef": "#22",
|
||||||
"path": "22",
|
"path": "22",
|
||||||
"title": "[DevFrontend] Exécution : plan de séance consultable",
|
"title": "[DevFrontend] Exécution : plan de séance consultable",
|
||||||
"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": 1784325851266
|
"updatedAt": 1784327426815
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"issueRef": "#23",
|
"issueRef": "#23",
|
||||||
|
|||||||
@ -81,7 +81,14 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(_plan.name),
|
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) {
|
body: switch (_mode) {
|
||||||
WorkoutExecutionMode.active => _buildActive(),
|
WorkoutExecutionMode.active => _buildActive(),
|
||||||
@ -301,6 +308,30 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
|||||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
builder: (context) => FractionallySizedBox(
|
||||||
|
heightFactor: 1,
|
||||||
|
child: _WorkoutPlanSheet(
|
||||||
|
plan: _plan,
|
||||||
|
currentPosition: _position,
|
||||||
|
setStates: setStates,
|
||||||
|
activeRest: activeRest,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _finishSet({required bool skipped}) async {
|
Future<void> _finishSet({required bool skipped}) async {
|
||||||
final score = double.tryParse(_scoreController.text.trim());
|
final score = double.tryParse(_scoreController.text.trim());
|
||||||
final actualTimeMs = DateTime.now()
|
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<SetResultPositionState> 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 = <String>[];
|
||||||
|
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 {
|
final class WorkoutExecutionPlan {
|
||||||
const WorkoutExecutionPlan({required this.name, required this.programs});
|
const WorkoutExecutionPlan({required this.name, required this.programs});
|
||||||
|
|
||||||
|
|||||||
@ -217,6 +217,69 @@ void main() {
|
|||||||
|
|
||||||
expect(repository.results.single.actualTimeMs, greaterThan(0));
|
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(
|
ActiveWorkoutSessionUseCases _activeUseCases(
|
||||||
|
|||||||
Reference in New Issue
Block a user