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:
@@ -10,6 +10,7 @@
|
|||||||
01468471ECA28EA66AFE2D6C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0EB6C76F68679844EC72178F /* Foundation.framework */; };
|
01468471ECA28EA66AFE2D6C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0EB6C76F68679844EC72178F /* Foundation.framework */; };
|
||||||
0318E7E610501FC1F958251B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3342ACCE085010828CF63565 /* Assets.xcassets */; };
|
0318E7E610501FC1F958251B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3342ACCE085010828CF63565 /* Assets.xcassets */; };
|
||||||
19F735674999FD63AB1C566F /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; };
|
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 */; };
|
1B690F0D16AB76A6854CA1F7 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; };
|
||||||
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */; };
|
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */; };
|
||||||
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969DB880395BDEB107C9F2FE /* RoutineStore.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 */; };
|
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, ); }; };
|
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 */; };
|
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 */; };
|
88924CCE2FD2A733002F3A80 /* GoogleSignIn in Frameworks */ = {isa = PBXBuildFile; productRef = 88924CCD2FD2A733002F3A80 /* GoogleSignIn */; };
|
||||||
88ABE0043024C45A002636FD /* CoachQuickLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0023024C45A002636FD /* CoachQuickLog.swift */; };
|
88ABE0043024C45A002636FD /* CoachQuickLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0023024C45A002636FD /* CoachQuickLog.swift */; };
|
||||||
88ABE0053024C45A002636FD /* CoachQuickSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0033024C45A002636FD /* CoachQuickSync.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 */; };
|
95D0478A499056FE922211EA /* RoutineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C5902EFB7F60563678E46 /* RoutineView.swift */; };
|
||||||
97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */; };
|
97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */; };
|
||||||
97600B69ADF2F19B56E79621 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.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, ); }; };
|
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 */; };
|
9E88D51C0B0255D24274DACC /* CoachLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BA75EEC3CBF1F46A9028C66 /* CoachLiveActivityBundle.swift */; };
|
||||||
A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */; };
|
A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */; };
|
||||||
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A420239FC6D065C8A599B61C /* ConnectivityManager.swift */; };
|
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A420239FC6D065C8A599B61C /* ConnectivityManager.swift */; };
|
||||||
B130613187FE0661DDF636C9 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.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 */; };
|
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4A07811751FB78AA0002 /* CoachAuth.swift */; };
|
||||||
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; };
|
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; };
|
||||||
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.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>"; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CoachLiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
@@ -302,6 +307,7 @@
|
|||||||
50B271D01FEDC1A000F3C39B /* public */,
|
50B271D01FEDC1A000F3C39B /* public */,
|
||||||
A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */,
|
A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */,
|
||||||
96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */,
|
96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */,
|
||||||
|
C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */,
|
||||||
11ECAD3DB1486DE606285921 /* CoachWidgetBridge.swift */,
|
11ECAD3DB1486DE606285921 /* CoachWidgetBridge.swift */,
|
||||||
97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */,
|
97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */,
|
||||||
6030BD533EBC2D1D26DB9479 /* LiveStore.swift */,
|
6030BD533EBC2D1D26DB9479 /* LiveStore.swift */,
|
||||||
@@ -526,6 +532,7 @@
|
|||||||
files = (
|
files = (
|
||||||
79A94FD334CC9EC24D1FB700 /* CoachWidgets.swift in Sources */,
|
79A94FD334CC9EC24D1FB700 /* CoachWidgets.swift in Sources */,
|
||||||
19F735674999FD63AB1C566F /* CoachWidgetSnapshot.swift in Sources */,
|
19F735674999FD63AB1C566F /* CoachWidgetSnapshot.swift in Sources */,
|
||||||
|
C0AC4E1Z0002HRZONES00001 /* HeartRateZones.swift in Sources */,
|
||||||
1B690F0D16AB76A6854CA1F7 /* CoachLiveActivityAttributes.swift in Sources */,
|
1B690F0D16AB76A6854CA1F7 /* CoachLiveActivityAttributes.swift in Sources */,
|
||||||
694B2F69591CAA85CE5DEC3A /* CoachLiveActivityWidget.swift in Sources */,
|
694B2F69591CAA85CE5DEC3A /* CoachLiveActivityWidget.swift in Sources */,
|
||||||
9E88D51C0B0255D24274DACC /* CoachLiveActivityBundle.swift in Sources */,
|
9E88D51C0B0255D24274DACC /* CoachLiveActivityBundle.swift in Sources */,
|
||||||
@@ -539,6 +546,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
B130613187FE0661DDF636C9 /* CoachWidgetSnapshot.swift in Sources */,
|
B130613187FE0661DDF636C9 /* CoachWidgetSnapshot.swift in Sources */,
|
||||||
|
C0AC4E1Z0005HRZONES00001 /* HeartRateZones.swift in Sources */,
|
||||||
2BAD3233026A4DFEEA02EAE8 /* CoachWidgetBridge.swift in Sources */,
|
2BAD3233026A4DFEEA02EAE8 /* CoachWidgetBridge.swift in Sources */,
|
||||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||||
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */,
|
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */,
|
||||||
@@ -564,6 +572,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
880A76E6302B62FB00F582D5 /* CoachWidgetSnapshot.swift in Sources */,
|
880A76E6302B62FB00F582D5 /* CoachWidgetSnapshot.swift in Sources */,
|
||||||
|
C0AC4E1Z0003HRZONES00001 /* HeartRateZones.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -572,6 +581,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
97600B69ADF2F19B56E79621 /* CoachWidgetSnapshot.swift in Sources */,
|
97600B69ADF2F19B56E79621 /* CoachWidgetSnapshot.swift in Sources */,
|
||||||
|
C0AC4E1Z0004HRZONES00001 /* HeartRateZones.swift in Sources */,
|
||||||
543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */,
|
543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */,
|
||||||
290D0F17978B7EF3DDB3A792 /* ContentView.swift in Sources */,
|
290D0F17978B7EF3DDB3A792 /* ContentView.swift in Sources */,
|
||||||
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */,
|
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */,
|
||||||
|
|||||||
@@ -95,6 +95,18 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
(call.getValue("coffeeCountToday") as? NSNumber)?.intValue ?? previous?.coffeeCountToday
|
(call.getValue("coffeeCountToday") as? NSNumber)?.intValue ?? previous?.coffeeCountToday
|
||||||
snapshot.kcalToday = number("kcalToday") ?? previous?.kcalToday
|
snapshot.kcalToday = number("kcalToday") ?? previous?.kcalToday
|
||||||
snapshot.kcalGoal = number("kcalGoal") ?? previous?.kcalGoal
|
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 {
|
guard CoachWidgetStore.save(snapshot) else {
|
||||||
call.reject("Échec de sauvegarde du snapshot widget (App Group indisponible ?)")
|
call.reject("Échec de sauvegarde du snapshot widget (App Group indisponible ?)")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ struct CoachWidgetSnapshot: Codable {
|
|||||||
/// antérieure reste décodable.
|
/// antérieure reste décodable.
|
||||||
var routine: RoutineProgress? = nil
|
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).
|
/// Séance planifiée du jour (nil = jour OFF / pas de séance).
|
||||||
struct TodaySession: Codable {
|
struct TodaySession: Codable {
|
||||||
var sport: String // running, cycling, strength, mobility, hiking, rest…
|
var sport: String // running, cycling, strength, mobility, hiking, rest…
|
||||||
|
|||||||
94
ios/App/App/HeartRateZones.swift
Normal file
94
ios/App/App/HeartRateZones.swift
Normal 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 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,14 @@ final class ConnectivityManager: NSObject, ObservableObject {
|
|||||||
|
|
||||||
@Published private(set) var isReachable = false
|
@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 let queue = DispatchQueue(label: "ch.hypnotruck.coach.connectivity")
|
||||||
private var buffer: [[String: Any]] = []
|
private var buffer: [[String: Any]] = []
|
||||||
|
|
||||||
@@ -22,6 +30,9 @@ final class ConnectivityManager: NSObject, ObservableObject {
|
|||||||
let session = WCSession.default
|
let session = WCSession.default
|
||||||
session.delegate = self
|
session.delegate = self
|
||||||
session.activate()
|
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]) {
|
func sendSample(_ payload: [String: Any]) {
|
||||||
@@ -224,6 +235,9 @@ extension ConnectivityManager: WCSessionDelegate {
|
|||||||
CoachWidgetStore.save(snapshot)
|
CoachWidgetStore.save(snapshot)
|
||||||
connLog.info("widgetSnapshot reçu de l'iPhone -> complications rechargées")
|
connLog.info("widgetSnapshot reçu de l'iPhone -> complications rechargées")
|
||||||
DispatchQueue.main.async {
|
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()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ struct LiveWorkoutView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
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: "Calories", value: "\(Int(workout.activeEnergyKcal))", unit: "kcal", color: .orange)
|
||||||
Metric(label: "Distance",
|
Metric(label: "Distance",
|
||||||
value: String(format: "%.2f", workout.distanceMeters / 1000),
|
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 {
|
private struct Metric: View {
|
||||||
let label: String
|
let label: String
|
||||||
let value: String
|
let value: String
|
||||||
|
|||||||
Reference in New Issue
Block a user