diff --git a/ios/App/App/CoachLiveBridge.swift b/ios/App/App/CoachLiveBridge.swift index f74eb35..d894bae 100644 --- a/ios/App/App/CoachLiveBridge.swift +++ b/ios/App/App/CoachLiveBridge.swift @@ -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) { diff --git a/ios/App/App/CoachLiveView.swift b/ios/App/App/CoachLiveView.swift index d739750..52ff7dd 100644 --- a/ios/App/App/CoachLiveView.swift +++ b/ios/App/App/CoachLiveView.swift @@ -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) + } +}