feat(live-native): cardiac-aware zones temps réel dans CoachLiveView

CoachLiveBridge.openNativeLive lit maintenant le token JS et le passe à
CoachLiveView(authToken:). La vue fetch /api/cardiac-zones au .onAppear
+ stocke en @State.

CardiacZones struct (Decodable) calque la payload du backend :
- méthode (karvonen / fcmax_pct), beta-bloquant, fcmax/fcrest observées, HRR
- Z1-Z5 lo/hi + clinical_target_bpm (HRKarv0.6 sous bb, Díaz-Buschmann 2014)
- zoneFor(hr:) -> ZoneMatch {id, lo, hi, color}

HRMetricCard (variante de MetricCard pour FC) :
- Chiffre + glow change de couleur dynamiquement selon zone courante
  (cyan Z1 récup -> rouge Z5 VO2max). Animation .easeOut 0.25s.
- Pill "Z3 · 128-145" sous l'unité bpm.
- "✓ Utile" si beta-bloqué + HR ≥ clinical_target_bpm.

Méthode 100% alignée Apple Watch Workout app (Karvonen HRR, recalc mensuel
max/rest, 5 zones) + ACSM. Aucune extrapolation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain
2026-05-28 11:39:16 +02:00
parent 98705981ba
commit a41a1b1aa9
2 changed files with 138 additions and 8 deletions

View File

