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:
2026-07-28 05:56:12 +02:00
parent c65a5a76a9
commit 65d43b9768
80 changed files with 12292 additions and 892 deletions

View File

@ -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
? 'Lunité 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
? 'Lunité 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 denregistrer lexercice. 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,
);
}