merge(main): chrono du score chronométré en exécution (ticket #27)
Fusionne feature/#27-chrono-score-execution — analyze propre, 75/75 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#27"
|
||||
version: 3
|
||||
version: 5
|
||||
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||
updatedAt: 1784328260160
|
||||
updatedAt: 1784378061444
|
||||
---
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
id: "1b0116c4-ccb5-4d58-b977-fbe122e043fa"
|
||||
number: 27
|
||||
title: "[DevFrontend] Chrono score intégré dans l'exécution de séance"
|
||||
status: "open"
|
||||
status: "closed"
|
||||
priority: "high"
|
||||
sprint: null
|
||||
links: [{"target":"#18","kind":"relatesTo"},{"target":"#25","kind":"dependsOn"},{"target":"#26","kind":"dependsOn"}]
|
||||
@ -10,7 +10,7 @@ agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}
|
||||
createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||
createdAt: 1784328248186
|
||||
updatedAt: 1784328260160
|
||||
version: 3
|
||||
updatedAt: 1784378061444
|
||||
version: 5
|
||||
---
|
||||
Dans lib/presentation/workout_execution_screen.dart, bloc dédié "Chrono score" (affichage mm:ss.d, boutons Démarrer/Arrêter/Reprendre/Réinitialiser) quand la mesure Score est en mode chrono. Terminer la série avec chrono non démarré -> confirmation "Aucun temps chronométré" (Démarrer le chrono / Terminer sans chrono). Terminer avec chrono en cours -> auto-stop et enregistrement. Passer pendant que le chrono tourne -> confirmation. Pause de séance -> chrono se met en pause avec la séance. Action secondaire "Modifier le temps" pour correction manuelle. Cf. mémoire "gametime-ux-score-chrono" pour le détail complet des cas limites.
|
||||
@ -303,13 +303,13 @@
|
||||
"issueRef": "#27",
|
||||
"path": "27",
|
||||
"title": "[DevFrontend] Chrono score intégré dans l'exécution de séance",
|
||||
"status": "open",
|
||||
"status": "closed",
|
||||
"priority": "high",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [
|
||||
"9933c93a-b8a1-4164-a3bb-7063fdad747d"
|
||||
],
|
||||
"updatedAt": 1784328260160
|
||||
"updatedAt": 1784378061444
|
||||
},
|
||||
{
|
||||
"issueRef": "#28",
|
||||
|
||||
@ -1188,6 +1188,24 @@ final class ActiveWorkoutSessionUseCases {
|
||||
);
|
||||
}
|
||||
|
||||
Future<ActiveScoreStopwatchState?> findScoreStopwatch({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
}) {
|
||||
return sessionRepository.findScoreStopwatchState(
|
||||
sessionId: sessionId,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
);
|
||||
}
|
||||
|
||||
int scoreStopwatchElapsedMilliseconds(ActiveScoreStopwatchState state) {
|
||||
return state.elapsedMillisecondsAt(clock.now());
|
||||
}
|
||||
|
||||
Future<List<SetResultPositionState>> listSetResults(String sessionId) async {
|
||||
final session = await _requiredSession(sessionId);
|
||||
final snapshots = _listSetSnapshots(session.resolvedTemplateSnapshotJson);
|
||||
|
||||
@ -38,6 +38,8 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
Timer? _restTicker;
|
||||
WorkoutHistory? _completedHistory;
|
||||
String? _activeRestStateId;
|
||||
ActiveScoreStopwatchState? _scoreStopwatch;
|
||||
int? _manualScoreTimeMs;
|
||||
late DateTime _seriesStartedAt;
|
||||
var _reps = 0;
|
||||
final _scoreController = TextEditingController();
|
||||
@ -63,6 +65,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
unawaited(_loadScoreStopwatch());
|
||||
unawaited(_restoreActiveRest());
|
||||
}
|
||||
|
||||
@ -151,6 +154,20 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
scoreController: _scoreController,
|
||||
onRepsChanged: (value) => setState(() => _reps = value),
|
||||
),
|
||||
if (_exercise.stopwatchScoreEnabled) ...[
|
||||
const SizedBox(height: 12),
|
||||
_ScoreStopwatchInput(
|
||||
elapsedLabel: _formatScoreStopwatch(
|
||||
Duration(milliseconds: _scoreStopwatchElapsedMs),
|
||||
),
|
||||
status: _scoreStopwatch?.status,
|
||||
onStart: _startScoreStopwatch,
|
||||
onStop: _stopScoreStopwatch,
|
||||
onResume: _resumeScoreStopwatch,
|
||||
onReset: _resetScoreStopwatch,
|
||||
onEdit: _editScoreStopwatchTime,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: () => _finishSet(skipped: false),
|
||||
@ -326,6 +343,120 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
int get _scoreStopwatchElapsedMs {
|
||||
final manual = _manualScoreTimeMs;
|
||||
if (manual != null) {
|
||||
return manual;
|
||||
}
|
||||
final state = _scoreStopwatch;
|
||||
if (state == null) {
|
||||
return 0;
|
||||
}
|
||||
return widget.activeUseCases.scoreStopwatchElapsedMilliseconds(state);
|
||||
}
|
||||
|
||||
Future<void> _loadScoreStopwatch() async {
|
||||
if (!_exercise.stopwatchScoreEnabled) {
|
||||
_scoreStopwatch = null;
|
||||
_manualScoreTimeMs = null;
|
||||
return;
|
||||
}
|
||||
final state = await widget.activeUseCases.findScoreStopwatch(
|
||||
sessionId: _session.metadata.id,
|
||||
programIndex: _position.programIndex,
|
||||
exerciseIndex: _position.exerciseIndex,
|
||||
setIndex: _position.setIndex,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _scoreStopwatch = state);
|
||||
}
|
||||
|
||||
Future<void> _startScoreStopwatch() async {
|
||||
final state = await widget.activeUseCases.startScoreStopwatch(
|
||||
sessionId: _session.metadata.id,
|
||||
programIndex: _position.programIndex,
|
||||
exerciseIndex: _position.exerciseIndex,
|
||||
setIndex: _position.setIndex,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_scoreStopwatch = state;
|
||||
_manualScoreTimeMs = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<ActiveScoreStopwatchState?> _stopScoreStopwatch() async {
|
||||
final state = _scoreStopwatch;
|
||||
if (state == null) return null;
|
||||
final stopped = await widget.activeUseCases.stopScoreStopwatch(
|
||||
sessionId: _session.metadata.id,
|
||||
programIndex: _position.programIndex,
|
||||
exerciseIndex: _position.exerciseIndex,
|
||||
setIndex: _position.setIndex,
|
||||
);
|
||||
if (!mounted) return stopped;
|
||||
setState(() => _scoreStopwatch = stopped);
|
||||
return stopped;
|
||||
}
|
||||
|
||||
Future<void> _resumeScoreStopwatch() async {
|
||||
final state = await widget.activeUseCases.resumeScoreStopwatch(
|
||||
sessionId: _session.metadata.id,
|
||||
programIndex: _position.programIndex,
|
||||
exerciseIndex: _position.exerciseIndex,
|
||||
setIndex: _position.setIndex,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_scoreStopwatch = state;
|
||||
_manualScoreTimeMs = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _resetScoreStopwatch() async {
|
||||
if (_scoreStopwatchElapsedMs > 0) {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Réinitialiser le chrono ?'),
|
||||
content: const Text('Le temps mesuré sera supprimé.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Réinitialiser'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
}
|
||||
await widget.activeUseCases.resetScoreStopwatch(
|
||||
sessionId: _session.metadata.id,
|
||||
programIndex: _position.programIndex,
|
||||
exerciseIndex: _position.exerciseIndex,
|
||||
setIndex: _position.setIndex,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_scoreStopwatch = null;
|
||||
_manualScoreTimeMs = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _editScoreStopwatchTime() async {
|
||||
final initial = Duration(milliseconds: _scoreStopwatchElapsedMs);
|
||||
final edited = await showDialog<int>(
|
||||
context: context,
|
||||
builder: (context) => _ScoreStopwatchEditDialog(initial: initial),
|
||||
);
|
||||
if (edited == null || !mounted) return;
|
||||
setState(() => _manualScoreTimeMs = edited);
|
||||
}
|
||||
|
||||
bool get _canPauseFromNavigation {
|
||||
return _mode == WorkoutExecutionMode.active ||
|
||||
_mode == WorkoutExecutionMode.rest;
|
||||
@ -338,6 +469,9 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
|
||||
Future<void> _pause() async {
|
||||
_restTicker?.cancel();
|
||||
if (_scoreStopwatch?.status == ActiveScoreStopwatchStatus.running) {
|
||||
await _stopScoreStopwatch();
|
||||
}
|
||||
_session = await widget.activeUseCases.pause(_session.metadata.id);
|
||||
if (!mounted) return;
|
||||
setState(() => _mode = WorkoutExecutionMode.paused);
|
||||
@ -348,6 +482,8 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
if (!mounted) return;
|
||||
final restoredRest = await _restoreActiveRest();
|
||||
if (!mounted || restoredRest) return;
|
||||
await _loadScoreStopwatch();
|
||||
if (!mounted) return;
|
||||
setState(() => _mode = WorkoutExecutionMode.active);
|
||||
}
|
||||
|
||||
@ -408,8 +544,88 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
}
|
||||
|
||||
Future<void> _finishSet({required bool skipped}) async {
|
||||
if (skipped &&
|
||||
_scoreStopwatch?.status == ActiveScoreStopwatchStatus.running) {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Passer cette série ?'),
|
||||
content: const Text('Le chrono en cours sera ignoré.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Continuer la série'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Passer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
await widget.activeUseCases.resetScoreStopwatch(
|
||||
sessionId: _session.metadata.id,
|
||||
programIndex: _position.programIndex,
|
||||
exerciseIndex: _position.exerciseIndex,
|
||||
setIndex: _position.setIndex,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_scoreStopwatch = null;
|
||||
_manualScoreTimeMs = null;
|
||||
});
|
||||
}
|
||||
if (!skipped &&
|
||||
_exercise.stopwatchScoreEnabled &&
|
||||
_scoreStopwatch == null &&
|
||||
_manualScoreTimeMs == null) {
|
||||
final action = await showDialog<_MissingStopwatchAction>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Aucun temps chronométré'),
|
||||
content: const Text("Tu n'as pas démarré le chrono score."),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(_MissingStopwatchAction.start),
|
||||
child: const Text('Démarrer le chrono'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(
|
||||
context,
|
||||
).pop(_MissingStopwatchAction.finishWithout),
|
||||
child: const Text('Terminer sans chrono'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (action == _MissingStopwatchAction.start) {
|
||||
await _startScoreStopwatch();
|
||||
return;
|
||||
}
|
||||
if (action != _MissingStopwatchAction.finishWithout) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await _recordAndAdvance(skipped: skipped);
|
||||
}
|
||||
|
||||
Future<void> _recordAndAdvance({required bool skipped}) async {
|
||||
try {
|
||||
final score = double.tryParse(_scoreController.text.trim());
|
||||
var actualScoreTimeMs = skipped ? null : _manualScoreTimeMs;
|
||||
if (!skipped &&
|
||||
_exercise.stopwatchScoreEnabled &&
|
||||
actualScoreTimeMs == null) {
|
||||
final state = _scoreStopwatch;
|
||||
if (state?.status == ActiveScoreStopwatchStatus.running) {
|
||||
final stopped = await _stopScoreStopwatch();
|
||||
actualScoreTimeMs = stopped?.accumulatedMs;
|
||||
} else {
|
||||
actualScoreTimeMs = state?.accumulatedMs;
|
||||
}
|
||||
}
|
||||
final actualTimeMs = DateTime.now()
|
||||
.toUtc()
|
||||
.difference(_seriesStartedAt)
|
||||
@ -425,6 +641,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
actualScore: skipped
|
||||
? null
|
||||
: (_exercise.manualScoreEnabled ? score : null),
|
||||
actualScoreTimeMs: actualScoreTimeMs,
|
||||
scoreInputModeSnapshot: _exercise.scoreInputMode,
|
||||
scoreLabelSnapshot: _exercise.scoreLabel,
|
||||
scoreUnitSnapshot: _exercise.scoreUnit,
|
||||
@ -548,6 +765,8 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
_scoreController.clear();
|
||||
_reps = 0;
|
||||
_activeRestStateId = null;
|
||||
_scoreStopwatch = null;
|
||||
_manualScoreTimeMs = null;
|
||||
_seriesStartedAt = DateTime.now().toUtc();
|
||||
_session = await widget.activeUseCases.updateProgress(
|
||||
sessionId: _session.metadata.id,
|
||||
@ -556,6 +775,8 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
setIndex: position.setIndex,
|
||||
);
|
||||
if (!mounted) return;
|
||||
await _loadScoreStopwatch();
|
||||
if (!mounted) return;
|
||||
setState(() => _mode = WorkoutExecutionMode.active);
|
||||
}
|
||||
|
||||
@ -676,7 +897,7 @@ final class SetMeasureInput extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
],
|
||||
if (exercise.scoreEnabled) ...[
|
||||
if (exercise.manualScoreEnabled) ...[
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: scoreController,
|
||||
@ -694,6 +915,176 @@ final class SetMeasureInput extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
enum _MissingStopwatchAction { start, finishWithout }
|
||||
|
||||
final class _ScoreStopwatchInput extends StatelessWidget {
|
||||
const _ScoreStopwatchInput({
|
||||
required this.elapsedLabel,
|
||||
required this.status,
|
||||
required this.onStart,
|
||||
required this.onStop,
|
||||
required this.onResume,
|
||||
required this.onReset,
|
||||
required this.onEdit,
|
||||
});
|
||||
|
||||
final String elapsedLabel;
|
||||
final ActiveScoreStopwatchStatus? status;
|
||||
final VoidCallback onStart;
|
||||
final VoidCallback onStop;
|
||||
final VoidCallback onResume;
|
||||
final VoidCallback onReset;
|
||||
final VoidCallback onEdit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final running = status == ActiveScoreStopwatchStatus.running;
|
||||
final stopped = status == ActiveScoreStopwatchStatus.stopped;
|
||||
return CourtBlazerAccentPanel(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Chrono score', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: Text(
|
||||
elapsedLabel,
|
||||
style: AppTextStyles.scoreNumber(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
if (status == null)
|
||||
FilledButton.icon(
|
||||
onPressed: onStart,
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label: const Text('Démarrer'),
|
||||
)
|
||||
else if (running)
|
||||
FilledButton.icon(
|
||||
onPressed: onStop,
|
||||
icon: const Icon(Icons.stop),
|
||||
label: const Text('Arrêter'),
|
||||
)
|
||||
else ...[
|
||||
FilledButton.icon(
|
||||
onPressed: onResume,
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label: const Text('Reprendre'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onReset,
|
||||
icon: const Icon(Icons.restart_alt),
|
||||
label: const Text('Réinitialiser'),
|
||||
),
|
||||
],
|
||||
if (stopped)
|
||||
TextButton.icon(
|
||||
onPressed: onEdit,
|
||||
icon: const Icon(Icons.edit),
|
||||
label: const Text('Modifier le temps'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class _ScoreStopwatchEditDialog extends StatefulWidget {
|
||||
const _ScoreStopwatchEditDialog({required this.initial});
|
||||
|
||||
final Duration initial;
|
||||
|
||||
@override
|
||||
State<_ScoreStopwatchEditDialog> createState() =>
|
||||
_ScoreStopwatchEditDialogState();
|
||||
}
|
||||
|
||||
final class _ScoreStopwatchEditDialogState
|
||||
extends State<_ScoreStopwatchEditDialog> {
|
||||
late final TextEditingController _minutesController;
|
||||
late final TextEditingController _secondsController;
|
||||
late final TextEditingController _tenthsController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final totalTenths = (widget.initial.inMilliseconds / 100).round();
|
||||
final minutes = totalTenths ~/ 600;
|
||||
final seconds = (totalTenths ~/ 10) % 60;
|
||||
final tenths = totalTenths % 10;
|
||||
_minutesController = TextEditingController(text: '$minutes');
|
||||
_secondsController = TextEditingController(text: '$seconds');
|
||||
_tenthsController = TextEditingController(text: '$tenths');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_minutesController.dispose();
|
||||
_secondsController.dispose();
|
||||
_tenthsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Modifier le temps'),
|
||||
content: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _minutesController,
|
||||
decoration: const InputDecoration(labelText: 'Min'),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _secondsController,
|
||||
decoration: const InputDecoration(labelText: 'S'),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _tenthsController,
|
||||
decoration: const InputDecoration(labelText: 'Dixièmes'),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final minutes = int.tryParse(_minutesController.text.trim()) ?? 0;
|
||||
final seconds = int.tryParse(_secondsController.text.trim()) ?? 0;
|
||||
final tenths = int.tryParse(_tenthsController.text.trim()) ?? 0;
|
||||
final milliseconds =
|
||||
(minutes * 60 * 1000) + (seconds * 1000) + (tenths * 100);
|
||||
Navigator.of(context).pop(milliseconds);
|
||||
},
|
||||
child: const Text('Enregistrer'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class _WorkoutPlanSheet extends StatefulWidget {
|
||||
const _WorkoutPlanSheet({
|
||||
required this.sessionId,
|
||||
@ -1202,6 +1593,10 @@ final class _EditSetResultSheetState extends State<_EditSetResultSheet> {
|
||||
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), (_) {
|
||||
@ -1287,7 +1682,9 @@ final class _EditSetResultSheetState extends State<_EditSetResultSheet> {
|
||||
TextField(
|
||||
controller: _scoreController,
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.exercise.scoreUnit == null
|
||||
labelText: widget.exercise.stopwatchScoreEnabled
|
||||
? 'Temps réalisé (chrono score, s)'
|
||||
: widget.exercise.scoreUnit == null
|
||||
? 'Score'
|
||||
: 'Score (${widget.exercise.scoreUnit})',
|
||||
),
|
||||
@ -1339,9 +1736,14 @@ final class _EditSetResultSheetState extends State<_EditSetResultSheet> {
|
||||
actualReps: widget.exercise.repsEnabled
|
||||
? int.tryParse(_repsController.text.trim())
|
||||
: null,
|
||||
actualScore: widget.exercise.scoreEnabled
|
||||
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();
|
||||
@ -1472,6 +1874,12 @@ String _setResultSummary(ActiveSetResult result) {
|
||||
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!),
|
||||
);
|
||||
parts.add('Temps réalisé $value');
|
||||
}
|
||||
return parts.isEmpty ? 'Aucun résultat' : parts.join(' · ');
|
||||
}
|
||||
|
||||
@ -1486,6 +1894,11 @@ int? _secondsToMilliseconds(String rawValue) {
|
||||
return seconds == null ? null : seconds * 1000;
|
||||
}
|
||||
|
||||
int? _decimalSecondsToMilliseconds(String rawValue) {
|
||||
final seconds = double.tryParse(rawValue.trim());
|
||||
return seconds == null ? null : (seconds * 1000).round();
|
||||
}
|
||||
|
||||
int _remainingSecondsForRest(ActiveRestState? rest) {
|
||||
if (rest == null) return 0;
|
||||
final elapsedSeconds = DateTime.now()
|
||||
@ -1695,6 +2108,10 @@ final class ExecutionExercise {
|
||||
return scoreEnabled && scoreInputMode == ScoreInputMode.manual;
|
||||
}
|
||||
|
||||
bool get stopwatchScoreEnabled {
|
||||
return scoreEnabled && scoreInputMode == ScoreInputMode.stopwatch;
|
||||
}
|
||||
|
||||
bool get hasMedia {
|
||||
return imageMediaIds.isNotEmpty || videoMediaId != null;
|
||||
}
|
||||
@ -1810,3 +2227,11 @@ String _formatDuration(Duration duration) {
|
||||
}
|
||||
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';
|
||||
}
|
||||
|
||||
@ -241,6 +241,160 @@ void main() {
|
||||
expect(find.text('Image 2'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('le chrono score démarre et s’arrête avec la bonne durée', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(400, 1400));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||||
final repository = _FakeActiveSessionRepository();
|
||||
final session = ActiveWorkoutSession(
|
||||
metadata: _metadata('session-1'),
|
||||
sourceWorkoutTemplateId: 'template-1',
|
||||
status: ActiveWorkoutStatus.running,
|
||||
startedAt: DateTime.utc(2026, 7, 17, 12),
|
||||
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
|
||||
elapsedActiveMs: 0,
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
resolvedTemplateSnapshotJson: _sessionSnapshot(
|
||||
scoreInputMode: ScoreInputMode.stopwatch,
|
||||
),
|
||||
);
|
||||
repository.session = session;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: WorkoutExecutionScreen(
|
||||
initialSession: session,
|
||||
activeUseCases: _activeUseCases(repository, clock),
|
||||
closeUseCase: _closeUseCase(repository, clock),
|
||||
historyUseCases: _historyUseCases(clock),
|
||||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Chrono score'), findsOneWidget);
|
||||
expect(find.text('00:00.0'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Démarrer'));
|
||||
await tester.pump();
|
||||
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 1, 200);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
expect(find.text('00:01.2'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Arrêter'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('00:01.2'), findsOneWidget);
|
||||
expect(
|
||||
repository.scoreStopwatchStates.single.status,
|
||||
ActiveScoreStopwatchStatus.stopped,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('terminer sans chrono démarré affiche une confirmation', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(400, 1400));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||||
final repository = _FakeActiveSessionRepository();
|
||||
final session = ActiveWorkoutSession(
|
||||
metadata: _metadata('session-1'),
|
||||
sourceWorkoutTemplateId: 'template-1',
|
||||
status: ActiveWorkoutStatus.running,
|
||||
startedAt: DateTime.utc(2026, 7, 17, 12),
|
||||
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
|
||||
elapsedActiveMs: 0,
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
resolvedTemplateSnapshotJson: _sessionSnapshot(
|
||||
scoreInputMode: ScoreInputMode.stopwatch,
|
||||
),
|
||||
);
|
||||
repository.session = session;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: WorkoutExecutionScreen(
|
||||
initialSession: session,
|
||||
activeUseCases: _activeUseCases(repository, clock),
|
||||
closeUseCase: _closeUseCase(repository, clock),
|
||||
historyUseCases: _historyUseCases(clock),
|
||||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Terminer la série'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Aucun temps chronométré'), findsOneWidget);
|
||||
expect(find.text("Tu n'as pas démarré le chrono score."), findsOneWidget);
|
||||
expect(find.text('Démarrer le chrono'), findsOneWidget);
|
||||
expect(find.text('Terminer sans chrono'), findsOneWidget);
|
||||
expect(repository.results, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('terminer avec chrono en cours l’arrête et enregistre', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(400, 1400));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||||
final repository = _FakeActiveSessionRepository();
|
||||
final session = ActiveWorkoutSession(
|
||||
metadata: _metadata('session-1'),
|
||||
sourceWorkoutTemplateId: 'template-1',
|
||||
status: ActiveWorkoutStatus.running,
|
||||
startedAt: DateTime.utc(2026, 7, 17, 12),
|
||||
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
|
||||
elapsedActiveMs: 0,
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
resolvedTemplateSnapshotJson: _sessionSnapshot(
|
||||
setsCount: 2,
|
||||
scoreInputMode: ScoreInputMode.stopwatch,
|
||||
),
|
||||
);
|
||||
repository.session = session;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: WorkoutExecutionScreen(
|
||||
initialSession: session,
|
||||
activeUseCases: _activeUseCases(repository, clock),
|
||||
closeUseCase: _closeUseCase(repository, clock),
|
||||
historyUseCases: _historyUseCases(clock),
|
||||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Démarrer'));
|
||||
await tester.pump();
|
||||
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2, 500);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.tap(find.text('Terminer la série'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repository.results.single.actualScoreTimeMs, 2500);
|
||||
expect(
|
||||
repository.scoreStopwatchStates.single.status,
|
||||
ActiveScoreStopwatchStatus.stopped,
|
||||
);
|
||||
expect(repository.session?.currentSetIndex, 1);
|
||||
});
|
||||
|
||||
testWidgets('un repos actif est repris avec le temps restant recalculé', (
|
||||
tester,
|
||||
) async {
|
||||
@ -850,7 +1004,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
required DateTime deletedAt,
|
||||
}) async {}
|
||||
}) async {
|
||||
scoreStopwatchStates.removeWhere(
|
||||
(state) =>
|
||||
state.activeWorkoutSessionId == sessionId &&
|
||||
state.programIndex == programIndex &&
|
||||
state.exerciseIndex == exerciseIndex &&
|
||||
state.setIndex == setIndex,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveSetResult(ActiveSetResult result) async {
|
||||
|
||||
Reference in New Issue
Block a user