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

@ -0,0 +1,126 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
void main() {
test('builds active timer notification content', () {
final content = buildSessionNotificationContent(
_projection(
exerciseName: 'Squats bulgares',
dominantTimer: _timer(label: 'Chrono étape', accumulatedMs: 134000),
),
);
expect(content.title, 'Squats bulgares');
expect(content.primaryLine, '02:14');
expect(content.secondaryLine, 'Série 2/4');
});
test('builds manual score notification content', () {
final content = buildSessionNotificationContent(
_projection(
exerciseName: 'Lancers francs',
hasManualScore: true,
currentManualScoreValue: 8,
),
);
expect(content.title, 'Lancers francs');
expect(content.primaryLine, 'Série 2/4 · 8');
});
test('builds rest notification content with next exercise', () {
final now = DateTime.utc(2026, 7, 26, 10);
final content = buildSessionNotificationContent(
_projection(
phase: WatchSessionPhase.restRunning,
exerciseName: 'Squats',
nextExerciseName: 'Fentes',
dominantTimer: _timer(
kind: WatchTimerKind.rest,
displayMode: WatchTimerDisplayMode.countdown,
runState: WatchTimerRunState.running,
accumulatedMs: 10000,
targetMs: 60000,
startedAtEpochMs: now.millisecondsSinceEpoch,
),
),
now: now.add(const Duration(seconds: 5)),
);
expect(content.title, 'Repos');
expect(content.primaryLine, '00:45 restant · Ensuite : Fentes');
});
test('prefixes paused content without ticking', () {
final content = buildSessionNotificationContent(
_projection(
phase: WatchSessionPhase.paused,
dominantTimer: _timer(
runState: WatchTimerRunState.paused,
accumulatedMs: 42000,
),
),
now: DateTime.utc(2026, 7, 26, 10),
);
expect(content.primaryLine, 'En pause · 00:42');
});
test('falls back to current step name without timer or score', () {
final content = buildSessionNotificationContent(
_projection(stepName: 'Gainage'),
);
expect(content.primaryLine, 'Gainage');
});
}
WatchSessionProjection _projection({
WatchSessionPhase phase = WatchSessionPhase.running,
String exerciseName = 'Pompes',
String? stepName,
WatchTimerProjection? dominantTimer,
bool hasManualScore = false,
double? currentManualScoreValue,
String? nextExerciseName,
}) {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 1,
projectedAtEpochMs: DateTime.utc(2026, 7, 26, 10).millisecondsSinceEpoch,
phase: phase,
phoneReachable: true,
seriesIndex: 2,
seriesTotal: 4,
exerciseName: exerciseName,
stepName: stepName,
dominantTimer: dominantTimer,
primaryAction: WatchPrimaryAction.pauseSession,
nextExerciseName: nextExerciseName,
hasManualScore: hasManualScore,
currentManualScoreValue: currentManualScoreValue,
canDecrementScore: (currentManualScoreValue ?? 0) > 0,
);
}
WatchTimerProjection _timer({
WatchTimerKind kind = WatchTimerKind.step,
String label = 'Chrono',
WatchTimerDisplayMode displayMode = WatchTimerDisplayMode.elapsed,
WatchTimerRunState runState = WatchTimerRunState.stopped,
int accumulatedMs = 0,
int? startedAtEpochMs,
int? targetMs,
}) {
return WatchTimerProjection(
kind: kind,
label: label,
displayMode: displayMode,
runState: runState,
referenceEpochMs: DateTime.utc(2026, 7, 26, 10).millisecondsSinceEpoch,
accumulatedMs: accumulatedMs,
startedAtEpochMs: startedAtEpochMs,
targetMs: targetMs,
);
}