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

@ -1,6 +1,7 @@
import '../infrastructure/local/local.dart';
import '../infrastructure/remote/remote.dart';
import '../infrastructure/security/security.dart';
import '../infrastructure/session_notification/session_notification.dart';
import '../infrastructure/watch_bridge/watch_bridge.dart';
import 'application.dart';
@ -12,6 +13,7 @@ abstract interface class AppDependencies {
WorkoutTemplateUseCases get workoutTemplateUseCases;
ActiveWorkoutSessionUseCases get activeWorkoutSessionUseCases;
ActiveExerciseStepUseCases get activeExerciseStepUseCases;
ActiveWorkoutSensorUseCases get activeWorkoutSensorUseCases;
CloseWorkoutSessionUseCase get closeWorkoutSessionUseCase;
WorkoutHistoryUseCases get workoutHistoryUseCases;
ProgressionStatsUseCase get progressionStatsUseCase;
@ -32,9 +34,11 @@ final class AppBootstrap implements AppDependencies {
required this.workoutTemplateUseCases,
required this.activeWorkoutSessionUseCases,
required this.activeExerciseStepUseCases,
required this.activeWorkoutSensorUseCases,
required this.watchCompanionProjectionUseCases,
required this.watchCompanionCommandHandler,
required this.watchWearDataLayerAdapter,
required this.sessionNotificationCoordinator,
required this.closeWorkoutSessionUseCase,
required this.workoutHistoryUseCases,
required this.progressionStatsUseCase,
@ -61,9 +65,12 @@ final class AppBootstrap implements AppDependencies {
final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases;
@override
final ActiveExerciseStepUseCases activeExerciseStepUseCases;
@override
final ActiveWorkoutSensorUseCases activeWorkoutSensorUseCases;
final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases;
final WatchCompanionCommandHandler watchCompanionCommandHandler;
final WatchWearDataLayerAdapter watchWearDataLayerAdapter;
final SessionNotificationCoordinator sessionNotificationCoordinator;
@override
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
@override
@ -122,6 +129,10 @@ final class AppBootstrap implements AppDependencies {
clock: clock,
ids: ids,
originDeviceId: originDeviceId,
activeSessionUseCases: activeWorkoutSessionUseCases,
);
final activeWorkoutSensorUseCases = ActiveWorkoutSensorUseCases(
clock: clock,
);
final watchCompanionProjectionUseCases = WatchCompanionProjectionUseCases(
sessionRepository: activeSessionRepository,
@ -135,12 +146,23 @@ final class AppBootstrap implements AppDependencies {
stepUseCases: activeExerciseStepUseCases,
projectionSource: watchCompanionProjectionUseCases,
);
final workoutHistoryUseCases = WorkoutHistoryUseCases(
repository: historyRepository,
clock: clock,
);
final watchWearDataLayerAdapter = WatchWearDataLayerAdapter(
nativeChannel: const MethodChannelWatchBridgeNativeChannel(),
commandIngress: watchCompanionCommandHandler,
projectionSource: watchCompanionProjectionUseCases,
workoutHistoryUseCases: workoutHistoryUseCases,
activeWorkoutSensorUseCases: activeWorkoutSensorUseCases,
);
final sessionNotificationCoordinator = SessionNotificationCoordinator(
projections: watchCompanionProjectionUseCases.projections,
gateway: const MethodChannelSessionNotificationGateway(),
);
await watchWearDataLayerAdapter.start();
sessionNotificationCoordinator.start();
await SeedStarterContentUseCase(
seedStateRepository: starterSeedRepository,
contentRepository: starterSeedRepository,
@ -198,9 +220,11 @@ final class AppBootstrap implements AppDependencies {
),
activeWorkoutSessionUseCases: activeWorkoutSessionUseCases,
activeExerciseStepUseCases: activeExerciseStepUseCases,
activeWorkoutSensorUseCases: activeWorkoutSensorUseCases,
watchCompanionProjectionUseCases: watchCompanionProjectionUseCases,
watchCompanionCommandHandler: watchCompanionCommandHandler,
watchWearDataLayerAdapter: watchWearDataLayerAdapter,
sessionNotificationCoordinator: sessionNotificationCoordinator,
closeWorkoutSessionUseCase: CloseWorkoutSessionUseCase(
sessionRepository: activeSessionRepository,
historyRepository: historyRepository,
@ -208,10 +232,7 @@ final class AppBootstrap implements AppDependencies {
ids: ids,
originDeviceId: originDeviceId,
),
workoutHistoryUseCases: WorkoutHistoryUseCases(
repository: historyRepository,
clock: clock,
),
workoutHistoryUseCases: workoutHistoryUseCases,
progressionStatsUseCase: ProgressionStatsUseCase(
repository: progressionStatsRepository,
clock: clock,
@ -255,8 +276,10 @@ final class AppBootstrap implements AppDependencies {
}
Future<void> dispose() async {
await sessionNotificationCoordinator.dispose();
await watchWearDataLayerAdapter.stop();
await watchCompanionProjectionUseCases.dispose();
await activeWorkoutSensorUseCases.dispose();
await database.close();
}
}

View File

@ -5,6 +5,7 @@
library;
export 'ports.dart';
export 'session_notification_use_cases.dart';
export 'starter_content/basket_starter_seed_v1.dart';
export 'starter_content/starter_content.dart';
export 'use_cases.dart';

View File

@ -485,6 +485,7 @@ enum RemoteAuthFailure {
invalidCredentials,
emailAlreadyUsed,
network,
server,
unknown,
}
@ -857,6 +858,7 @@ abstract interface class ActiveSessionRepository {
Future<void> saveSetTimerState(ActiveSetTimerState state);
Future<void> saveRestState(ActiveRestState restState);
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state);
Future<void> saveManualScoreState(ActiveManualScoreState state);
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,
);
@ -868,6 +870,13 @@ abstract interface class ActiveSessionRepository {
required int setIndex,
required DateTime deletedAt,
});
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
});
Future<ActiveRestState?> findRestStateById(String id);
Future<ActiveScoreStopwatchState?> findScoreStopwatchState({
required String sessionId,
@ -875,6 +884,12 @@ abstract interface class ActiveSessionRepository {
required int exerciseIndex,
required int setIndex,
});
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
});
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
required int programIndex,
@ -899,12 +914,19 @@ abstract interface class ActiveSessionRepository {
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
String sessionId,
);
Future<List<ActiveManualScoreState>> listManualScoreStates(String sessionId);
}
abstract interface class WorkoutHistoryRepository {
Future<WorkoutHistory?> findById(String id);
Future<List<WorkoutHistory>> listActive();
Future<void> save(WorkoutHistory history);
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
});
Future<void> saveSetResult(WorkoutHistorySetResult result);
Future<void> saveStepResult(WorkoutHistoryStepResult result);
Future<void> delete(String id, DateTime deletedAt);

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);
}

File diff suppressed because it is too large Load Diff