feat(exécution): chrono du score chronométré pendant la séance (ticket #27)
Ajoute le chrono du score chronométré (fondation domain/config des tickets #25/#26) sur workout_execution_screen.dart, avec les use cases associés. flutter 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:
@ -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 {
|
Future<List<SetResultPositionState>> listSetResults(String sessionId) async {
|
||||||
final session = await _requiredSession(sessionId);
|
final session = await _requiredSession(sessionId);
|
||||||
final snapshots = _listSetSnapshots(session.resolvedTemplateSnapshotJson);
|
final snapshots = _listSetSnapshots(session.resolvedTemplateSnapshotJson);
|
||||||
|
|||||||
@ -38,6 +38,8 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
|||||||
Timer? _restTicker;
|
Timer? _restTicker;
|
||||||
WorkoutHistory? _completedHistory;
|
WorkoutHistory? _completedHistory;
|
||||||
String? _activeRestStateId;
|
String? _activeRestStateId;
|
||||||
|
ActiveScoreStopwatchState? _scoreStopwatch;
|
||||||
|
int? _manualScoreTimeMs;
|
||||||
late DateTime _seriesStartedAt;
|
late DateTime _seriesStartedAt;
|
||||||
var _reps = 0;
|
var _reps = 0;
|
||||||
final _scoreController = TextEditingController();
|
final _scoreController = TextEditingController();
|
||||||
@ -63,6 +65,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
|||||||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
});
|
});
|
||||||
|
unawaited(_loadScoreStopwatch());
|
||||||
unawaited(_restoreActiveRest());
|
unawaited(_restoreActiveRest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -151,6 +154,20 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
|||||||
scoreController: _scoreController,
|
scoreController: _scoreController,
|
||||||
onRepsChanged: (value) => setState(() => _reps = value),
|
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),
|
const SizedBox(height: 24),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => _finishSet(skipped: false),
|
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 {
|
bool get _canPauseFromNavigation {
|
||||||
return _mode == WorkoutExecutionMode.active ||
|
return _mode == WorkoutExecutionMode.active ||
|
||||||
_mode == WorkoutExecutionMode.rest;
|
_mode == WorkoutExecutionMode.rest;
|
||||||
@ -338,6 +469,9 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
|||||||
|
|
||||||
Future<void> _pause() async {
|
Future<void> _pause() async {
|
||||||
_restTicker?.cancel();
|
_restTicker?.cancel();
|
||||||
|
if (_scoreStopwatch?.status == ActiveScoreStopwatchStatus.running) {
|
||||||
|
await _stopScoreStopwatch();
|
||||||
|
}
|
||||||
_session = await widget.activeUseCases.pause(_session.metadata.id);
|
_session = await widget.activeUseCases.pause(_session.metadata.id);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _mode = WorkoutExecutionMode.paused);
|
setState(() => _mode = WorkoutExecutionMode.paused);
|
||||||
@ -348,6 +482,8 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final restoredRest = await _restoreActiveRest();
|
final restoredRest = await _restoreActiveRest();
|
||||||
if (!mounted || restoredRest) return;
|
if (!mounted || restoredRest) return;
|
||||||
|
await _loadScoreStopwatch();
|
||||||
|
if (!mounted) return;
|
||||||
setState(() => _mode = WorkoutExecutionMode.active);
|
setState(() => _mode = WorkoutExecutionMode.active);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -408,8 +544,88 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _finishSet({required bool skipped}) async {
|
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 {
|
try {
|
||||||
final score = double.tryParse(_scoreController.text.trim());
|
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()
|
final actualTimeMs = DateTime.now()
|
||||||
.toUtc()
|
.toUtc()
|
||||||
.difference(_seriesStartedAt)
|
.difference(_seriesStartedAt)
|
||||||
@ -425,6 +641,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
|||||||
actualScore: skipped
|
actualScore: skipped
|
||||||
? null
|
? null
|
||||||
: (_exercise.manualScoreEnabled ? score : null),
|
: (_exercise.manualScoreEnabled ? score : null),
|
||||||
|
actualScoreTimeMs: actualScoreTimeMs,
|
||||||
scoreInputModeSnapshot: _exercise.scoreInputMode,
|
scoreInputModeSnapshot: _exercise.scoreInputMode,
|
||||||
scoreLabelSnapshot: _exercise.scoreLabel,
|
scoreLabelSnapshot: _exercise.scoreLabel,
|
||||||
scoreUnitSnapshot: _exercise.scoreUnit,
|
scoreUnitSnapshot: _exercise.scoreUnit,
|
||||||
@ -548,6 +765,8 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
|||||||
_scoreController.clear();
|
_scoreController.clear();
|
||||||
_reps = 0;
|
_reps = 0;
|
||||||
_activeRestStateId = null;
|
_activeRestStateId = null;
|
||||||
|
_scoreStopwatch = null;
|
||||||
|
_manualScoreTimeMs = null;
|
||||||
_seriesStartedAt = DateTime.now().toUtc();
|
_seriesStartedAt = DateTime.now().toUtc();
|
||||||
_session = await widget.activeUseCases.updateProgress(
|
_session = await widget.activeUseCases.updateProgress(
|
||||||
sessionId: _session.metadata.id,
|
sessionId: _session.metadata.id,
|
||||||
@ -556,6 +775,8 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
|||||||
setIndex: position.setIndex,
|
setIndex: position.setIndex,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
await _loadScoreStopwatch();
|
||||||
|
if (!mounted) return;
|
||||||
setState(() => _mode = WorkoutExecutionMode.active);
|
setState(() => _mode = WorkoutExecutionMode.active);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -676,7 +897,7 @@ final class SetMeasureInput extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (exercise.scoreEnabled) ...[
|
if (exercise.manualScoreEnabled) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
controller: scoreController,
|
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 {
|
final class _WorkoutPlanSheet extends StatefulWidget {
|
||||||
const _WorkoutPlanSheet({
|
const _WorkoutPlanSheet({
|
||||||
required this.sessionId,
|
required this.sessionId,
|
||||||
@ -1202,6 +1593,10 @@ final class _EditSetResultSheetState extends State<_EditSetResultSheet> {
|
|||||||
if (actualScore != null) {
|
if (actualScore != null) {
|
||||||
_scoreController.text = _formatScore(actualScore);
|
_scoreController.text = _formatScore(actualScore);
|
||||||
}
|
}
|
||||||
|
final actualScoreTimeMs = result?.actualScoreTimeMs;
|
||||||
|
if (actualScoreTimeMs != null) {
|
||||||
|
_scoreController.text = (actualScoreTimeMs / 1000).toStringAsFixed(1);
|
||||||
|
}
|
||||||
_remainingRestSeconds = _remainingSecondsForRest(widget.activeRest);
|
_remainingRestSeconds = _remainingSecondsForRest(widget.activeRest);
|
||||||
if (widget.activeRest != null && _remainingRestSeconds > 0) {
|
if (widget.activeRest != null && _remainingRestSeconds > 0) {
|
||||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
@ -1287,7 +1682,9 @@ final class _EditSetResultSheetState extends State<_EditSetResultSheet> {
|
|||||||
TextField(
|
TextField(
|
||||||
controller: _scoreController,
|
controller: _scoreController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: widget.exercise.scoreUnit == null
|
labelText: widget.exercise.stopwatchScoreEnabled
|
||||||
|
? 'Temps réalisé (chrono score, s)'
|
||||||
|
: widget.exercise.scoreUnit == null
|
||||||
? 'Score'
|
? 'Score'
|
||||||
: 'Score (${widget.exercise.scoreUnit})',
|
: 'Score (${widget.exercise.scoreUnit})',
|
||||||
),
|
),
|
||||||
@ -1339,9 +1736,14 @@ final class _EditSetResultSheetState extends State<_EditSetResultSheet> {
|
|||||||
actualReps: widget.exercise.repsEnabled
|
actualReps: widget.exercise.repsEnabled
|
||||||
? int.tryParse(_repsController.text.trim())
|
? int.tryParse(_repsController.text.trim())
|
||||||
: null,
|
: null,
|
||||||
actualScore: widget.exercise.scoreEnabled
|
actualScore: widget.exercise.manualScoreEnabled
|
||||||
? double.tryParse(_scoreController.text.trim())
|
? double.tryParse(_scoreController.text.trim())
|
||||||
: null,
|
: null,
|
||||||
|
actualScoreTimeMs: widget.exercise.stopwatchScoreEnabled
|
||||||
|
? _decimalSecondsToMilliseconds(_scoreController.text)
|
||||||
|
: null,
|
||||||
|
scoreInputModeSnapshot: widget.exercise.scoreInputMode,
|
||||||
|
scoreLabelSnapshot: widget.exercise.scoreLabel,
|
||||||
scoreUnitSnapshot: widget.exercise.scoreUnit,
|
scoreUnitSnapshot: widget.exercise.scoreUnit,
|
||||||
);
|
);
|
||||||
await _finishSave();
|
await _finishSave();
|
||||||
@ -1472,6 +1874,12 @@ String _setResultSummary(ActiveSetResult result) {
|
|||||||
final unit = result.scoreUnitSnapshot?.trim();
|
final unit = result.scoreUnitSnapshot?.trim();
|
||||||
parts.add(unit == null || unit.isEmpty ? 'Score $value' : '$value $unit');
|
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(' · ');
|
return parts.isEmpty ? 'Aucun résultat' : parts.join(' · ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1486,6 +1894,11 @@ int? _secondsToMilliseconds(String rawValue) {
|
|||||||
return seconds == null ? null : seconds * 1000;
|
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) {
|
int _remainingSecondsForRest(ActiveRestState? rest) {
|
||||||
if (rest == null) return 0;
|
if (rest == null) return 0;
|
||||||
final elapsedSeconds = DateTime.now()
|
final elapsedSeconds = DateTime.now()
|
||||||
@ -1695,6 +2108,10 @@ final class ExecutionExercise {
|
|||||||
return scoreEnabled && scoreInputMode == ScoreInputMode.manual;
|
return scoreEnabled && scoreInputMode == ScoreInputMode.manual;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get stopwatchScoreEnabled {
|
||||||
|
return scoreEnabled && scoreInputMode == ScoreInputMode.stopwatch;
|
||||||
|
}
|
||||||
|
|
||||||
bool get hasMedia {
|
bool get hasMedia {
|
||||||
return imageMediaIds.isNotEmpty || videoMediaId != null;
|
return imageMediaIds.isNotEmpty || videoMediaId != null;
|
||||||
}
|
}
|
||||||
@ -1810,3 +2227,11 @@ String _formatDuration(Duration duration) {
|
|||||||
}
|
}
|
||||||
return '$minutes:$seconds';
|
return '$minutes:$seconds';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _formatScoreStopwatch(Duration duration) {
|
||||||
|
final totalTenths = (duration.inMilliseconds / 100).floor();
|
||||||
|
final minutes = (totalTenths ~/ 600).toString().padLeft(2, '0');
|
||||||
|
final seconds = ((totalTenths ~/ 10) % 60).toString().padLeft(2, '0');
|
||||||
|
final tenths = totalTenths % 10;
|
||||||
|
return '$minutes:$seconds.$tenths';
|
||||||
|
}
|
||||||
|
|||||||
@ -241,6 +241,160 @@ void main() {
|
|||||||
expect(find.text('Image 2'), findsOneWidget);
|
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é', (
|
testWidgets('un repos actif est repris avec le temps restant recalculé', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@ -850,7 +1004,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
required int exerciseIndex,
|
required int exerciseIndex,
|
||||||
required int setIndex,
|
required int setIndex,
|
||||||
required DateTime deletedAt,
|
required DateTime deletedAt,
|
||||||
}) async {}
|
}) async {
|
||||||
|
scoreStopwatchStates.removeWhere(
|
||||||
|
(state) =>
|
||||||
|
state.activeWorkoutSessionId == sessionId &&
|
||||||
|
state.programIndex == programIndex &&
|
||||||
|
state.exerciseIndex == exerciseIndex &&
|
||||||
|
state.setIndex == setIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> saveSetResult(ActiveSetResult result) async {
|
Future<void> saveSetResult(ActiveSetResult result) async {
|
||||||
|
|||||||
Reference in New Issue
Block a user