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:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user