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:
@@ -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) {
|
||||
|
||||
@@ -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,12 +73,12 @@ 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,
|
||||
isPulsing: store.status == .live,
|
||||
pulsePhase: pulse)
|
||||
// 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",
|
||||
value: store.kcalText,
|
||||
unit: "kcal",
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user