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