Files
coach-ios/ios/App/CoachWatch/ContentView.swift
Sylvain Bettinelli a61ba25590 Test de VMA guide au poignet
Pendant un test, le telephone est dans une poche : la montre est le seul ecran
consultable en courant, et c'est elle qui porte deja vitesse, distance et FC via
WorkoutManager.

Deux protocoles. Paliers d'une minute de 8 km/h par pas de 0,5, avec haptique au
changement et l'ecart a l'allure cible affiche — la vitesse brute demanderait un
calcul mental a chaque foulee. Ou six minutes, la distance parcourue donnant
directement la VMA.

⚠️ AUCUN calcul cote montre : elle transmet des mesures (paliers tenus,
distance, FC max), le serveur calcule. Deux implementations d'une meme formule
divergent toujours. Le POST passe par CoachRoutineBridge, qui a deja
l'abonnement NotificationCenter, la base d'API et le cookie — un fichier de plus
devrait etre cable a la main dans le pbxproj.

⚠️ Les paliers avancent sur `elapsedSec` de HKLiveWorkoutBuilder, PAS sur un
compteur accumule au fil du Timer de la vue : ce Timer ne tourne pas quand
l'ecran s'eteint, c'est-a-dire des que le poignet retombe — soit pendant
l'essentiel du test. Meme famille de piege que la Live Activity fantome.

L'envoi reprend la livraison differee de la routine : un test se fait dehors,
l'iPhone peut etre hors de portee, et refaire un test maximal parce qu'un
message s'est perdu n'est pas acceptable. Si l'envoi echoue, les chiffres
restent affiches pour une saisie manuelle.

VmaTestView.swift est un fichier NOUVEAU : cable dans le pbxproj (4 entrees,
sauvegarde project.pbxproj.bak-vma). Verification : `grep -c "VmaTestView.swift
in Sources"` doit rendre 2 (declaration + phase de compilation = 1 cible).

⚠️ NON COMPILE — a builder au prochain passage sur le Mac.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 18:47:03 +00:00

194 lines
6.9 KiB
Swift

import SwiftUI
import HealthKit
struct ContentView: View {
@EnvironmentObject private var workout: WorkoutManager
var body: some View {
Group {
if workout.isRunning {
LiveWorkoutView()
} else {
ActivityPickerView()
}
}
.task {
// Déclenchement auto du mode test (validation simulateur) :
// SIMCTL_CHILD_AUTOSIM=1 xcrun simctl launch
if ProcessInfo.processInfo.environment["AUTOSIM"] == "1" {
workout.startSimulation()
}
}
}
}
struct ActivityPickerView: View {
@EnvironmentObject private var workout: WorkoutManager
@State private var showRoutine = false
@State private var showVma = false
private struct Activity: Identifiable {
let id = UUID()
let label: String
let symbol: String
let type: HKWorkoutActivityType
let location: HKWorkoutSessionLocationType
}
private let activities: [Activity] = [
.init(label: "Course", symbol: "figure.run", type: .running, location: .outdoor),
.init(label: "Vélo", symbol: "figure.outdoor.cycle", type: .cycling, location: .outdoor),
.init(label: "Marche", symbol: "figure.walk", type: .walking, location: .outdoor),
.init(label: "Renfo", symbol: "dumbbell", type: .traditionalStrengthTraining, location: .indoor)
]
var body: some View {
ScrollView {
VStack(spacing: 8) {
ForEach(activities) { activity in
Button {
Task {
await workout.startWorkout(activityType: activity.type,
locationType: activity.location)
}
} label: {
Label(activity.label, systemImage: activity.symbol)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
Button {
showRoutine = true
} label: {
Label("Routine du jour", systemImage: "figure.cooldown")
.frame(maxWidth: .infinity, alignment: .leading)
}
.tint(.cyan)
Button {
showVma = true
} label: {
Label("Test VMA", systemImage: "speedometer")
.frame(maxWidth: .infinity, alignment: .leading)
}
.tint(.orange)
Button {
workout.startSimulation()
} label: {
Label("Test (FC simulée)", systemImage: "waveform.path.ecg")
.frame(maxWidth: .infinity, alignment: .leading)
}
.tint(.gray)
if let message = workout.statusMessage {
Text(message)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 4)
}
.navigationTitle("Coach")
.sheet(isPresented: $showRoutine) {
RoutineView()
}
.sheet(isPresented: $showVma) {
VmaTestView()
}
}
}
struct LiveWorkoutView: View {
@EnvironmentObject private var workout: WorkoutManager
@EnvironmentObject private var connectivity: ConnectivityManager
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
Metric(label: "FC", value: "\(Int(workout.heartRate))", unit: "bpm", color: .red)
Metric(label: "Calories", value: "\(Int(workout.activeEnergyKcal))", unit: "kcal", color: .orange)
Metric(label: "Distance",
value: String(format: "%.2f", workout.distanceMeters / 1000),
unit: "km", color: .blue)
// km/h plutôt que l'allure min/km : l'app Exercice d'Apple ne
// propose la vitesse qu'à vélo, jamais en course.
Metric(label: "Vitesse",
value: workout.speedKmh > 0
? String(format: "%.1f", workout.speedKmh) : "",
unit: "km/h", color: .cyan)
Metric(label: "Durée", value: Self.timeString(workout.elapsedSec), unit: "", color: .green)
HStack(spacing: 4) {
Image(systemName: connectivity.isReachable
? "iphone.radiowaves.left.and.right" : "iphone.slash")
.foregroundStyle(connectivity.isReachable ? .green : .secondary)
Text(connectivity.isReachable ? "iPhone connecté" : "iPhone hors de portée")
.font(.caption2)
.foregroundStyle(.secondary)
}
// Pause/reprise : la Live Activity de l'iPhone pilote les mêmes
// appels, l'état affiché ici doit donc suivre `isPaused` et non
// un état local au bouton.
Button {
workout.isPaused ? workout.resume() : workout.pause()
} label: {
Label(workout.isPaused ? "Reprendre" : "Pause",
systemImage: workout.isPaused ? "play.fill" : "pause.fill")
.frame(maxWidth: .infinity)
}
.tint(workout.isPaused ? .green : .yellow)
Button(role: .destructive) {
workout.end()
} label: {
Label("Terminer", systemImage: "stop.fill")
.frame(maxWidth: .infinity)
}
}
.padding(.horizontal, 4)
}
}
private static func timeString(_ seconds: Double) -> String {
let total = Int(seconds)
let h = total / 3600
let m = (total % 3600) / 60
let s = total % 60
return h > 0
? String(format: "%d:%02d:%02d", h, m, s)
: String(format: "%02d:%02d", m, s)
}
}
private struct Metric: View {
let label: String
let value: String
let unit: String
let color: Color
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
HStack(alignment: .firstTextBaseline, spacing: 2) {
Text(value)
.font(.system(size: 30, weight: .semibold, design: .rounded))
.foregroundStyle(color)
if !unit.isEmpty {
Text(unit)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
#Preview {
ContentView()
.environmentObject(WorkoutManager.shared)
.environmentObject(ConnectivityManager.shared)
}