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

@@ -10,6 +10,7 @@
01468471ECA28EA66AFE2D6C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0EB6C76F68679844EC72178F /* Foundation.framework */; };
0318E7E610501FC1F958251B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3342ACCE085010828CF63565 /* Assets.xcassets */; };
19F735674999FD63AB1C566F /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; };
C0AC4E1Z0002HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
1B690F0D16AB76A6854CA1F7 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; };
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */; };
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969DB880395BDEB107C9F2FE /* RoutineStore.swift */; };
@@ -37,6 +38,7 @@
880A76D3302B601000F582D5 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D2302B601000F582D5 /* SwiftUI.framework */; };
880A76DE302B601100F582D5 /* CoachWatchWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 880A76CF302B601000F582D5 /* CoachWatchWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
880A76E6302B62FB00F582D5 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; };
C0AC4E1Z0003HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
88924CCE2FD2A733002F3A80 /* GoogleSignIn in Frameworks */ = {isa = PBXBuildFile; productRef = 88924CCD2FD2A733002F3A80 /* GoogleSignIn */; };
88ABE0043024C45A002636FD /* CoachQuickLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0023024C45A002636FD /* CoachQuickLog.swift */; };
88ABE0053024C45A002636FD /* CoachQuickSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0033024C45A002636FD /* CoachQuickSync.swift */; };
@@ -48,11 +50,13 @@
95D0478A499056FE922211EA /* RoutineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C5902EFB7F60563678E46 /* RoutineView.swift */; };
97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */; };
97600B69ADF2F19B56E79621 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; };
C0AC4E1Z0004HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
9E88D51C0B0255D24274DACC /* CoachLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BA75EEC3CBF1F46A9028C66 /* CoachLiveActivityBundle.swift */; };
A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */; };
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A420239FC6D065C8A599B61C /* ConnectivityManager.swift */; };
B130613187FE0661DDF636C9 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; };
C0AC4E1Z0005HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4A07811751FB78AA0002 /* CoachAuth.swift */; };
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; };
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; };
@@ -158,6 +162,7 @@
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkoutManager.swift; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWidgetSnapshot.swift; sourceTree = "<group>"; };
C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HeartRateZones.swift; sourceTree = "<group>"; };
969DB880395BDEB107C9F2FE /* RoutineStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineStore.swift; sourceTree = "<group>"; };
97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachRoutineBridge.swift; sourceTree = "<group>"; };
9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CoachLiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -302,6 +307,7 @@
50B271D01FEDC1A000F3C39B /* public */,
A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */,
96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */,
C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */,
11ECAD3DB1486DE606285921 /* CoachWidgetBridge.swift */,
97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */,
6030BD533EBC2D1D26DB9479 /* LiveStore.swift */,
@@ -526,6 +532,7 @@
files = (
79A94FD334CC9EC24D1FB700 /* CoachWidgets.swift in Sources */,
19F735674999FD63AB1C566F /* CoachWidgetSnapshot.swift in Sources */,
C0AC4E1Z0002HRZONES00001 /* HeartRateZones.swift in Sources */,
1B690F0D16AB76A6854CA1F7 /* CoachLiveActivityAttributes.swift in Sources */,
694B2F69591CAA85CE5DEC3A /* CoachLiveActivityWidget.swift in Sources */,
9E88D51C0B0255D24274DACC /* CoachLiveActivityBundle.swift in Sources */,
@@ -539,6 +546,7 @@
buildActionMask = 2147483647;
files = (
B130613187FE0661DDF636C9 /* CoachWidgetSnapshot.swift in Sources */,
C0AC4E1Z0005HRZONES00001 /* HeartRateZones.swift in Sources */,
2BAD3233026A4DFEEA02EAE8 /* CoachWidgetBridge.swift in Sources */,
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */,
@@ -564,6 +572,7 @@
buildActionMask = 2147483647;
files = (
880A76E6302B62FB00F582D5 /* CoachWidgetSnapshot.swift in Sources */,
C0AC4E1Z0003HRZONES00001 /* HeartRateZones.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -572,6 +581,7 @@
buildActionMask = 2147483647;
files = (
97600B69ADF2F19B56E79621 /* CoachWidgetSnapshot.swift in Sources */,
C0AC4E1Z0004HRZONES00001 /* HeartRateZones.swift in Sources */,
543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */,
290D0F17978B7EF3DDB3A792 /* ContentView.swift in Sources */,
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */,

View File

@@ -95,6 +95,18 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
(call.getValue("coffeeCountToday") as? NSNumber)?.intValue ?? previous?.coffeeCountToday
snapshot.kcalToday = number("kcalToday") ?? previous?.kcalToday
snapshot.kcalGoal = number("kcalGoal") ?? previous?.kcalGoal
/* Zones cardiaques : même règle de report que la nutrition un appel
partiel ne doit pas les effacer, sinon la montre repasserait à la FC
nue au premier rafraîchissement incomplet. Pas de garde du jour
ici : contrairement à la routine, des zones ne périment pas à
minuit ; elles ne changent que sur validation explicite. */
if let raw = call.getObject("zones"),
let data = try? JSONSerialization.data(withJSONObject: raw),
let parsed = try? JSONDecoder().decode(HeartRateZones.self, from: data) {
snapshot.zones = parsed
} else {
snapshot.zones = previous?.zones
}
guard CoachWidgetStore.save(snapshot) else {
call.reject("Échec de sauvegarde du snapshot widget (App Group indisponible ?)")
return

View File

@@ -75,6 +75,12 @@ struct CoachWidgetSnapshot: Codable {
/// antérieure reste décodable.
var routine: RoutineProgress? = nil
/// Zones cardiaques en vigueur, pour que la montre traduise la FC en zone
/// pendant la séance. Modèle dans `HeartRateZones.swift`.
/// Version FIGÉE servie par `/api/cardiac-zones` jamais recalculée
/// côté natif (cf. l'en-tête de ce fichier).
var zones: HeartRateZones? = nil
/// Séance planifiée du jour (nil = jour OFF / pas de séance).
struct TodaySession: Codable {
var sport: String // running, cycling, strength, mobility, hiking, rest

View File

@@ -0,0 +1,94 @@
// HeartRateZones.swift
// Zones cardiaques transportées vers la montre et les widgets.
//
// Ce fichier doit appartenir aux cibles App, CoachLiveActivity ET CoachWatch
// cf. docs/widgets-runbook-mac.md (étape « Target Membership »). C'est le
// modèle partagé, il ne doit exister qu'en un exemplaire.
//
// **Pourquoi ces zones voyagent.** Pendant une séance, la montre n'affichait
// que la FC brute : « 142 bpm », sans dire dans quelle zone on se trouve. C'est
// pourtant au poignet que le chiffre a besoin d'être traduit l'écran iPhone
// portait cette information, mais on ne le regarde pas en courant.
//
// Les bornes viennent de `/api/cardiac-zones`, qui sert la version **figée**
// et datée : la même que le calendrier et que les bornes poussées à WorkoutKit.
// Ne jamais les recalculer côté natif ce serait rouvrir les jeux de zones
// concurrents supprimés le 12/08/2026.
import SwiftUI
/// Bornes Z1Z5 en battements par minute, telles que servies par le backend.
struct HeartRateZones: Codable, Equatable, Sendable {
var z1: [Int]?
var z2: [Int]?
var z3: [Int]?
var z4: [Int]?
var z5: [Int]?
/// FC max de référence retenue. Sert à situer une FC au-delà de Z5.
var fcmaxUsed: Int?
/// Zone contenant cette FC, ou `nil` si aucune ne la couvre.
///
/// Les bornes se chevauchent d'un battement côté backend (Z1 100-109,
/// Z2 109-118) : on prend la zone la plus HAUTE qui contient la valeur,
/// sinon 109 bpm s'afficherait en Z1 alors que c'est le plancher de Z2.
func zone(for bpm: Int) -> HeartRateZone? {
for zone in HeartRateZone.allCases.reversed() {
guard let bounds = self[zone], bounds.count == 2 else { continue }
if bpm >= bounds[0] && bpm <= bounds[1] { return zone }
}
// Au-dessus de la borne haute de Z5 : on reste en Z5 plutôt que de
// n'afficher rien. Un effort maximal n'est pas une absence de zone.
if let top = self[.z5], top.count == 2, bpm > top[1] { return .z5 }
return nil
}
subscript(zone: HeartRateZone) -> [Int]? {
switch zone {
case .z1: return z1
case .z2: return z2
case .z3: return z3
case .z4: return z4
case .z5: return z5
}
}
}
/// Les cinq zones, de la récupération à la VO max.
enum HeartRateZone: String, CaseIterable, Codable, Sendable {
case z1, z2, z3, z4, z5
var label: String {
switch self {
case .z1: return "Z1"
case .z2: return "Z2"
case .z3: return "Z3"
case .z4: return "Z4"
case .z5: return "Z5"
}
}
/// Nom de la zone, pour VoiceOver et l'écran de fin de séance.
var name: String {
switch self {
case .z1: return "Récupération"
case .z2: return "Endurance"
case .z3: return "Tempo"
case .z4: return "Seuil"
case .z5: return "VO2 max"
}
}
/// 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 .z2: return .green
case .z3: return .yellow
case .z4: return .orange
case .z5: return .red
}
}
}

View File

@@ -13,6 +13,14 @@ final class ConnectivityManager: NSObject, ObservableObject {
@Published private(set) var isReachable = false
/// Zones cardiaques en vigueur, poussées par l'iPhone avec le snapshot des
/// complications. Sert à traduire la FC en zone pendant la séance.
///
/// Publiée depuis `ConnectivityManager` plutôt que lue à chaque rendu :
/// `CoachWidgetStore.load()` décode du JSON, l'appeler dans un `body`
/// rafraîchi à chaque battement coûterait pour rien.
@Published private(set) var zones: HeartRateZones?
private let queue = DispatchQueue(label: "ch.hypnotruck.coach.connectivity")
private var buffer: [[String: Any]] = []
@@ -22,6 +30,9 @@ final class ConnectivityManager: NSObject, ObservableObject {
let session = WCSession.default
session.delegate = self
session.activate()
// La montre a pu redémarrer depuis le dernier envoi de l'iPhone : on
// repart du dernier snapshot connu plutôt que d'attendre le suivant.
zones = CoachWidgetStore.load()?.zones
}
func sendSample(_ payload: [String: Any]) {
@@ -224,6 +235,9 @@ extension ConnectivityManager: WCSessionDelegate {
CoachWidgetStore.save(snapshot)
connLog.info("widgetSnapshot reçu de l'iPhone -> complications rechargées")
DispatchQueue.main.async {
// Des zones absentes d'un snapshot partiel ne doivent pas effacer
// celles en place : la séance en cours perdrait son repère.
if let received = snapshot.zones { self.zones = received }
WidgetCenter.shared.reloadAllTimelines()
}
}

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