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

@ -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,