From fb4033130f0972467a330879ad6db738defa0498 Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Wed, 12 Aug 2026 19:20:16 +0000 Subject: [PATCH] =?UTF-8?q?Watch=20:=20cible=20clinique,=20couleurs=20alig?= =?UTF-8?q?n=C3=A9es=20sur=20l'iPhone,=20aper=C3=A7us=20Xcode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `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. --- ios/App/App/HeartRateZones.swift | 16 +++++++- ios/App/CoachWatch/ContentView.swift | 55 +++++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/ios/App/App/HeartRateZones.swift b/ios/App/App/HeartRateZones.swift index 15fa6cd..25c0d44 100644 --- a/ios/App/App/HeartRateZones.swift +++ b/ios/App/App/HeartRateZones.swift @@ -25,6 +25,18 @@ struct HeartRateZones: Codable, Equatable, Sendable { var z5: [Int]? /// FC max de référence retenue. Sert à situer une FC au-delà de Z5. var fcmaxUsed: Int? + /// Cible d'entraînement utile sous bêtabloquant : HRKarv0,60 — 60 % de la + /// réserve cardiaque. ⚠️ Ce n'est PAS le bas de Z4 : Díaz-Buschmann 2014 + /// (Eur J Prev Cardiol, ESC) montre que la Karvonen standard sous-évalue + /// le seuil anaérobie chez le patient bêtabloqué, et situe la zone utile + /// vers Z2-Z3. C'est le repère clinique, celui qui compte ici. + var clinicalTargetBpm: Int? + + /// L'effort est-il dans la zone d'entraînement utile ? + func isUseful(bpm: Int) -> Bool { + guard let target = clinicalTargetBpm else { return false } + return bpm >= target + } /// Zone contenant cette FC, ou `nil` si aucune ne la couvre. /// @@ -78,13 +90,15 @@ enum HeartRateZone: String, CaseIterable, Codable, Sendable { } } + /// ⚠️ Couleurs alignées sur `CoachLiveView` (iPhone) : le même effort doit + /// avoir la même teinte sur les deux écrans de la même app. /// ⚠️ La couleur ne porte JAMAIS l'information seule : le libellé « Z3 » /// l'accompagne toujours. Une pastille de couleur nue est illisible pour /// un daltonien, et le contraste d'un écran de montre en plein soleil ne /// se prête pas aux nuances. var color: Color { switch self { - case .z1: return .blue + case .z1: return .cyan case .z2: return .green case .z3: return .yellow case .z4: return .orange diff --git a/ios/App/CoachWatch/ContentView.swift b/ios/App/CoachWatch/ContentView.swift index 775ec36..f595bdc 100644 --- a/ios/App/CoachWatch/ContentView.swift +++ b/ios/App/CoachWatch/ContentView.swift @@ -202,15 +202,31 @@ private struct HeartRateMetric: View { .foregroundStyle(.secondary) } if let zone, let bounds = zones?[zone], bounds.count == 2 { - Text("\(zone.name) · \(bounds[0])–\(bounds[1])") - .font(.caption2) - .foregroundStyle(.secondary) + 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(zone.map { "\(bpm) battements par minute, zone \($0.label), \($0.name)" } - ?? "\(bpm) battements par minute") + .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)" } } @@ -244,3 +260,32 @@ private struct Metric: View { .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) +}