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,198 @@
import 'dart:async';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
abstract interface class SessionNotificationGateway {
Future<void> show(SessionNotificationContent content);
Future<void> clear();
}
final class SessionNotificationContent {
const SessionNotificationContent({
required this.title,
required this.primaryLine,
this.secondaryLine,
});
final String title;
final String primaryLine;
final String? secondaryLine;
Map<String, Object?> toJson() {
return {
'title': title,
'primaryLine': primaryLine,
'secondaryLine': secondaryLine,
};
}
}
final class SessionNotificationCoordinator {
SessionNotificationCoordinator({
required Stream<WatchSessionProjection> projections,
required SessionNotificationGateway gateway,
Duration tickInterval = const Duration(seconds: 1),
}) : _projections = projections,
_gateway = gateway,
_tickInterval = tickInterval;
final Stream<WatchSessionProjection> _projections;
final SessionNotificationGateway _gateway;
final Duration _tickInterval;
StreamSubscription<WatchSessionProjection>? _subscription;
Timer? _timer;
WatchSessionProjection? _latestProjection;
void start() {
if (_subscription != null) {
return;
}
_subscription = _projections.listen(_handleProjection);
}
Future<void> dispose() async {
_timer?.cancel();
_timer = null;
await _subscription?.cancel();
_subscription = null;
}
void _handleProjection(WatchSessionProjection projection) {
_latestProjection = projection;
if (projection.phase == WatchSessionPhase.noActiveSession ||
projection.deviceSessionId.isEmpty) {
_timer?.cancel();
_timer = null;
unawaited(_gateway.clear().catchError((_) {}));
return;
}
_show(projection);
if (_timerShouldRun(projection)) {
_timer ??= Timer.periodic(_tickInterval, (_) {
final latest = _latestProjection;
if (latest != null) {
_show(latest);
}
});
} else {
_timer?.cancel();
_timer = null;
}
}
void _show(WatchSessionProjection projection) {
unawaited(
_gateway
.show(buildSessionNotificationContent(projection))
.catchError((_) {}),
);
}
}
SessionNotificationContent buildSessionNotificationContent(
WatchSessionProjection projection, {
DateTime? now,
}) {
final phase = projection.phase;
final paused =
phase == WatchSessionPhase.paused ||
phase == WatchSessionPhase.restPaused;
final timer = projection.dominantTimer;
final title =
phase == WatchSessionPhase.restRunning ||
phase == WatchSessionPhase.restPaused
? 'Repos'
: projection.exerciseName.isEmpty
? 'Séance en cours'
: projection.exerciseName;
final primary = switch (phase) {
WatchSessionPhase.restRunning ||
WatchSessionPhase.restPaused => _restLine(projection, now: now),
_ when timer != null => _timerText(timer, now: now),
_ => _measureLine(projection),
};
return SessionNotificationContent(
title: title,
primaryLine: paused ? 'En pause · $primary' : primary,
secondaryLine: _secondaryLine(projection),
);
}
bool _timerShouldRun(WatchSessionProjection projection) {
final timer = projection.dominantTimer;
return timer != null && timer.runState == WatchTimerRunState.running;
}
String _restLine(WatchSessionProjection projection, {DateTime? now}) {
final timer = projection.dominantTimer;
final value = timer == null ? '--:--' : _timerText(timer, now: now);
final next = projection.nextExerciseName;
if (next == null || next.isEmpty) {
return '$value restant';
}
return '$value restant · Ensuite : $next';
}
String _measureLine(WatchSessionProjection projection) {
final score = projection.currentManualScoreValue;
if (projection.hasManualScore && score != null) {
return 'Série ${projection.seriesIndex}/${projection.seriesTotal} · ${_scoreText(score)}';
}
if (projection.stepName case final stepName? when stepName.isNotEmpty) {
return stepName;
}
if (projection.seriesIndex > 0 && projection.seriesTotal > 0) {
return 'Série ${projection.seriesIndex}/${projection.seriesTotal}';
}
return projection.statusLabel ?? 'Séance en cours';
}
String? _secondaryLine(WatchSessionProjection projection) {
final parts = <String>[];
if (projection.seriesIndex > 0 && projection.seriesTotal > 0) {
parts.add('Série ${projection.seriesIndex}/${projection.seriesTotal}');
}
if (projection.stepIndex != null && projection.stepTotal != null) {
parts.add('Étape ${projection.stepIndex}/${projection.stepTotal}');
}
return parts.isEmpty ? null : parts.join(' · ');
}
String _timerText(WatchTimerProjection timer, {DateTime? now}) {
final duration = _displayDuration(timer, now: now);
final totalSeconds = duration.inSeconds;
final minutes = (totalSeconds ~/ 60).toString().padLeft(2, '0');
final seconds = (totalSeconds % 60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
Duration _displayDuration(WatchTimerProjection timer, {DateTime? now}) {
final elapsed = _elapsedMs(timer, now: now);
if (timer.displayMode == WatchTimerDisplayMode.countdown &&
timer.targetMs != null) {
return Duration(
milliseconds: (timer.targetMs! - elapsed).clamp(0, 1 << 31).toInt(),
);
}
return Duration(milliseconds: elapsed);
}
int _elapsedMs(WatchTimerProjection timer, {DateTime? now}) {
if (timer.runState != WatchTimerRunState.running ||
timer.startedAtEpochMs == null) {
return timer.accumulatedMs;
}
final reference =
now?.toUtc().millisecondsSinceEpoch ??
DateTime.now().toUtc().millisecondsSinceEpoch;
return timer.accumulatedMs +
(reference - timer.startedAtEpochMs!).clamp(0, 1 << 31).toInt();
}
String _scoreText(double value) {
if (value == value.roundToDouble()) {
return value.toInt().toString();
}
return value.toStringAsFixed(1);
}