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>
194 lines
6.9 KiB
Swift
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)
|
|
}
|