@@ -70,17 +70,19 @@ public class CoachLiveBridgePlugin: CAPPlugin, CAPBridgedPlugin {
call.resolve(["presented": false, "reason": "iOS < 26"])
return
}
// Token passé par le JS pour que CoachLiveView puisse fetch
// /api/cardiac-zones (auth backend = token query param accepté).
let token = call.getString("token") ?? ""
DispatchQueue.main.async { [weak self] in
guard let vc = self?.bridge?.viewController else {
call.resolve(["presented": false, "reason": "no host vc"])
return
}
// Évite de re-présenter si une autre vue est déjà au-dessus.
if vc.presentedViewController != nil {
call.resolve(["presented": false, "reason": "already presented"])
return
}
let host = UIHostingController(rootView: CoachLiveView())
let host = UIHostingController(rootView: CoachLiveView(authToken: token))
host.modalPresentationStyle = .fullScreen
host.view.backgroundColor = .black
vc.present(host, animated: true) {

View File

@@ -9,11 +9,57 @@
import SwiftUI
// Cardiac-aware zones (Karvonen HRR si beta-bloquant, %FCmax sinon).
// Méthode alignée Apple Watch Workout app (apd897dccddf) + ACSM Guidelines.
// Fetché depuis /api/cardiac-zones au .onAppear de CoachLiveView.
struct CardiacZones: Decodable {
let method: String
let hasBetablocker: Bool
let fcmaxUsed: Int
let fcrestUsed: Int?
let hrr: Int?
let clinicalTargetBpm: Int?
let z1: [Int]
let z2: [Int]
let z3: [Int]
let z4: [Int]
let z5: [Int]
enum CodingKeys: String, CodingKey {
case method
case hasBetablocker = "has_betablocker"
case fcmaxUsed = "fcmax_used"
case fcrestUsed = "fcrest_used"
case hrr
case clinicalTargetBpm = "clinical_target_bpm"
case z1 = "Z1", z2 = "Z2", z3 = "Z3", z4 = "Z4", z5 = "Z5"
}
struct ZoneMatch { let id: String; let lo: Int; let hi: Int; let color: Color }
func zoneFor(hr: Int) -> ZoneMatch? {
guard hr > 0 else { return nil }
// Cherche Z5 Z1 (le premier match en descendant).
let table: [(String, [Int], Color)] = [
("Z5", z5, .red), ("Z4", z4, .orange),
("Z3", z3, .yellow), ("Z2", z2, .green), ("Z1", z1, .cyan),
]
for (id, range, color) in table where range.count == 2 && hr >= range[0] {
return ZoneMatch(id: id, lo: range[0], hi: range[1], color: color)
}
return nil
}
}
@available(iOS 26.0, *)
struct CoachLiveView: View {
let authToken: String
@StateObject private var store = LiveStore.shared
@Environment(\.dismiss) private var dismiss
@State private var pulse: Bool = false
@State private var zones: CardiacZones?
init(authToken: String = "") { self.authToken = authToken }
var body: some View {
ZStack {
@@ -27,10 +73,10 @@ struct CoachLiveView: View {
LazyVGrid(columns: [GridItem(.flexible(), spacing: 14),
GridItem(.flexible(), spacing: 14)],
spacing: 14) {
MetricCard(label: "Fréquence",
value: store.heartRateText,
unit: "bpm",
color: .red,
// HR card : couleur dynamique selon zone cardio courante.
HRMetricCard(value: store.heartRateText,
hrInt: Int(store.heartRate),
zones: zones,
isPulsing: store.status == .live,
pulsePhase: pulse)
MetricCard(label: "Calories",
@@ -76,6 +122,27 @@ struct CoachLiveView: View {
withAnimation(.easeInOut(duration: 0.55).repeatForever(autoreverses: true)) {
pulse = true
}
fetchZones()
}
}
// Fetch /api/cardiac-zones (token query param requis pour _check_auth)
private func fetchZones() {
guard zones == nil else { return }
var comps = URLComponents(string: "https://coach.hypnotruck.ch/api/cardiac-zones")
if !authToken.isEmpty {
comps?.queryItems = [URLQueryItem(name: "token", value: authToken)]
}
guard let url = comps?.url else { return }
Task {
do {
let (data, resp) = try await URLSession.shared.data(from: url)
guard let http = resp as? HTTPURLResponse, http.statusCode == 200 else { return }
let parsed = try JSONDecoder().decode(CardiacZones.self, from: data)
await MainActor.run { self.zones = parsed }
} catch {
// Silencieux : si fetch échoue, la HR card retombe sur couleur rouge fixe (fallback OK).
}
}
}
@@ -177,3 +244,64 @@ private struct MetricCard: View {
.scaleEffect(isPulsing && pulsePhase ? 1.025 : 1.0)
}
}
// HRMetricCard : variante spécialisée FC avec zone cardio dynamique
// (couleur + pill Z1-Z5 + indicateur "zone utile" si beta-bloquant et
// HR clinical_target_bpm = HRKarv0.6 per Díaz-Buschmann 2014 ESC).
@available(iOS 26.0, *)
private struct HRMetricCard: View {
let value: String
let hrInt: Int
let zones: CardiacZones?
let isPulsing: Bool
let pulsePhase: Bool
private var match: CardiacZones.ZoneMatch? { zones?.zoneFor(hr: hrInt) }
private var color: Color { match?.color ?? .red }
private var isUsefulZone: Bool {
guard let target = zones?.clinicalTargetBpm else { return false }
return hrInt >= target
}
var body: some View {
VStack(spacing: 6) {
Text("FRÉQUENCE")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.tracking(0.6)
Text(value)
.font(.system(size: 48, weight: .heavy, design: .rounded).monospacedDigit())
.foregroundStyle(color)
.shadow(color: color.opacity(0.55), radius: 18, x: 0, y: 0)
.contentTransition(.numericText())
.animation(.easeOut(duration: 0.25), value: color)
Text("bpm")
.font(.caption2)
.foregroundStyle(.tertiary)
if let m = match {
HStack(spacing: 6) {
Text("\(m.id) · \(m.lo)-\(m.hi)")
.font(.caption2.weight(.bold))
.tracking(0.5)
.foregroundStyle(m.color)
.padding(.horizontal, 8).padding(.vertical, 3)
.background(m.color.opacity(0.22),
in: Capsule())
if isUsefulZone {
Text("✓ Utile")
.font(.caption2.weight(.bold))
.foregroundStyle(.green)
}
}
.padding(.top, 2)
}
}
.frame(maxWidth: .infinity, minHeight: 130)
.padding(.vertical, 14)
.glassEffect(.regular.tint(color.opacity(0.18)),
in: RoundedRectangle(cornerRadius: 22, style: .continuous))
.scaleEffect(isPulsing && pulsePhase ? 1.025 : 1.0)
.animation(.easeOut(duration: 0.25), value: color)
}
}