Deux ajouts lies par le meme constat : ce qu'on peut mesurer honnetement. MARCHE 6 MINUTES — troisieme protocole de la vue de test. Sous-maximal, sans risque, c'est la reference en cardiologie et le seul des trois qui ne demande aucun effort maximal. Type d'activite HealthKit .walking (la distance et la vitesse ne vivent pas sur le meme quantity type que la course). RECUPERATION CARDIAQUE — le pont collecte desormais heartRateRecoveryOneMinute, la valeur calculee par la Watch elle-meme. Pourquoi ne pas la deriver de la serie de FC post-seance, deja en base : mesure faite sur les 19 seances exploitables de la prod, la baisse mediane a 1 minute ressort a 2 bpm, avec 7 valeurs nulles ou negatives (min -10, max 15). Ce n'est pas physiologique : la fin de seance HealthKit ne coincide pas avec l'arret de l'effort. Une courbe batie la-dessus monterait et descendrait sans rien mesurer. ⚠️ Le sample n'est PAS rattache au workout : predicateForObjects(from:) ne le rendrait jamais. Apple l'ecrit comme un echantillon independant horodate juste apres la seance, d'ou une requete par fenetre temporelle. ⚠️ NON COMPILE. La courbe de tendance cote web attend ce build : sans lui, le champ reste absent et il n'y a rien a tracer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
282 lines
11 KiB
Swift
282 lines
11 KiB
Swift
// 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)
|
||
}
|
||
}
|