Suite de la demande : voir des km/h en courant, ce que l'app Exercice d'Apple ne permet pas (vitesse réservée au vélo, allure min/km imposée en course, aucun réglage). La vitesse instantanée vient de HealthKit lui-même : HKLiveWorkoutDataSource collecte .runningSpeed d'office en course extérieure (Series 6+) et .cyclingSpeed à vélo (watchOS 11+). Elle est lue en mostRecentQuantity — c'est la vitesse à l'instant t qu'on veut afficher, pas la moyenne de la séance. Sur un appareil qui ne la produit pas, repli sur distance/temps. Affichée sur les trois surfaces : écran de séance de la montre, Live Activity (bannière + île dépliée) et vue native /live. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
316 lines
12 KiB
Swift
316 lines
12 KiB
Swift
// CoachLiveView.swift
|
|
// Vue SwiftUI native pour le live workout, avec le VRAI Liquid Glass d'iOS 26
|
|
// (.glassEffect / GlassEffectContainer). Présentée en sheet par-dessus la
|
|
// WebView quand /live se charge (cf. CoachLiveBridge.openNativeLive). Source
|
|
// de données : LiveStore.shared (alimenté par CoachLiveBridge via WCSession).
|
|
//
|
|
// Fallback : si iOS < 26, openNativeLive ne présente rien et la WebView affiche
|
|
// /live en CSS glassmorphism (cf. live_workout.html).
|
|
|
|
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 {
|
|
auroraBackground.ignoresSafeArea()
|
|
|
|
VStack(spacing: 18) {
|
|
header
|
|
statusRow
|
|
|
|
GlassEffectContainer(spacing: 14) {
|
|
LazyVGrid(columns: [GridItem(.flexible(), spacing: 14),
|
|
GridItem(.flexible(), spacing: 14)],
|
|
spacing: 14) {
|
|
// 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",
|
|
color: .green,
|
|
isPulsing: false,
|
|
pulsePhase: pulse)
|
|
MetricCard(label: "Distance",
|
|
value: store.distanceKmText,
|
|
unit: "km",
|
|
color: .orange,
|
|
isPulsing: false,
|
|
pulsePhase: pulse)
|
|
MetricCard(label: "Allure",
|
|
value: store.paceText,
|
|
unit: "min/km",
|
|
color: .purple,
|
|
isPulsing: false,
|
|
pulsePhase: pulse)
|
|
// L'allure reste (référence en course), la vitesse
|
|
// s'ajoute : Apple ne donne jamais de km/h à pied.
|
|
MetricCard(label: "Vitesse",
|
|
value: store.speedKmhText,
|
|
unit: "km/h",
|
|
color: .cyan,
|
|
isPulsing: false,
|
|
pulsePhase: pulse)
|
|
}
|
|
}
|
|
|
|
if let activity = store.activityLabel {
|
|
Label(activity, systemImage: "figure.run")
|
|
.font(.callout.weight(.medium))
|
|
.padding(.horizontal, 16).padding(.vertical, 8)
|
|
.glassEffect(.regular, in: .capsule)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
Text(store.elapsedText)
|
|
.font(.system(size: 56, weight: .heavy, design: .rounded).monospacedDigit())
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 36).padding(.vertical, 14)
|
|
.glassEffect(.regular.tint(.white.opacity(0.10)), in: .capsule)
|
|
}
|
|
.padding(20)
|
|
}
|
|
.preferredColorScheme(.dark)
|
|
.onAppear {
|
|
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).
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Sub-views
|
|
|
|
private var header: some View {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Live workout").font(.largeTitle.bold())
|
|
Text("Apple Watch → iPhone")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Button { dismiss() } label: {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.frame(width: 36, height: 36)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.glassEffect(.regular.interactive(), in: .circle)
|
|
}
|
|
}
|
|
|
|
private var statusRow: some View {
|
|
HStack(spacing: 10) {
|
|
Circle()
|
|
.fill(statusColor)
|
|
.frame(width: 10, height: 10)
|
|
.scaleEffect(store.status == .live && pulse ? 1.45 : 1.0)
|
|
Text(statusText).font(.callout)
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 14).padding(.vertical, 10)
|
|
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
}
|
|
|
|
private var auroraBackground: some View {
|
|
ZStack {
|
|
Color.black
|
|
RadialGradient(colors: [Color.red.opacity(0.55), .clear],
|
|
center: .init(x: 0.18, y: 0.12),
|
|
startRadius: 5, endRadius: 480)
|
|
RadialGradient(colors: [Color.orange.opacity(0.45), .clear],
|
|
center: .init(x: 0.88, y: 0.88),
|
|
startRadius: 5, endRadius: 520)
|
|
RadialGradient(colors: [Color.green.opacity(0.30), .clear],
|
|
center: .init(x: 0.65, y: 0.42),
|
|
startRadius: 5, endRadius: 420)
|
|
}
|
|
.blur(radius: 24)
|
|
}
|
|
|
|
private var statusColor: Color {
|
|
switch store.status {
|
|
case .waiting: return .orange
|
|
case .live: return .green
|
|
case .lost: return .red
|
|
}
|
|
}
|
|
private var statusText: String {
|
|
switch store.status {
|
|
case .waiting: return "En attente de la Watch…"
|
|
case .live: return "En direct"
|
|
case .lost: return "Connexion perdue — Bluetooth ?"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - MetricCard (extrait pour clarté + scaleEffect isolé)
|
|
|
|
@available(iOS 26.0, *)
|
|
private struct MetricCard: View {
|
|
let label: String
|
|
let value: String
|
|
let unit: String
|
|
let color: Color
|
|
let isPulsing: Bool
|
|
let pulsePhase: Bool
|
|
|
|
var body: some View {
|
|
VStack(spacing: 6) {
|
|
Text(label.uppercased())
|
|
.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())
|
|
Text(unit)
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.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)
|
|
}
|
|
}
|
|
|
|
// ─── 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)
|
|
}
|
|
}
|