feat(watch): clôture lot #91 - fréquence cardiaque live, notifications de séance et finitions montre
Consolide le lot applicatif watch companion validé : - télémétrie fréquence cardiaque live remontée montre -> téléphone (collecteur watch, adapter Wear Data Layer, persistance Drift, propagation aux écrans historique/programme/profil/exécution) - notifications de séance en arrière-plan côté téléphone (service foreground de statut + passerelle applicative) - finitions montre : chrono d'étape, score d'étape, retrait du bouton "lancer une séance", thème, icônes et polices watch_app Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user