merge(develop): clôture lot #91 Wear OS - fréquence cardiaque, notifications, finitions montre
Intègre le commit de clôture du lot #91 resté non fusionné. Conflits attendus sur entities.dart, workout_execution_screen.dart et watch_companion_projection_test.dart : arbitrage Architect - garder la base feature/ticket91-wear-os-watch-sync sur ces 3 fichiers puis réinjecter uniquement les invariants chronos utiles (Durée / Temps réalisé). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> # Conflicts: # lib/domain/entities.dart # lib/presentation/workout_execution_screen.dart # test/application/watch_companion_projection_test.dart
This commit is contained in:
@ -681,26 +681,27 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
'Mode de saisie',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
RadioListTile<ScoreInputMode>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Saisie libre'),
|
||||
value: ScoreInputMode.manual,
|
||||
groupValue: _scoreInputMode,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
setState(() => _scoreInputMode = value);
|
||||
},
|
||||
),
|
||||
RadioListTile<ScoreInputMode>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Chrono intégré'),
|
||||
subtitle: const Text('Temps réalisé'),
|
||||
value: ScoreInputMode.stopwatch,
|
||||
RadioGroup<ScoreInputMode>(
|
||||
groupValue: _scoreInputMode,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
setState(() => _scoreInputMode = value);
|
||||
},
|
||||
child: const Column(
|
||||
children: [
|
||||
RadioListTile<ScoreInputMode>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Saisie libre'),
|
||||
value: ScoreInputMode.manual,
|
||||
),
|
||||
RadioListTile<ScoreInputMode>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Chrono intégré'),
|
||||
subtitle: Text('Temps réalisé'),
|
||||
value: ScoreInputMode.stopwatch,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_scoreInputMode == ScoreInputMode.manual) ...[
|
||||
const SizedBox(height: 12),
|
||||
@ -865,7 +866,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: _stepDrafts.length,
|
||||
onReorder: _reorderStep,
|
||||
onReorderItem: _reorderStep,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildStepCard(context, index, _stepDrafts[index]);
|
||||
},
|
||||
@ -901,6 +902,13 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
int index,
|
||||
_ExerciseStepDraft draft,
|
||||
) {
|
||||
final canLinkToSeriesScore =
|
||||
_hasScore && _scoreInputMode == ScoreInputMode.manual;
|
||||
if ((!canLinkToSeriesScore ||
|
||||
draft.scoreInputMode != ScoreInputMode.manual) &&
|
||||
draft.linkedToSeriesScore) {
|
||||
draft.linkedToSeriesScore = false;
|
||||
}
|
||||
final targetLabel = draft.type == ExerciseStepType.time
|
||||
? 'Durée par défaut (s)'
|
||||
: 'Répétitions par défaut';
|
||||
@ -979,25 +987,26 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('Type d’étape', style: Theme.of(context).textTheme.titleSmall),
|
||||
RadioListTile<ExerciseStepType>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Temps'),
|
||||
value: ExerciseStepType.time,
|
||||
groupValue: draft.type,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
setState(() => draft.type = value);
|
||||
},
|
||||
),
|
||||
RadioListTile<ExerciseStepType>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Répétitions'),
|
||||
value: ExerciseStepType.reps,
|
||||
RadioGroup<ExerciseStepType>(
|
||||
groupValue: draft.type,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
setState(() => draft.type = value);
|
||||
},
|
||||
child: const Column(
|
||||
children: [
|
||||
RadioListTile<ExerciseStepType>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Temps'),
|
||||
value: ExerciseStepType.time,
|
||||
),
|
||||
RadioListTile<ExerciseStepType>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Répétitions'),
|
||||
value: ExerciseStepType.reps,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextFormField(
|
||||
controller: draft.targetController,
|
||||
@ -1021,79 +1030,103 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
'Mode de score',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
RadioListTile<ScoreInputMode>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Saisie libre'),
|
||||
value: ScoreInputMode.manual,
|
||||
RadioGroup<ScoreInputMode>(
|
||||
groupValue: draft.scoreInputMode,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
setState(() => draft.scoreInputMode = value);
|
||||
},
|
||||
),
|
||||
RadioListTile<ScoreInputMode>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Chrono intégré'),
|
||||
value: ScoreInputMode.stopwatch,
|
||||
groupValue: draft.scoreInputMode,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
setState(() => draft.scoreInputMode = value);
|
||||
setState(() {
|
||||
draft.scoreInputMode = value;
|
||||
draft.linkedToSeriesScore = false;
|
||||
});
|
||||
},
|
||||
child: const Column(
|
||||
children: [
|
||||
RadioListTile<ScoreInputMode>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Saisie libre'),
|
||||
value: ScoreInputMode.manual,
|
||||
),
|
||||
RadioListTile<ScoreInputMode>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Chrono intégré'),
|
||||
value: ScoreInputMode.stopwatch,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (draft.scoreInputMode == ScoreInputMode.manual) ...[
|
||||
TextFormField(
|
||||
controller: draft.scoreLabelController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Score à saisir',
|
||||
if (canLinkToSeriesScore) ...[
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Utiliser le score de la série'),
|
||||
subtitle: const Text(
|
||||
'Le score de cette étape alimente directement le score '
|
||||
'de la série.',
|
||||
),
|
||||
value: draft.linkedToSeriesScore,
|
||||
onChanged: (value) {
|
||||
setState(() => draft.linkedToSeriesScore = value);
|
||||
},
|
||||
),
|
||||
validator: (value) {
|
||||
if (!_stepsEnabled || !draft.hasScore) return null;
|
||||
if (draft.scoreInputMode != ScoreInputMode.manual) {
|
||||
return null;
|
||||
}
|
||||
return value == null || value.trim().isEmpty
|
||||
? 'Le libellé du score est obligatoire.'
|
||||
: null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: draft.scoreUnitController,
|
||||
decoration: const InputDecoration(labelText: 'Unité'),
|
||||
validator: (value) {
|
||||
if (!_stepsEnabled || !draft.hasScore) return null;
|
||||
if (draft.scoreInputMode != ScoreInputMode.manual) {
|
||||
return null;
|
||||
}
|
||||
return value == null || value.trim().isEmpty
|
||||
? 'L’unité du score est obligatoire.'
|
||||
: null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: draft.scoreTargetController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Score par défaut',
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (!draft.linkedToSeriesScore) ...[
|
||||
TextFormField(
|
||||
controller: draft.scoreLabelController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Score à saisir',
|
||||
),
|
||||
validator: (value) {
|
||||
if (!_stepsEnabled || !draft.hasScore) return null;
|
||||
if (draft.scoreInputMode != ScoreInputMode.manual ||
|
||||
draft.linkedToSeriesScore) {
|
||||
return null;
|
||||
}
|
||||
return value == null || value.trim().isEmpty
|
||||
? 'Le libellé du score est obligatoire.'
|
||||
: null;
|
||||
},
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: draft.scoreUnitController,
|
||||
decoration: const InputDecoration(labelText: 'Unité'),
|
||||
validator: (value) {
|
||||
if (!_stepsEnabled || !draft.hasScore) return null;
|
||||
if (draft.scoreInputMode != ScoreInputMode.manual ||
|
||||
draft.linkedToSeriesScore) {
|
||||
return null;
|
||||
}
|
||||
return value == null || value.trim().isEmpty
|
||||
? 'L’unité du score est obligatoire.'
|
||||
: null;
|
||||
},
|
||||
),
|
||||
validator: (value) {
|
||||
if (!_stepsEnabled ||
|
||||
!draft.hasScore ||
|
||||
draft.scoreInputMode != ScoreInputMode.manual ||
|
||||
value == null ||
|
||||
value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return _nonNegativeDoubleValidator(
|
||||
value,
|
||||
'Saisis un score supérieur ou égal à 0.',
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: draft.scoreTargetController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Score par défaut',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
validator: (value) {
|
||||
if (!_stepsEnabled ||
|
||||
!draft.hasScore ||
|
||||
draft.scoreInputMode != ScoreInputMode.manual ||
|
||||
draft.linkedToSeriesScore ||
|
||||
value == null ||
|
||||
value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return _nonNegativeDoubleValidator(
|
||||
value,
|
||||
'Saisis un score supérieur ou égal à 0.',
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
] else ...[
|
||||
if (draft.type == ExerciseStepType.time) ...[
|
||||
const SizedBox(height: 8),
|
||||
@ -1163,9 +1196,6 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
|
||||
void _reorderStep(int oldIndex, int newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
final draft = _stepDrafts.removeAt(oldIndex);
|
||||
_stepDrafts.insert(newIndex, draft);
|
||||
});
|
||||
@ -1254,6 +1284,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
scoreInputMode: step.scoreInputMode,
|
||||
scoreLabel: step.scoreLabel,
|
||||
scoreUnit: step.scoreUnit,
|
||||
linkedToSeriesScore: step.linkedToSeriesScore,
|
||||
scoreTarget: step.scoreInputMode == ScoreInputMode.stopwatch
|
||||
? _optionalDoubleText(
|
||||
_millisecondsToSeconds(step.defaultTargetScoreTimeMs),
|
||||
@ -1268,6 +1299,10 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
for (var index = 0; index < _stepDrafts.length; index++)
|
||||
_stepDrafts[index].toExerciseStep(
|
||||
position: index,
|
||||
seriesScoreLabel: _scoreLabelController.text.trim(),
|
||||
seriesScoreUnit: _scoreUnitController.text.trim(),
|
||||
canLinkToSeriesScore:
|
||||
_hasScore && _scoreInputMode == ScoreInputMode.manual,
|
||||
defaultTargetScoreTimeMs:
|
||||
_stepDrafts[index].scoreInputMode == ScoreInputMode.stopwatch
|
||||
? _optionalSecondsToMilliseconds(
|
||||
@ -1386,32 +1421,34 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
}
|
||||
|
||||
setState(() => _saving = true);
|
||||
final scoreInputMode = _hasScore ? _scoreInputMode : ScoreInputMode.manual;
|
||||
final scoreLabel = _hasScore
|
||||
? switch (scoreInputMode) {
|
||||
ScoreInputMode.manual => _scoreLabelController.text.trim(),
|
||||
ScoreInputMode.stopwatch => 'Temps réalisé',
|
||||
}
|
||||
: null;
|
||||
final scoreUnit = _hasScore && scoreInputMode == ScoreInputMode.manual
|
||||
? _scoreUnitController.text.trim()
|
||||
: null;
|
||||
final defaultTargetTimeSeconds = _hasTime
|
||||
? int.parse(_defaultTimeController.text.trim())
|
||||
: null;
|
||||
final defaultTargetReps = _hasReps
|
||||
? int.parse(_defaultRepsController.text.trim())
|
||||
: null;
|
||||
final defaultTargetScore =
|
||||
_hasScore && scoreInputMode == ScoreInputMode.manual
|
||||
? double.parse(_defaultScoreController.text.trim())
|
||||
: null;
|
||||
final defaultTargetScoreTimeMs =
|
||||
_hasScore && scoreInputMode == ScoreInputMode.stopwatch
|
||||
? _optionalSecondsToMilliseconds(_defaultScoreTimeController.text)
|
||||
: null;
|
||||
final steps = _buildSteps();
|
||||
try {
|
||||
final scoreInputMode = _hasScore
|
||||
? _scoreInputMode
|
||||
: ScoreInputMode.manual;
|
||||
final scoreLabel = _hasScore
|
||||
? switch (scoreInputMode) {
|
||||
ScoreInputMode.manual => _scoreLabelController.text.trim(),
|
||||
ScoreInputMode.stopwatch => 'Temps réalisé',
|
||||
}
|
||||
: null;
|
||||
final scoreUnit = _hasScore && scoreInputMode == ScoreInputMode.manual
|
||||
? _scoreUnitController.text.trim()
|
||||
: null;
|
||||
final defaultTargetTimeSeconds = _hasTime
|
||||
? int.parse(_defaultTimeController.text.trim())
|
||||
: null;
|
||||
final defaultTargetReps = _hasReps
|
||||
? int.parse(_defaultRepsController.text.trim())
|
||||
: null;
|
||||
final defaultTargetScore =
|
||||
_hasScore && scoreInputMode == ScoreInputMode.manual
|
||||
? double.parse(_defaultScoreController.text.trim())
|
||||
: null;
|
||||
final defaultTargetScoreTimeMs =
|
||||
_hasScore && scoreInputMode == ScoreInputMode.stopwatch
|
||||
? _optionalSecondsToMilliseconds(_defaultScoreTimeController.text)
|
||||
: null;
|
||||
final steps = _buildSteps();
|
||||
if (exercise == null) {
|
||||
await widget.exerciseUseCases.create(
|
||||
name: _nameController.text.trim(),
|
||||
@ -1462,8 +1499,10 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
navigator.pop(true);
|
||||
}
|
||||
}
|
||||
} on Exception catch (error) {
|
||||
_showSnackBar(error.toString());
|
||||
} on Exception {
|
||||
_showSnackBar(
|
||||
'Impossible d’enregistrer l’exercice. Vérifie les champs puis réessaie.',
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _saving = false);
|
||||
@ -1556,6 +1595,7 @@ final class _ExerciseStepDraft {
|
||||
required String defaultTargetValue,
|
||||
this.hasScore = false,
|
||||
this.scoreInputMode = ScoreInputMode.manual,
|
||||
this.linkedToSeriesScore = false,
|
||||
String? scoreLabel,
|
||||
String? scoreUnit,
|
||||
String? scoreTarget,
|
||||
@ -1569,6 +1609,7 @@ final class _ExerciseStepDraft {
|
||||
ExerciseStepType type;
|
||||
bool hasScore;
|
||||
ScoreInputMode scoreInputMode;
|
||||
bool linkedToSeriesScore;
|
||||
final TextEditingController nameController;
|
||||
final TextEditingController targetController;
|
||||
final TextEditingController scoreLabelController;
|
||||
@ -1584,6 +1625,7 @@ final class _ExerciseStepDraft {
|
||||
return nameController.text.trim().isNotEmpty ||
|
||||
targetController.text.trim().isNotEmpty ||
|
||||
hasScore ||
|
||||
linkedToSeriesScore ||
|
||||
scoreLabelController.text.trim().isNotEmpty ||
|
||||
scoreUnitController.text.trim().isNotEmpty ||
|
||||
scoreTargetController.text.trim().isNotEmpty;
|
||||
@ -1597,6 +1639,7 @@ final class _ExerciseStepDraft {
|
||||
defaultTargetValue: targetController.text,
|
||||
hasScore: hasScore,
|
||||
scoreInputMode: scoreInputMode,
|
||||
linkedToSeriesScore: linkedToSeriesScore,
|
||||
scoreLabel: scoreLabelController.text,
|
||||
scoreUnit: scoreUnitController.text,
|
||||
scoreTarget: scoreTargetController.text,
|
||||
@ -1605,9 +1648,17 @@ final class _ExerciseStepDraft {
|
||||
|
||||
ExerciseStep toExerciseStep({
|
||||
required int position,
|
||||
required String seriesScoreLabel,
|
||||
required String seriesScoreUnit,
|
||||
required bool canLinkToSeriesScore,
|
||||
required int? defaultTargetScoreTimeMs,
|
||||
}) {
|
||||
final scoreTargetText = scoreTargetController.text.trim();
|
||||
final linksToSeries =
|
||||
hasScore &&
|
||||
scoreInputMode == ScoreInputMode.manual &&
|
||||
canLinkToSeriesScore &&
|
||||
linkedToSeriesScore;
|
||||
return ExerciseStep(
|
||||
id: id,
|
||||
position: position,
|
||||
@ -1617,14 +1668,19 @@ final class _ExerciseStepDraft {
|
||||
hasScore: hasScore,
|
||||
scoreInputMode: hasScore ? scoreInputMode : ScoreInputMode.manual,
|
||||
scoreLabel: hasScore && scoreInputMode == ScoreInputMode.manual
|
||||
? scoreLabelController.text.trim()
|
||||
? linksToSeries
|
||||
? seriesScoreLabel
|
||||
: scoreLabelController.text.trim()
|
||||
: null,
|
||||
scoreUnit: hasScore && scoreInputMode == ScoreInputMode.manual
|
||||
? scoreUnitController.text.trim()
|
||||
? linksToSeries
|
||||
? seriesScoreUnit
|
||||
: scoreUnitController.text.trim()
|
||||
: null,
|
||||
defaultTargetScore:
|
||||
hasScore &&
|
||||
scoreInputMode == ScoreInputMode.manual &&
|
||||
!linksToSeries &&
|
||||
scoreTargetText.isNotEmpty
|
||||
? double.parse(scoreTargetText)
|
||||
: null,
|
||||
@ -1632,6 +1688,7 @@ final class _ExerciseStepDraft {
|
||||
hasScore && scoreInputMode == ScoreInputMode.stopwatch
|
||||
? defaultTargetScoreTimeMs
|
||||
: null,
|
||||
linkedToSeriesScore: linksToSeries,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ final class HistoryListScreen extends StatefulWidget {
|
||||
required this.closeUseCase,
|
||||
this.mediaUseCases,
|
||||
this.stepUseCases,
|
||||
this.sensorUseCases,
|
||||
this.performanceReferenceUseCase,
|
||||
this.onOpenProgression,
|
||||
super.key,
|
||||
@ -24,6 +25,7 @@ final class HistoryListScreen extends StatefulWidget {
|
||||
final WorkoutTemplateUseCases workoutTemplateUseCases;
|
||||
final ActiveWorkoutSessionUseCases activeUseCases;
|
||||
final ActiveExerciseStepUseCases? stepUseCases;
|
||||
final ActiveWorkoutSensorUseCases? sensorUseCases;
|
||||
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
|
||||
final CloseWorkoutSessionUseCase closeUseCase;
|
||||
final MediaUseCases? mediaUseCases;
|
||||
@ -110,6 +112,7 @@ final class _HistoryListScreenState extends State<HistoryListScreen> {
|
||||
workoutTemplateUseCases: widget.workoutTemplateUseCases,
|
||||
activeUseCases: widget.activeUseCases,
|
||||
stepUseCases: widget.stepUseCases,
|
||||
sensorUseCases: widget.sensorUseCases,
|
||||
performanceReferenceUseCase: widget.performanceReferenceUseCase,
|
||||
closeUseCase: widget.closeUseCase,
|
||||
mediaUseCases: widget.mediaUseCases,
|
||||
@ -133,6 +136,7 @@ final class HistoryDetailScreen extends StatelessWidget {
|
||||
required this.closeUseCase,
|
||||
this.mediaUseCases,
|
||||
this.stepUseCases,
|
||||
this.sensorUseCases,
|
||||
this.performanceReferenceUseCase,
|
||||
super.key,
|
||||
});
|
||||
@ -142,6 +146,7 @@ final class HistoryDetailScreen extends StatelessWidget {
|
||||
final WorkoutTemplateUseCases workoutTemplateUseCases;
|
||||
final ActiveWorkoutSessionUseCases activeUseCases;
|
||||
final ActiveExerciseStepUseCases? stepUseCases;
|
||||
final ActiveWorkoutSensorUseCases? sensorUseCases;
|
||||
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
|
||||
final CloseWorkoutSessionUseCase closeUseCase;
|
||||
final MediaUseCases? mediaUseCases;
|
||||
@ -157,6 +162,14 @@ final class HistoryDetailScreen extends StatelessWidget {
|
||||
Text(_formatDateTime(history.startedAt)),
|
||||
const SizedBox(height: 4),
|
||||
Text('Durée : ${_formatDurationMs(history.totalActiveMs)}'),
|
||||
if (history.averageHeartRateBpm != null &&
|
||||
history.maxHeartRateBpm != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
_HistoryHeartRateSummary(
|
||||
averageHeartRateBpm: history.averageHeartRateBpm!,
|
||||
maxHeartRateBpm: history.maxHeartRateBpm!,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
for (final program in detail.programs) ...[
|
||||
Text(program.name, style: Theme.of(context).textTheme.titleMedium),
|
||||
@ -202,13 +215,28 @@ final class HistoryDetailScreen extends StatelessWidget {
|
||||
}
|
||||
|
||||
Future<void> _restart(BuildContext context) async {
|
||||
final hasBlockingOpenSession = await _hasBlockingOpenSession();
|
||||
if (!context.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;
|
||||
if (sourceId != null &&
|
||||
await workoutTemplateUseCases.findById(sourceId) != null) {
|
||||
session = await activeUseCases.startFromTemplate(sourceId);
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
try {
|
||||
if (sourceId != null &&
|
||||
await workoutTemplateUseCases.findById(sourceId) != null) {
|
||||
session = await activeUseCases.startFromTemplate(sourceId);
|
||||
} else {
|
||||
session = await activeUseCases.startFromHistory(history);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
@ -217,7 +245,16 @@ final class HistoryDetailScreen extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
session = await activeUseCases.startFromHistory(history);
|
||||
} on DomainException {
|
||||
if (!context.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 (!context.mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
@ -226,6 +263,7 @@ final class HistoryDetailScreen extends StatelessWidget {
|
||||
initialSession: session,
|
||||
activeUseCases: activeUseCases,
|
||||
stepUseCases: stepUseCases,
|
||||
sensorUseCases: sensorUseCases,
|
||||
closeUseCase: closeUseCase,
|
||||
historyUseCases: historyUseCases,
|
||||
workoutTemplateUseCases: workoutTemplateUseCases,
|
||||
@ -236,6 +274,22 @@ final class HistoryDetailScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _hasBlockingOpenSession() async {
|
||||
final openSession = await activeUseCases.findOpen();
|
||||
if (openSession == null) {
|
||||
return false;
|
||||
}
|
||||
if (WorkoutExecutionPlan.tryFromSession(openSession) != null) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
await activeUseCases.abandon(openSession.metadata.id);
|
||||
} on DomainException {
|
||||
// Legacy invalid sessions must not block a restart.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
@ -261,6 +315,79 @@ final class HistoryDetailScreen extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
final class _HistoryHeartRateSummary extends StatelessWidget {
|
||||
const _HistoryHeartRateSummary({
|
||||
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: _HistoryHeartRateMetric(
|
||||
label: 'Moyenne',
|
||||
value: '${averageHeartRateBpm.round()}',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _HistoryHeartRateMetric(
|
||||
label: 'Max',
|
||||
value: '$maxHeartRateBpm',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class _HistoryHeartRateMetric extends StatelessWidget {
|
||||
const _HistoryHeartRateMetric({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 HistoryDetailData {
|
||||
const HistoryDetailData({required this.programs});
|
||||
|
||||
|
||||
@ -93,7 +93,10 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
|
||||
if (session == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final plan = WorkoutExecutionPlan.fromSession(session);
|
||||
final plan = WorkoutExecutionPlan.tryFromSession(session);
|
||||
if (plan == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final position = ExecutionPosition(
|
||||
programIndex: session.currentProgramIndex,
|
||||
exerciseIndex: session.currentExerciseIndex,
|
||||
@ -158,6 +161,7 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
|
||||
programUseCases: widget.bootstrap.programUseCases,
|
||||
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
|
||||
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
|
||||
sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases,
|
||||
performanceReferenceUseCase:
|
||||
widget.bootstrap.exercisePerformanceReferenceUseCase,
|
||||
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
|
||||
@ -182,6 +186,7 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
|
||||
widget.bootstrap.workoutTemplateUseCases,
|
||||
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
|
||||
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
|
||||
sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases,
|
||||
performanceReferenceUseCase:
|
||||
widget.bootstrap.exercisePerformanceReferenceUseCase,
|
||||
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
|
||||
@ -257,21 +262,33 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
|
||||
}
|
||||
|
||||
Future<void> _resume(ActiveWorkoutSession session) async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WorkoutExecutionScreen(
|
||||
initialSession: session,
|
||||
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
|
||||
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
|
||||
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
|
||||
historyUseCases: widget.bootstrap.workoutHistoryUseCases,
|
||||
workoutTemplateUseCases: widget.bootstrap.workoutTemplateUseCases,
|
||||
performanceReferenceUseCase:
|
||||
widget.bootstrap.exercisePerformanceReferenceUseCase,
|
||||
mediaUseCases: widget.bootstrap.mediaUseCases,
|
||||
try {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WorkoutExecutionScreen(
|
||||
initialSession: session,
|
||||
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
|
||||
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
|
||||
sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases,
|
||||
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
|
||||
historyUseCases: widget.bootstrap.workoutHistoryUseCases,
|
||||
workoutTemplateUseCases: widget.bootstrap.workoutTemplateUseCases,
|
||||
performanceReferenceUseCase:
|
||||
widget.bootstrap.exercisePerformanceReferenceUseCase,
|
||||
mediaUseCases: widget.bootstrap.mediaUseCases,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
} on DomainException {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Cette séance ne peut plus être reprise. Elle a été abandonnée automatiquement.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
_reloadOpenSession();
|
||||
}
|
||||
|
||||
@ -89,14 +89,13 @@ final class _ProfileScreenState extends State<ProfileScreen> {
|
||||
),
|
||||
),
|
||||
);
|
||||
if (connected == true && mounted) {
|
||||
_reloadSession();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Compte connecté. Synchronisation en arrière-plan.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (connected != true || !mounted || !context.mounted) return;
|
||||
_reloadSession();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Compte connecté. Synchronisation en arrière-plan.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openRegister(BuildContext context) async {
|
||||
@ -109,14 +108,13 @@ final class _ProfileScreenState extends State<ProfileScreen> {
|
||||
),
|
||||
),
|
||||
);
|
||||
if (connected == true && mounted) {
|
||||
_reloadSession();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Compte connecté. Synchronisation en arrière-plan.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (connected != true || !mounted || !context.mounted) return;
|
||||
_reloadSession();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Compte connecté. Synchronisation en arrière-plan.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmLogout(BuildContext context) async {
|
||||
@ -189,13 +187,12 @@ final class _SignedOutProfile extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Compte optionnel',
|
||||
'Compte GameTime',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'GameTime fonctionne entièrement sans compte. Connecte-toi '
|
||||
'seulement si tu veux sauvegarder tes données en ligne ou '
|
||||
'Connecte-toi pour sauvegarder tes données en ligne et '
|
||||
'partager des programmes et séances.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@ -481,7 +478,8 @@ abstract interface class LocalBackupFileExporter {
|
||||
});
|
||||
}
|
||||
|
||||
final class SharePlusLocalBackupFileExporter implements LocalBackupFileExporter {
|
||||
final class SharePlusLocalBackupFileExporter
|
||||
implements LocalBackupFileExporter {
|
||||
const SharePlusLocalBackupFileExporter();
|
||||
|
||||
@override
|
||||
@ -950,11 +948,6 @@ final class _LoginScreenState extends State<LoginScreen> {
|
||||
},
|
||||
child: const Text('Créer un compte'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Tu peux continuer à utiliser GameTime sans compte.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -1103,7 +1096,7 @@ final class _RegisterScreenState extends State<RegisterScreen> {
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Le compte sert à synchroniser tes données et partager tes '
|
||||
"contenus. L'app reste utilisable sans compte.",
|
||||
'contenus.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
@ -1211,6 +1204,8 @@ String _loginErrorMessage(RemoteAuthFailure failure) {
|
||||
RemoteAuthFailure.invalidCredentials => 'Email ou mot de passe incorrect.',
|
||||
RemoteAuthFailure.network =>
|
||||
'Connexion impossible pour le moment. Réessaie plus tard.',
|
||||
RemoteAuthFailure.server =>
|
||||
'Serveur indisponible pour le moment. Réessaie plus tard.',
|
||||
_ => 'Connexion impossible pour le moment. Réessaie plus tard.',
|
||||
};
|
||||
}
|
||||
@ -1221,6 +1216,8 @@ String _registerErrorMessage(RemoteAuthFailure failure) {
|
||||
'Un compte existe déjà avec cet email.',
|
||||
RemoteAuthFailure.network =>
|
||||
'Création impossible pour le moment. Réessaie plus tard.',
|
||||
RemoteAuthFailure.server =>
|
||||
'Serveur indisponible pour le moment. Réessaie plus tard.',
|
||||
_ => 'Création impossible pour le moment. Réessaie plus tard.',
|
||||
};
|
||||
}
|
||||
|
||||
@ -420,6 +420,7 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final canSave = !_saving && _exercises.isNotEmpty;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
@ -436,15 +437,21 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
minimum: const EdgeInsets.all(16),
|
||||
child: FilledButton.icon(
|
||||
onPressed: _saving ? null : _save,
|
||||
icon: _saving
|
||||
? const SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.check),
|
||||
label: const Text('Enregistrer'),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: canSave ? _save : null,
|
||||
icon: _saving
|
||||
? const SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.check),
|
||||
label: const Text('Enregistrer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
@ -495,7 +502,8 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
|
||||
child: _exercises.isEmpty
|
||||
? const _CenteredMessage(
|
||||
title: 'Aucun exercice ajouté',
|
||||
message: 'Ajoute un exercice depuis la bibliothèque.',
|
||||
message:
|
||||
'Ajoute au moins un exercice pour enregistrer ce programme.',
|
||||
)
|
||||
: ReorderableListView.builder(
|
||||
buildDefaultDragHandles: false,
|
||||
@ -595,6 +603,9 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_exercises.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
@ -1402,7 +1413,7 @@ final class _CenteredMessage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@ -172,7 +172,7 @@ final class ShareAccountRequiredScreen extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Connecte-toi pour envoyer $targetLabel à un autre compte '
|
||||
'GameTime. Le reste de l’app reste utilisable sans compte.',
|
||||
'GameTime.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -19,6 +19,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
|
||||
required this.historyUseCases,
|
||||
this.mediaUseCases,
|
||||
this.stepUseCases,
|
||||
this.sensorUseCases,
|
||||
this.performanceReferenceUseCase,
|
||||
this.shareUseCases,
|
||||
this.authUseCases,
|
||||
@ -30,6 +31,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
|
||||
final ProgramUseCases programUseCases;
|
||||
final ActiveWorkoutSessionUseCases activeUseCases;
|
||||
final ActiveExerciseStepUseCases? stepUseCases;
|
||||
final ActiveWorkoutSensorUseCases? sensorUseCases;
|
||||
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
|
||||
final CloseWorkoutSessionUseCase closeUseCase;
|
||||
final WorkoutHistoryUseCases historyUseCases;
|
||||
@ -264,9 +266,16 @@ final class _WorkoutTemplateListScreenState
|
||||
}
|
||||
|
||||
Future<void> _start(WorkoutTemplate template) async {
|
||||
final openSession = await widget.activeUseCases.findOpen();
|
||||
if (_templateExerciseCount(template) == 0) {
|
||||
_showSnackBar(
|
||||
'Cette séance ne contient aucun exercice. Ajoute un programme avec '
|
||||
'au moins un exercice avant de la lancer.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final hasBlockingOpenSession = await _hasBlockingOpenSession();
|
||||
if (!mounted) return;
|
||||
if (openSession != null) {
|
||||
if (hasBlockingOpenSession) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
@ -277,9 +286,18 @@ final class _WorkoutTemplateListScreenState
|
||||
);
|
||||
return;
|
||||
}
|
||||
final session = await widget.activeUseCases.startFromTemplate(
|
||||
template.metadata.id,
|
||||
);
|
||||
final ActiveWorkoutSession session;
|
||||
try {
|
||||
session = await widget.activeUseCases.startFromTemplate(
|
||||
template.metadata.id,
|
||||
);
|
||||
} on DomainException {
|
||||
_showSnackBar(
|
||||
'Cette séance ne contient aucun exercice. Ajoute un programme avec '
|
||||
'au moins un exercice avant de la lancer.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
@ -292,11 +310,28 @@ final class _WorkoutTemplateListScreenState
|
||||
workoutTemplateUseCases: widget.workoutTemplateUseCases,
|
||||
performanceReferenceUseCase: widget.performanceReferenceUseCase,
|
||||
mediaUseCases: widget.mediaUseCases,
|
||||
sensorUseCases: widget.sensorUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _hasBlockingOpenSession() async {
|
||||
final openSession = await widget.activeUseCases.findOpen();
|
||||
if (openSession == null) {
|
||||
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 new start.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(WorkoutTemplate template) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
@ -458,6 +493,7 @@ final class _WorkoutTemplateFormScreenState
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final canSave = !_saving && _programs.isNotEmpty;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_isEditing ? 'Modifier la séance' : 'Créer une séance'),
|
||||
@ -472,15 +508,21 @@ final class _WorkoutTemplateFormScreenState
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
minimum: const EdgeInsets.all(16),
|
||||
child: FilledButton.icon(
|
||||
onPressed: _saving ? null : _save,
|
||||
icon: _saving
|
||||
? const SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.check),
|
||||
label: const Text('Enregistrer'),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: canSave ? _save : null,
|
||||
icon: _saving
|
||||
? const SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.check),
|
||||
label: const Text('Enregistrer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
@ -521,13 +563,14 @@ final class _WorkoutTemplateFormScreenState
|
||||
child: _programs.isEmpty
|
||||
? const _CenteredMessage(
|
||||
title: 'Aucun programme ajouté',
|
||||
message: 'Ajoute un programme pour composer la séance.',
|
||||
message:
|
||||
'Ajoute au moins un programme pour enregistrer cette séance.',
|
||||
)
|
||||
: ReorderableListView.builder(
|
||||
buildDefaultDragHandles: false,
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
itemCount: _programs.length,
|
||||
onReorder: _reorderProgram,
|
||||
onReorderItem: _reorderProgram,
|
||||
itemBuilder: (context, index) {
|
||||
final program = _programs[index];
|
||||
return Card(
|
||||
@ -579,9 +622,6 @@ final class _WorkoutTemplateFormScreenState
|
||||
|
||||
void _reorderProgram(int oldIndex, int newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
final item = _programs.removeAt(oldIndex);
|
||||
_programs.insert(newIndex, item);
|
||||
});
|
||||
@ -599,6 +639,9 @@ final class _WorkoutTemplateFormScreenState
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_programs.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
@ -1050,6 +1093,7 @@ List<ExerciseStep> _parseExerciseSteps(Object? value) {
|
||||
scoreUnit: step['scoreUnit'] as String?,
|
||||
defaultTargetScore: (step['defaultTargetScore'] as num?)?.toDouble(),
|
||||
defaultTargetScoreTimeMs: step['defaultTargetScoreTimeMs'] as int?,
|
||||
linkedToSeriesScore: step['linkedToSeriesScore'] == true,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
@ -1062,12 +1106,16 @@ String _autoStartHelpText(bool active) {
|
||||
|
||||
String _templateSummary(WorkoutTemplate template) {
|
||||
final programCount = template.programs.length;
|
||||
final exerciseCount = template.programs.fold<int>(
|
||||
final exerciseCount = _templateExerciseCount(template);
|
||||
return '$programCount programme${programCount > 1 ? 's' : ''} · '
|
||||
'$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}';
|
||||
}
|
||||
|
||||
int _templateExerciseCount(WorkoutTemplate template) {
|
||||
return template.programs.fold<int>(
|
||||
0,
|
||||
(total, program) => total + _exerciseCount(program.programSnapshotJson),
|
||||
);
|
||||
return '$programCount programme${programCount > 1 ? 's' : ''} · '
|
||||
'$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}';
|
||||
}
|
||||
|
||||
String _programDraftSummary(WorkoutTemplateProgramDraft program) {
|
||||
@ -1099,8 +1147,12 @@ String _exerciseDraftSummary(WorkoutTemplateExerciseDraft exercise) {
|
||||
}
|
||||
|
||||
int _exerciseCount(String programSnapshotJson) {
|
||||
final snapshot = jsonDecode(programSnapshotJson) as Map<String, dynamic>;
|
||||
return (snapshot['exercises'] as List<dynamic>? ?? const []).length;
|
||||
try {
|
||||
final snapshot = jsonDecode(programSnapshotJson) as Map<String, dynamic>;
|
||||
return (snapshot['exercises'] as List<dynamic>? ?? const []).length;
|
||||
} on Exception {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
final class _CenteredMessage extends StatelessWidget {
|
||||
@ -1119,7 +1171,7 @@ final class _CenteredMessage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
Reference in New Issue
Block a user