import 'dart:async'; import 'package:watch_bridge_contract/watch_bridge_contract.dart'; abstract interface class SessionNotificationGateway { Future show(SessionNotificationContent content); Future clear(); } final class SessionNotificationContent { const SessionNotificationContent({ required this.title, required this.primaryLine, this.secondaryLine, }); final String title; final String primaryLine; final String? secondaryLine; Map toJson() { return { 'title': title, 'primaryLine': primaryLine, 'secondaryLine': secondaryLine, }; } } final class SessionNotificationCoordinator { SessionNotificationCoordinator({ required Stream projections, required SessionNotificationGateway gateway, Duration tickInterval = const Duration(seconds: 1), }) : _projections = projections, _gateway = gateway, _tickInterval = tickInterval; final Stream _projections; final SessionNotificationGateway _gateway; final Duration _tickInterval; StreamSubscription? _subscription; Timer? _timer; WatchSessionProjection? _latestProjection; void start() { if (_subscription != null) { return; } _subscription = _projections.listen(_handleProjection); } Future 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 = []; 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); }