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>
199 lines
7.9 KiB
Swift
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"
|
|
}
|
|
}
|
|
}
|