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