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

@ -52,6 +52,94 @@ void main() {
});
});
group('WatchSensorSummary', () {
test('round-trips heart rate summary through JSON', () {
const summary = WatchSensorSummary(
sessionId: 'session-1',
sampleCount: 12,
minHeartRateBpm: 91,
averageHeartRateBpm: 128.5,
maxHeartRateBpm: 174,
totalDistanceMeters: 842.4,
totalCaloriesKcal: 186.2,
);
final decoded = WatchSensorSummary.fromJson(
jsonDecode(jsonEncode(summary.toJson())) as Map<String, Object?>,
);
expect(decoded, summary);
});
test('falls back safely for absent fields', () {
final summary = WatchSensorSummary.fromJson({});
expect(summary.schemaVersion, watchBridgeSchemaVersion);
expect(summary.sessionId, '');
expect(summary.sampleCount, 0);
expect(summary.minHeartRateBpm, isNull);
expect(summary.averageHeartRateBpm, isNull);
expect(summary.maxHeartRateBpm, isNull);
expect(summary.totalDistanceMeters, isNull);
expect(summary.totalCaloriesKcal, isNull);
});
});
group('WatchSensorSample', () {
test('round-trips live telemetry sample through JSON', () {
const sample = WatchSensorSample(
sampleId: 'sample-1',
sessionId: 'session-1',
capturedAtEpochMs: 1710000000300,
programIndex: 0,
exerciseIndex: 1,
setIndex: 2,
passageIndex: 3,
stepIndex: 4,
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
stepSnapshotId: 'step-snapshot-1',
heartRateBpm: 142,
distanceMeters: 840.5,
caloriesKcal: 184.2,
);
final decoded = WatchSensorSample.fromJson(
jsonDecode(jsonEncode(sample.toJson())) as Map<String, Object?>,
);
expect(decoded, sample);
});
test('falls back safely for absent fields', () {
final sample = WatchSensorSample.fromJson({});
expect(sample.schemaVersion, watchBridgeSchemaVersion);
expect(sample.sampleId, isNull);
expect(sample.sessionId, '');
expect(sample.recordedAtEpochMs, 0);
expect(sample.capturedAtEpochMs, 0);
expect(sample.programIndex, isNull);
expect(sample.exerciseIndex, isNull);
expect(sample.setIndex, isNull);
expect(sample.passageIndex, isNull);
expect(sample.stepIndex, isNull);
expect(sample.heartRateBpm, isNull);
expect(sample.distanceMeters, isNull);
expect(sample.caloriesKcal, isNull);
});
test('accepts legacy recordedAtEpochMs as captured timestamp', () {
final sample = WatchSensorSample.fromJson({
'sessionId': 'session-1',
'recordedAtEpochMs': 1710000000300,
});
expect(sample.capturedAtEpochMs, 1710000000300);
expect(sample.recordedAtEpochMs, 1710000000300);
});
});
group('WatchSessionProjection', () {
test('round-trips every phase, primary action, and secondary action', () {
for (final phase in WatchSessionPhase.values) {
@ -65,6 +153,9 @@ void main() {
seriesIndex: 2,
seriesTotal: 5,
exerciseName: 'Pompes tempo',
programIndex: 0,
exerciseIndex: 1,
setIndex: 1,
passageIndex: 1,
passageTotal: 3,
stepIndex: 2,
@ -76,6 +167,12 @@ void main() {
secondaryActions: WatchSecondaryAction.values,
nextExerciseName: 'Fentes sautees',
statusLabel: 'Chrono etape',
hasManualScore: true,
currentManualScoreValue: 7.5,
canDecrementScore: true,
manualScoreTargetValue: 10,
manualScoreTargetLabel: 'Cible',
manualScoreScope: WatchManualScoreScope.step,
);
final decoded = WatchSessionProjection.fromJson(
@ -114,6 +211,12 @@ void main() {
]);
expect(projection.nextExerciseName, isNull);
expect(projection.statusLabel, isNull);
expect(projection.hasManualScore, isFalse);
expect(projection.currentManualScoreValue, isNull);
expect(projection.canDecrementScore, isFalse);
expect(projection.manualScoreTargetValue, isNull);
expect(projection.manualScoreTargetLabel, isNull);
expect(projection.manualScoreScope, isNull);
});
test('falls back to neutral values for absent required fields', () {
@ -130,6 +233,12 @@ void main() {
expect(projection.exerciseName, '');
expect(projection.primaryAction, WatchPrimaryAction.none);
expect(projection.secondaryActions, isEmpty);
expect(projection.hasManualScore, isFalse);
expect(projection.currentManualScoreValue, isNull);
expect(projection.canDecrementScore, isFalse);
expect(projection.manualScoreTargetValue, isNull);
expect(projection.manualScoreTargetLabel, isNull);
expect(projection.manualScoreScope, isNull);
});
});