Consolide le lot applicatif watch companion validé : - télémétrie fréquence cardiaque live remontée montre -> téléphone (collecteur watch, adapter Wear Data Layer, persistance Drift, propagation aux écrans historique/programme/profil/exécution) - notifications de séance en arrière-plan côté téléphone (service foreground de statut + passerelle applicative) - finitions montre : chrono d'étape, score d'étape, retrait du bouton "lancer une séance", thème, icônes et polices watch_app Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4877 lines
152 KiB
Dart
4877 lines
152 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.performanceReferenceUseCase,
|
||
this.mediaUseCases,
|
||
this.mediaAssetLoader,
|
||
this.videoMediaBuilder,
|
||
this.stepUseCases,
|
||
this.sensorUseCases,
|
||
this.stepAudioCuePlayer,
|
||
super.key,
|
||
});
|
||
|
||
final ActiveWorkoutSession initialSession;
|
||
final ActiveWorkoutSessionUseCases activeUseCases;
|
||
final ActiveExerciseStepUseCases? stepUseCases;
|
||
final CloseWorkoutSessionUseCase closeUseCase;
|
||
final WorkoutHistoryUseCases historyUseCases;
|
||
final WorkoutTemplateUseCases workoutTemplateUseCases;
|
||
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
|
||
final MediaUseCases? mediaUseCases;
|
||
final Future<MediaAsset?> Function(String id)? mediaAssetLoader;
|
||
final VideoMediaBuilder? videoMediaBuilder;
|
||
final ActiveWorkoutSensorUseCases? sensorUseCases;
|
||
final ExerciseStepAudioCuePlayer? stepAudioCuePlayer;
|
||
|
||
@override
|
||
State<WorkoutExecutionScreen> createState() => _WorkoutExecutionScreenState();
|
||
}
|
||
|
||
final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||
late ActiveWorkoutSession _session;
|
||
WorkoutExecutionPlan? _validatedPlan;
|
||
late WorkoutExecutionMode _mode;
|
||
Timer? _ticker;
|
||
Timer? _restTicker;
|
||
Timer? _scoreStopwatchTicker;
|
||
Timer? _stepTicker;
|
||
Timer? _stepScoreTicker;
|
||
StreamSubscription<ActiveWorkoutSensorState>? _sensorSubscription;
|
||
var _externalSyncInFlight = false;
|
||
WorkoutHistory? _completedHistory;
|
||
ActiveWorkoutSensorState? _sensorState;
|
||
String? _activeRestStateId;
|
||
ActiveSetTimerState? _setTimer;
|
||
ActiveScoreStopwatchState? _scoreStopwatch;
|
||
var _scoreStopwatchLoadGeneration = 0;
|
||
ActiveExerciseStepProgressView? _stepProgress;
|
||
late final ExerciseStepAudioCuePlayer _stepAudioCuePlayer;
|
||
var _ownsStepAudioCuePlayer = false;
|
||
final _stepScoreController = TextEditingController();
|
||
int? _manualScoreTimeMs;
|
||
DateTime? _stepScoreStartedAt;
|
||
var _stepScoreAccumulatedMs = 0;
|
||
var _stepScoreRunning = false;
|
||
var _lastCountdownBeepSecond = -1;
|
||
var _advancingStep = false;
|
||
var _reps = 0;
|
||
Future<ExercisePerformanceReference>? _performanceReference;
|
||
final _scoreController = TextEditingController();
|
||
final _scoreFocusNode = FocusNode();
|
||
Future<void>? _manualScorePersistInFlight;
|
||
var _remainingRestSeconds = 0;
|
||
String? _invalidSessionMessage;
|
||
|
||
ExecutionPosition get _position => ExecutionPosition(
|
||
programIndex: _session.currentProgramIndex,
|
||
exerciseIndex: _session.currentExerciseIndex,
|
||
setIndex: _session.currentSetIndex,
|
||
);
|
||
|
||
ExecutionExercise get _exercise => _plan.exerciseAt(_position);
|
||
|
||
WorkoutExecutionPlan get _plan => _validatedPlan!;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_session = widget.initialSession;
|
||
try {
|
||
_validatedPlan = WorkoutExecutionPlan.fromSession(_session);
|
||
} on Object {
|
||
_invalidSessionMessage =
|
||
'Cette séance ne peut plus être reprise. Elle a été abandonnée automatiquement.';
|
||
unawaited(_abandonInvalidSession());
|
||
return;
|
||
}
|
||
_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);
|
||
_performanceReference = _loadPerformanceReference();
|
||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
||
if (mounted) {
|
||
setState(() {});
|
||
unawaited(_syncExternalSessionChanges());
|
||
}
|
||
});
|
||
_scoreFocusNode.addListener(_handleScoreFocusChange);
|
||
_sensorState = widget.sensorUseCases?.current(_session.metadata.id);
|
||
_sensorSubscription = widget.sensorUseCases?.updates.listen((state) {
|
||
if (!mounted || state.sessionId != _session.metadata.id) {
|
||
return;
|
||
}
|
||
setState(() => _sensorState = state);
|
||
});
|
||
unawaited(_loadSetTimer());
|
||
unawaited(_loadScoreStopwatch());
|
||
unawaited(_syncManualScoreInput(force: true));
|
||
unawaited(_loadStepProgress());
|
||
unawaited(_restoreActiveRest());
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_ticker?.cancel();
|
||
_restTicker?.cancel();
|
||
_scoreStopwatchTicker?.cancel();
|
||
_stepTicker?.cancel();
|
||
_stepScoreTicker?.cancel();
|
||
unawaited(_sensorSubscription?.cancel());
|
||
if (_ownsStepAudioCuePlayer) {
|
||
unawaited(_stepAudioCuePlayer.dispose());
|
||
}
|
||
_scoreController.dispose();
|
||
_scoreFocusNode.dispose();
|
||
_stepScoreController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _handleScoreFocusChange() {
|
||
if (!_scoreFocusNode.hasFocus) {
|
||
unawaited(_persistManualScoreInput());
|
||
}
|
||
}
|
||
|
||
Future<void> _abandonInvalidSession() async {
|
||
try {
|
||
await widget.activeUseCases.abandon(_session.metadata.id);
|
||
} on Exception {
|
||
// The UI still needs to leave the user on an explicit recovery state.
|
||
}
|
||
if (mounted) {
|
||
setState(() {});
|
||
}
|
||
}
|
||
|
||
void _showInvalidSessionState() {
|
||
_ticker?.cancel();
|
||
_restTicker?.cancel();
|
||
_scoreStopwatchTicker?.cancel();
|
||
_stepTicker?.cancel();
|
||
_stepScoreTicker?.cancel();
|
||
setState(() {
|
||
_invalidSessionMessage =
|
||
'Cette séance ne peut plus être reprise. Elle a été abandonnée automatiquement.';
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final invalidMessage = _invalidSessionMessage;
|
||
if (invalidMessage != null) {
|
||
return Scaffold(
|
||
appBar: AppBar(title: const Text('Séance indisponible')),
|
||
body: _InvalidSessionMessage(
|
||
message: invalidMessage,
|
||
onBackHome: () =>
|
||
Navigator.of(context).popUntil((route) => route.isFirst),
|
||
),
|
||
);
|
||
}
|
||
return PopScope(
|
||
canPop: false,
|
||
onPopInvokedWithResult: _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: _ExecutionAppBarTitle(
|
||
workoutName: _plan.name,
|
||
programName: _plan.programAt(_position).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,
|
||
setTimerLabel: _setTimerLabel,
|
||
setTimerComplete: _setTimer?.status == ActiveSetTimerStatus.stopped,
|
||
showStartButton: _shouldShowStartExerciseButton,
|
||
onStartExercise: _startExercise,
|
||
onOpenMedia: _exercise.hasMedia
|
||
? () => _openExerciseMedia(_exercise)
|
||
: null,
|
||
),
|
||
),
|
||
if (_hasLiveSensorMetrics(_sensorState)) ...[
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||
child: _LiveSensorBar(
|
||
sensorState: _sensorState!,
|
||
muted: _isSensorStateStale(_sensorState),
|
||
),
|
||
),
|
||
],
|
||
if (_performanceReference != null) ...[
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||
child: PerformanceReferenceCard(
|
||
reference: _performanceReference!,
|
||
activeMeasures: _exercise.activePerformanceMeasures,
|
||
scoreUnit: _exercise.scoreUnit,
|
||
usesStepPassages: _exercise.steps.isNotEmpty,
|
||
),
|
||
),
|
||
],
|
||
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,
|
||
),
|
||
),
|
||
if (_exercise.manualScoreEnabled) ...[
|
||
const SizedBox(height: 8),
|
||
SizedBox(
|
||
height: 66,
|
||
child: _StepSetResultSummary(
|
||
exercise: _exercise,
|
||
scoreController: _scoreController,
|
||
scoreFocusNode: _scoreFocusNode,
|
||
onScoreSubmitted: (_) =>
|
||
unawaited(_persistManualScoreInput()),
|
||
),
|
||
),
|
||
],
|
||
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,
|
||
scoreFocusNode: _scoreFocusNode,
|
||
onScoreSubmitted: (_) =>
|
||
unawaited(_persistManualScoreInput()),
|
||
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'),
|
||
if (_hasLiveSensorMetrics(_sensorState)) ...[
|
||
const SizedBox(height: 12),
|
||
_LiveSensorBar(
|
||
sensorState: _sensorState!,
|
||
muted: _isSensorStateStale(_sensorState),
|
||
),
|
||
],
|
||
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),
|
||
if (_hasLiveSensorMetrics(_sensorState)) ...[
|
||
const SizedBox(height: 12),
|
||
_LiveSensorBar(
|
||
sensorState: _sensorState!,
|
||
muted: _isSensorStateStale(_sensorState),
|
||
),
|
||
],
|
||
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)}'),
|
||
if (_caloriesLabel(_sensorState) case final caloriesLabel?) ...[
|
||
const SizedBox(height: 8),
|
||
Text('Calories : $caloriesLabel'),
|
||
],
|
||
if (_completedHistory case final history?
|
||
when history.averageHeartRateBpm != null &&
|
||
history.maxHeartRateBpm != null) ...[
|
||
const SizedBox(height: 16),
|
||
_HeartRateSummaryCard(
|
||
averageHeartRateBpm: history.averageHeartRateBpm!,
|
||
maxHeartRateBpm: history.maxHeartRateBpm!,
|
||
),
|
||
],
|
||
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> _syncManualScoreInput({bool force = false}) async {
|
||
if (!_exercise.manualScoreEnabled) {
|
||
if (force && _scoreController.text.isNotEmpty) {
|
||
_scoreController.clear();
|
||
}
|
||
return;
|
||
}
|
||
if (_scoreFocusNode.hasFocus && !force) {
|
||
return;
|
||
}
|
||
final state = await widget.activeUseCases.findManualScore(
|
||
sessionId: _session.metadata.id,
|
||
programIndex: _position.programIndex,
|
||
exerciseIndex: _position.exerciseIndex,
|
||
setIndex: _position.setIndex,
|
||
);
|
||
if (!mounted) {
|
||
return;
|
||
}
|
||
final nextText = state == null ? '' : _formatScore(state.value);
|
||
if (_scoreController.text != nextText) {
|
||
_scoreController.text = nextText;
|
||
}
|
||
}
|
||
|
||
Future<void> _persistManualScoreInput() async {
|
||
final inFlight = _manualScorePersistInFlight;
|
||
if (inFlight != null) {
|
||
await inFlight;
|
||
return;
|
||
}
|
||
final future = _persistManualScoreInputOnce();
|
||
_manualScorePersistInFlight = future;
|
||
try {
|
||
await future;
|
||
} finally {
|
||
if (identical(_manualScorePersistInFlight, future)) {
|
||
_manualScorePersistInFlight = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _persistManualScoreInputOnce() async {
|
||
if (!_exercise.manualScoreEnabled) {
|
||
return;
|
||
}
|
||
final rawValue = _scoreController.text.trim();
|
||
if (rawValue.isEmpty) {
|
||
return;
|
||
}
|
||
final value = double.tryParse(rawValue);
|
||
if (value == null) {
|
||
return;
|
||
}
|
||
final result = await widget.activeUseCases.setManualScore(
|
||
sessionId: _session.metadata.id,
|
||
programIndex: _position.programIndex,
|
||
exerciseIndex: _position.exerciseIndex,
|
||
setIndex: _position.setIndex,
|
||
value: value,
|
||
);
|
||
if (!mounted || _scoreFocusNode.hasFocus) {
|
||
return;
|
||
}
|
||
final nextText = _formatScore(result.state.value);
|
||
if (_scoreController.text != nextText) {
|
||
_scoreController.text = nextText;
|
||
}
|
||
}
|
||
|
||
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> _syncExternalSessionChanges() async {
|
||
if (_externalSyncInFlight || !mounted) {
|
||
return;
|
||
}
|
||
_externalSyncInFlight = true;
|
||
try {
|
||
final session = await widget.activeUseCases.findOpen();
|
||
if (!mounted ||
|
||
session == null ||
|
||
session.metadata.id != _session.metadata.id) {
|
||
return;
|
||
}
|
||
final sessionChanged =
|
||
session.status != _session.status ||
|
||
session.currentProgramIndex != _session.currentProgramIndex ||
|
||
session.currentExerciseIndex != _session.currentExerciseIndex ||
|
||
session.currentSetIndex != _session.currentSetIndex;
|
||
_session = session;
|
||
if (sessionChanged) {
|
||
_reps = _initialRepsFor(_exercise);
|
||
}
|
||
await _syncManualScoreInput(force: sessionChanged);
|
||
await _loadSetTimer();
|
||
await _loadScoreStopwatch();
|
||
await _readStepProgress();
|
||
await _restoreActiveRest();
|
||
if (!mounted) {
|
||
return;
|
||
}
|
||
setState(() {
|
||
_mode = switch (session.status) {
|
||
ActiveWorkoutStatus.running =>
|
||
_activeRestStateId == null
|
||
? WorkoutExecutionMode.active
|
||
: WorkoutExecutionMode.rest,
|
||
ActiveWorkoutStatus.paused => WorkoutExecutionMode.paused,
|
||
ActiveWorkoutStatus.completed ||
|
||
ActiveWorkoutStatus.abandoned ||
|
||
ActiveWorkoutStatus.savedExit => WorkoutExecutionMode.finished,
|
||
};
|
||
});
|
||
_refreshScoreStopwatchTicker();
|
||
_refreshStepTicker();
|
||
} finally {
|
||
_externalSyncInFlight = false;
|
||
}
|
||
}
|
||
|
||
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, Object? result) {
|
||
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 {
|
||
try {
|
||
_session = await widget.activeUseCases.resume(_session.metadata.id);
|
||
} on DomainException {
|
||
if (!mounted) return;
|
||
_showInvalidSessionState();
|
||
return;
|
||
}
|
||
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) {
|
||
if (!mounted) return;
|
||
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'),
|
||
),
|
||
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) {
|
||
if (!mounted) return;
|
||
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'),
|
||
),
|
||
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 {
|
||
if (!skipped) {
|
||
await _persistManualScoreInput();
|
||
}
|
||
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;
|
||
_performanceReference = _loadPerformanceReferenceFor(
|
||
_plan.exerciseAt(position),
|
||
position.setIndex,
|
||
);
|
||
_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 _syncManualScoreInput(force: true);
|
||
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;
|
||
final hasBlockingOpenSession = await _hasBlockingOpenSessionForRestart();
|
||
if (!mounted) return;
|
||
if (hasBlockingOpenSession) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text(
|
||
'Une séance est déjà en cours. Termine-la ou reprends-la avant '
|
||
'd’en lancer une nouvelle.',
|
||
),
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
ActiveWorkoutSession session;
|
||
final sourceId = history.sourceWorkoutTemplateId;
|
||
try {
|
||
if (sourceId != null &&
|
||
await widget.workoutTemplateUseCases.findById(sourceId) != null) {
|
||
session = await widget.activeUseCases.startFromTemplate(sourceId);
|
||
} else {
|
||
session = await widget.activeUseCases.startFromHistory(history);
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text(
|
||
"La séance originale n'existe plus. Une copie va être utilisée.",
|
||
),
|
||
),
|
||
);
|
||
}
|
||
} on DomainException {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text(
|
||
'Cette séance ne peut pas être relancée car elle ne contient aucun exercice.',
|
||
),
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
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,
|
||
performanceReferenceUseCase: widget.performanceReferenceUseCase,
|
||
mediaUseCases: widget.mediaUseCases,
|
||
mediaAssetLoader: widget.mediaAssetLoader,
|
||
videoMediaBuilder: widget.videoMediaBuilder,
|
||
sensorUseCases: widget.sensorUseCases,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<bool> _hasBlockingOpenSessionForRestart() async {
|
||
final openSession = await widget.activeUseCases.findOpen();
|
||
if (openSession == null ||
|
||
openSession.metadata.id == _session.metadata.id) {
|
||
return false;
|
||
}
|
||
if (WorkoutExecutionPlan.tryFromSession(openSession) != null) {
|
||
return true;
|
||
}
|
||
try {
|
||
await widget.activeUseCases.abandon(openSession.metadata.id);
|
||
} on DomainException {
|
||
// Legacy invalid sessions must not block a restart.
|
||
}
|
||
return false;
|
||
}
|
||
|
||
int _initialRepsFor(ExecutionExercise exercise) {
|
||
if (!exercise.repsEnabled) return 0;
|
||
return exercise.targetReps ?? 0;
|
||
}
|
||
|
||
Future<ExercisePerformanceReference>? _loadPerformanceReference() {
|
||
return _loadPerformanceReferenceFor(_exercise, _position.setIndex);
|
||
}
|
||
|
||
Future<ExercisePerformanceReference>? _loadPerformanceReferenceFor(
|
||
ExecutionExercise exercise,
|
||
int setIndex,
|
||
) {
|
||
final useCase = widget.performanceReferenceUseCase;
|
||
final sourceExerciseId = exercise.sourceExerciseId;
|
||
if (useCase == null || sourceExerciseId == null) {
|
||
return null;
|
||
}
|
||
return useCase.getExercisePerformanceReference(
|
||
exerciseId: sourceExerciseId,
|
||
activeMeasures: exercise.activePerformanceMeasures,
|
||
currentSetIndex: setIndex,
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _HeartRateSummaryCard extends StatelessWidget {
|
||
const _HeartRateSummaryCard({
|
||
required this.averageHeartRateBpm,
|
||
required this.maxHeartRateBpm,
|
||
});
|
||
|
||
final double averageHeartRateBpm;
|
||
final int maxHeartRateBpm;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return CourtBlazerAccentPanel(
|
||
padding: const EdgeInsets.all(12),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(
|
||
'Fréquence cardiaque',
|
||
style: Theme.of(context).textTheme.titleSmall,
|
||
),
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: _HeartRateMetric(
|
||
label: 'Moyenne',
|
||
value: '${averageHeartRateBpm.round()}',
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: _HeartRateMetric(
|
||
label: 'Max',
|
||
value: '$maxHeartRateBpm',
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _HeartRateMetric extends StatelessWidget {
|
||
const _HeartRateMetric({required this.label, required this.value});
|
||
|
||
final String label;
|
||
final String value;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(label, style: Theme.of(context).textTheme.labelMedium),
|
||
RichText(
|
||
text: TextSpan(
|
||
style: AppTextStyles.scoreNumber(context),
|
||
children: [
|
||
TextSpan(text: value),
|
||
TextSpan(
|
||
text: ' bpm',
|
||
style: Theme.of(context).textTheme.bodySmall,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
final class PerformanceReferenceCard extends StatelessWidget {
|
||
const PerformanceReferenceCard({
|
||
required this.reference,
|
||
required this.activeMeasures,
|
||
this.scoreUnit,
|
||
this.usesStepPassages = false,
|
||
super.key,
|
||
});
|
||
|
||
final Future<ExercisePerformanceReference> reference;
|
||
final ActivePerformanceMeasures activeMeasures;
|
||
final String? scoreUnit;
|
||
final bool usesStepPassages;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return FutureBuilder<ExercisePerformanceReference>(
|
||
future: reference,
|
||
builder: (context, snapshot) {
|
||
if (snapshot.connectionState != ConnectionState.done) {
|
||
return const _PerformanceReferenceShell(
|
||
child: Center(child: Text('Chargement…')),
|
||
);
|
||
}
|
||
if (snapshot.hasError) {
|
||
return const _PerformanceReferenceShell(
|
||
child: Center(child: Text('Références indisponibles')),
|
||
);
|
||
}
|
||
final reference = snapshot.data;
|
||
if (reference == null || !activeMeasures.hasAny) {
|
||
return const _PerformanceReferenceShell(
|
||
child: Center(child: Text('Aucune référence pour ces mesures')),
|
||
);
|
||
}
|
||
if (!reference.hasAnyHistoryForExercise) {
|
||
return const _PerformanceReferenceShell(
|
||
child: Center(child: Text('Première fois sur cet exercice')),
|
||
);
|
||
}
|
||
final lastLabel = _formatSetPerformance(
|
||
reference.last,
|
||
activeMeasures,
|
||
scoreUnit: scoreUnit,
|
||
usesStepPassages: usesStepPassages,
|
||
);
|
||
final recordLabel = _formatMetricPerformance(
|
||
reference.record,
|
||
scoreUnit: scoreUnit,
|
||
usesStepPassages: usesStepPassages,
|
||
);
|
||
if (lastLabel == null && recordLabel == null) {
|
||
return const _PerformanceReferenceShell(
|
||
child: Center(child: Text('Aucune référence pour ces mesures')),
|
||
);
|
||
}
|
||
return _PerformanceReferenceShell(
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: _PerformanceReferenceColumn(
|
||
title: 'Dernière',
|
||
value: lastLabel ?? '—',
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: _PerformanceReferenceColumn(
|
||
title: _recordTitle(reference.record),
|
||
value: recordLabel ?? '—',
|
||
alignEnd: true,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _PerformanceReferenceShell extends StatelessWidget {
|
||
const _PerformanceReferenceShell({required this.child});
|
||
|
||
final Widget child;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final tokens = courtBlazerTokensOf(context);
|
||
return Container(
|
||
constraints: const BoxConstraints(minHeight: 64),
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surface,
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: tokens.border),
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(
|
||
'Références',
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: tokens.mutedText,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
child,
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _PerformanceReferenceColumn extends StatelessWidget {
|
||
const _PerformanceReferenceColumn({
|
||
required this.title,
|
||
required this.value,
|
||
this.alignEnd = false,
|
||
});
|
||
|
||
final String title;
|
||
final String value;
|
||
final bool alignEnd;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final tokens = courtBlazerTokensOf(context);
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: alignEnd
|
||
? CrossAxisAlignment.end
|
||
: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
title,
|
||
style: theme.textTheme.labelSmall?.copyWith(color: tokens.mutedText),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
value,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
textAlign: alignEnd ? TextAlign.end : TextAlign.start,
|
||
style: theme.textTheme.titleSmall,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
final class SetMeasureInput extends StatelessWidget {
|
||
const SetMeasureInput({
|
||
required this.exercise,
|
||
this.showTime = true,
|
||
required this.reps,
|
||
required this.scoreController,
|
||
required this.scoreFocusNode,
|
||
required this.onScoreSubmitted,
|
||
required this.onRepsChanged,
|
||
super.key,
|
||
});
|
||
|
||
final ExecutionExercise exercise;
|
||
final bool showTime;
|
||
final int reps;
|
||
final TextEditingController scoreController;
|
||
final FocusNode scoreFocusNode;
|
||
final ValueChanged<String> onScoreSubmitted;
|
||
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'
|
||
: _formatDuration(
|
||
Duration(seconds: exercise.targetTimeSeconds!),
|
||
),
|
||
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,
|
||
focusNode: scoreFocusNode,
|
||
style: AppTextStyles.scoreNumber(context),
|
||
decoration: InputDecoration(
|
||
labelText: exercise.scoreUnit == null
|
||
? 'Score'
|
||
: 'Score (${exercise.scoreUnit})',
|
||
),
|
||
keyboardType: TextInputType.number,
|
||
onSubmitted: onScoreSubmitted,
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _ExecutionAppBarTitle extends StatelessWidget {
|
||
const _ExecutionAppBarTitle({
|
||
required this.workoutName,
|
||
required this.programName,
|
||
});
|
||
|
||
final String workoutName;
|
||
final String programName;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final tokens = courtBlazerTokensOf(context);
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
workoutName,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: Theme.of(context).appBarTheme.titleTextStyle,
|
||
),
|
||
Row(
|
||
children: [
|
||
Flexible(
|
||
child: Text(
|
||
programName,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: Theme.of(
|
||
context,
|
||
).textTheme.bodySmall?.copyWith(color: tokens.mutedText),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _LiveSensorBar extends StatelessWidget {
|
||
const _LiveSensorBar({required this.sensorState, required this.muted});
|
||
|
||
final ActiveWorkoutSensorState sensorState;
|
||
final bool muted;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Opacity(
|
||
opacity: muted ? 0.45 : 1,
|
||
child: Wrap(
|
||
spacing: 8,
|
||
runSpacing: 4,
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
children: [
|
||
if (sensorState.latestHeartRateBpm case final heartRate?)
|
||
_LiveSensorPill(
|
||
icon: Icons.favorite,
|
||
label: 'FC $heartRate bpm',
|
||
muted: false,
|
||
),
|
||
if (_distanceLabel(sensorState) case final distanceLabel?)
|
||
_LiveSensorPill(
|
||
icon: Icons.directions_run,
|
||
label: distanceLabel,
|
||
muted: false,
|
||
),
|
||
if (_caloriesLabel(sensorState) case final caloriesLabel?)
|
||
_LiveSensorPill(
|
||
icon: Icons.local_fire_department,
|
||
label: caloriesLabel,
|
||
muted: false,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _LiveSensorPill extends StatelessWidget {
|
||
const _LiveSensorPill({
|
||
required this.icon,
|
||
required this.label,
|
||
required this.muted,
|
||
});
|
||
|
||
final IconData icon;
|
||
final String label;
|
||
final bool muted;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final color = Theme.of(context).colorScheme.primary;
|
||
return Opacity(
|
||
opacity: muted ? 0.45 : 1,
|
||
child: DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
border: Border.all(color: color.withValues(alpha: 0.55)),
|
||
borderRadius: BorderRadius.circular(999),
|
||
),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(icon, size: 11, color: color),
|
||
const SizedBox(width: 3),
|
||
Text(
|
||
label,
|
||
maxLines: 1,
|
||
style: Theme.of(
|
||
context,
|
||
).textTheme.labelSmall?.copyWith(color: color, fontSize: 10),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _ExecutionContextHeader extends StatelessWidget {
|
||
const _ExecutionContextHeader({
|
||
required this.exercise,
|
||
required this.currentSet,
|
||
required this.totalSets,
|
||
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? 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: [
|
||
SizedBox(
|
||
width: 76,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
'SÉRIE',
|
||
style: theme.textTheme.labelMedium?.copyWith(
|
||
color: tokens.mutedText,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
'$currentSet / $totalSets',
|
||
maxLines: 1,
|
||
style: AppTextStyles.scoreNumber(
|
||
context,
|
||
).copyWith(color: theme.colorScheme.primary),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
exercise.name,
|
||
maxLines: 1,
|
||
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'),
|
||
),
|
||
),
|
||
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 _InvalidSessionMessage extends StatelessWidget {
|
||
const _InvalidSessionMessage({
|
||
required this.message,
|
||
required this.onBackHome,
|
||
});
|
||
|
||
final String message;
|
||
final VoidCallback onBackHome;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Icon(
|
||
Icons.error_outline,
|
||
size: 40,
|
||
color: Theme.of(context).colorScheme.error,
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
message,
|
||
textAlign: TextAlign.center,
|
||
style: Theme.of(context).textTheme.titleMedium,
|
||
),
|
||
const SizedBox(height: 24),
|
||
FilledButton(
|
||
onPressed: onBackHome,
|
||
child: const Text('Revenir à l’accueil'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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.exercise,
|
||
required this.scoreController,
|
||
required this.scoreFocusNode,
|
||
required this.onScoreSubmitted,
|
||
});
|
||
|
||
final ExecutionExercise exercise;
|
||
final TextEditingController scoreController;
|
||
final FocusNode scoreFocusNode;
|
||
final ValueChanged<String> onScoreSubmitted;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final tokens = courtBlazerTokensOf(context);
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surface,
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: tokens.border),
|
||
),
|
||
child: TextField(
|
||
controller: scoreController,
|
||
focusNode: scoreFocusNode,
|
||
decoration: InputDecoration(
|
||
isDense: true,
|
||
labelText: exercise.scoreUnit == null
|
||
? 'Score'
|
||
: 'Score (${exercise.scoreUnit})',
|
||
),
|
||
keyboardType: TextInputType.number,
|
||
onSubmitted: onScoreSubmitted,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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),
|
||
Expanded(
|
||
child: sequenceComplete || currentStep == null
|
||
? Align(
|
||
alignment: Alignment.topLeft,
|
||
child: _SequenceCompleteSummary(
|
||
completedPassages: completedPassages,
|
||
expectedPassages: view.expectedPassages,
|
||
),
|
||
)
|
||
: _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 LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
return SingleChildScrollView(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: ConstrainedBox(
|
||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||
child: IntrinsicHeight(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(
|
||
step.name,
|
||
style: Theme.of(context).textTheme.headlineSmall,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
const SizedBox(height: 4),
|
||
Expanded(
|
||
child: step.type == ExerciseStepType.time
|
||
? _TimedStepBody(
|
||
step: step,
|
||
remainingLabel: remainingLabel,
|
||
running:
|
||
view.state.status ==
|
||
ActiveExerciseStepProgressStatus.runningTimer,
|
||
readyToStart: _isNextTimedStepReady(view),
|
||
onStartTimer: onStartTimer,
|
||
)
|
||
: Align(
|
||
alignment: Alignment.topCenter,
|
||
child: FittedBox(
|
||
fit: BoxFit.scaleDown,
|
||
alignment: Alignment.topCenter,
|
||
child: _RepsStepBody(
|
||
step: step,
|
||
onCompleteStep: onCompleteStep,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
if (step.hasScore) ...[
|
||
const SizedBox(height: 8),
|
||
_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 : ${_formatShortSeconds(step.defaultTargetValue)}'),
|
||
const SizedBox(height: 4),
|
||
Center(
|
||
child: Text(
|
||
remainingLabel,
|
||
style: Theme.of(context).textTheme.displayMedium?.copyWith(
|
||
fontFamily: 'Anton',
|
||
fontFeatures: AppTextStyles.timer(context).fontFeatures,
|
||
color: Theme.of(context).colorScheme.primary,
|
||
),
|
||
),
|
||
),
|
||
if (!running) ...[
|
||
const SizedBox(height: 8),
|
||
if (readyToStart) ...[
|
||
Text(
|
||
'Chrono prêt',
|
||
textAlign: TextAlign.center,
|
||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||
color: Theme.of(context).colorScheme.primary,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
],
|
||
FilledButton.icon(
|
||
onPressed: onStartTimer,
|
||
style: FilledButton.styleFrom(
|
||
minimumSize: const Size.fromHeight(48),
|
||
),
|
||
icon: const Icon(Icons.play_arrow),
|
||
label: const Text('Lancer'),
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
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' : 'Lancer'),
|
||
),
|
||
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('Lancer'),
|
||
)
|
||
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();
|
||
final plan = WorkoutExecutionPlan(
|
||
name: snapshot['name'] as String? ?? 'Séance',
|
||
programs: programs,
|
||
);
|
||
final position = ExecutionPosition(
|
||
programIndex: session.currentProgramIndex,
|
||
exerciseIndex: session.currentExerciseIndex,
|
||
setIndex: session.currentSetIndex,
|
||
);
|
||
if (!plan.isPlayable || !plan.contains(position)) {
|
||
throw const InvalidWorkoutExecutionPlanException();
|
||
}
|
||
return plan;
|
||
}
|
||
|
||
static WorkoutExecutionPlan? tryFromSession(ActiveWorkoutSession session) {
|
||
try {
|
||
return WorkoutExecutionPlan.fromSession(session);
|
||
} on Object {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
final String name;
|
||
final List<ExecutionProgram> programs;
|
||
|
||
bool get isPlayable {
|
||
return programs.any((program) => program.exercises.isNotEmpty);
|
||
}
|
||
|
||
bool contains(ExecutionPosition position) {
|
||
if (position.programIndex < 0 || position.programIndex >= programs.length) {
|
||
return false;
|
||
}
|
||
final program = programs[position.programIndex];
|
||
if (position.exerciseIndex < 0 ||
|
||
position.exerciseIndex >= program.exercises.length) {
|
||
return false;
|
||
}
|
||
final exercise = program.exercises[position.exerciseIndex];
|
||
return position.setIndex >= 0 && position.setIndex < exercise.setsCount;
|
||
}
|
||
|
||
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 InvalidWorkoutExecutionPlanException implements Exception {
|
||
const InvalidWorkoutExecutionPlanException();
|
||
|
||
@override
|
||
String toString() => 'Workout execution plan is not playable.';
|
||
}
|
||
|
||
final class ExecutionExercise {
|
||
const ExecutionExercise({
|
||
required this.id,
|
||
this.sourceExerciseId,
|
||
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,
|
||
sourceExerciseId: exercise['sourceExerciseId'] 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? sourceExerciseId;
|
||
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;
|
||
}
|
||
|
||
ActivePerformanceMeasures get activePerformanceMeasures {
|
||
return ActivePerformanceMeasures(
|
||
timeEnabled: timeEnabled,
|
||
repsEnabled: repsEnabled,
|
||
scoreEnabled: scoreEnabled,
|
||
scoreInputMode: scoreInputMode,
|
||
);
|
||
}
|
||
}
|
||
|
||
String? _formatSetPerformance(
|
||
WorkoutHistorySetPerformance? performance,
|
||
ActivePerformanceMeasures measures, {
|
||
required String? scoreUnit,
|
||
required bool usesStepPassages,
|
||
}) {
|
||
if (performance == null) return null;
|
||
final values = [
|
||
if (measures.timeEnabled && performance.actualTimeMs != null)
|
||
_formatReferenceMeasureTime(performance.actualTimeMs!),
|
||
if (measures.repsEnabled && performance.actualReps != null)
|
||
_formatReferenceReps(
|
||
performance.actualReps!,
|
||
usesStepPassages: usesStepPassages,
|
||
),
|
||
if (measures.scoreEnabled)
|
||
_formatReferenceScore(
|
||
actualScore: performance.actualScore,
|
||
actualScoreTimeMs: performance.actualScoreTimeMs,
|
||
scoreInputMode: measures.scoreInputMode,
|
||
scoreUnit: scoreUnit,
|
||
),
|
||
].whereType<String>().toList();
|
||
if (values.isEmpty) return null;
|
||
return values.join(' · ');
|
||
}
|
||
|
||
String? _formatMetricPerformance(
|
||
WorkoutHistoryMetricPerformance? performance, {
|
||
required String? scoreUnit,
|
||
required bool usesStepPassages,
|
||
}) {
|
||
if (performance == null) return null;
|
||
return switch (performance.metric) {
|
||
PerformanceMetric.time =>
|
||
performance.actualTimeMs == null
|
||
? null
|
||
: _formatReferenceMeasureTime(performance.actualTimeMs!),
|
||
PerformanceMetric.reps =>
|
||
performance.actualReps == null
|
||
? null
|
||
: _formatReferenceReps(
|
||
performance.actualReps!,
|
||
usesStepPassages: usesStepPassages,
|
||
),
|
||
PerformanceMetric.score =>
|
||
performance.scoreInputModeSnapshot == ScoreInputMode.stopwatch
|
||
? performance.actualScoreTimeMs == null
|
||
? null
|
||
: _formatReferenceStopwatchScore(performance.actualScoreTimeMs!)
|
||
: performance.actualScore == null
|
||
? null
|
||
: _formatReferenceManualScore(performance.actualScore!, scoreUnit),
|
||
};
|
||
}
|
||
|
||
String _recordTitle(WorkoutHistoryMetricPerformance? performance) {
|
||
return switch (performance?.metric) {
|
||
PerformanceMetric.time => 'Max',
|
||
PerformanceMetric.score
|
||
when performance?.scoreInputModeSnapshot == ScoreInputMode.stopwatch =>
|
||
'Meilleur temps',
|
||
_ => 'Record',
|
||
};
|
||
}
|
||
|
||
String _formatReferenceReps(int reps, {required bool usesStepPassages}) {
|
||
return '$reps ${usesStepPassages ? 'passages' : 'reps'}';
|
||
}
|
||
|
||
String? _formatReferenceScore({
|
||
required double? actualScore,
|
||
required int? actualScoreTimeMs,
|
||
required ScoreInputMode scoreInputMode,
|
||
required String? scoreUnit,
|
||
}) {
|
||
if (scoreInputMode == ScoreInputMode.stopwatch) {
|
||
return actualScoreTimeMs == null
|
||
? null
|
||
: _formatReferenceStopwatchScore(actualScoreTimeMs);
|
||
}
|
||
return actualScore == null
|
||
? null
|
||
: _formatReferenceManualScore(actualScore, scoreUnit);
|
||
}
|
||
|
||
String _formatReferenceManualScore(double score, String? scoreUnit) {
|
||
final value = score == score.roundToDouble()
|
||
? score.toInt().toString()
|
||
: score.toStringAsFixed(1);
|
||
final unit = scoreUnit?.trim();
|
||
if (unit == null || unit.isEmpty) {
|
||
return value;
|
||
}
|
||
return '$value $unit';
|
||
}
|
||
|
||
String _formatReferenceMeasureTime(int milliseconds) {
|
||
final totalSeconds = (milliseconds / 1000).round().clamp(0, 999999).toInt();
|
||
if (totalSeconds < 60) {
|
||
return '$totalSeconds s';
|
||
}
|
||
final minutes = totalSeconds ~/ 60;
|
||
final seconds = (totalSeconds % 60).toString().padLeft(2, '0');
|
||
return '$minutes:$seconds';
|
||
}
|
||
|
||
String _formatReferenceStopwatchScore(int milliseconds) {
|
||
return _formatScoreStopwatch(Duration(milliseconds: milliseconds));
|
||
}
|
||
|
||
String _formatShortSeconds(int seconds) {
|
||
if (seconds < 60) {
|
||
return '$seconds s';
|
||
}
|
||
final minutes = seconds ~/ 60;
|
||
final remainingSeconds = (seconds % 60).toString().padLeft(2, '0');
|
||
return '$minutes:$remainingSeconds';
|
||
}
|
||
|
||
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?,
|
||
linkedToSeriesScore: step['linkedToSeriesScore'] == true,
|
||
);
|
||
})
|
||
.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';
|
||
}
|
||
|
||
bool _isSensorStateStale(ActiveWorkoutSensorState? state) {
|
||
if (state == null) {
|
||
return false;
|
||
}
|
||
return DateTime.now().toUtc().difference(state.latestSampleAt) >
|
||
const Duration(seconds: 15);
|
||
}
|
||
|
||
String? _distanceLabel(ActiveWorkoutSensorState? state) {
|
||
final meters = state?.latestDistanceMeters;
|
||
if (meters == null || meters < 0) {
|
||
return null;
|
||
}
|
||
if (meters >= 1000) {
|
||
return '${(meters / 1000).toStringAsFixed(2)} km';
|
||
}
|
||
return '${meters.round()} m';
|
||
}
|
||
|
||
String? _caloriesLabel(ActiveWorkoutSensorState? state) {
|
||
final actualCalories = state?.latestCaloriesKcal;
|
||
if (actualCalories != null && actualCalories >= 0) {
|
||
return '${actualCalories.round()} kcal';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
bool _hasLiveSensorMetrics(ActiveWorkoutSensorState? state) {
|
||
return state != null &&
|
||
(state.latestHeartRateBpm != null ||
|
||
state.latestDistanceMeters != null ||
|
||
_caloriesLabel(state) != null);
|
||
}
|
||
|
||
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);
|
||
}
|