Réorganise l'écran d'exécution autour d'une carte "Exercice actif" sous le compteur SÉRIE X/Y (nom, médias, temps de série, action "Démarrer l'exercice"). Le timer de série devient un état persistant dédié (plus un DateTime UI volatile), pause-aware, et "Démarrer l'exercice" lance en une action tous les chronos qui doivent démarrer en début de série (série, première étape temps, score chrono si stopwatch). Implémentation restée non commitée depuis sa réalisation ; QA a validé les vérifications statiques exécutables en sandbox (dart analyze, git diff --check). flutter analyze / flutter test restent à relancer dans un environnement avec cache Flutter SDK accessible. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3985 lines
125 KiB
Dart
3985 lines
125 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:video_player/video_player.dart';
|
||
|
||
import '../application/application.dart';
|
||
import '../domain/domain.dart';
|
||
import 'exercise_step_audio.dart';
|
||
import 'history_screen.dart';
|
||
import 'theme.dart';
|
||
|
||
enum WorkoutExecutionMode { active, rest, paused, finished }
|
||
|
||
typedef VideoMediaBuilder =
|
||
Widget Function(BuildContext context, MediaAsset asset);
|
||
|
||
final class WorkoutExecutionScreen extends StatefulWidget {
|
||
const WorkoutExecutionScreen({
|
||
required this.initialSession,
|
||
required this.activeUseCases,
|
||
required this.closeUseCase,
|
||
required this.historyUseCases,
|
||
required this.workoutTemplateUseCases,
|
||
this.mediaUseCases,
|
||
this.mediaAssetLoader,
|
||
this.videoMediaBuilder,
|
||
this.stepUseCases,
|
||
this.stepAudioCuePlayer,
|
||
super.key,
|
||
});
|
||
|
||
final ActiveWorkoutSession initialSession;
|
||
final ActiveWorkoutSessionUseCases activeUseCases;
|
||
final ActiveExerciseStepUseCases? stepUseCases;
|
||
final CloseWorkoutSessionUseCase closeUseCase;
|
||
final WorkoutHistoryUseCases historyUseCases;
|
||
final WorkoutTemplateUseCases workoutTemplateUseCases;
|
||
final MediaUseCases? mediaUseCases;
|
||
final Future<MediaAsset?> Function(String id)? mediaAssetLoader;
|
||
final VideoMediaBuilder? videoMediaBuilder;
|
||
final ExerciseStepAudioCuePlayer? stepAudioCuePlayer;
|
||
|
||
@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;
|
||
Timer? _scoreStopwatchTicker;
|
||
Timer? _stepTicker;
|
||
Timer? _stepScoreTicker;
|
||
WorkoutHistory? _completedHistory;
|
||
String? _activeRestStateId;
|
||
ActiveSetTimerState? _setTimer;
|
||
ActiveScoreStopwatchState? _scoreStopwatch;
|
||
var _scoreStopwatchLoadGeneration = 0;
|
||
ActiveExerciseStepProgressView? _stepProgress;
|
||
late final ExerciseStepAudioCuePlayer _stepAudioCuePlayer;
|
||
late final bool _ownsStepAudioCuePlayer;
|
||
final _stepScoreController = TextEditingController();
|
||
int? _manualScoreTimeMs;
|
||
DateTime? _stepScoreStartedAt;
|
||
var _stepScoreAccumulatedMs = 0;
|
||
var _stepScoreRunning = false;
|
||
var _lastCountdownBeepSecond = -1;
|
||
var _advancingStep = false;
|
||
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);
|
||
_ownsStepAudioCuePlayer =
|
||
widget.stepAudioCuePlayer == null && widget.stepUseCases != null;
|
||
_stepAudioCuePlayer =
|
||
widget.stepAudioCuePlayer ??
|
||
(widget.stepUseCases == null
|
||
? const NoOpExerciseStepAudioCuePlayer()
|
||
: AudioplayersExerciseStepAudioCuePlayer());
|
||
_mode = _session.status == ActiveWorkoutStatus.running
|
||
? WorkoutExecutionMode.active
|
||
: WorkoutExecutionMode.paused;
|
||
_reps = _initialRepsFor(_exercise);
|
||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
||
if (mounted) setState(() {});
|
||
});
|
||
unawaited(_loadSetTimer());
|
||
unawaited(_loadScoreStopwatch());
|
||
unawaited(_loadStepProgress());
|
||
unawaited(_restoreActiveRest());
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_ticker?.cancel();
|
||
_restTicker?.cancel();
|
||
_scoreStopwatchTicker?.cancel();
|
||
_stepTicker?.cancel();
|
||
_stepScoreTicker?.cancel();
|
||
if (_ownsStepAudioCuePlayer) {
|
||
unawaited(_stepAudioCuePlayer.dispose());
|
||
}
|
||
_scoreController.dispose();
|
||
_stepScoreController.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')),
|
||
],
|
||
),
|
||
bottomNavigationBar: _mode == WorkoutExecutionMode.active
|
||
? SafeArea(
|
||
top: false,
|
||
minimum: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||
child: _SetActionBar(
|
||
onFinishSet: () => _finishSet(skipped: false),
|
||
onSkipSet: () => _finishSet(skipped: true),
|
||
),
|
||
)
|
||
: null,
|
||
body: switch (_mode) {
|
||
WorkoutExecutionMode.active => _buildActive(),
|
||
WorkoutExecutionMode.rest => _buildRest(),
|
||
WorkoutExecutionMode.paused => _buildPaused(),
|
||
WorkoutExecutionMode.finished => _buildFinished(),
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildActive() {
|
||
final hasStepSequence =
|
||
_exercise.steps.isNotEmpty && widget.stepUseCases != null;
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||
child: _ExecutionContextHeader(
|
||
exercise: _exercise,
|
||
currentSet: _position.setIndex + 1,
|
||
totalSets: _exercise.setsCount,
|
||
progressLabel: _plan.progressLabel(_position),
|
||
setTimerLabel: _setTimerLabel,
|
||
setTimerComplete:
|
||
_setTimer?.status == ActiveSetTimerStatus.stopped,
|
||
showStartButton: _shouldShowStartExerciseButton,
|
||
onStartExercise: _startExercise,
|
||
onOpenMedia: _exercise.hasMedia
|
||
? () => _openExerciseMedia(_exercise)
|
||
: null,
|
||
elapsedLabel: _formatDuration(
|
||
Duration(
|
||
milliseconds: widget.activeUseCases.elapsedActiveMilliseconds(
|
||
_session,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||
child: hasStepSequence
|
||
? Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Expanded(
|
||
child: _StepSequencePanel(
|
||
view: _stepProgress,
|
||
remainingLabel: _stepRemainingLabel,
|
||
stepScoreController: _stepScoreController,
|
||
stepScoreElapsedLabel: _formatScoreStopwatch(
|
||
Duration(milliseconds: _stepScoreElapsedMs),
|
||
),
|
||
stepScoreRunning: _stepScoreRunning,
|
||
onStartTimer: _startCurrentStepTimer,
|
||
onCompleteStep: _completeCurrentStep,
|
||
onSkipStep: _skipCurrentStep,
|
||
onSkipPassage: _skipCurrentPassage,
|
||
onSkipSequence: _skipSequence,
|
||
onStartStepScore: _startStepScore,
|
||
onStopStepScore: _stopStepScore,
|
||
onResetStepScore: _resetStepScore,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
SizedBox(
|
||
height: _exercise.manualScoreEnabled ? 66 : 48,
|
||
child: _StepSetResultSummary(
|
||
view: _stepProgress,
|
||
exercise: _exercise,
|
||
reps: _reps,
|
||
scoreController: _scoreController,
|
||
),
|
||
),
|
||
if (_exercise.stopwatchScoreEnabled) ...[
|
||
const SizedBox(height: 8),
|
||
SizedBox(
|
||
height: 96,
|
||
child: _ScoreStopwatchInput(
|
||
elapsedLabel: _formatScoreStopwatch(
|
||
Duration(milliseconds: _scoreStopwatchElapsedMs),
|
||
),
|
||
status: _scoreStopwatch?.status,
|
||
onStart: _startScoreStopwatch,
|
||
onStop: _stopScoreStopwatch,
|
||
onResume: _resumeScoreStopwatch,
|
||
onReset: _resetScoreStopwatch,
|
||
onEdit: _editScoreStopwatchTime,
|
||
compact: true,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
)
|
||
: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
if (_exercise.stopwatchScoreEnabled) ...[
|
||
_ScoreStopwatchInput(
|
||
elapsedLabel: _formatScoreStopwatch(
|
||
Duration(milliseconds: _scoreStopwatchElapsedMs),
|
||
),
|
||
status: _scoreStopwatch?.status,
|
||
onStart: _startScoreStopwatch,
|
||
onStop: _stopScoreStopwatch,
|
||
onResume: _resumeScoreStopwatch,
|
||
onReset: _resetScoreStopwatch,
|
||
onEdit: _editScoreStopwatchTime,
|
||
),
|
||
const SizedBox(height: 12),
|
||
],
|
||
Expanded(
|
||
child: ListView(
|
||
padding: const EdgeInsets.only(bottom: 16),
|
||
children: [
|
||
SetMeasureInput(
|
||
exercise: _exercise,
|
||
showTime: false,
|
||
reps: _reps,
|
||
scoreController: _scoreController,
|
||
onRepsChanged: (value) =>
|
||
setState(() => _reps = value),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
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 l’exercice',
|
||
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 à l’accueil'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
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,
|
||
mediaAssetLoader: _loadMediaAsset,
|
||
videoMediaBuilder: widget.videoMediaBuilder,
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<MediaAsset?> _loadMediaAsset(String id) {
|
||
final loader = widget.mediaAssetLoader;
|
||
if (loader != null) return loader(id);
|
||
final mediaUseCases = widget.mediaUseCases;
|
||
if (mediaUseCases == null) return Future<MediaAsset?>.value();
|
||
return mediaUseCases.findById(id);
|
||
}
|
||
|
||
int get _setTimerElapsedMs {
|
||
final state = _setTimer;
|
||
if (state == null) {
|
||
return 0;
|
||
}
|
||
return widget.activeUseCases.setTimerElapsedMilliseconds(state);
|
||
}
|
||
|
||
String? get _setTimerLabel {
|
||
if (!_exercise.timeEnabled) {
|
||
return null;
|
||
}
|
||
final state = _setTimer;
|
||
if (state == null) {
|
||
final targetSeconds = _exercise.targetTimeSeconds;
|
||
if (targetSeconds == null) {
|
||
return 'Chronométrer';
|
||
}
|
||
return _formatDuration(Duration(seconds: targetSeconds));
|
||
}
|
||
return _formatDuration(Duration(milliseconds: _setTimerElapsedMs));
|
||
}
|
||
|
||
bool get _shouldShowStartExerciseButton {
|
||
final hasGlobalTimer =
|
||
_exercise.timeEnabled ||
|
||
_exercise.stopwatchScoreEnabled ||
|
||
(_exercise.steps.isNotEmpty &&
|
||
_exercise.steps.first.type == ExerciseStepType.time);
|
||
return hasGlobalTimer && !_hasSetExecutionStarted;
|
||
}
|
||
|
||
bool get _hasSetExecutionStarted {
|
||
if (_setTimer != null) {
|
||
return true;
|
||
}
|
||
if (_scoreStopwatch != null) {
|
||
return true;
|
||
}
|
||
final view = _stepProgress;
|
||
if (view == null) {
|
||
return false;
|
||
}
|
||
return view.results.isNotEmpty ||
|
||
view.state.currentPassageIndex > 0 ||
|
||
view.state.currentStepIndex > 0 ||
|
||
view.state.status == ActiveExerciseStepProgressStatus.runningTimer ||
|
||
view.state.status == ActiveExerciseStepProgressStatus.pausedTimer ||
|
||
view.state.status == ActiveExerciseStepProgressStatus.sequenceComplete;
|
||
}
|
||
|
||
bool get _shouldConfirmSkipSet {
|
||
return _setTimer?.status == ActiveSetTimerStatus.running ||
|
||
_scoreStopwatch?.status == ActiveScoreStopwatchStatus.running ||
|
||
_hasStartedStepSequence;
|
||
}
|
||
|
||
bool get _shouldPromptStartExerciseChrono {
|
||
if (_exercise.steps.isNotEmpty && widget.stepUseCases != null) {
|
||
return _isFirstTimedStepNotStarted;
|
||
}
|
||
return (_exercise.timeEnabled && _setTimer == null) ||
|
||
_isFirstTimedStepNotStarted;
|
||
}
|
||
|
||
bool get _isFirstTimedStepNotStarted {
|
||
if (_exercise.steps.isEmpty ||
|
||
_exercise.steps.first.type != ExerciseStepType.time) {
|
||
return false;
|
||
}
|
||
final view = _stepProgress;
|
||
if (view == null) {
|
||
return true;
|
||
}
|
||
return view.state.currentPassageIndex == 0 &&
|
||
view.state.currentStepIndex == 0 &&
|
||
view.state.status == ActiveExerciseStepProgressStatus.stoppedTimer &&
|
||
view.results.isEmpty;
|
||
}
|
||
|
||
bool get _hasStartedStepSequence {
|
||
final view = _stepProgress;
|
||
if (view == null) {
|
||
return false;
|
||
}
|
||
return view.results.isNotEmpty ||
|
||
view.state.currentPassageIndex > 0 ||
|
||
view.state.currentStepIndex > 0 ||
|
||
view.state.status == ActiveExerciseStepProgressStatus.runningTimer ||
|
||
view.state.status == ActiveExerciseStepProgressStatus.pausedTimer;
|
||
}
|
||
|
||
Future<void> _loadSetTimer() async {
|
||
if (!_exercise.timeEnabled) {
|
||
if (!mounted) return;
|
||
setState(() => _setTimer = null);
|
||
return;
|
||
}
|
||
final state = await widget.activeUseCases.findSetTimer(
|
||
sessionId: _session.metadata.id,
|
||
programIndex: _position.programIndex,
|
||
exerciseIndex: _position.exerciseIndex,
|
||
setIndex: _position.setIndex,
|
||
);
|
||
if (!mounted) return;
|
||
setState(() => _setTimer = state);
|
||
}
|
||
|
||
Future<void> _startExercise() async {
|
||
try {
|
||
final result = await widget.activeUseCases.startSetExecution(
|
||
sessionId: _session.metadata.id,
|
||
programIndex: _position.programIndex,
|
||
exerciseIndex: _position.exerciseIndex,
|
||
setIndex: _position.setIndex,
|
||
);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_setTimer = result.setTimer;
|
||
_scoreStopwatch = result.scoreStopwatch ?? _scoreStopwatch;
|
||
_manualScoreTimeMs = null;
|
||
});
|
||
await _loadScoreStopwatch();
|
||
await _readStepProgress();
|
||
_refreshScoreStopwatchTicker();
|
||
_refreshStepTicker();
|
||
} on Exception catch (error) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text("Impossible de démarrer l’exercice : $error")),
|
||
);
|
||
}
|
||
}
|
||
|
||
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;
|
||
_refreshScoreStopwatchTicker();
|
||
return;
|
||
}
|
||
final generation = _scoreStopwatchLoadGeneration;
|
||
final state = await widget.activeUseCases.findScoreStopwatch(
|
||
sessionId: _session.metadata.id,
|
||
programIndex: _position.programIndex,
|
||
exerciseIndex: _position.exerciseIndex,
|
||
setIndex: _position.setIndex,
|
||
);
|
||
if (!mounted) return;
|
||
if (generation != _scoreStopwatchLoadGeneration) return;
|
||
if (state == null && _scoreStopwatch != null) return;
|
||
setState(() => _scoreStopwatch = state);
|
||
_refreshScoreStopwatchTicker();
|
||
}
|
||
|
||
Future<void> _startScoreStopwatch() async {
|
||
_scoreStopwatchLoadGeneration++;
|
||
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;
|
||
});
|
||
_refreshScoreStopwatchTicker();
|
||
}
|
||
|
||
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);
|
||
_refreshScoreStopwatchTicker();
|
||
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;
|
||
});
|
||
_refreshScoreStopwatchTicker();
|
||
}
|
||
|
||
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;
|
||
_scoreStopwatchLoadGeneration++;
|
||
setState(() {
|
||
_scoreStopwatch = null;
|
||
_manualScoreTimeMs = null;
|
||
});
|
||
_refreshScoreStopwatchTicker();
|
||
}
|
||
|
||
void _refreshScoreStopwatchTicker() {
|
||
final shouldTick =
|
||
_scoreStopwatch?.status == ActiveScoreStopwatchStatus.running;
|
||
if (!shouldTick) {
|
||
_scoreStopwatchTicker?.cancel();
|
||
_scoreStopwatchTicker = null;
|
||
return;
|
||
}
|
||
if (_scoreStopwatchTicker != null) return;
|
||
_scoreStopwatchTicker = Timer.periodic(const Duration(milliseconds: 100), (
|
||
_,
|
||
) {
|
||
if (mounted) setState(() {});
|
||
});
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
String get _stepRemainingLabel {
|
||
final view = _stepProgress;
|
||
final step = view?.currentStep;
|
||
if (view == null || step == null || step.type != ExerciseStepType.time) {
|
||
return '';
|
||
}
|
||
final stepUseCases = widget.stepUseCases;
|
||
if (stepUseCases == null) return '';
|
||
final remainingMs = stepUseCases.remainingMilliseconds(
|
||
state: view.state,
|
||
step: step,
|
||
);
|
||
return _formatStepCountdown(Duration(milliseconds: remainingMs));
|
||
}
|
||
|
||
int get _stepScoreElapsedMs {
|
||
if (!_stepScoreRunning || _stepScoreStartedAt == null) {
|
||
return _stepScoreAccumulatedMs;
|
||
}
|
||
return _stepScoreAccumulatedMs +
|
||
DateTime.now().toUtc().difference(_stepScoreStartedAt!).inMilliseconds;
|
||
}
|
||
|
||
Future<void> _loadStepProgress({bool startNextTimed = false}) async {
|
||
final stepUseCases = widget.stepUseCases;
|
||
if (_exercise.steps.isEmpty || stepUseCases == null) {
|
||
_stepTicker?.cancel();
|
||
_stepTicker = null;
|
||
if (!mounted) return;
|
||
setState(() => _stepProgress = null);
|
||
return;
|
||
}
|
||
try {
|
||
final view = await stepUseCases.startOrResumeProgress(
|
||
sessionId: _session.metadata.id,
|
||
programIndex: _position.programIndex,
|
||
exerciseIndex: _position.exerciseIndex,
|
||
setIndex: _position.setIndex,
|
||
);
|
||
if (!mounted) return;
|
||
_applyStepProgress(view);
|
||
if (startNextTimed) {
|
||
await _startCurrentStepTimerIfNeeded();
|
||
}
|
||
_refreshStepTicker();
|
||
} on Exception catch (error) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('Impossible de charger la séquence : $error')),
|
||
);
|
||
}
|
||
}
|
||
|
||
Future<void> _readStepProgress({bool startNextTimed = false}) async {
|
||
final stepUseCases = widget.stepUseCases;
|
||
if (_exercise.steps.isEmpty || stepUseCases == null) return;
|
||
try {
|
||
final view = await stepUseCases.readProgress(
|
||
sessionId: _session.metadata.id,
|
||
programIndex: _position.programIndex,
|
||
exerciseIndex: _position.exerciseIndex,
|
||
setIndex: _position.setIndex,
|
||
);
|
||
if (!mounted) return;
|
||
_applyStepProgress(view);
|
||
if (startNextTimed) {
|
||
await _startCurrentStepTimerIfNeeded();
|
||
}
|
||
_refreshStepTicker();
|
||
} on Exception catch (error) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('Impossible de rafraîchir la séquence : $error'),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
void _applyStepProgress(ActiveExerciseStepProgressView view) {
|
||
final previous = _stepProgress;
|
||
final previousKey = previous == null
|
||
? null
|
||
: '${previous.state.currentPassageIndex}:${previous.state.currentStepIndex}';
|
||
final nextKey =
|
||
'${view.state.currentPassageIndex}:${view.state.currentStepIndex}';
|
||
if (previousKey != null && previousKey != nextKey) {
|
||
_resetStepScoreInput();
|
||
}
|
||
setState(() => _stepProgress = view);
|
||
}
|
||
|
||
void _refreshStepTicker() {
|
||
final view = _stepProgress;
|
||
final running =
|
||
_mode == WorkoutExecutionMode.active &&
|
||
view?.state.status == ActiveExerciseStepProgressStatus.runningTimer;
|
||
if (!running) {
|
||
_stepTicker?.cancel();
|
||
_stepTicker = null;
|
||
_lastCountdownBeepSecond = -1;
|
||
return;
|
||
}
|
||
if (_stepTicker != null) return;
|
||
_stepTicker = Timer.periodic(const Duration(milliseconds: 100), (_) {
|
||
unawaited(_tickStepTimer());
|
||
});
|
||
}
|
||
|
||
Future<void> _tickStepTimer() async {
|
||
if (!mounted || _advancingStep) return;
|
||
final view = _stepProgress;
|
||
final step = view?.currentStep;
|
||
if (view == null || step == null || step.type != ExerciseStepType.time) {
|
||
return;
|
||
}
|
||
final stepUseCases = widget.stepUseCases;
|
||
if (stepUseCases == null) return;
|
||
final remainingMs = stepUseCases.remainingMilliseconds(
|
||
state: view.state,
|
||
step: step,
|
||
);
|
||
final remainingSecond = (remainingMs / 1000).ceil();
|
||
if (remainingSecond >= 1 &&
|
||
remainingSecond <= 3 &&
|
||
remainingSecond != _lastCountdownBeepSecond) {
|
||
_lastCountdownBeepSecond = remainingSecond;
|
||
await _playStepAudioCue(short: true);
|
||
}
|
||
if (remainingMs <= 0) {
|
||
_advancingStep = true;
|
||
_stepTicker?.cancel();
|
||
_stepTicker = null;
|
||
await _playStepAudioCue(short: false);
|
||
await _readStepProgress(startNextTimed: true);
|
||
_advancingStep = false;
|
||
return;
|
||
}
|
||
setState(() {});
|
||
}
|
||
|
||
Future<void> _playStepAudioCue({required bool short}) async {
|
||
try {
|
||
if (short) {
|
||
await _stepAudioCuePlayer.playShortCountdownBeep();
|
||
} else {
|
||
await _stepAudioCuePlayer.playLongCompletionBeep();
|
||
}
|
||
} on Object catch (error) {
|
||
debugPrint('Audio cue ignored: $error');
|
||
}
|
||
}
|
||
|
||
Future<void> _startCurrentStepTimer() async {
|
||
if (_stepProgress?.currentStep?.type != ExerciseStepType.time) return;
|
||
try {
|
||
final stepUseCases = widget.stepUseCases;
|
||
if (stepUseCases == null) return;
|
||
final state = await stepUseCases.startTimer(
|
||
sessionId: _session.metadata.id,
|
||
programIndex: _position.programIndex,
|
||
exerciseIndex: _position.exerciseIndex,
|
||
setIndex: _position.setIndex,
|
||
);
|
||
if (!mounted || _stepProgress == null) return;
|
||
setState(() {
|
||
_stepProgress = ActiveExerciseStepProgressView(
|
||
state: state,
|
||
steps: _stepProgress!.steps,
|
||
expectedPassages: _stepProgress!.expectedPassages,
|
||
results: _stepProgress!.results,
|
||
);
|
||
});
|
||
_refreshStepTicker();
|
||
} on Exception catch (error) {
|
||
_showStepError('Impossible de démarrer la séquence : $error');
|
||
}
|
||
}
|
||
|
||
Future<void> _startCurrentStepTimerIfNeeded() {
|
||
_refreshStepTicker();
|
||
return Future<void>.value();
|
||
}
|
||
|
||
Future<void> _completeCurrentStep() async {
|
||
final view = _stepProgress;
|
||
final step = view?.currentStep;
|
||
if (view == null || step == null) return;
|
||
try {
|
||
final stepUseCases = widget.stepUseCases;
|
||
if (stepUseCases == null) return;
|
||
await stepUseCases.completeCurrentStep(
|
||
sessionId: _session.metadata.id,
|
||
programIndex: _position.programIndex,
|
||
exerciseIndex: _position.exerciseIndex,
|
||
setIndex: _position.setIndex,
|
||
actualReps: step.type == ExerciseStepType.reps
|
||
? step.defaultTargetValue
|
||
: null,
|
||
actualScore: _actualStepScore(step),
|
||
actualScoreTimeMs: _actualStepScoreTimeMs(step),
|
||
);
|
||
_resetStepScoreInput();
|
||
await _readStepProgress(startNextTimed: true);
|
||
} on Exception catch (error) {
|
||
_showStepError('Impossible de valider l’étape : $error');
|
||
}
|
||
}
|
||
|
||
Future<void> _skipCurrentStep() async {
|
||
final confirmed = await _confirmStepSkip(
|
||
'Passer cette étape ?',
|
||
_stepProgress?.state.status ==
|
||
ActiveExerciseStepProgressStatus.runningTimer
|
||
? 'Le chrono de cette étape sera arrêté.'
|
||
: 'Cette étape sera marquée comme passée.',
|
||
'Passer l’étape',
|
||
);
|
||
if (confirmed != true) return;
|
||
try {
|
||
final stepUseCases = widget.stepUseCases;
|
||
if (stepUseCases == null) return;
|
||
await stepUseCases.skipCurrentStep(
|
||
sessionId: _session.metadata.id,
|
||
programIndex: _position.programIndex,
|
||
exerciseIndex: _position.exerciseIndex,
|
||
setIndex: _position.setIndex,
|
||
);
|
||
_resetStepScoreInput();
|
||
await _readStepProgress(startNextTimed: true);
|
||
} on Exception catch (error) {
|
||
_showStepError('Impossible de passer l’étape : $error');
|
||
}
|
||
}
|
||
|
||
Future<void> _skipCurrentPassage() async {
|
||
final confirmed = await _confirmStepSkip(
|
||
'Passer ce passage ?',
|
||
'Les étapes restantes de ce passage seront marquées comme passées.',
|
||
'Passer ce passage',
|
||
);
|
||
if (confirmed != true) return;
|
||
try {
|
||
final stepUseCases = widget.stepUseCases;
|
||
if (stepUseCases == null) return;
|
||
await stepUseCases.skipCurrentPassage(
|
||
sessionId: _session.metadata.id,
|
||
programIndex: _position.programIndex,
|
||
exerciseIndex: _position.exerciseIndex,
|
||
setIndex: _position.setIndex,
|
||
);
|
||
_resetStepScoreInput();
|
||
await _readStepProgress(startNextTimed: true);
|
||
} on Exception catch (error) {
|
||
_showStepError('Impossible de passer ce passage : $error');
|
||
}
|
||
}
|
||
|
||
Future<void> _skipSequence() async {
|
||
final confirmed = await _confirmStepSkip(
|
||
'Passer la séquence ?',
|
||
'Toutes les étapes restantes seront marquées comme passées.',
|
||
'Passer la séquence',
|
||
);
|
||
if (confirmed != true) return;
|
||
try {
|
||
final stepUseCases = widget.stepUseCases;
|
||
if (stepUseCases == null) return;
|
||
await stepUseCases.skipSequence(
|
||
sessionId: _session.metadata.id,
|
||
programIndex: _position.programIndex,
|
||
exerciseIndex: _position.exerciseIndex,
|
||
setIndex: _position.setIndex,
|
||
);
|
||
_resetStepScoreInput();
|
||
await _readStepProgress();
|
||
} on Exception catch (error) {
|
||
_showStepError('Impossible de passer la séquence : $error');
|
||
}
|
||
}
|
||
|
||
Future<bool?> _confirmStepSkip(
|
||
String title,
|
||
String content,
|
||
String actionLabel,
|
||
) {
|
||
return showDialog<bool>(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: Text(title),
|
||
content: Text(content),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(false),
|
||
child: const Text('Annuler'),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.of(context).pop(true),
|
||
child: Text(actionLabel),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
double? _actualStepScore(ExerciseStep step) {
|
||
if (!step.hasScore || step.scoreInputMode != ScoreInputMode.manual) {
|
||
return null;
|
||
}
|
||
return double.tryParse(_stepScoreController.text.trim());
|
||
}
|
||
|
||
int? _actualStepScoreTimeMs(ExerciseStep step) {
|
||
if (!step.hasScore || step.scoreInputMode != ScoreInputMode.stopwatch) {
|
||
return null;
|
||
}
|
||
final elapsed = _stepScoreElapsedMs;
|
||
return elapsed > 0 ? elapsed : null;
|
||
}
|
||
|
||
void _startStepScore() {
|
||
if (_stepScoreRunning) return;
|
||
setState(() {
|
||
_stepScoreRunning = true;
|
||
_stepScoreStartedAt = DateTime.now().toUtc();
|
||
});
|
||
_stepScoreTicker ??= Timer.periodic(const Duration(milliseconds: 100), (_) {
|
||
if (mounted) setState(() {});
|
||
});
|
||
}
|
||
|
||
void _stopStepScore() {
|
||
if (!_stepScoreRunning || _stepScoreStartedAt == null) return;
|
||
setState(() {
|
||
_stepScoreAccumulatedMs = _stepScoreElapsedMs;
|
||
_stepScoreStartedAt = null;
|
||
_stepScoreRunning = false;
|
||
});
|
||
_stepScoreTicker?.cancel();
|
||
_stepScoreTicker = null;
|
||
}
|
||
|
||
void _resetStepScore() {
|
||
setState(_resetStepScoreInput);
|
||
}
|
||
|
||
void _resetStepScoreInput() {
|
||
_stepScoreController.clear();
|
||
_stepScoreStartedAt = null;
|
||
_stepScoreAccumulatedMs = 0;
|
||
_stepScoreRunning = false;
|
||
_stepScoreTicker?.cancel();
|
||
_stepScoreTicker = null;
|
||
}
|
||
|
||
void _showStepError(String message) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(SnackBar(content: Text(message)));
|
||
}
|
||
|
||
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();
|
||
_stepTicker?.cancel();
|
||
_stepTicker = null;
|
||
if (_stepScoreRunning) {
|
||
_stopStepScore();
|
||
}
|
||
_session = await widget.activeUseCases.pause(_session.metadata.id);
|
||
if (!mounted) return;
|
||
await _loadSetTimer();
|
||
await _loadScoreStopwatch();
|
||
await _readStepProgress();
|
||
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 _loadSetTimer();
|
||
await _loadScoreStopwatch();
|
||
await _loadStepProgress();
|
||
if (_stepProgress?.state.status ==
|
||
ActiveExerciseStepProgressStatus.pausedTimer) {
|
||
await _startCurrentStepTimer();
|
||
}
|
||
if (!mounted) return;
|
||
setState(() => _mode = WorkoutExecutionMode.active);
|
||
_refreshStepTicker();
|
||
}
|
||
|
||
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,
|
||
stepUseCases: widget.stepUseCases,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _finishSet({required bool skipped}) async {
|
||
var finishWithoutChrono = false;
|
||
if (skipped && _shouldConfirmSkipSet) {
|
||
final confirmed = await _confirmStepSkip(
|
||
'Passer la série ?',
|
||
'Les chronos ou la séquence en cours seront ignorés.',
|
||
'Passer la série',
|
||
);
|
||
if (confirmed != true) return;
|
||
}
|
||
if (!skipped && _shouldPromptStartExerciseChrono) {
|
||
final action = await showDialog<_MissingSetTimerAction>(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: const Text('Chrono non lancé'),
|
||
content: const Text("Tu n’as pas démarré le chrono de cette série."),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () =>
|
||
Navigator.of(context).pop(_MissingSetTimerAction.start),
|
||
child: const Text('Démarrer l’exercice'),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.of(
|
||
context,
|
||
).pop(_MissingSetTimerAction.finishWithout),
|
||
child: const Text('Terminer sans chrono'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (action == _MissingSetTimerAction.start) {
|
||
await _startExercise();
|
||
return;
|
||
}
|
||
if (action != _MissingSetTimerAction.finishWithout) {
|
||
return;
|
||
}
|
||
finishWithoutChrono = true;
|
||
}
|
||
if (!skipped &&
|
||
!finishWithoutChrono &&
|
||
_exercise.stopwatchScoreEnabled &&
|
||
_scoreStopwatch == null &&
|
||
_manualScoreTimeMs == null) {
|
||
final action = await showDialog<_MissingStopwatchAction>(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: const Text('Chrono non lancé'),
|
||
content: const Text("Tu n’as pas démarré le chrono de cette série."),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () =>
|
||
Navigator.of(context).pop(_MissingStopwatchAction.start),
|
||
child: const Text('Démarrer l’exercice'),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.of(
|
||
context,
|
||
).pop(_MissingStopwatchAction.finishWithout),
|
||
child: const Text('Terminer sans chrono'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (action == _MissingStopwatchAction.start) {
|
||
await _startExercise();
|
||
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());
|
||
final setTimer = skipped
|
||
? await widget.activeUseCases.skipSetExecutionTimers(
|
||
sessionId: _session.metadata.id,
|
||
programIndex: _position.programIndex,
|
||
exerciseIndex: _position.exerciseIndex,
|
||
setIndex: _position.setIndex,
|
||
)
|
||
: await widget.activeUseCases.stopSetExecutionTimers(
|
||
sessionId: _session.metadata.id,
|
||
programIndex: _position.programIndex,
|
||
exerciseIndex: _position.exerciseIndex,
|
||
setIndex: _position.setIndex,
|
||
);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_setTimer = setTimer;
|
||
if (skipped) {
|
||
_scoreStopwatch = null;
|
||
_manualScoreTimeMs = null;
|
||
}
|
||
});
|
||
await _loadScoreStopwatch();
|
||
await _readStepProgress();
|
||
var actualScoreTimeMs = skipped ? null : _manualScoreTimeMs;
|
||
if (!skipped &&
|
||
_exercise.stopwatchScoreEnabled &&
|
||
actualScoreTimeMs == null) {
|
||
final state = _scoreStopwatch;
|
||
actualScoreTimeMs = state?.accumulatedMs;
|
||
}
|
||
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 ? setTimer?.accumulatedMs : 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 remainingMs = rest.remainingMillisecondsAt(DateTime.now().toUtc());
|
||
return (remainingMs / 1000).ceil().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 = _initialRepsFor(_plan.exerciseAt(position));
|
||
_activeRestStateId = null;
|
||
_setTimer = null;
|
||
_scoreStopwatch = null;
|
||
_scoreStopwatchLoadGeneration++;
|
||
_manualScoreTimeMs = null;
|
||
_stepProgress = null;
|
||
_resetStepScoreInput();
|
||
_refreshScoreStopwatchTicker();
|
||
_refreshStepTicker();
|
||
_session = await widget.activeUseCases.updateProgress(
|
||
sessionId: _session.metadata.id,
|
||
programIndex: position.programIndex,
|
||
exerciseIndex: position.exerciseIndex,
|
||
setIndex: position.setIndex,
|
||
);
|
||
if (!mounted) return;
|
||
await _loadSetTimer();
|
||
await _loadScoreStopwatch();
|
||
await _loadStepProgress();
|
||
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,
|
||
stepUseCases: widget.stepUseCases,
|
||
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,
|
||
stepUseCases: widget.stepUseCases,
|
||
closeUseCase: widget.closeUseCase,
|
||
historyUseCases: widget.historyUseCases,
|
||
workoutTemplateUseCases: widget.workoutTemplateUseCases,
|
||
mediaUseCases: widget.mediaUseCases,
|
||
mediaAssetLoader: widget.mediaAssetLoader,
|
||
videoMediaBuilder: widget.videoMediaBuilder,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
int _initialRepsFor(ExecutionExercise exercise) {
|
||
if (!exercise.repsEnabled) return 0;
|
||
return exercise.targetReps ?? 0;
|
||
}
|
||
}
|
||
|
||
final class SetMeasureInput extends StatelessWidget {
|
||
const SetMeasureInput({
|
||
required this.exercise,
|
||
this.showTime = true,
|
||
required this.reps,
|
||
required this.scoreController,
|
||
required this.onRepsChanged,
|
||
super.key,
|
||
});
|
||
|
||
final ExecutionExercise exercise;
|
||
final bool showTime;
|
||
final int reps;
|
||
final TextEditingController scoreController;
|
||
final ValueChanged<int> onRepsChanged;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
if (exercise.timeEnabled && showTime)
|
||
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,
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _ExecutionContextHeader extends StatelessWidget {
|
||
const _ExecutionContextHeader({
|
||
required this.exercise,
|
||
required this.currentSet,
|
||
required this.totalSets,
|
||
required this.progressLabel,
|
||
required this.elapsedLabel,
|
||
required this.setTimerLabel,
|
||
required this.setTimerComplete,
|
||
required this.showStartButton,
|
||
required this.onStartExercise,
|
||
required this.onOpenMedia,
|
||
});
|
||
|
||
final ExecutionExercise exercise;
|
||
final int currentSet;
|
||
final int totalSets;
|
||
final String progressLabel;
|
||
final String elapsedLabel;
|
||
final String? setTimerLabel;
|
||
final bool setTimerComplete;
|
||
final bool showStartButton;
|
||
final VoidCallback onStartExercise;
|
||
final VoidCallback? onOpenMedia;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final tokens = courtBlazerTokensOf(context);
|
||
return CourtBlazerAccentPanel(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Wrap(
|
||
spacing: 8,
|
||
runSpacing: 4,
|
||
children: [
|
||
Text(
|
||
'Série $currentSet/$totalSets',
|
||
style: theme.textTheme.labelLarge?.copyWith(
|
||
color: theme.colorScheme.primary,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
Text(
|
||
progressLabel,
|
||
style: theme.textTheme.labelMedium?.copyWith(
|
||
color: tokens.mutedText,
|
||
),
|
||
),
|
||
Text(
|
||
elapsedLabel,
|
||
style: theme.textTheme.labelMedium?.copyWith(
|
||
color: tokens.mutedText,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
exercise.name,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.titleLarge,
|
||
),
|
||
if (setTimerLabel != null) ...[
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
setTimerComplete
|
||
? 'Temps de série terminé : $setTimerLabel'
|
||
: 'Temps de série : $setTimerLabel',
|
||
style: theme.textTheme.labelLarge?.copyWith(
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
if (showStartButton)
|
||
Tooltip(
|
||
message: 'Démarrer l’exercice',
|
||
child: FilledButton.icon(
|
||
onPressed: onStartExercise,
|
||
style: FilledButton.styleFrom(
|
||
minimumSize: const Size(52, 52),
|
||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||
),
|
||
icon: const Icon(Icons.play_arrow),
|
||
label: const Text('Démarrer l’exercice'),
|
||
),
|
||
),
|
||
if (onOpenMedia != null) ...[
|
||
const SizedBox(width: 8),
|
||
Tooltip(
|
||
message: 'Voir les médias de l’exercice',
|
||
child: OutlinedButton(
|
||
onPressed: onOpenMedia,
|
||
style: OutlinedButton.styleFrom(
|
||
minimumSize: const Size(52, 52),
|
||
padding: EdgeInsets.zero,
|
||
),
|
||
child: const Icon(Icons.perm_media_outlined),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _SetActionBar extends StatelessWidget {
|
||
const _SetActionBar({
|
||
required this.onFinishSet,
|
||
required this.onSkipSet,
|
||
});
|
||
|
||
final VoidCallback onFinishSet;
|
||
final VoidCallback onSkipSet;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
FilledButton(
|
||
onPressed: onFinishSet,
|
||
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(56)),
|
||
child: const Text('Terminer la série'),
|
||
),
|
||
const SizedBox(height: 8),
|
||
OutlinedButton(
|
||
onPressed: onSkipSet,
|
||
style: OutlinedButton.styleFrom(
|
||
minimumSize: const Size.fromHeight(52),
|
||
),
|
||
child: const Text('Passer la série'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _StepSetResultSummary extends StatelessWidget {
|
||
const _StepSetResultSummary({
|
||
required this.view,
|
||
required this.exercise,
|
||
required this.reps,
|
||
required this.scoreController,
|
||
});
|
||
|
||
final ActiveExerciseStepProgressView? view;
|
||
final ExecutionExercise exercise;
|
||
final int reps;
|
||
final TextEditingController scoreController;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final completedPassages = _completedPassagesFor(view);
|
||
final expectedPassages = view?.expectedPassages ?? reps;
|
||
final theme = Theme.of(context);
|
||
final tokens = courtBlazerTokensOf(context);
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surface,
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: tokens.border),
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
expectedPassages > 0
|
||
? 'Passages réalisés : $completedPassages / $expectedPassages'
|
||
: 'Passages réalisés : $completedPassages',
|
||
style: theme.textTheme.titleSmall,
|
||
),
|
||
),
|
||
if (exercise.manualScoreEnabled) ...[
|
||
const SizedBox(width: 12),
|
||
SizedBox(
|
||
width: 128,
|
||
child: TextField(
|
||
controller: scoreController,
|
||
decoration: InputDecoration(
|
||
isDense: true,
|
||
labelText: exercise.scoreUnit == null
|
||
? 'Score'
|
||
: 'Score (${exercise.scoreUnit})',
|
||
),
|
||
keyboardType: TextInputType.number,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
int _completedPassagesFor(ActiveExerciseStepProgressView? view) {
|
||
if (view == null) return 0;
|
||
if (view.state.status == ActiveExerciseStepProgressStatus.sequenceComplete) {
|
||
return view.expectedPassages;
|
||
}
|
||
return view.state.currentPassageIndex;
|
||
}
|
||
|
||
enum _MissingSetTimerAction { start, finishWithout }
|
||
|
||
enum _MissingStopwatchAction { start, finishWithout }
|
||
|
||
final class _StepSequencePanel extends StatelessWidget {
|
||
const _StepSequencePanel({
|
||
required this.view,
|
||
required this.remainingLabel,
|
||
required this.stepScoreController,
|
||
required this.stepScoreElapsedLabel,
|
||
required this.stepScoreRunning,
|
||
required this.onStartTimer,
|
||
required this.onCompleteStep,
|
||
required this.onSkipStep,
|
||
required this.onSkipPassage,
|
||
required this.onSkipSequence,
|
||
required this.onStartStepScore,
|
||
required this.onStopStepScore,
|
||
required this.onResetStepScore,
|
||
});
|
||
|
||
final ActiveExerciseStepProgressView? view;
|
||
final String remainingLabel;
|
||
final TextEditingController stepScoreController;
|
||
final String stepScoreElapsedLabel;
|
||
final bool stepScoreRunning;
|
||
final VoidCallback onStartTimer;
|
||
final VoidCallback onCompleteStep;
|
||
final VoidCallback onSkipStep;
|
||
final VoidCallback onSkipPassage;
|
||
final VoidCallback onSkipSequence;
|
||
final VoidCallback onStartStepScore;
|
||
final VoidCallback onStopStepScore;
|
||
final VoidCallback onResetStepScore;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final view = this.view;
|
||
if (view == null) {
|
||
return const _BoundedAccentPanel(
|
||
padding: EdgeInsets.all(12),
|
||
child: Center(child: CircularProgressIndicator()),
|
||
);
|
||
}
|
||
final currentStep = view.currentStep;
|
||
final sequenceComplete =
|
||
view.state.status == ActiveExerciseStepProgressStatus.sequenceComplete;
|
||
final completedPassages = sequenceComplete
|
||
? view.expectedPassages
|
||
: view.state.currentPassageIndex;
|
||
return _BoundedAccentPanel(
|
||
padding: const EdgeInsets.all(10),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
view.expectedPassages > 1 && !sequenceComplete
|
||
? 'Passage ${view.state.currentPassageIndex + 1} / ${view.expectedPassages}'
|
||
: view.expectedPassages > 1
|
||
? 'Séquence terminée'
|
||
: 'Passage en cours',
|
||
style: Theme.of(context).textTheme.titleMedium,
|
||
),
|
||
),
|
||
Text(
|
||
'SÉQUENCE',
|
||
style: Theme.of(context).textTheme.labelMedium,
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 6),
|
||
SingleChildScrollView(
|
||
scrollDirection: Axis.horizontal,
|
||
child: Row(
|
||
children: [
|
||
for (var index = 0; index < view.steps.length; index++) ...[
|
||
_StepProgressChip(
|
||
index: index,
|
||
status: _stepDisplayStatus(view, index),
|
||
),
|
||
if (index < view.steps.length - 1) const SizedBox(width: 6),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
if (sequenceComplete || currentStep == null)
|
||
Expanded(
|
||
child: Align(
|
||
alignment: Alignment.topLeft,
|
||
child: _SequenceCompleteSummary(
|
||
completedPassages: completedPassages,
|
||
expectedPassages: view.expectedPassages,
|
||
),
|
||
),
|
||
)
|
||
else
|
||
Expanded(
|
||
child: _CurrentStepPane(
|
||
view: view,
|
||
step: currentStep,
|
||
remainingLabel: remainingLabel,
|
||
stepScoreController: stepScoreController,
|
||
stepScoreElapsedLabel: stepScoreElapsedLabel,
|
||
stepScoreRunning: stepScoreRunning,
|
||
onStartTimer: onStartTimer,
|
||
onCompleteStep: onCompleteStep,
|
||
onSkipStep: onSkipStep,
|
||
onSkipPassage: onSkipPassage,
|
||
onSkipSequence: onSkipSequence,
|
||
onStartStepScore: onStartStepScore,
|
||
onStopStepScore: onStopStepScore,
|
||
onResetStepScore: onResetStepScore,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
enum _StepSkipAction { passage, sequence }
|
||
|
||
final class _BoundedAccentPanel extends StatelessWidget {
|
||
const _BoundedAccentPanel({required this.child, required this.padding});
|
||
|
||
final Widget child;
|
||
final EdgeInsetsGeometry padding;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final tokens = courtBlazerTokensOf(context);
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surface,
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: tokens.border),
|
||
),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Container(height: 2, color: tokens.accent),
|
||
Expanded(child: Padding(padding: padding, child: child)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _CurrentStepPane extends StatelessWidget {
|
||
const _CurrentStepPane({
|
||
required this.view,
|
||
required this.step,
|
||
required this.remainingLabel,
|
||
required this.stepScoreController,
|
||
required this.stepScoreElapsedLabel,
|
||
required this.stepScoreRunning,
|
||
required this.onStartTimer,
|
||
required this.onCompleteStep,
|
||
required this.onSkipStep,
|
||
required this.onSkipPassage,
|
||
required this.onSkipSequence,
|
||
required this.onStartStepScore,
|
||
required this.onStopStepScore,
|
||
required this.onResetStepScore,
|
||
});
|
||
|
||
final ActiveExerciseStepProgressView view;
|
||
final ExerciseStep step;
|
||
final String remainingLabel;
|
||
final TextEditingController stepScoreController;
|
||
final String stepScoreElapsedLabel;
|
||
final bool stepScoreRunning;
|
||
final VoidCallback onStartTimer;
|
||
final VoidCallback onCompleteStep;
|
||
final VoidCallback onSkipStep;
|
||
final VoidCallback onSkipPassage;
|
||
final VoidCallback onSkipSequence;
|
||
final VoidCallback onStartStepScore;
|
||
final VoidCallback onStopStepScore;
|
||
final VoidCallback onResetStepScore;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(
|
||
'ÉTAPE ${view.state.currentStepIndex + 1} / ${view.steps.length}',
|
||
style: Theme.of(context).textTheme.labelLarge,
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
step.name,
|
||
style: Theme.of(context).textTheme.headlineSmall,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
const SizedBox(height: 4),
|
||
Flexible(
|
||
fit: FlexFit.tight,
|
||
child: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final body = step.type == ExerciseStepType.time
|
||
? _TimedStepBody(
|
||
step: step,
|
||
remainingLabel: remainingLabel,
|
||
running:
|
||
view.state.status ==
|
||
ActiveExerciseStepProgressStatus.runningTimer,
|
||
readyToStart: _isNextTimedStepReady(view),
|
||
onStartTimer: onStartTimer,
|
||
)
|
||
: _RepsStepBody(step: step, onCompleteStep: onCompleteStep);
|
||
return Align(
|
||
alignment: Alignment.topCenter,
|
||
child: FittedBox(
|
||
fit: BoxFit.scaleDown,
|
||
alignment: Alignment.topCenter,
|
||
child: SizedBox(width: constraints.maxWidth, child: body),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
if (step.hasScore) ...[
|
||
const SizedBox(height: 8),
|
||
Flexible(
|
||
fit: FlexFit.loose,
|
||
child: _StepScoreInput(
|
||
step: step,
|
||
controller: stepScoreController,
|
||
elapsedLabel: stepScoreElapsedLabel,
|
||
running: stepScoreRunning,
|
||
onStart: onStartStepScore,
|
||
onStop: onStopStepScore,
|
||
onReset: onResetStepScore,
|
||
),
|
||
),
|
||
],
|
||
const SizedBox(height: 4),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: OutlinedButton(
|
||
onPressed: onSkipStep,
|
||
style: OutlinedButton.styleFrom(
|
||
minimumSize: const Size.fromHeight(44),
|
||
),
|
||
child: const Text('Passer l’étape'),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
PopupMenuButton<_StepSkipAction>(
|
||
tooltip: 'Plus d’actions',
|
||
icon: const Icon(Icons.more_horiz),
|
||
onSelected: (action) {
|
||
if (action == _StepSkipAction.passage) {
|
||
onSkipPassage();
|
||
} else {
|
||
onSkipSequence();
|
||
}
|
||
},
|
||
itemBuilder: (context) => const [
|
||
PopupMenuItem(
|
||
value: _StepSkipAction.passage,
|
||
child: Text('Passer ce passage'),
|
||
),
|
||
PopupMenuItem(
|
||
value: _StepSkipAction.sequence,
|
||
child: Text('Passer la séquence'),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _TimedStepBody extends StatelessWidget {
|
||
const _TimedStepBody({
|
||
required this.step,
|
||
required this.remainingLabel,
|
||
required this.running,
|
||
required this.readyToStart,
|
||
required this.onStartTimer,
|
||
});
|
||
|
||
final ExerciseStep step;
|
||
final String remainingLabel;
|
||
final bool running;
|
||
final bool readyToStart;
|
||
final VoidCallback onStartTimer;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text('Objectif : ${step.defaultTargetValue} s'),
|
||
const SizedBox(height: 8),
|
||
Center(
|
||
child: Text(
|
||
remainingLabel,
|
||
style: AppTextStyles.timer(
|
||
context,
|
||
).copyWith(color: Theme.of(context).colorScheme.primary),
|
||
),
|
||
),
|
||
if (!running) ...[
|
||
const SizedBox(height: 12),
|
||
if (readyToStart) ...[
|
||
Text(
|
||
'Chrono suivant prêt',
|
||
textAlign: TextAlign.center,
|
||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||
color: Theme.of(context).colorScheme.primary,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
],
|
||
FilledButton.icon(
|
||
onPressed: onStartTimer,
|
||
icon: const Icon(Icons.play_arrow),
|
||
label: Text(
|
||
readyToStart ? 'Démarrer le chrono' : 'Démarrer la séquence',
|
||
),
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
bool _isNextTimedStepReady(ActiveExerciseStepProgressView view) {
|
||
final step = view.currentStep;
|
||
if (step == null ||
|
||
step.type != ExerciseStepType.time ||
|
||
view.state.status != ActiveExerciseStepProgressStatus.stoppedTimer) {
|
||
return false;
|
||
}
|
||
return view.results.any((result) {
|
||
if (result.passageIndex < view.state.currentPassageIndex) {
|
||
return true;
|
||
}
|
||
return result.passageIndex == view.state.currentPassageIndex &&
|
||
result.stepIndex < view.state.currentStepIndex;
|
||
});
|
||
}
|
||
|
||
final class _RepsStepBody extends StatelessWidget {
|
||
const _RepsStepBody({required this.step, required this.onCompleteStep});
|
||
|
||
final ExerciseStep step;
|
||
final VoidCallback onCompleteStep;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
children: [
|
||
Text(
|
||
'${step.defaultTargetValue}',
|
||
style: AppTextStyles.timer(
|
||
context,
|
||
).copyWith(color: Theme.of(context).colorScheme.primary),
|
||
),
|
||
Text('RÉPÉTITIONS', style: Theme.of(context).textTheme.labelLarge),
|
||
const SizedBox(height: 12),
|
||
FilledButton.icon(
|
||
onPressed: onCompleteStep,
|
||
icon: const Icon(Icons.check),
|
||
label: const Text('Étape suivante'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _StepScoreInput extends StatelessWidget {
|
||
const _StepScoreInput({
|
||
required this.step,
|
||
required this.controller,
|
||
required this.elapsedLabel,
|
||
required this.running,
|
||
required this.onStart,
|
||
required this.onStop,
|
||
required this.onReset,
|
||
});
|
||
|
||
final ExerciseStep step;
|
||
final TextEditingController controller;
|
||
final String elapsedLabel;
|
||
final bool running;
|
||
final VoidCallback onStart;
|
||
final VoidCallback onStop;
|
||
final VoidCallback onReset;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (step.scoreInputMode == ScoreInputMode.stopwatch) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(
|
||
'Chrono score d’étape',
|
||
style: Theme.of(context).textTheme.titleSmall,
|
||
),
|
||
const SizedBox(height: 8),
|
||
Center(
|
||
child: Text(
|
||
elapsedLabel,
|
||
style: AppTextStyles.scoreNumber(context),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Wrap(
|
||
spacing: 8,
|
||
alignment: WrapAlignment.center,
|
||
children: [
|
||
FilledButton.icon(
|
||
onPressed: running ? onStop : onStart,
|
||
icon: Icon(running ? Icons.stop : Icons.play_arrow),
|
||
label: Text(running ? 'Arrêter' : 'Démarrer'),
|
||
),
|
||
OutlinedButton.icon(
|
||
onPressed: onReset,
|
||
icon: const Icon(Icons.restart_alt),
|
||
label: const Text('Réinitialiser'),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
final unit = step.scoreUnit?.trim();
|
||
return TextField(
|
||
controller: controller,
|
||
decoration: InputDecoration(
|
||
labelText: unit == null || unit.isEmpty
|
||
? 'Score de l’étape'
|
||
: 'Score de l’étape ($unit)',
|
||
),
|
||
keyboardType: TextInputType.number,
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _SequenceCompleteSummary extends StatelessWidget {
|
||
const _SequenceCompleteSummary({
|
||
required this.completedPassages,
|
||
required this.expectedPassages,
|
||
});
|
||
|
||
final int completedPassages;
|
||
final int expectedPassages;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(
|
||
'Séquence terminée',
|
||
style: Theme.of(context).textTheme.headlineSmall,
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text('$completedPassages / $expectedPassages passages réalisés'),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _StepProgressChip extends StatelessWidget {
|
||
const _StepProgressChip({required this.index, required this.status});
|
||
|
||
final int index;
|
||
final _StepDisplayStatus status;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final tokens = courtBlazerTokensOf(context);
|
||
final background = switch (status) {
|
||
_StepDisplayStatus.current => theme.colorScheme.primary,
|
||
_StepDisplayStatus.completed => tokens.success.withAlpha(35),
|
||
_StepDisplayStatus.skipped => theme.colorScheme.surface,
|
||
_StepDisplayStatus.todo => theme.colorScheme.surface,
|
||
};
|
||
final border = switch (status) {
|
||
_StepDisplayStatus.current => theme.colorScheme.primary,
|
||
_StepDisplayStatus.completed => tokens.success,
|
||
_StepDisplayStatus.skipped => tokens.mutedText,
|
||
_StepDisplayStatus.todo => tokens.border,
|
||
};
|
||
final foreground = status == _StepDisplayStatus.current
|
||
? theme.colorScheme.onPrimary
|
||
: theme.colorScheme.onSurface;
|
||
return Container(
|
||
width: 30,
|
||
height: 30,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
color: background,
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: border),
|
||
),
|
||
child: Text(
|
||
'${index + 1}',
|
||
style: theme.textTheme.labelLarge?.copyWith(color: foreground),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
enum _StepDisplayStatus { completed, skipped, current, todo }
|
||
|
||
_StepDisplayStatus _stepDisplayStatus(
|
||
ActiveExerciseStepProgressView view,
|
||
int stepIndex,
|
||
) {
|
||
for (final result in view.results) {
|
||
if (result.passageIndex == view.state.currentPassageIndex &&
|
||
result.stepIndex == stepIndex) {
|
||
return result.status == SetResultStatus.completed
|
||
? _StepDisplayStatus.completed
|
||
: _StepDisplayStatus.skipped;
|
||
}
|
||
}
|
||
if (view.state.status != ActiveExerciseStepProgressStatus.sequenceComplete &&
|
||
stepIndex == view.state.currentStepIndex) {
|
||
return _StepDisplayStatus.current;
|
||
}
|
||
if (stepIndex < view.state.currentStepIndex) {
|
||
return _StepDisplayStatus.completed;
|
||
}
|
||
return _StepDisplayStatus.todo;
|
||
}
|
||
|
||
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,
|
||
this.compact = false,
|
||
});
|
||
|
||
final String elapsedLabel;
|
||
final ActiveScoreStopwatchStatus? status;
|
||
final VoidCallback onStart;
|
||
final VoidCallback onStop;
|
||
final VoidCallback onResume;
|
||
final VoidCallback onReset;
|
||
final VoidCallback onEdit;
|
||
final bool compact;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final running = status == ActiveScoreStopwatchStatus.running;
|
||
final stopped = status == ActiveScoreStopwatchStatus.stopped;
|
||
return CourtBlazerAccentPanel(
|
||
padding: compact
|
||
? const EdgeInsets.symmetric(horizontal: 14, vertical: 10)
|
||
: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
'Chrono score',
|
||
style: compact
|
||
? Theme.of(context).textTheme.titleSmall
|
||
: Theme.of(context).textTheme.titleMedium,
|
||
),
|
||
),
|
||
Text(
|
||
elapsedLabel,
|
||
style: compact
|
||
? Theme.of(context).textTheme.titleLarge
|
||
: AppTextStyles.scoreNumber(context),
|
||
),
|
||
],
|
||
),
|
||
SizedBox(height: compact ? 8 : 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.stepUseCases,
|
||
this.activeRest,
|
||
});
|
||
|
||
final String sessionId;
|
||
final WorkoutExecutionPlan plan;
|
||
final ExecutionPosition currentPosition;
|
||
final List<SetResultPositionState> setStates;
|
||
final ActiveWorkoutSessionUseCases activeUseCases;
|
||
final ActiveExerciseStepUseCases? stepUseCases;
|
||
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,
|
||
stepUseCases: widget.stepUseCases,
|
||
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,
|
||
required this.mediaAssetLoader,
|
||
this.videoMediaBuilder,
|
||
this.restRemainingSeconds,
|
||
});
|
||
|
||
final ExecutionExercise exercise;
|
||
final Future<MediaAsset?> Function(String id) mediaAssetLoader;
|
||
final VideoMediaBuilder? videoMediaBuilder;
|
||
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 l’exercice'),
|
||
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),
|
||
mediaAssetLoader: widget.mediaAssetLoader,
|
||
)
|
||
else if (hasVideo)
|
||
_VideoMediaPanel(
|
||
videoMediaId: exercise.videoMediaId!,
|
||
mediaAssetLoader: widget.mediaAssetLoader,
|
||
videoMediaBuilder: widget.videoMediaBuilder,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _ImageMediaGallery extends StatelessWidget {
|
||
const _ImageMediaGallery({
|
||
required this.imageMediaIds,
|
||
required this.pageController,
|
||
required this.currentIndex,
|
||
required this.onPageChanged,
|
||
required this.mediaAssetLoader,
|
||
});
|
||
|
||
final List<String> imageMediaIds;
|
||
final PageController pageController;
|
||
final int currentIndex;
|
||
final ValueChanged<int> onPageChanged;
|
||
final Future<MediaAsset?> Function(String id) mediaAssetLoader;
|
||
|
||
@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,
|
||
mediaAssetLoader: mediaAssetLoader,
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _ImageMediaPage extends StatelessWidget {
|
||
const _ImageMediaPage({
|
||
required this.mediaAssetId,
|
||
required this.index,
|
||
required this.mediaAssetLoader,
|
||
});
|
||
|
||
final String mediaAssetId;
|
||
final int index;
|
||
final Future<MediaAsset?> Function(String id) mediaAssetLoader;
|
||
|
||
@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: FutureBuilder<MediaAsset?>(
|
||
future: mediaAssetLoader(mediaAssetId),
|
||
builder: (context, snapshot) {
|
||
if (snapshot.connectionState != ConnectionState.done) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
final asset = snapshot.data;
|
||
if (asset == null || asset.kind != MediaKind.image) {
|
||
return _MissingMediaPlaceholder(
|
||
icon: Icons.broken_image_outlined,
|
||
title: 'Image indisponible',
|
||
subtitle: mediaAssetId,
|
||
);
|
||
}
|
||
return ClipRRect(
|
||
borderRadius: BorderRadius.circular(6),
|
||
child: Image.file(
|
||
_fileFromLocalUri(asset.localUri),
|
||
key: ValueKey('exercise-image-${asset.metadata.id}'),
|
||
fit: BoxFit.contain,
|
||
errorBuilder: (context, error, stackTrace) {
|
||
return _MissingMediaPlaceholder(
|
||
icon: Icons.broken_image_outlined,
|
||
title: 'Image introuvable',
|
||
subtitle: asset.metadata.id,
|
||
);
|
||
},
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _VideoMediaPanel extends StatelessWidget {
|
||
const _VideoMediaPanel({
|
||
required this.videoMediaId,
|
||
required this.mediaAssetLoader,
|
||
this.videoMediaBuilder,
|
||
});
|
||
|
||
final String videoMediaId;
|
||
final Future<MediaAsset?> Function(String id) mediaAssetLoader;
|
||
final VideoMediaBuilder? videoMediaBuilder;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return CourtBlazerAccentPanel(
|
||
padding: const EdgeInsets.all(16),
|
||
child: FutureBuilder<MediaAsset?>(
|
||
future: mediaAssetLoader(videoMediaId),
|
||
builder: (context, snapshot) {
|
||
if (snapshot.connectionState != ConnectionState.done) {
|
||
return const SizedBox(
|
||
height: 260,
|
||
child: Center(child: CircularProgressIndicator()),
|
||
);
|
||
}
|
||
final asset = snapshot.data;
|
||
if (asset == null || asset.kind != MediaKind.video) {
|
||
return SizedBox(
|
||
height: 260,
|
||
child: _MissingMediaPlaceholder(
|
||
icon: Icons.videocam_off_outlined,
|
||
title: 'Vidéo indisponible',
|
||
subtitle: videoMediaId,
|
||
),
|
||
);
|
||
}
|
||
final builder = videoMediaBuilder;
|
||
if (builder != null) {
|
||
return builder(context, asset);
|
||
}
|
||
return _VideoPlayerSurface(asset: asset);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _VideoPlayerSurface extends StatefulWidget {
|
||
const _VideoPlayerSurface({required this.asset});
|
||
|
||
final MediaAsset asset;
|
||
|
||
@override
|
||
State<_VideoPlayerSurface> createState() => _VideoPlayerSurfaceState();
|
||
}
|
||
|
||
final class _VideoPlayerSurfaceState extends State<_VideoPlayerSurface> {
|
||
late final VideoPlayerController _controller;
|
||
late final Future<void> _initialize;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_controller = VideoPlayerController.file(
|
||
_fileFromLocalUri(widget.asset.localUri),
|
||
);
|
||
_initialize = _controller.initialize().then((_) {
|
||
if (mounted) setState(() {});
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_controller.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return FutureBuilder<void>(
|
||
future: _initialize,
|
||
builder: (context, snapshot) {
|
||
if (snapshot.connectionState != ConnectionState.done) {
|
||
return const SizedBox(
|
||
height: 260,
|
||
child: Center(child: CircularProgressIndicator()),
|
||
);
|
||
}
|
||
if (snapshot.hasError || !_controller.value.isInitialized) {
|
||
return SizedBox(
|
||
height: 260,
|
||
child: _MissingMediaPlaceholder(
|
||
icon: Icons.videocam_off_outlined,
|
||
title: 'Vidéo introuvable',
|
||
subtitle: widget.asset.metadata.id,
|
||
),
|
||
);
|
||
}
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
AspectRatio(
|
||
aspectRatio: _controller.value.aspectRatio,
|
||
child: VideoPlayer(_controller),
|
||
),
|
||
const SizedBox(height: 12),
|
||
IconButton.filled(
|
||
tooltip: _controller.value.isPlaying ? 'Pause' : 'Lecture',
|
||
onPressed: () async {
|
||
if (_controller.value.isPlaying) {
|
||
await _controller.pause();
|
||
} else {
|
||
await _controller.play();
|
||
}
|
||
if (mounted) setState(() {});
|
||
},
|
||
icon: Icon(
|
||
_controller.value.isPlaying ? Icons.pause : Icons.play_arrow,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _MissingMediaPlaceholder extends StatelessWidget {
|
||
const _MissingMediaPlaceholder({
|
||
required this.icon,
|
||
required this.title,
|
||
required this.subtitle,
|
||
});
|
||
|
||
final IconData icon;
|
||
final String title;
|
||
final String subtitle;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(icon, size: 64, color: Theme.of(context).colorScheme.primary),
|
||
const SizedBox(height: 16),
|
||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||
const SizedBox(height: 4),
|
||
Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
File _fileFromLocalUri(String localUri) {
|
||
final uri = Uri.tryParse(localUri);
|
||
if (uri != null && uri.hasScheme) {
|
||
return File.fromUri(uri);
|
||
}
|
||
return File(localUri);
|
||
}
|
||
|
||
final class _WorkoutPlanSetTile extends StatefulWidget {
|
||
const _WorkoutPlanSetTile({
|
||
required this.position,
|
||
required this.currentPosition,
|
||
required this.exercise,
|
||
required this.onUpdated,
|
||
required this.activeUseCases,
|
||
required this.sessionId,
|
||
this.stepUseCases,
|
||
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 ActiveExerciseStepUseCases? stepUseCases;
|
||
final String sessionId;
|
||
final ActiveRestState? activeRest;
|
||
|
||
@override
|
||
State<_WorkoutPlanSetTile> createState() => _WorkoutPlanSetTileState();
|
||
}
|
||
|
||
final class _WorkoutPlanSetTileState extends State<_WorkoutPlanSetTile> {
|
||
late final Future<ActiveExerciseStepProgressView?> _stepProgress;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_stepProgress = _loadStepProgress();
|
||
}
|
||
|
||
Future<ActiveExerciseStepProgressView?> _loadStepProgress() async {
|
||
final stepUseCases = widget.stepUseCases;
|
||
if (stepUseCases == null || widget.exercise.steps.isEmpty) {
|
||
return null;
|
||
}
|
||
final planState = _planSetState(
|
||
widget.position,
|
||
widget.currentPosition,
|
||
widget.state,
|
||
);
|
||
if (planState == _PlanSetStatus.todo) {
|
||
return null;
|
||
}
|
||
return stepUseCases.readProgress(
|
||
sessionId: widget.sessionId,
|
||
programIndex: widget.position.programIndex,
|
||
exerciseIndex: widget.position.exerciseIndex,
|
||
setIndex: widget.position.setIndex,
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final planState = _planSetState(
|
||
widget.position,
|
||
widget.currentPosition,
|
||
widget.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 ${widget.position.setIndex + 1}'),
|
||
subtitle: _WorkoutPlanSetSubtitle(
|
||
status: planState,
|
||
result: widget.state?.result,
|
||
exercise: widget.exercise,
|
||
stepProgress: _stepProgress,
|
||
),
|
||
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: widget.sessionId,
|
||
position: widget.position,
|
||
exercise: widget.exercise,
|
||
state: widget.state,
|
||
status: status,
|
||
activeUseCases: widget.activeUseCases,
|
||
activeRest: widget.activeRest,
|
||
onSaved: widget.onUpdated,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _WorkoutPlanSetSubtitle extends StatelessWidget {
|
||
const _WorkoutPlanSetSubtitle({
|
||
required this.status,
|
||
required this.result,
|
||
required this.exercise,
|
||
required this.stepProgress,
|
||
});
|
||
|
||
final _PlanSetStatus status;
|
||
final ActiveSetResult? result;
|
||
final ExecutionExercise exercise;
|
||
final Future<ActiveExerciseStepProgressView?> stepProgress;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final resultLabel = _planSetSubtitle(status, result, exercise);
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (resultLabel.isNotEmpty) Text(resultLabel),
|
||
if (exercise.steps.isNotEmpty)
|
||
FutureBuilder<ActiveExerciseStepProgressView?>(
|
||
future: stepProgress,
|
||
builder: (context, snapshot) {
|
||
final progress = snapshot.data;
|
||
final label = progress == null
|
||
? 'Séquence : 0/${exercise.steps.length} étapes'
|
||
: _planStepSequenceSummary(progress);
|
||
return Padding(
|
||
padding: EdgeInsets.only(top: resultLabel.isEmpty ? 0 : 4),
|
||
child: Text(label),
|
||
);
|
||
},
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
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 _planStepSequenceSummary(ActiveExerciseStepProgressView view) {
|
||
final state = view.state;
|
||
final stepsCount = view.steps.length;
|
||
final passageIndex =
|
||
state.status == ActiveExerciseStepProgressStatus.sequenceComplete
|
||
? (view.expectedPassages - 1).clamp(0, 9999).toInt()
|
||
: state.currentPassageIndex;
|
||
final completedInPassage =
|
||
state.status == ActiveExerciseStepProgressStatus.sequenceComplete
|
||
? stepsCount
|
||
: view.results
|
||
.where((result) => result.passageIndex == passageIndex)
|
||
.map((result) => result.stepIndex)
|
||
.toSet()
|
||
.length
|
||
.clamp(0, stepsCount)
|
||
.toInt();
|
||
final passageLabel = view.expectedPassages > 1
|
||
? ' · Passage ${passageIndex + 1}/${view.expectedPassages}'
|
||
: '';
|
||
return 'Séquence : $completedInPassage/$stepsCount étapes$passageLabel';
|
||
}
|
||
|
||
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 remainingMs = rest.remainingMillisecondsAt(DateTime.now().toUtc());
|
||
return (remainingMs / 1000).ceil().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.steps = 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),
|
||
steps: _exerciseStepsFromSnapshot(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 List<ExerciseStep> steps;
|
||
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,
|
||
];
|
||
}
|
||
|
||
List<ExerciseStep> _exerciseStepsFromSnapshot(Map<String, dynamic> exercise) {
|
||
final rawSteps = exercise['exerciseStepsSnapshot'];
|
||
if (rawSteps is! List) return const [];
|
||
return rawSteps
|
||
.map((rawStep) {
|
||
final step = rawStep as Map<String, dynamic>;
|
||
final typeName = step['type'] as String? ?? ExerciseStepType.time.name;
|
||
return ExerciseStep(
|
||
id: step['id'] as String,
|
||
position: step['position'] as int,
|
||
name: step['name'] as String,
|
||
type: ExerciseStepType.values.firstWhere(
|
||
(type) => type.name == typeName,
|
||
orElse: () => ExerciseStepType.time,
|
||
),
|
||
defaultTargetValue: step['defaultTargetValue'] as int,
|
||
hasScore: step['hasScore'] as bool? ?? false,
|
||
scoreInputMode: _scoreInputModeFromSnapshot(step['scoreInputMode']),
|
||
scoreLabel: step['scoreLabel'] as String?,
|
||
scoreUnit: step['scoreUnit'] as String?,
|
||
defaultTargetScore: (step['defaultTargetScore'] as num?)?.toDouble(),
|
||
defaultTargetScoreTimeMs: step['defaultTargetScoreTimeMs'] as int?,
|
||
);
|
||
})
|
||
.toList(growable: false);
|
||
}
|
||
|
||
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,
|
||
);
|
||
}
|
||
}
|
||
|
||
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 _formatStepCountdown(Duration duration) {
|
||
final totalSeconds = (duration.inMilliseconds / 1000)
|
||
.ceil()
|
||
.clamp(0, 9999)
|
||
.toInt();
|
||
final minutes = (totalSeconds ~/ 60).toString().padLeft(2, '0');
|
||
final seconds = (totalSeconds % 60).toString().padLeft(2, '0');
|
||
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);
|
||
}
|