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) +}