Watch : afficher la zone cardiaque pendant la séance

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.
This commit is contained in:
Sylvain Bettinelli
2026-08-12 19:13:07 +00:00
parent 8a25f3604a
commit eb819b68ca
6 changed files with 190 additions and 1 deletions

View File

@@ -105,7 +105,7 @@ struct LiveWorkoutView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
Metric(label: "FC", value: "\(Int(workout.heartRate))", unit: "bpm", color: .red)
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),
@@ -161,6 +161,59 @@ struct LiveWorkoutView: View {
}
}
/// 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