Files
coach-ios/ios/App/CoachWatch/ContentView.swift
Sylvain Bettinelli fb4033130f Watch : cible clinique, couleurs alignées sur l'iPhone, aperçus Xcode
- `HeartRateZones` porte `clinicalTargetBpm` et `isUseful(bpm:)`. Sous
  bêtabloquant, le repère utile n'est PAS le bas de Z4 mais HRKarv0,60
  (Díaz-Buschmann 2014, ESC : la Karvonen standard sous-évalue le seuil chez le
  patient traité). Un sceau vert l'indique sous la plage, et VoiceOver le dit —
  une icône seule ne suffit pas.
- Z1 passe de `.blue` à `.cyan` : `CoachLiveView` (iPhone) utilise déjà cyan.
  Le même effort doit avoir la même teinte sur les deux écrans de la même app ;
  j'avais introduit la divergence en écrivant le modèle sans regarder l'existant.
- Deux `#Preview` avec les zones RÉELLES de production (Z1 100-109 … Z5
  134-143) : les cinq états d'un coup, plus le cas « zones inconnues ». 109 bpm
  y figure exprès — c'est le plancher de Z2, il vérifie la règle du
  chevauchement d'un battement.

⚠️ `CoachLiveView.CardiacZones` reste une SECONDE implémentation de la même
règle, avec sa propre table de zones. Non fusionnée ici pour ne pas mélanger
deux chantiers ; c'est le même motif que les deux traductions WorkoutKit
divergentes corrigées ce matin.

⚠️ NON COMPILÉ — build Xcode requis.
2026-08-12 19:20:16 +00:00

292 lines
11 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
HStack(spacing: 4) {
Text("\(zone.name) · \(bounds[0])\(bounds[1])")
// Sous bêtabloquant, le repère qui compte n'est pas le bas
// de Z4 mais HRKarv0,60 (Díaz-Buschmann 2014, ESC) : la
// Karvonen standard sous-évalue le seuil chez le patient
// traité. L'écran iPhone l'affiche déjà.
if zones?.isUseful(bpm: bpm) == true {
Image(systemName: "checkmark.seal.fill")
.foregroundStyle(.green)
.accessibilityHidden(true)
}
}
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Fréquence cardiaque")
.accessibilityValue(accessibilityDescription)
}
private var accessibilityDescription: String {
guard let zone else { return "\(bpm) battements par minute" }
let useful = zones?.isUseful(bpm: bpm) == true ? ", zone d'entraînement utile" : ""
return "\(bpm) battements par minute, zone \(zone.label), \(zone.name)\(useful)"
}
}
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)
}
// Zones réelles de production, figées le 12/08/2026 pour voir le rendu sans
// lancer de séance ni brancher la montre. Ouvre ce fichier dans Xcode et
// active le canevas () : les cinq états s'affichent d'un coup.
private let previewZones = HeartRateZones(
z1: [100, 109], z2: [109, 118], z3: [118, 126],
z4: [126, 134], z5: [134, 143],
fcmaxUsed: 143, clinicalTargetBpm: 109
)
#Preview("FC · les 5 zones") {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
// 104 Z1, 115 Z2 (la cible des séances CdC), 122 Z3,
// 130 Z4, 141 Z5. 109 vérifie la règle du chevauchement :
// c'est le plancher de Z2, il ne doit PAS s'afficher Z1.
ForEach([104, 109, 115, 122, 130, 141], id: \.self) { bpm in
HeartRateMetric(bpm: bpm, zones: previewZones)
}
}
.padding(.horizontal, 4)
}
}
#Preview("FC · zones inconnues") {
// Repli quand l'iPhone n'a jamais poussé de snapshot : la FC reste
// lisible, en rouge, sans zone inventée.
HeartRateMetric(bpm: 128, zones: nil)
}