Files
GameTime/lib/presentation/workout_execution_screen.dart
Blomios 1a315d586f feat(ui): affichage du chrono de score dans le plan et l'historique (ticket #28)
Affiche le chrono du score chronométré sur workout_execution_screen.dart
(plan de séance) et history_screen.dart (historique). flutter analyze
propre, 77/77 tests verts, build APK debug validé. Dernier ticket du
backlog — clôture aussi le ticket parent #18 (score chronométré).

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

2265 lines
69 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;
ActiveScoreStopwatchState? _scoreStopwatch;
int? _manualScoreTimeMs;
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(_loadScoreStopwatch());
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),
_SeriesCounterCard(
currentSet: _position.setIndex + 1,
totalSets: _exercise.setsCount,
),
const SizedBox(height: 16),
Text(_exercise.name, style: Theme.of(context).textTheme.headlineMedium),
if (_exercise.hasMedia) ...[
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: Tooltip(
message: 'Voir les médias de lexercice',
child: OutlinedButton.icon(
onPressed: () => _openExerciseMedia(_exercise),
icon: const Icon(Icons.perm_media_outlined),
label: const Text('Voir médias'),
),
),
),
],
const SizedBox(height: 16),
SetMeasureInput(
exercise: _exercise,
reps: _reps,
scoreController: _scoreController,
onRepsChanged: (value) => setState(() => _reps = value),
),
if (_exercise.stopwatchScoreEnabled) ...[
const SizedBox(height: 12),
_ScoreStopwatchInput(
elapsedLabel: _formatScoreStopwatch(
Duration(milliseconds: _scoreStopwatchElapsedMs),
),
status: _scoreStopwatch?.status,
onStart: _startScoreStopwatch,
onStop: _stopScoreStopwatch,
onResume: _resumeScoreStopwatch,
onReset: _resetScoreStopwatch,
onEdit: _editScoreStopwatchTime,
),
],
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}', textAlign: TextAlign.center),
const SizedBox(height: 8),
Text(
'Série ${next.setIndex + 1} / ${nextExercise.setsCount}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontSize: 30,
),
),
if (nextExercise.hasMedia) ...[
const SizedBox(height: 12),
Center(
child: Tooltip(
message: 'Voir les médias de lexercice',
child: OutlinedButton.icon(
onPressed: () => _openExerciseMedia(nextExercise),
icon: const Icon(Icons.perm_media_outlined),
label: const Text('Voir médias'),
),
),
),
],
],
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'),
),
],
),
);
}
Future<void> _openExerciseMedia(ExecutionExercise exercise) async {
if (!exercise.hasMedia) {
return;
}
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => _ExerciseMediaSheet(
exercise: exercise,
restRemainingSeconds: _mode == WorkoutExecutionMode.rest
? _remainingRestSeconds
: null,
),
);
}
int get _scoreStopwatchElapsedMs {
final manual = _manualScoreTimeMs;
if (manual != null) {
return manual;
}
final state = _scoreStopwatch;
if (state == null) {
return 0;
}
return widget.activeUseCases.scoreStopwatchElapsedMilliseconds(state);
}
Future<void> _loadScoreStopwatch() async {
if (!_exercise.stopwatchScoreEnabled) {
_scoreStopwatch = null;
_manualScoreTimeMs = null;
return;
}
final state = await widget.activeUseCases.findScoreStopwatch(
sessionId: _session.metadata.id,
programIndex: _position.programIndex,
exerciseIndex: _position.exerciseIndex,
setIndex: _position.setIndex,
);
if (!mounted) return;
setState(() => _scoreStopwatch = state);
}
Future<void> _startScoreStopwatch() async {
final state = await widget.activeUseCases.startScoreStopwatch(
sessionId: _session.metadata.id,
programIndex: _position.programIndex,
exerciseIndex: _position.exerciseIndex,
setIndex: _position.setIndex,
);
if (!mounted) return;
setState(() {
_scoreStopwatch = state;
_manualScoreTimeMs = null;
});
}
Future<ActiveScoreStopwatchState?> _stopScoreStopwatch() async {
final state = _scoreStopwatch;
if (state == null) return null;
final stopped = await widget.activeUseCases.stopScoreStopwatch(
sessionId: _session.metadata.id,
programIndex: _position.programIndex,
exerciseIndex: _position.exerciseIndex,
setIndex: _position.setIndex,
);
if (!mounted) return stopped;
setState(() => _scoreStopwatch = stopped);
return stopped;
}
Future<void> _resumeScoreStopwatch() async {
final state = await widget.activeUseCases.resumeScoreStopwatch(
sessionId: _session.metadata.id,
programIndex: _position.programIndex,
exerciseIndex: _position.exerciseIndex,
setIndex: _position.setIndex,
);
if (!mounted) return;
setState(() {
_scoreStopwatch = state;
_manualScoreTimeMs = null;
});
}
Future<void> _resetScoreStopwatch() async {
if (_scoreStopwatchElapsedMs > 0) {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Réinitialiser le chrono ?'),
content: const Text('Le temps mesuré sera supprimé.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Réinitialiser'),
),
],
),
);
if (confirmed != true) return;
}
await widget.activeUseCases.resetScoreStopwatch(
sessionId: _session.metadata.id,
programIndex: _position.programIndex,
exerciseIndex: _position.exerciseIndex,
setIndex: _position.setIndex,
);
if (!mounted) return;
setState(() {
_scoreStopwatch = null;
_manualScoreTimeMs = null;
});
}
Future<void> _editScoreStopwatchTime() async {
final initial = Duration(milliseconds: _scoreStopwatchElapsedMs);
final edited = await showDialog<int>(
context: context,
builder: (context) => _ScoreStopwatchEditDialog(initial: initial),
);
if (edited == null || !mounted) return;
setState(() => _manualScoreTimeMs = edited);
}
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();
if (_scoreStopwatch?.status == ActiveScoreStopwatchStatus.running) {
await _stopScoreStopwatch();
}
_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;
await _loadScoreStopwatch();
if (!mounted) 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 {
if (skipped &&
_scoreStopwatch?.status == ActiveScoreStopwatchStatus.running) {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Passer cette série ?'),
content: const Text('Le chrono en cours sera ignoré.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Continuer la série'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Passer'),
),
],
),
);
if (confirmed != true) return;
await widget.activeUseCases.resetScoreStopwatch(
sessionId: _session.metadata.id,
programIndex: _position.programIndex,
exerciseIndex: _position.exerciseIndex,
setIndex: _position.setIndex,
);
if (!mounted) return;
setState(() {
_scoreStopwatch = null;
_manualScoreTimeMs = null;
});
}
if (!skipped &&
_exercise.stopwatchScoreEnabled &&
_scoreStopwatch == null &&
_manualScoreTimeMs == null) {
final action = await showDialog<_MissingStopwatchAction>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Aucun temps chronométré'),
content: const Text("Tu n'as pas démarré le chrono score."),
actions: [
TextButton(
onPressed: () =>
Navigator.of(context).pop(_MissingStopwatchAction.start),
child: const Text('Démarrer le chrono'),
),
FilledButton(
onPressed: () => Navigator.of(
context,
).pop(_MissingStopwatchAction.finishWithout),
child: const Text('Terminer sans chrono'),
),
],
),
);
if (action == _MissingStopwatchAction.start) {
await _startScoreStopwatch();
return;
}
if (action != _MissingStopwatchAction.finishWithout) {
return;
}
}
await _recordAndAdvance(skipped: skipped);
}
Future<void> _recordAndAdvance({required bool skipped}) async {
try {
final score = double.tryParse(_scoreController.text.trim());
var actualScoreTimeMs = skipped ? null : _manualScoreTimeMs;
if (!skipped &&
_exercise.stopwatchScoreEnabled &&
actualScoreTimeMs == null) {
final state = _scoreStopwatch;
if (state?.status == ActiveScoreStopwatchStatus.running) {
final stopped = await _stopScoreStopwatch();
actualScoreTimeMs = stopped?.accumulatedMs;
} else {
actualScoreTimeMs = state?.accumulatedMs;
}
}
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.manualScoreEnabled ? score : null),
actualScoreTimeMs: actualScoreTimeMs,
scoreInputModeSnapshot: _exercise.scoreInputMode,
scoreLabelSnapshot: _exercise.scoreLabel,
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);
} on Exception catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Impossible de terminer la série : $error')),
);
return;
}
}
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;
_scoreStopwatch = null;
_manualScoreTimeMs = 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;
await _loadScoreStopwatch();
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.manualScoreEnabled) ...[
const SizedBox(height: 12),
TextField(
controller: scoreController,
style: AppTextStyles.scoreNumber(context),
decoration: InputDecoration(
labelText: exercise.scoreUnit == null
? 'Score'
: 'Score (${exercise.scoreUnit})',
),
keyboardType: TextInputType.number,
),
],
],
);
}
}
enum _MissingStopwatchAction { start, finishWithout }
final class _ScoreStopwatchInput extends StatelessWidget {
const _ScoreStopwatchInput({
required this.elapsedLabel,
required this.status,
required this.onStart,
required this.onStop,
required this.onResume,
required this.onReset,
required this.onEdit,
});
final String elapsedLabel;
final ActiveScoreStopwatchStatus? status;
final VoidCallback onStart;
final VoidCallback onStop;
final VoidCallback onResume;
final VoidCallback onReset;
final VoidCallback onEdit;
@override
Widget build(BuildContext context) {
final running = status == ActiveScoreStopwatchStatus.running;
final stopped = status == ActiveScoreStopwatchStatus.stopped;
return CourtBlazerAccentPanel(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Chrono score', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
Center(
child: Text(
elapsedLabel,
style: AppTextStyles.scoreNumber(context),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
if (status == null)
FilledButton.icon(
onPressed: onStart,
icon: const Icon(Icons.play_arrow),
label: const Text('Démarrer'),
)
else if (running)
FilledButton.icon(
onPressed: onStop,
icon: const Icon(Icons.stop),
label: const Text('Arrêter'),
)
else ...[
FilledButton.icon(
onPressed: onResume,
icon: const Icon(Icons.play_arrow),
label: const Text('Reprendre'),
),
OutlinedButton.icon(
onPressed: onReset,
icon: const Icon(Icons.restart_alt),
label: const Text('Réinitialiser'),
),
],
if (stopped)
TextButton.icon(
onPressed: onEdit,
icon: const Icon(Icons.edit),
label: const Text('Modifier le temps'),
),
],
),
],
),
);
}
}
final class _ScoreStopwatchEditDialog extends StatefulWidget {
const _ScoreStopwatchEditDialog({required this.initial});
final Duration initial;
@override
State<_ScoreStopwatchEditDialog> createState() =>
_ScoreStopwatchEditDialogState();
}
final class _ScoreStopwatchEditDialogState
extends State<_ScoreStopwatchEditDialog> {
late final TextEditingController _minutesController;
late final TextEditingController _secondsController;
late final TextEditingController _tenthsController;
@override
void initState() {
super.initState();
final totalTenths = (widget.initial.inMilliseconds / 100).round();
final minutes = totalTenths ~/ 600;
final seconds = (totalTenths ~/ 10) % 60;
final tenths = totalTenths % 10;
_minutesController = TextEditingController(text: '$minutes');
_secondsController = TextEditingController(text: '$seconds');
_tenthsController = TextEditingController(text: '$tenths');
}
@override
void dispose() {
_minutesController.dispose();
_secondsController.dispose();
_tenthsController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Modifier le temps'),
content: Row(
children: [
Expanded(
child: TextField(
controller: _minutesController,
decoration: const InputDecoration(labelText: 'Min'),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _secondsController,
decoration: const InputDecoration(labelText: 'S'),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _tenthsController,
decoration: const InputDecoration(labelText: 'Dixièmes'),
keyboardType: TextInputType.number,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () {
final minutes = int.tryParse(_minutesController.text.trim()) ?? 0;
final seconds = int.tryParse(_secondsController.text.trim()) ?? 0;
final tenths = int.tryParse(_tenthsController.text.trim()) ?? 0;
final milliseconds =
(minutes * 60 * 1000) + (seconds * 1000) + (tenths * 100);
Navigator.of(context).pop(milliseconds);
},
child: const Text('Enregistrer'),
),
],
);
}
}
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),
);
}
}
enum _MediaSheetSection { images, video }
final class _ExerciseMediaSheet extends StatefulWidget {
const _ExerciseMediaSheet({
required this.exercise,
this.restRemainingSeconds,
});
final ExecutionExercise exercise;
final int? restRemainingSeconds;
@override
State<_ExerciseMediaSheet> createState() => _ExerciseMediaSheetState();
}
final class _ExerciseMediaSheetState extends State<_ExerciseMediaSheet> {
late final PageController _pageController;
late _MediaSheetSection _section;
var _imageIndex = 0;
@override
void initState() {
super.initState();
_pageController = PageController();
_section = widget.exercise.imageMediaIds.isNotEmpty
? _MediaSheetSection.images
: _MediaSheetSection.video;
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final exercise = widget.exercise;
final hasImages = exercise.imageMediaIds.isNotEmpty;
final hasVideo = exercise.videoMediaId != null;
return SizedBox(
height: MediaQuery.of(context).size.height * 0.95,
child: Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Médias de lexercice'),
Text(exercise.name, style: Theme.of(context).textTheme.bodySmall),
],
),
automaticallyImplyLeading: false,
actions: [
IconButton(
tooltip: 'Fermer',
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
if (widget.restRemainingSeconds != null) ...[
_RestPlanBanner(remainingSeconds: widget.restRemainingSeconds!),
const SizedBox(height: 16),
],
if (hasImages && hasVideo) ...[
Wrap(
spacing: 8,
children: [
ChoiceChip(
label: const Text('Images'),
selected: _section == _MediaSheetSection.images,
onSelected: (_) {
setState(() => _section = _MediaSheetSection.images);
},
),
ChoiceChip(
label: const Text('Vidéo'),
selected: _section == _MediaSheetSection.video,
onSelected: (_) {
setState(() => _section = _MediaSheetSection.video);
},
),
],
),
const SizedBox(height: 16),
],
if (_section == _MediaSheetSection.images && hasImages)
_ImageMediaGallery(
imageMediaIds: exercise.imageMediaIds,
pageController: _pageController,
currentIndex: _imageIndex,
onPageChanged: (index) => setState(() => _imageIndex = index),
)
else if (hasVideo)
_VideoMediaPanel(videoMediaId: exercise.videoMediaId!),
],
),
),
);
}
}
final class _ImageMediaGallery extends StatelessWidget {
const _ImageMediaGallery({
required this.imageMediaIds,
required this.pageController,
required this.currentIndex,
required this.onPageChanged,
});
final List<String> imageMediaIds;
final PageController pageController;
final int currentIndex;
final ValueChanged<int> onPageChanged;
@override
Widget build(BuildContext context) {
return CourtBlazerAccentPanel(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerRight,
child: Text(
'${currentIndex + 1}/${imageMediaIds.length}',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
),
const SizedBox(height: 12),
SizedBox(
height: 320,
child: PageView.builder(
controller: pageController,
itemCount: imageMediaIds.length,
onPageChanged: onPageChanged,
itemBuilder: (context, index) {
return _ImageMediaPage(
mediaAssetId: imageMediaIds[index],
index: index,
);
},
),
),
],
),
);
}
}
final class _ImageMediaPage extends StatelessWidget {
const _ImageMediaPage({required this.mediaAssetId, required this.index});
final String mediaAssetId;
final int index;
@override
Widget build(BuildContext context) {
final tokens = courtBlazerTokensOf(context);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border: Border.all(color: tokens.border),
color: Theme.of(context).colorScheme.surface,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.image_outlined,
size: 64,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'Image ${index + 1}',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(mediaAssetId, style: Theme.of(context).textTheme.bodySmall),
],
),
);
}
}
final class _VideoMediaPanel extends StatelessWidget {
const _VideoMediaPanel({required this.videoMediaId});
final String videoMediaId;
@override
Widget build(BuildContext context) {
return CourtBlazerAccentPanel(
padding: const EdgeInsets.all(16),
child: SizedBox(
height: 260,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.play_circle_outline,
size: 64,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text('Vidéo', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(videoMediaId, style: Theme.of(context).textTheme.bodySmall),
],
),
),
);
}
}
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, exercise)),
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);
}
final actualScoreTimeMs = result?.actualScoreTimeMs;
if (actualScoreTimeMs != null) {
_scoreController.text = (actualScoreTimeMs / 1000).toStringAsFixed(1);
}
_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.stopwatchScoreEnabled
? 'Temps réalisé (chrono score, s)'
: 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.manualScoreEnabled
? double.tryParse(_scoreController.text.trim())
: null,
actualScoreTimeMs: widget.exercise.stopwatchScoreEnabled
? _decimalSecondsToMilliseconds(_scoreController.text)
: null,
scoreInputModeSnapshot: widget.exercise.scoreInputMode,
scoreLabelSnapshot: widget.exercise.scoreLabel,
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,
ExecutionExercise exercise,
) {
if (status == _PlanSetStatus.completed && result != null) {
return _setResultSummary(result, exercise: exercise);
}
if (status == _PlanSetStatus.passed) {
return 'Aucun résultat';
}
return '';
}
String _setResultSummary(
ActiveSetResult result, {
ExecutionExercise? exercise,
}) {
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');
}
if (result.actualScoreTimeMs != null) {
final value = _formatScoreStopwatch(
Duration(milliseconds: result.actualScoreTimeMs!),
);
final targetScoreTimeMs = exercise?.targetScoreTimeMs;
if (targetScoreTimeMs == null) {
parts.add('Score chrono $value');
} else {
final targetValue = _formatScoreStopwatchTarget(
Duration(milliseconds: targetScoreTimeMs),
);
parts.add('Score chrono $value / objectif $targetValue');
}
}
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? _decimalSecondsToMilliseconds(String rawValue) {
final seconds = double.tryParse(rawValue.trim());
return seconds == null ? null : (seconds * 1000).round();
}
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);
return 'Programme ${position.programIndex + 1}/${programs.length} · '
'Exercice ${position.exerciseIndex + 1}/${program.exercises.length}';
}
}
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.imageMediaIds = const [],
this.videoMediaId,
this.scoreInputMode = ScoreInputMode.manual,
this.scoreLabel,
this.targetTimeSeconds,
this.targetReps,
this.targetScore,
this.targetScoreTimeMs,
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,
imageMediaIds: _imageMediaIdsFromSnapshot(exercise),
videoMediaId:
exercise['videoMediaIdSnapshot'] as String? ??
exercise['exerciseVideoMediaIdSnapshot'] as String?,
scoreInputMode: _scoreInputModeFromSnapshot(
exercise['scoreInputModeSnapshot'],
),
scoreLabel: exercise['scoreLabelSnapshot'] as String?,
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(),
targetScoreTimeMs:
(override?['targetScoreTimeMsOverride'] as int?) ??
(exercise['targetScoreTimeMs'] as int?),
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 List<String> imageMediaIds;
final String? videoMediaId;
final ScoreInputMode scoreInputMode;
final String? scoreLabel;
final int? targetTimeSeconds;
final int? targetReps;
final double? targetScore;
final int? targetScoreTimeMs;
final String? scoreUnit;
bool get manualScoreEnabled {
return scoreEnabled && scoreInputMode == ScoreInputMode.manual;
}
bool get stopwatchScoreEnabled {
return scoreEnabled && scoreInputMode == ScoreInputMode.stopwatch;
}
bool get hasMedia {
return imageMediaIds.isNotEmpty || videoMediaId != null;
}
}
List<String> _imageMediaIdsFromSnapshot(Map<String, dynamic> exercise) {
final imageMediaIds =
exercise['imageMediaIdsSnapshot'] ??
exercise['exerciseImageMediaIdsSnapshot'];
if (imageMediaIds is List) {
return imageMediaIds
.whereType<String>()
.where((id) => id.trim().isNotEmpty)
.toList(growable: false);
}
final imageMediaId = exercise['exerciseImageMediaIdSnapshot'] as String?;
return [
if (imageMediaId != null && imageMediaId.trim().isNotEmpty) imageMediaId,
];
}
ScoreInputMode _scoreInputModeFromSnapshot(Object? value) {
return switch (value) {
'stopwatch' => ScoreInputMode.stopwatch,
_ => ScoreInputMode.manual,
};
}
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),
],
),
);
}
}
final class _SeriesCounterCard extends StatelessWidget {
const _SeriesCounterCard({required this.currentSet, required this.totalSets});
final int currentSet;
final int totalSets;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = courtBlazerTokensOf(context);
return CourtBlazerAccentPanel(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'SÉRIE',
style: theme.textTheme.labelSmall?.copyWith(
color: tokens.mutedText,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Text(
'$currentSet / $totalSets',
style: theme.textTheme.displayLarge?.copyWith(
color: theme.colorScheme.primary,
fontSize: 48,
),
),
],
),
);
}
}
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';
}
String _formatScoreStopwatch(Duration duration) {
final totalTenths = (duration.inMilliseconds / 100).floor();
final minutes = (totalTenths ~/ 600).toString().padLeft(2, '0');
final seconds = ((totalTenths ~/ 10) % 60).toString().padLeft(2, '0');
final tenths = totalTenths % 10;
return '$minutes:$seconds.$tenths';
}
String _formatScoreStopwatchTarget(Duration duration) {
if (duration.inMilliseconds % 1000 == 0) {
return _formatDuration(duration);
}
return _formatScoreStopwatch(duration);
}