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

@ -71,6 +71,32 @@ void main() {
);
});
testWidgets('le détail affiche la fréquence cardiaque quand elle existe', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: HistoryDetailScreen(
history: _history(
id: 'history-1',
averageHeartRateBpm: 126.4,
maxHeartRateBpm: 171,
),
historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()),
workoutTemplateUseCases: _workoutTemplateUseCases(),
activeUseCases: _activeUseCases(),
closeUseCase: _closeUseCase(),
),
),
);
expect(find.text('Fréquence cardiaque'), findsOneWidget);
expect(find.text('Moyenne'), findsOneWidget);
expect(find.text('Max'), findsOneWidget);
expect(find.text('126 bpm', findRichText: true), findsOneWidget);
expect(find.text('171 bpm', findRichText: true), findsOneWidget);
});
testWidgets('le détail affiche le score chrono comme temps réalisé', (
tester,
) async {
@ -156,6 +182,72 @@ void main() {
expect(repository.deletedIds, ['history-1']);
});
testWidgets('relancer un historique vide affiche une erreur sans navigation', (
tester,
) async {
final activeRepository = _FakeActiveSessionRepository();
await tester.pumpWidget(
MaterialApp(
home: HistoryDetailScreen(
history: _history(id: 'history-empty', emptySnapshot: true),
historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()),
workoutTemplateUseCases: _workoutTemplateUseCases(),
activeUseCases: _activeUseCases(activeRepository),
closeUseCase: _closeUseCase(),
),
),
);
await tester.tap(find.text('Relancer cette séance'));
await tester.pump();
expect(
find.text(
'Cette séance ne peut pas être relancée car elle ne contient aucun exercice.',
),
findsOneWidget,
);
expect(find.byType(WorkoutExecutionScreen), findsNothing);
});
testWidgets('une session legacy invalide ne bloque pas une relance valide', (
tester,
) async {
final activeRepository = _FakeActiveSessionRepository()
..session = ActiveWorkoutSession(
metadata: _metadata('legacy-session'),
sourceWorkoutTemplateId: 'template-legacy',
status: ActiveWorkoutStatus.paused,
startedAt: DateTime.utc(2026, 7, 17, 9),
lastPersistedAt: DateTime.utc(2026, 7, 17, 9),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _emptyResolvedSnapshot(),
);
await tester.pumpWidget(
MaterialApp(
home: HistoryDetailScreen(
history: _history(id: 'history-1'),
historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()),
workoutTemplateUseCases: _workoutTemplateUseCases(),
activeUseCases: _activeUseCases(activeRepository),
closeUseCase: _closeUseCase(),
),
),
);
await tester.tap(find.text('Relancer cette séance'));
await tester.pumpAndSettle();
expect(find.textContaining('Une séance est déjà en cours.'), findsNothing);
expect(find.byType(WorkoutExecutionScreen), findsOneWidget);
expect(activeRepository.session?.sourceWorkoutTemplateId, isNull);
});
}
WorkoutHistory _history({
@ -163,6 +255,9 @@ WorkoutHistory _history({
DateTime? startedAt,
bool stopwatchScore = false,
bool withStepResults = false,
bool emptySnapshot = false,
double? averageHeartRateBpm,
int? maxHeartRateBpm,
}) {
final start = startedAt ?? DateTime.utc(2026, 7, 17, 10);
return WorkoutHistory(
@ -173,13 +268,17 @@ WorkoutHistory _history({
startedAt: start,
endedAt: start.add(const Duration(minutes: 30)),
totalActiveMs: 1800000,
averageHeartRateBpm: averageHeartRateBpm,
maxHeartRateBpm: maxHeartRateBpm,
completed: true,
historySnapshotJson: jsonEncode({
'sessionId': 'session-1',
'resolvedTemplateSnapshotJson': _resolvedSnapshot(
stopwatchScore: stopwatchScore,
withSteps: withStepResults,
),
'resolvedTemplateSnapshotJson': emptySnapshot
? _emptyResolvedSnapshot()
: _resolvedSnapshot(
stopwatchScore: stopwatchScore,
withSteps: withStepResults,
),
'results': [
{
'programSnapshotId': 'program-snapshot-1',
@ -308,6 +407,20 @@ String _resolvedSnapshot({
});
}
String _emptyResolvedSnapshot() {
return jsonEncode({
'name': 'Séance vide',
'programs': [
{
'id': 'program-snapshot-empty',
'programNameSnapshot': 'Programme vide',
'programSnapshotJson': jsonEncode({'exercises': const []}),
},
],
'overrides': const [],
});
}
EntityMetadata _metadata(String id) {
return EntityMetadata(
id: id,
@ -336,9 +449,11 @@ WorkoutTemplateUseCases _workoutTemplateUseCases() {
);
}
ActiveWorkoutSessionUseCases _activeUseCases() {
ActiveWorkoutSessionUseCases _activeUseCases([
_FakeActiveSessionRepository? repository,
]) {
return ActiveWorkoutSessionUseCases(
sessionRepository: _FakeActiveSessionRepository(),
sessionRepository: repository ?? _FakeActiveSessionRepository(),
templateRepository: _FakeWorkoutTemplateRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17)),
ids: _FakeIds(),
@ -392,6 +507,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
@ -441,11 +564,15 @@ final class _FakeProgramRepository implements ProgramRepository {
}
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<ActiveWorkoutSession?> findById(String id) async => null;
ActiveWorkoutSession? session;
@override
Future<ActiveWorkoutSession?> findOpen() async => null;
Future<ActiveWorkoutSession?> findById(String id) async {
return session?.metadata.id == id ? session : null;
}
@override
Future<ActiveWorkoutSession?> findOpen() async => session;
@override
Future<ActiveRestState?> findRestStateById(String id) async => null;
@ -458,6 +585,14 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
required int setIndex,
}) async => null;
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async => null;
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -486,6 +621,13 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return const [];
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return const [];
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return const [];
@ -511,7 +653,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
}
@override
Future<void> save(ActiveWorkoutSession session) async {}
Future<void> save(ActiveWorkoutSession session) async {
this.session = session;
}
@override
Future<void> saveRestState(ActiveRestState restState) async {}
@ -519,6 +663,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {}
@ -539,6 +686,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
required DateTime deletedAt,
}) async {}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {}
@override
Future<void> saveSetResult(ActiveSetResult result) async {}
}