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(
heightFactor: 1,
child: _WorkoutPlanSheet(
sessionId: _session.metadata.id,
plan: _plan,
currentPosition: _position,
setStates: setStates,
activeRest: activeRest,
activeUseCases: widget.activeUseCases,
),
),
);
@ -609,15 +611,19 @@ final class SetMeasureInput extends StatelessWidget {
final class _WorkoutPlanSheet extends StatefulWidget {
const _WorkoutPlanSheet({
required this.sessionId,
required this.plan,
required this.currentPosition,
required this.setStates,
required this.activeUseCases,
this.activeRest,
});
final String sessionId;
final WorkoutExecutionPlan plan;
final ExecutionPosition currentPosition;
final List<SetResultPositionState> setStates;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveRestState? activeRest;
@override
@ -627,10 +633,12 @@ final class _WorkoutPlanSheet extends StatefulWidget {
final class _WorkoutPlanSheetState extends State<_WorkoutPlanSheet> {
Timer? _timer;
late int _remainingRestSeconds;
late List<SetResultPositionState> _setStates;
@override
void initState() {
super.initState();
_setStates = widget.setStates;
_remainingRestSeconds = _remainingSecondsForRest(widget.activeRest);
if (widget.activeRest != null && _remainingRestSeconds > 0) {
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
@ -653,7 +661,7 @@ final class _WorkoutPlanSheetState extends State<_WorkoutPlanSheet> {
@override
Widget build(BuildContext context) {
final statesByPosition = {
for (final state in widget.setStates) _stateKey(state): state,
for (final state in _setStates) _stateKey(state): state,
};
return Scaffold(
appBar: AppBar(
@ -715,12 +723,20 @@ final class _WorkoutPlanSheetState extends State<_WorkoutPlanSheet> {
setIndex: setIndex,
),
currentPosition: widget.currentPosition,
exercise: widget
.plan
.programs[programIndex]
.exercises[exerciseIndex],
state:
statesByPosition[_positionKey(
programIndex,
exerciseIndex,
setIndex,
)],
onUpdated: _refreshAfterEdit,
activeUseCases: widget.activeUseCases,
sessionId: widget.sessionId,
activeRest: widget.activeRest,
),
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 {
@ -752,17 +779,30 @@ final class _WorkoutPlanSetTile extends StatelessWidget {
const _WorkoutPlanSetTile({
required this.position,
required this.currentPosition,
required this.exercise,
required this.onUpdated,
required this.activeUseCases,
required this.sessionId,
this.activeRest,
this.state,
});
final ExecutionPosition position;
final ExecutionPosition currentPosition;
final ExecutionExercise exercise;
final SetResultPositionState? state;
final Future<void> Function() onUpdated;
final ActiveWorkoutSessionUseCases activeUseCases;
final String sessionId;
final ActiveRestState? activeRest;
@override
Widget build(BuildContext context) {
final planState = _planSetState(position, currentPosition, state);
final tappable = planState != _PlanSetStatus.todo;
final VoidCallback? onTap = tappable
? () => unawaited(_handleTap(context, planState))
: null;
final tokens = courtBlazerTokensOf(context);
return Card(
margin: const EdgeInsets.only(bottom: 8),
@ -778,11 +818,273 @@ final class _WorkoutPlanSetTile extends StatelessWidget {
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,
trailing: _PlanStatusBadge(status: planState, onTap: onTap),
onTap: onTap,
),
);
}
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();
}
int? _secondsToMilliseconds(String rawValue) {
final seconds = int.tryParse(rawValue.trim());
return seconds == null ? null : seconds * 1000;
}
int _remainingSecondsForRest(ActiveRestState? rest) {
if (rest == null) return 0;
final elapsedSeconds = DateTime.now()