feat(ui): édition ponctuelle des séries en séance (ticket #23)

Expose l'édition ponctuelle des séries (backend du ticket #21) sur
workout_execution_screen.dart, sans impacter le modèle de séance.
flutter 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:37:57 +02:00
parent 104d809dd6
commit c2f807e588
2 changed files with 441 additions and 6 deletions

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