From eb819b68ca6e1a7b0e10af2c6e6579640373ad25 Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Wed, 12 Aug 2026 19:13:07 +0000 Subject: [PATCH] =?UTF-8?q?Watch=20:=20afficher=20la=20zone=20cardiaque=20?= =?UTF-8?q?pendant=20la=20s=C3=A9ance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ios/App/App.xcodeproj/project.pbxproj | 10 +++ ios/App/App/CoachWidgetBridge.swift | 12 +++ ios/App/App/CoachWidgetSnapshot.swift | 6 ++ ios/App/App/HeartRateZones.swift | 94 ++++++++++++++++++++ ios/App/CoachWatch/ConnectivityManager.swift | 14 +++ ios/App/CoachWatch/ContentView.swift | 55 +++++++++++- 6 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 ios/App/App/HeartRateZones.swift diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index f13883b..a45fb37 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; }; + C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HeartRateZones.swift; sourceTree = ""; }; 969DB880395BDEB107C9F2FE /* RoutineStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineStore.swift; sourceTree = ""; }; 97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachRoutineBridge.swift; sourceTree = ""; }; 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 */, diff --git a/ios/App/App/CoachWidgetBridge.swift b/ios/App/App/CoachWidgetBridge.swift index 3af4b1a..002e331 100644 --- a/ios/App/App/CoachWidgetBridge.swift +++ b/ios/App/App/CoachWidgetBridge.swift @@ -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 diff --git a/ios/App/App/CoachWidgetSnapshot.swift b/ios/App/App/CoachWidgetSnapshot.swift index ab818e8..f239023 100644 --- a/ios/App/App/CoachWidgetSnapshot.swift +++ b/ios/App/App/CoachWidgetSnapshot.swift @@ -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… diff --git a/ios/App/App/HeartRateZones.swift b/ios/App/App/HeartRateZones.swift new file mode 100644 index 0000000..15fa6cd --- /dev/null +++ b/ios/App/App/HeartRateZones.swift @@ -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 Z1…Z5 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 + } + } +} diff --git a/ios/App/CoachWatch/ConnectivityManager.swift b/ios/App/CoachWatch/ConnectivityManager.swift index 76ff341..e2437bb 100644 --- a/ios/App/CoachWatch/ConnectivityManager.swift +++ b/ios/App/CoachWatch/ConnectivityManager.swift @@ -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() } } diff --git a/ios/App/CoachWatch/ContentView.swift b/ios/App/CoachWatch/ContentView.swift index 638bc53..775ec36 100644 --- a/ios/App/CoachWatch/ContentView.swift +++ b/ios/App/CoachWatch/ContentView.swift @@ -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