feat(ui): navigation libre dans le plan de séance (ticket #22)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 00:30:41 +02:00
parent 1cca07df58
commit a936478413
2 changed files with 385 additions and 1 deletions

View File

@ -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});

View File

@ -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(