L'app Coach de la montre n'affichait que la FC brute — aucune notion de zone dans `ContentView.swift`. C'est pourtant au poignet que « 142 bpm » a besoin d'être traduit ; l'écran iPhone portait déjà l'information, mais on ne le regarde pas en courant. - `HeartRateZones.swift` : modèle partagé (App + CoachLiveActivity + les deux cibles Watch), câblé dans les 4 phases Sources du pbxproj comme `CoachWidgetSnapshot.swift`. ⚠️ Les bornes viennent de `/api/cardiac-zones`, version FIGÉE et datée — jamais recalculées côté natif, ce serait rouvrir les jeux de zones concurrents supprimés le 12/08. - `zone(for:)` retient la zone la plus HAUTE qui contient la valeur : les bornes se chevauchent d'un battement (Z1 100-109, Z2 109-118), et 109 bpm doit se lire Z2, pas Z1. Au-delà du haut de Z5, on reste en Z5 — un effort maximal n'est pas une absence de zone. - `ConnectivityManager` publie les zones reçues avec le snapshot et repart du dernier connu au lancement (la montre a pu redémarrer). Un snapshot partiel n'efface pas celles en place. - `HeartRateMetric` : pastille « Z3 », valeur colorée, et la plage sous le chiffre. ⚠️ La couleur ne porte jamais l'information seule — illisible pour un daltonien, et le contraste d'un écran de montre en plein soleil ne se prête pas aux nuances. Libellé VoiceOver explicite. `switch` en `return` explicites plutôt qu'en expression : la syntaxe courte exige Swift 5.9 pile, et je ne peux pas compiler ici. ⚠️ NON COMPILÉ — build Xcode requis, avec `getSnapshot` (`8a25f36`) en attente.
247 lines
9.1 KiB
Swift
247 lines
9.1 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) {
|
||
HeartRateMetric(bpm: Int(workout.heartRate), zones: connectivity.zones)
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// FC de la séance, accompagnée de sa zone quand les zones sont connues.
|
||
///
|
||
/// ⚠️ La couleur ne porte jamais l'information seule : « Z3 » est écrit à côté.
|
||
/// Une pastille nue serait illisible pour un daltonien, et le contraste d'un
|
||
/// écran de montre en plein soleil ne se prête pas aux nuances.
|
||
private struct HeartRateMetric: View {
|
||
let bpm: Int
|
||
let zones: HeartRateZones?
|
||
|
||
private var zone: HeartRateZone? {
|
||
guard bpm > 0 else { return nil }
|
||
return zones?.zone(for: bpm)
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
HStack(spacing: 6) {
|
||
Text("FC")
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
if let zone {
|
||
Text(zone.label)
|
||
.font(.caption2.weight(.bold))
|
||
.padding(.horizontal, 6)
|
||
.padding(.vertical, 1)
|
||
.background(zone.color.opacity(0.25), in: Capsule())
|
||
.foregroundStyle(zone.color)
|
||
}
|
||
}
|
||
HStack(alignment: .firstTextBaseline, spacing: 2) {
|
||
Text("\(bpm)")
|
||
.font(.system(size: 30, weight: .semibold, design: .rounded))
|
||
// La valeur prend la couleur de la zone : d'un coup d'œil,
|
||
// sans lire, on sait si l'on est trop haut.
|
||
.foregroundStyle(zone?.color ?? .red)
|
||
.contentTransition(.numericText())
|
||
Text("bpm")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
if let zone, let bounds = zones?[zone], bounds.count == 2 {
|
||
Text("\(zone.name) · \(bounds[0])–\(bounds[1])")
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.accessibilityElement(children: .ignore)
|
||
.accessibilityLabel("Fréquence cardiaque")
|
||
.accessibilityValue(zone.map { "\(bpm) battements par minute, zone \($0.label), \($0.name)" }
|
||
?? "\(bpm) battements par minute")
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|