Files
coach-ios/ios/App/App/LiveStore.swift
Sylvain Bettinelli 4f8699fd37 Vitesse en km/h sur la montre et dans la Live Activity
Suite de la demande : voir des km/h en courant, ce que l'app Exercice
d'Apple ne permet pas (vitesse réservée au vélo, allure min/km imposée en
course, aucun réglage).

La vitesse instantanée vient de HealthKit lui-même : HKLiveWorkoutDataSource
collecte .runningSpeed d'office en course extérieure (Series 6+) et
.cyclingSpeed à vélo (watchOS 11+). Elle est lue en mostRecentQuantity —
c'est la vitesse à l'instant t qu'on veut afficher, pas la moyenne de la
séance. Sur un appareil qui ne la produit pas, repli sur distance/temps.

Affichée sur les trois surfaces : écran de séance de la montre, Live
Activity (bannière + île dépliée) et vue native /live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 13:31:13 +00:00

199 lines
7.9 KiB
Swift

// LiveStore.swift
// Bus interne main-actor pour les samples de séance live. Alimenté par
// CoachLiveBridge (WCSession) et consommé par la vue SwiftUI native
// CoachLiveView (Liquid Glass iOS 26). Le bridge continue aussi à publier
// vers le JS pour la page web /live (fallback / debug).
import Foundation
import Combine
@MainActor
final class LiveStore: ObservableObject {
static let shared = LiveStore()
enum Status: Equatable {
case waiting
case live
case lost
}
@Published var heartRate: Double = 0
@Published var activeEnergyKcal: Double = 0
@Published var distanceMeters: Double = 0
@Published var speedKmh: Double = 0
@Published var elapsedSec: TimeInterval = 0
@Published var activityType: String? = nil
@Published var status: Status = .waiting
@Published var lastSampleAt: Date? = nil
@Published var isPaused: Bool = false
private var watchdog: Timer?
private let lostThreshold: TimeInterval = 8
private let endAfterLost: TimeInterval = 60
private var lostSince: Date?
private init() {
watchdog = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in
guard let self else { return }
guard let ts = self.lastSampleAt else { return }
// En pause, HealthKit cesse de collecter : l'absence de sample
// est normale et ne doit ni afficher « connexion perdue », ni
// finir par terminer la Live Activity.
guard !self.isPaused else { return }
let sinceLast = Date().timeIntervalSince(ts)
if self.status == .live, sinceLast > self.lostThreshold {
// Transition live -> lost : signale à la Live Activity
// que la connexion est perdue (heartbeat off, statut orange).
self.status = .lost
self.lostSince = Date()
if #available(iOS 16.2, *) {
LiveActivityManager.shared.update(state: self.currentState(isLive: false))
}
} else if self.status == .lost,
let lostAt = self.lostSince,
Date().timeIntervalSince(lostAt) > self.endAfterLost {
// Resté .lost trop longtemps : on termine l'activité.
if #available(iOS 16.2, *) {
LiveActivityManager.shared.end()
}
self.lostSince = nil
}
}
}
}
/// Appelé depuis CoachLiveBridge à chaque sample reçu (WCSession).
/// nonisolated : peut être appelé depuis n'importe quel thread (WCSession
/// callbacks viennent hors main).
nonisolated func ingest(_ sample: [String: Any]) {
let hr = sample["heartRate"] as? Double
let kcal = sample["activeEnergyKcal"] as? Double
let dist = sample["distanceMeters"] as? Double
let spd = sample["speedKmh"] as? Double
let elap = sample["elapsedSec"] as? Double
let act = sample["activityType"] as? String
Task { @MainActor [weak self] in
guard let self else { return }
let wasNotLive = self.status != .live
if let v = hr { self.heartRate = v }
if let v = kcal { self.activeEnergyKcal = v }
if let v = dist { self.distanceMeters = v }
if let v = spd { self.speedKmh = v }
if let v = elap { self.elapsedSec = v }
if let v = act { self.activityType = v }
self.lastSampleAt = Date()
self.status = .live
self.lostSince = nil
// Hook Live Activity / Dynamic Island (iOS 16.1+ uniquement,
// l'app cible iOS 15 mais ActivityKit n'existe qu'à partir de 16.1)
if #available(iOS 16.2, *) {
let state = self.currentState(isLive: true)
if wasNotLive {
LiveActivityManager.shared.start(
label: self.activityLabel ?? "Séance",
symbol: self.activitySymbol,
state: state
)
} else {
LiveActivityManager.shared.update(state: state)
}
}
}
}
/// Pause / reprise annoncée par la montre. Met à jour la Live Activity pour
/// que son bouton bascule (Pause Reprendre).
/// nonisolated : appelé depuis un callback WCSession, hors main.
nonisolated func setPaused(_ paused: Bool) {
Task { @MainActor [weak self] in
guard let self else { return }
self.isPaused = paused
if #available(iOS 16.2, *) {
LiveActivityManager.shared.update(state: self.currentState(isLive: self.status == .live))
}
}
}
/// Fin de séance annoncée par la montre (`liveEnded`). Termine la Live
/// Activity tout de suite, sans attendre le watchdog celui-ci repose sur
/// un Timer du main runloop, qui ne tourne pas quand l'app iPhone est
/// suspendue en arrière-plan (le cas normal pendant une séance).
/// nonisolated : appelé depuis un callback WCSession, hors main.
nonisolated func endLive() {
Task { @MainActor [weak self] in
guard let self else { return }
self.status = .waiting
self.lostSince = nil
self.lastSampleAt = nil
self.isPaused = false
if #available(iOS 16.2, *) {
LiveActivityManager.shared.end()
}
}
}
@available(iOS 16.2, *)
private func currentState(isLive: Bool) -> CoachLiveActivityAttributes.ContentState {
CoachLiveActivityAttributes.ContentState(
heartRate: heartRate,
activeEnergyKcal: activeEnergyKcal,
distanceMeters: distanceMeters,
speedKmh: speedKmh,
elapsedSec: elapsedSec,
isLive: isLive,
isPaused: isPaused
)
}
// MARK: - Formatted accessors (utilisés par CoachLiveView)
var heartRateText: String { heartRate > 0 ? String(Int(heartRate.rounded())) : "" }
var kcalText: String { activeEnergyKcal > 0 ? String(Int(activeEnergyKcal.rounded())) : "" }
var distanceKmText: String {
distanceMeters > 0 ? String(format: "%.2f", distanceMeters / 1000) : ""
}
var speedKmhText: String {
speedKmh > 0 ? String(format: "%.1f", speedKmh) : ""
}
var paceText: String {
guard distanceMeters > 50, elapsedSec > 10 else { return "" }
let minPerKm = (elapsedSec / 60) / (distanceMeters / 1000)
guard minPerKm > 2, minPerKm < 30 else { return "" }
let m = Int(minPerKm)
let s = Int(((minPerKm - Double(m)) * 60).rounded())
return "\(m):\(String(format: "%02d", s))"
}
var elapsedText: String {
let sec = Int(elapsedSec)
let h = sec / 3600
let m = (sec % 3600) / 60
let s = sec % 60
if h > 0 { return "\(h)h\(String(format: "%02d", m))" }
return "\(m):\(String(format: "%02d", s))"
}
var activityLabel: String? {
switch activityType {
case "running": return "Course"
case "cycling": return "Vélo"
case "walking": return "Marche"
case "hiking": return "Rando"
case "strength": return "Renfo"
case "mobility": return "Mobilité"
default: return nil
}
}
/// SF Symbol pour l'activité (utilisé par la Live Activity).
var activitySymbol: String {
switch activityType {
case "cycling": return "bicycle"
case "walking": return "figure.walk"
case "hiking": return "figure.walk"
case "strength": return "dumbbell.fill"
case "mobility": return "figure.yoga"
default: return "figure.run"
}
}
}