Files
coach-ios/ios/App/App/CoachLiveView.swift
Sylvain Bettinelli 4f8699fd37 Vitesse en km/h sur la montre et dans la Live Activity
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>
2026-08-11 13:31:13 +00:00

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)
}
}