Files
GameTime/lib/presentation/workout_execution_screen.dart
Blomios 05a935afc1 fix(execution): comportement de la flèche retour en séance (ticket #31)
Corrige la flèche de retour sur workout_execution_screen.dart : elle
ouvre désormais le choix pause/abandon ou quitter-et-sauvegarder au
lieu de ne rien faire. flutter analyze propre, 36/36 tests verts,
build APK debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:58:10 +02:00

1444 lines
44 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import '../application/application.dart';
import '../domain/domain.dart';
import 'history_screen.dart';
import 'theme.dart';
enum WorkoutExecutionMode { active, rest, paused, finished }
final class WorkoutExecutionScreen extends StatefulWidget {
const WorkoutExecutionScreen({
required this.initialSession,
required this.activeUseCases,
required this.closeUseCase,
required this.historyUseCases,
required this.workoutTemplateUseCases,
super.key,
});
final ActiveWorkoutSession initialSession;
final ActiveWorkoutSessionUseCases activeUseCases;
final CloseWorkoutSessionUseCase closeUseCase;
final WorkoutHistoryUseCases historyUseCases;
final WorkoutTemplateUseCases workoutTemplateUseCases;
@override
State<WorkoutExecutionScreen> createState() => _WorkoutExecutionScreenState();
}
final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
late ActiveWorkoutSession _session;
late final WorkoutExecutionPlan _plan;
late WorkoutExecutionMode _mode;
Timer? _ticker;
Timer? _restTicker;
WorkoutHistory? _completedHistory;
String? _activeRestStateId;
late DateTime _seriesStartedAt;
var _reps = 0;
final _scoreController = TextEditingController();
var _remainingRestSeconds = 0;
ExecutionPosition get _position => ExecutionPosition(
programIndex: _session.currentProgramIndex,
exerciseIndex: _session.currentExerciseIndex,
setIndex: _session.currentSetIndex,
);
ExecutionExercise get _exercise => _plan.exerciseAt(_position);
@override
void initState() {
super.initState();
_session = widget.initialSession;
_plan = WorkoutExecutionPlan.fromSession(_session);
_mode = _session.status == ActiveWorkoutStatus.running
? WorkoutExecutionMode.active
: WorkoutExecutionMode.paused;
_seriesStartedAt = DateTime.now().toUtc();
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
unawaited(_restoreActiveRest());
}
@override
void dispose() {
_ticker?.cancel();
_restTicker?.cancel();
_scoreController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvoked: _handleSystemBack,
child: Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
leading: _canPauseFromNavigation
? IconButton(
tooltip: 'Mettre en pause',
onPressed: () => unawaited(_pause()),
icon: const Icon(Icons.arrow_back),
)
: null,
title: Text(_plan.name),
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(),
WorkoutExecutionMode.rest => _buildRest(),
WorkoutExecutionMode.paused => _buildPaused(),
WorkoutExecutionMode.finished => _buildFinished(),
},
),
);
}
Widget _buildActive() {
return ListView(
padding: const EdgeInsets.all(16),
children: [
_Header(
elapsedLabel: _formatDuration(
Duration(
milliseconds: widget.activeUseCases.elapsedActiveMilliseconds(
_session,
),
),
),
progressLabel: _plan.progressLabel(_position),
),
const SizedBox(height: 24),
Text(_exercise.name, style: Theme.of(context).textTheme.headlineMedium),
const SizedBox(height: 16),
SetMeasureInput(
exercise: _exercise,
reps: _reps,
scoreController: _scoreController,
onRepsChanged: (value) => setState(() => _reps = value),
),
const SizedBox(height: 24),
FilledButton(
onPressed: () => _finishSet(skipped: false),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(56)),
child: const Text('Terminer la série'),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: () => _finishSet(skipped: true),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(52),
),
child: const Text('Passer'),
),
],
);
}
Widget _buildRest() {
final next = _plan.nextPosition(_position);
final nextExercise = next == null ? null : _plan.exerciseAt(next);
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Repos avant la prochaine série'),
const SizedBox(height: 24),
Center(
child: Text(
_formatDuration(Duration(seconds: _remainingRestSeconds)),
style: AppTextStyles.timer(context),
),
),
if (next != null && nextExercise != null) ...[
const SizedBox(height: 24),
Text(
'Ensuite : ${nextExercise.name} · '
'Série ${next.setIndex + 1}/${nextExercise.setsCount}',
textAlign: TextAlign.center,
),
],
const Spacer(),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => _adjustRest(-15),
child: const Text('-15 s'),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton(
onPressed: () => _adjustRest(15),
child: const Text('+15 s'),
),
),
],
),
const SizedBox(height: 12),
FilledButton(
onPressed: _skipRest,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
),
child: const Text('Ignorer le repos'),
),
const SizedBox(height: 12),
TextButton(onPressed: _pause, child: const Text('Pause')),
],
),
);
}
Widget _buildPaused() {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Pause', style: Theme.of(context).textTheme.headlineMedium),
const SizedBox(height: 24),
FilledButton(
onPressed: _resume,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(56),
),
child: const Text('Reprendre'),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: _quitAndSave,
child: const Text('Quitter et sauvegarder'),
),
const Spacer(),
TextButton(
onPressed: _confirmAbandon,
child: const Text('Abandonner la séance'),
),
],
),
);
}
Widget _buildFinished() {
final elapsed = Duration(
milliseconds: widget.activeUseCases.elapsedActiveMilliseconds(_session),
);
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Séance terminée',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 12),
Text('Temps total : ${_formatDuration(elapsed)}'),
const SizedBox(height: 24),
OutlinedButton(
onPressed: _completedHistory == null ? null : _openHistoryDetail,
child: const Text('Voir le détail'),
),
OutlinedButton(
onPressed: _completedHistory == null ? null : _restartCompleted,
child: const Text('Relancer cette séance'),
),
FilledButton(
onPressed: () {
Navigator.of(context).popUntil((route) => route.isFirst);
},
child: const Text('Retour à laccueil'),
),
],
),
);
}
bool get _canPauseFromNavigation {
return _mode == WorkoutExecutionMode.active ||
_mode == WorkoutExecutionMode.rest;
}
void _handleSystemBack(bool didPop) {
if (didPop || !_canPauseFromNavigation) return;
unawaited(_pause());
}
Future<void> _pause() async {
_restTicker?.cancel();
_session = await widget.activeUseCases.pause(_session.metadata.id);
if (!mounted) return;
setState(() => _mode = WorkoutExecutionMode.paused);
}
Future<void> _resume() async {
_session = await widget.activeUseCases.resume(_session.metadata.id);
if (!mounted) return;
final restoredRest = await _restoreActiveRest();
if (!mounted || restoredRest) return;
setState(() => _mode = WorkoutExecutionMode.active);
}
Future<void> _quitAndSave() async {
await widget.activeUseCases.quitAndSave(_session.metadata.id);
if (!mounted) return;
Navigator.of(context).popUntil((route) => route.isFirst);
}
Future<void> _confirmAbandon() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Abandonner la séance ?'),
content: const Text('Cette action est définitive.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Abandonner'),
),
],
),
);
if (confirmed != true) return;
await widget.activeUseCases.abandon(_session.metadata.id);
if (!mounted) return;
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(
sessionId: _session.metadata.id,
plan: _plan,
currentPosition: _position,
setStates: setStates,
activeRest: activeRest,
activeUseCases: widget.activeUseCases,
),
),
);
}
Future<void> _finishSet({required bool skipped}) async {
final score = double.tryParse(_scoreController.text.trim());
final actualTimeMs = DateTime.now()
.toUtc()
.difference(_seriesStartedAt)
.inMilliseconds;
await widget.activeUseCases.recordCurrentSetResult(
sessionId: _session.metadata.id,
programSnapshotId: _plan.programAt(_position).id,
exerciseSnapshotId: _exercise.id,
programIndex: _position.programIndex,
exerciseIndex: _position.exerciseIndex,
setIndex: _position.setIndex,
actualReps: skipped ? null : (_exercise.repsEnabled ? _reps : null),
actualScore: skipped ? null : (_exercise.scoreEnabled ? score : null),
scoreUnitSnapshot: _exercise.scoreUnit,
actualTimeMs: skipped
? null
: (_exercise.timeEnabled ? actualTimeMs : null),
);
final next = _plan.nextPosition(_position);
if (next == null) {
await _complete();
return;
}
final shouldRest = _plan.shouldShowRestAfter(_position);
if (shouldRest) {
final rest = await widget.activeUseCases.startRestAfterSet(
sessionId: _session.metadata.id,
afterProgramIndex: _position.programIndex,
afterExerciseIndex: _position.exerciseIndex,
afterSetIndex: _position.setIndex,
plannedRestSeconds: _exercise.restSeconds,
);
if (!mounted) return;
_activeRestStateId = rest.metadata.id;
_remainingRestSeconds = _exercise.restSeconds;
_startRestTicker(next);
setState(() => _mode = WorkoutExecutionMode.rest);
return;
}
await _moveTo(next);
}
void _startRestTicker(ExecutionPosition next) {
_restTicker?.cancel();
_restTicker = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) return;
if (_remainingRestSeconds <= 1) {
timer.cancel();
unawaited(_finishRestAndMove(next));
} else {
setState(() => _remainingRestSeconds -= 1);
}
});
}
Future<bool> _restoreActiveRest() async {
final rest = await widget.activeUseCases.findActiveRest(
sessionId: _session.metadata.id,
);
if (!mounted || rest == null) {
return false;
}
final after = ExecutionPosition(
programIndex: rest.afterProgramIndex,
exerciseIndex: rest.afterExerciseIndex,
setIndex: rest.afterSetIndex,
);
final next = _plan.nextPosition(after);
final remaining = _remainingSecondsFor(rest);
_activeRestStateId = rest.metadata.id;
if (next == null || remaining <= 0) {
await _finishRestAndMove(next);
return true;
}
_remainingRestSeconds = remaining;
_startRestTicker(next);
if (!mounted) return true;
setState(() => _mode = WorkoutExecutionMode.rest);
return true;
}
int _remainingSecondsFor(ActiveRestState rest) {
final elapsedSeconds = DateTime.now()
.toUtc()
.difference(rest.startedAt.toUtc())
.inSeconds;
return (rest.adjustedRestSeconds - elapsedSeconds).clamp(0, 9999).toInt();
}
Future<void> _adjustRest(int deltaSeconds) async {
final restStateId = _activeRestStateId;
setState(() {
_remainingRestSeconds = (_remainingRestSeconds + deltaSeconds)
.clamp(0, 9999)
.toInt();
});
if (restStateId == null) return;
await widget.activeUseCases.adjustRestSeconds(
restStateId: restStateId,
deltaSeconds: deltaSeconds,
);
}
Future<void> _skipRest() async {
_restTicker?.cancel();
final next = _plan.nextPosition(_position);
await _finishRestAndMove(next);
}
Future<void> _finishRestAndMove(ExecutionPosition? next) async {
final restStateId = _activeRestStateId;
_activeRestStateId = null;
if (restStateId != null) {
await widget.activeUseCases.skipRest(restStateId: restStateId);
}
if (next == null) {
await _complete();
return;
}
await _moveTo(next);
}
Future<void> _moveTo(ExecutionPosition position) async {
_scoreController.clear();
_reps = 0;
_activeRestStateId = null;
_seriesStartedAt = DateTime.now().toUtc();
_session = await widget.activeUseCases.updateProgress(
sessionId: _session.metadata.id,
programIndex: position.programIndex,
exerciseIndex: position.exerciseIndex,
setIndex: position.setIndex,
);
if (!mounted) return;
setState(() => _mode = WorkoutExecutionMode.active);
}
Future<void> _complete() async {
_session = await widget.activeUseCases.complete(_session.metadata.id);
_completedHistory = await widget.closeUseCase.close(
sessionId: _session.metadata.id,
nameSnapshot: _plan.name,
completed: true,
);
if (!mounted) return;
setState(() => _mode = WorkoutExecutionMode.finished);
}
Future<void> _openHistoryDetail() async {
final history = _completedHistory;
if (history == null) return;
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => HistoryDetailScreen(
history: history,
historyUseCases: widget.historyUseCases,
workoutTemplateUseCases: widget.workoutTemplateUseCases,
activeUseCases: widget.activeUseCases,
closeUseCase: widget.closeUseCase,
),
),
);
}
Future<void> _restartCompleted() async {
final history = _completedHistory;
if (history == null) return;
ActiveWorkoutSession session;
final sourceId = history.sourceWorkoutTemplateId;
if (sourceId != null &&
await widget.workoutTemplateUseCases.findById(sourceId) != null) {
session = await widget.activeUseCases.startFromTemplate(sourceId);
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"La séance originale n'existe plus. Une copie va être utilisée.",
),
),
);
}
session = await widget.activeUseCases.startFromHistory(history);
}
if (!mounted) return;
await Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (context) => WorkoutExecutionScreen(
initialSession: session,
activeUseCases: widget.activeUseCases,
closeUseCase: widget.closeUseCase,
historyUseCases: widget.historyUseCases,
workoutTemplateUseCases: widget.workoutTemplateUseCases,
),
),
);
}
}
final class SetMeasureInput extends StatelessWidget {
const SetMeasureInput({
required this.exercise,
required this.reps,
required this.scoreController,
required this.onRepsChanged,
super.key,
});
final ExecutionExercise exercise;
final int reps;
final TextEditingController scoreController;
final ValueChanged<int> onRepsChanged;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (exercise.timeEnabled)
Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
const Text('Temps'),
const SizedBox(height: 8),
Text(
exercise.targetTimeSeconds == null
? 'Chronométrer'
: '${exercise.targetTimeSeconds} s',
style: AppTextStyles.scoreNumber(context),
),
],
),
),
),
if (exercise.repsEnabled) ...[
const SizedBox(height: 12),
Row(
children: [
const Expanded(child: Text('Répétitions')),
IconButton(
onPressed: () =>
onRepsChanged((reps - 1).clamp(0, 999).toInt()),
icon: const Icon(Icons.remove),
),
Text('$reps', style: AppTextStyles.scoreNumber(context)),
IconButton(
onPressed: () => onRepsChanged(reps + 1),
icon: const Icon(Icons.add),
),
],
),
],
if (exercise.scoreEnabled) ...[
const SizedBox(height: 12),
TextField(
controller: scoreController,
style: AppTextStyles.scoreNumber(context),
decoration: InputDecoration(
labelText: exercise.scoreUnit == null
? 'Score'
: 'Score (${exercise.scoreUnit})',
),
keyboardType: TextInputType.number,
),
],
],
);
}
}
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
State<_WorkoutPlanSheet> createState() => _WorkoutPlanSheetState();
}
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), (_) {
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 _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,
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),
],
],
],
),
);
}
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 {
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,
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),
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: 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.',
),
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'),
),
],
),
);
}
}
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? _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()
.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});
factory WorkoutExecutionPlan.fromSession(ActiveWorkoutSession session) {
final snapshot =
jsonDecode(session.resolvedTemplateSnapshotJson)
as Map<String, dynamic>;
final overrides = snapshot['overrides'] as List<dynamic>? ?? const [];
final programs = (snapshot['programs'] as List<dynamic>? ?? const []).map((
rawProgram,
) {
final program = rawProgram as Map<String, dynamic>;
final programSnapshot =
jsonDecode(program['programSnapshotJson'] as String)
as Map<String, dynamic>;
return ExecutionProgram.fromSnapshot(
id: program['id'] as String,
name: program['programNameSnapshot'] as String,
snapshot: programSnapshot,
overrides: overrides,
);
}).toList();
return WorkoutExecutionPlan(
name: snapshot['name'] as String? ?? 'Séance',
programs: programs,
);
}
final String name;
final List<ExecutionProgram> programs;
ExecutionProgram programAt(ExecutionPosition position) {
return programs[position.programIndex];
}
ExecutionExercise exerciseAt(ExecutionPosition position) {
return programAt(position).exercises[position.exerciseIndex];
}
ExecutionPosition? nextPosition(ExecutionPosition position) {
final exercise = exerciseAt(position);
if (position.setIndex + 1 < exercise.setsCount) {
return position.copyWith(setIndex: position.setIndex + 1);
}
final program = programAt(position);
if (position.exerciseIndex + 1 < program.exercises.length) {
return ExecutionPosition(
programIndex: position.programIndex,
exerciseIndex: position.exerciseIndex + 1,
setIndex: 0,
);
}
if (position.programIndex + 1 < programs.length) {
return ExecutionPosition(
programIndex: position.programIndex + 1,
exerciseIndex: 0,
setIndex: 0,
);
}
return null;
}
bool shouldShowRestAfter(ExecutionPosition position) {
return nextPosition(position) != null &&
exerciseAt(position).restSeconds > 0;
}
String progressLabel(ExecutionPosition position) {
final program = programAt(position);
final exercise = exerciseAt(position);
return 'Programme ${position.programIndex + 1}/${programs.length} · '
'Exercice ${position.exerciseIndex + 1}/${program.exercises.length} · '
'Série ${position.setIndex + 1}/${exercise.setsCount}';
}
}
final class ExecutionProgram {
const ExecutionProgram({
required this.id,
required this.name,
required this.exercises,
});
factory ExecutionProgram.fromSnapshot({
required String id,
required String name,
required Map<String, dynamic> snapshot,
required List<dynamic> overrides,
}) {
final exercises = (snapshot['exercises'] as List<dynamic>? ?? const []).map(
(raw) {
final exercise = raw as Map<String, dynamic>;
Map<String, dynamic>? override;
for (final rawOverride in overrides) {
final item = rawOverride as Map<String, dynamic>;
if (item['workoutTemplateProgramId'] == id &&
item['snapshotProgramExerciseId'] == exercise['id']) {
override = item;
break;
}
}
return ExecutionExercise.fromSnapshot(exercise, override);
},
).toList();
return ExecutionProgram(id: id, name: name, exercises: exercises);
}
final String id;
final String name;
final List<ExecutionExercise> exercises;
}
final class ExecutionExercise {
const ExecutionExercise({
required this.id,
required this.name,
required this.setsCount,
required this.timeEnabled,
required this.repsEnabled,
required this.scoreEnabled,
required this.restSeconds,
this.targetTimeSeconds,
this.targetReps,
this.targetScore,
this.scoreUnit,
});
factory ExecutionExercise.fromSnapshot(
Map<String, dynamic> exercise,
Map<String, dynamic>? override,
) {
return ExecutionExercise(
id: exercise['id'] as String,
name: exercise['exerciseNameSnapshot'] as String,
setsCount:
(override?['setsCountOverride'] as int?) ??
(exercise['setsCount'] as int),
timeEnabled: exercise['timeEnabled'] as bool,
repsEnabled: exercise['repsEnabled'] as bool,
scoreEnabled: exercise['scoreEnabled'] as bool,
restSeconds: exercise['restSecondsOverride'] as int? ?? 0,
targetTimeSeconds:
(override?['targetTimeSecondsOverride'] as int?) ??
(exercise['targetTimeSeconds'] as int?),
targetReps:
(override?['targetRepsOverride'] as int?) ??
(exercise['targetReps'] as int?),
targetScore:
(override?['targetScoreOverride'] as num?)?.toDouble() ??
(exercise['targetScore'] as num?)?.toDouble(),
scoreUnit: exercise['scoreUnitSnapshot'] as String?,
);
}
final String id;
final String name;
final int setsCount;
final bool timeEnabled;
final bool repsEnabled;
final bool scoreEnabled;
final int restSeconds;
final int? targetTimeSeconds;
final int? targetReps;
final double? targetScore;
final String? scoreUnit;
}
final class ExecutionPosition {
const ExecutionPosition({
required this.programIndex,
required this.exerciseIndex,
required this.setIndex,
});
final int programIndex;
final int exerciseIndex;
final int setIndex;
ExecutionPosition copyWith({int? setIndex}) {
return ExecutionPosition(
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex ?? this.setIndex,
);
}
}
final class _Header extends StatelessWidget {
const _Header({required this.elapsedLabel, required this.progressLabel});
final String elapsedLabel;
final String progressLabel;
@override
Widget build(BuildContext context) {
return CourtBlazerAccentPanel(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(elapsedLabel, style: AppTextStyles.scoreNumber(context)),
const SizedBox(height: 4),
Text(progressLabel),
],
),
);
}
}
String _formatDuration(Duration duration) {
final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
final hours = duration.inHours;
if (hours > 0) {
return '$hours:$minutes:$seconds';
}
return '$minutes:$seconds';
}