// VmaTestView.swift // Test de VMA guidé au poignet, cible CoachWatch. // // Pourquoi au poignet plutôt que sur le téléphone : pendant un test, le // téléphone est dans une poche. La montre est le seul écran consultable en // courant, et c'est elle qui porte déjà la vitesse, la distance et la FC via // WorkoutManager (HKLiveWorkoutBuilder). // // ⚠️ Aucun calcul de VMA ici : la vue mesure (paliers tenus, distance) et // envoie ces mesures à l'iPhone, qui les poste à /api/vma. Le calcul vit dans // web/vma.py, en un seul endroit — deux implémentations d'une même formule // finissent toujours par diverger. // // ⚠️ Sous bêtabloquant, la FC ne sert PAS à mesurer : elle est affichée pour // que l'utilisateur voie son plafond arriver, et transmise pour que le serveur // signale un test interrompu par le cœur plutôt que par les jambes. import SwiftUI import HealthKit import WatchKit struct VmaTestView: View { enum Protocole: String, CaseIterable { case stages = "Paliers" case sixMin = "6 min course" // Test de marche 6 minutes : sous-maximal, référence en cardiologie. // Le seul des trois qui ne demande aucun effort maximal. case walk6 = "6 min marche" } @StateObject private var workout = WorkoutManager.shared @Environment(\.dismiss) private var dismiss // Paramètres du protocole à paliers. Départ volontairement bas : la marche // rapide est à 6 km/h, un départ à 8 laisse une vraie marge de montée. private let startKmh = 8.0 private let stepKmh = 0.5 private let stageSeconds = 60.0 @State private var proto: Protocole = .stages @State private var started = false @State private var finished = false @State private var stageIndex = 0 // 0 = premier palier en cours @State private var maxHr = 0.0 @State private var sent = false @State private var sendError: String? private let tick = Timer.publish(every: 0.2, on: .main, in: .common).autoconnect() private var targetKmh: Double { startKmh + stepKmh * Double(stageIndex) } private var completedStages: Int { stageIndex } // paliers TERMINÉS private var sixMinRemaining: Double { max(0, 360 - workout.elapsedSec) } /// Avancement dans le palier courant, dérivé de la durée de séance et NON /// d'un compteur accumulé au fil du Timer. /// /// ⚠️ Le Timer de la vue ne tourne pas quand l'écran s'éteint — et il /// s'éteint dès que le poignet retombe, c'est-à-dire pendant l'essentiel du /// test. Un décompte accumulé dériverait donc de plusieurs secondes par /// palier. `elapsedSec` vient de HKLiveWorkoutBuilder, qui continue de /// courir en arrière-plan : c'est la seule horloge fiable ici. private var stageElapsed: Double { workout.elapsedSec.truncatingRemainder(dividingBy: stageSeconds) } var body: some View { Group { if finished { resultView } else if started { runningView } else { setupView } } .onReceive(tick) { _ in onTick() } .navigationTitle("Test VMA") } // MARK: - Avant le départ private var setupView: some View { ScrollView { VStack(alignment: .leading, spacing: 10) { Picker("Protocole", selection: $proto) { ForEach(Protocole.allCases, id: \.self) { Text($0.rawValue).tag($0) } } .labelsHidden() .frame(height: 60) Text(protocolHelp) .font(.caption2) .foregroundStyle(.secondary) Button("Démarrer") { start() } .buttonStyle(.borderedProminent) .tint(.orange) } .padding(.horizontal, 4) } } private var protocolHelp: String { switch proto { case .stages: return "Paliers d'une minute, de 8 km/h par pas de 0,5. La montre vibre à chaque changement. Arrête-toi quand tu ne tiens plus l'allure." case .sixMin: return "Six minutes à allure maximale régulière, sur terrain plat. La distance parcourue donne la VMA." case .walk6: return "Marche six minutes à ton pas le plus rapide, sans courir. Sous-maximal : c'est le test de référence en cardiologie." } } // MARK: - Pendant le test private var runningView: some View { VStack(spacing: 6) { if proto == .stages { Text("Palier \(stageIndex + 1)") .font(.caption2).foregroundStyle(.secondary) Text(String(format: "%.1f", targetKmh)) .font(.system(size: 44, weight: .semibold, design: .rounded)) .monospacedDigit() Text("km/h visés").font(.caption2).foregroundStyle(.secondary) ProgressView(value: min(1, stageElapsed / stageSeconds)) .tint(.orange) // Écart à la cible : c'est la seule information qui aide à // corriger en courant. La vitesse brute demanderait un calcul // mental à chaque foulée. Text(paceHint) .font(.caption) .foregroundStyle(paceOnTarget ? .green : .orange) } else { Text(timeString(sixMinRemaining)) .font(.system(size: 44, weight: .semibold, design: .rounded)) .monospacedDigit() Text("restant").font(.caption2).foregroundStyle(.secondary) Text(String(format: "%.0f m", workout.distanceMeters)) .font(.title3).monospacedDigit() } HStack(spacing: 10) { Label(String(format: "%.0f", workout.heartRate), systemImage: "heart.fill") .foregroundStyle(.red) Label(String(format: "%.1f", workout.speedKmh), systemImage: "speedometer") } .font(.caption2) .monospacedDigit() Button("J'arrête") { finish() } .buttonStyle(.bordered) .tint(.red) } .padding(.horizontal, 4) } private var paceOnTarget: Bool { abs(workout.speedKmh - targetKmh) <= 0.4 } private var paceHint: String { // ⚠️ La vitesse GPS est erratique les premières secondes d'acquisition : // pas d'indication tant que la montre n'a rien de crédible. guard workout.speedKmh > 1 else { return "acquisition…" } let d = workout.speedKmh - targetKmh if abs(d) <= 0.4 { return "allure tenue" } return d < 0 ? String(format: "accélère (%.1f)", -d) : String(format: "ralentis (%.1f)", d) } // MARK: - Après private var resultView: some View { ScrollView { VStack(spacing: 8) { if proto == .stages { Text("\(completedStages) paliers") .font(.title3) Text(completedStages > 0 ? String(format: "dernier tenu : %.1f km/h", startKmh + stepKmh * Double(completedStages - 1)) : "aucun palier terminé") .font(.caption2).foregroundStyle(.secondary) } else { Text(String(format: "%.0f m", workout.distanceMeters)).font(.title3) Text(proto == .walk6 ? "en 6 minutes de marche" : "en 6 minutes") .font(.caption2).foregroundStyle(.secondary) } if maxHr > 0 { Text(String(format: "FC max %.0f bpm", maxHr)) .font(.caption2).foregroundStyle(.secondary) } if sent { Label("Envoyé au téléphone", systemImage: "checkmark.circle.fill") .font(.caption2).foregroundStyle(.green) } else if let e = sendError { // Échec d'envoi ≠ test perdu : les chiffres restent lisibles // à l'écran, l'utilisateur peut les saisir dans l'app. Text(e).font(.caption2).foregroundStyle(.orange) Button("Réessayer") { send() }.buttonStyle(.bordered) } Button("Terminer") { dismiss() }.buttonStyle(.bordered) } .padding(.horizontal, 4) } } // MARK: - Machine à états private func start() { stageIndex = 0 maxHr = 0 sent = false sendError = nil finished = false started = true // startWorkout est async (@MainActor) : même appel que ContentView. // Marche et course sont deux types HealthKit distincts : la distance // et la vitesse ne sont pas collectées sur le même quantity type. Task { await workout.startWorkout(activityType: proto == .walk6 ? .walking : .running, locationType: .outdoor) } WKInterfaceDevice.current().play(.start) } private func onTick() { guard started, !finished else { return } if workout.heartRate > maxHr { maxHr = workout.heartRate } if proto == .stages { let reached = Int(workout.elapsedSec / stageSeconds) if reached > stageIndex { stageIndex = reached // le palier vient d'être TERMINÉ WKInterfaceDevice.current().play(.notification) } } else if workout.elapsedSec >= 360 { finish() } } private func finish() { guard !finished else { return } finished = true started = false workout.end() WKInterfaceDevice.current().play(.success) send() } private func send() { sendError = nil var payload: [String: Any] = [ "vmaTest": true, "protocol": protocolKey, "maxHr": Int(maxHr.rounded()), ] if proto == .stages { payload["startKmh"] = startKmh payload["stepKmh"] = stepKmh payload["completed"] = completedStages // Fraction du palier en cours au moment de l'arrêt : le serveur // n'en crédite que la moitié, et seulement au-delà de 50 %. payload["partialRatio"] = min(1.0, stageElapsed / stageSeconds) } else { payload["distanceM"] = Int(workout.distanceMeters.rounded()) } if ConnectivityManager.shared.sendVmaResult(payload) { sent = true } else { sendError = "iPhone injoignable — note les chiffres." } } private var protocolKey: String { switch proto { case .stages: return "stages" case .sixMin: return "cooper6" case .walk6: return "walk6" } } private func timeString(_ s: Double) -> String { let t = Int(s.rounded()) return String(format: "%d:%02d", t / 60, t % 60) } }