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:
@ -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});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user