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:
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
|
||||
198
lib/application/session_notification_use_cases.dart
Normal file
198
lib/application/session_notification_use_cases.dart
Normal 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
Reference in New Issue
Block a user