Files
coach-ios/ios/App/App/LiveStore.swift
Sylvain Bettinelli 21d39f2e98 Piloter la séance depuis la Live Activity (Pause / Terminer)
Comme l'app Exercice : deux boutons sur la bannière de l'écran verrouillé et
dans l'île dépliée. iOS n'expose aucun geste de balayage pour révéler des
actions — depuis iOS 17, ce sont des boutons intégrés (App Intents), et c'est
le seul mécanisme public.

- Intents `CoachTogglePauseIntent` / `CoachEndWorkoutIntent`, conformes à
  LiveActivityIntent (sans quoi `perform()` n'est jamais appelé).
- Ils vivent dans CoachLiveActivityAttributes.swift, seul fichier déjà membre
  des deux targets : un fichier neuf imposerait une manip Target Membership
  dans Xcode, source d'erreurs répétées ici.
- Commandes relayées à la montre par WCSession, avec repli transferUserInfo :
  isReachable retombe à false quand la séance tourne en arrière-plan profond,
  la commande est alors différée au réveil de l'app montre.
- L'état de pause remonte depuis HKWorkoutSession (seule source fiable : la
  montre peut mettre en pause d'elle-même) et bascule le libellé du bouton.
- Le watchdog de LiveStore est neutralisé pendant la pause : sans ça, l'absence
  de samples aurait affiché « connexion perdue » puis terminé l'activité.
- L'app watchOS gagne le bouton Pause qui lui manquait, aligné sur le même état.

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

192 lines
7.7 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 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 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 = 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,
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 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"
}
}
}