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"])
|
call.resolve(["presented": false, "reason": "iOS < 26"])
|
||||||
return
|
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
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let vc = self?.bridge?.viewController else {
|
guard let vc = self?.bridge?.viewController else {
|
||||||
call.resolve(["presented": false, "reason": "no host vc"])
|
call.resolve(["presented": false, "reason": "no host vc"])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Évite de re-présenter si une autre vue est déjà au-dessus.
|
|
||||||
if vc.presentedViewController != nil {
|
if vc.presentedViewController != nil {
|
||||||
call.resolve(["presented": false, "reason": "already presented"])
|
call.resolve(["presented": false, "reason": "already presented"])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let host = UIHostingController(rootView: CoachLiveView())
|
let host = UIHostingController(rootView: CoachLiveView(authToken: token))
|
||||||
host.modalPresentationStyle = .fullScreen
|
host.modalPresentationStyle = .fullScreen
|
||||||
host.view.backgroundColor = .black
|
host.view.backgroundColor = .black
|
||||||
vc.present(host, animated: true) {
|
vc.present(host, animated: true) {
|
||||||
|
|||||||
@@ -9,11 +9,57 @@
|
|||||||
|
|
||||||
import SwiftUI
|
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, *)
|
@available(iOS 26.0, *)
|
||||||
struct CoachLiveView: View {
|
struct CoachLiveView: View {
|
||||||
|
let authToken: String
|
||||||
@StateObject private var store = LiveStore.shared
|
@StateObject private var store = LiveStore.shared
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var pulse: Bool = false
|
@State private var pulse: Bool = false
|
||||||
|
@State private var zones: CardiacZones?
|
||||||
|
|
||||||
|
init(authToken: String = "") { self.authToken = authToken }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -27,12 +73,12 @@ struct CoachLiveView: View {
|
|||||||
LazyVGrid(columns: [GridItem(.flexible(), spacing: 14),
|
LazyVGrid(columns: [GridItem(.flexible(), spacing: 14),
|
||||||
GridItem(.flexible(), spacing: 14)],
|
GridItem(.flexible(), spacing: 14)],
|
||||||
spacing: 14) {
|
spacing: 14) {
|
||||||
MetricCard(label: "Fréquence",
|
// HR card : couleur dynamique selon zone cardio courante.
|
||||||
value: store.heartRateText,
|
HRMetricCard(value: store.heartRateText,
|
||||||
unit: "bpm",
|
hrInt: Int(store.heartRate),
|
||||||
color: .red,
|
zones: zones,
|
||||||
isPulsing: store.status == .live,
|
isPulsing: store.status == .live,
|
||||||
pulsePhase: pulse)
|
pulsePhase: pulse)
|
||||||
MetricCard(label: "Calories",
|
MetricCard(label: "Calories",
|
||||||
value: store.kcalText,
|
value: store.kcalText,
|
||||||
unit: "kcal",
|
unit: "kcal",
|
||||||
@@ -76,6 +122,27 @@ struct CoachLiveView: View {
|
|||||||
withAnimation(.easeInOut(duration: 0.55).repeatForever(autoreverses: true)) {
|
withAnimation(.easeInOut(duration: 0.55).repeatForever(autoreverses: true)) {
|
||||||
pulse = 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)
|
.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