Vitesse en km/h sur la montre et dans la Live Activity

Suite de la demande : voir des km/h en courant, ce que l'app Exercice
d'Apple ne permet pas (vitesse réservée au vélo, allure min/km imposée en
course, aucun réglage).

La vitesse instantanée vient de HealthKit lui-même : HKLiveWorkoutDataSource
collecte .runningSpeed d'office en course extérieure (Series 6+) et
.cyclingSpeed à vélo (watchOS 11+). Elle est lue en mostRecentQuantity —
c'est la vitesse à l'instant t qu'on veut afficher, pas la moyenne de la
séance. Sur un appareil qui ne la produit pas, repli sur distance/temps.

Affichée sur les trois surfaces : écran de séance de la montre, Live
Activity (bannière + île dépliée) et vue native /live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-08-11 13:31:13 +00:00
parent 21d39f2e98
commit 4f8699fd37
6 changed files with 71 additions and 3 deletions

View File

@@ -14,6 +14,7 @@ final class WorkoutManager: NSObject, ObservableObject {
@Published private(set) var heartRate = 0.0
@Published private(set) var activeEnergyKcal = 0.0
@Published private(set) var distanceMeters = 0.0
@Published private(set) var speedKmh = 0.0
@Published private(set) var elapsedSec = 0.0
@Published private(set) var isRunning = false
@Published private(set) var isPaused = false
@@ -42,7 +43,13 @@ final class WorkoutManager: NSObject, ObservableObject {
let distanceType = Self.distanceType(for: activityType)
let hr = HKQuantityType(.heartRate)
let energy = HKQuantityType(.activeEnergyBurned)
let read: Set<HKObjectType> = [hr, energy, distanceType]
// Vitesse instantanée mesurée par la montre. HKLiveWorkoutDataSource la
// collecte d'office (course extérieure sur Series 6+, vélo sur
// watchOS 11+), mais la lecture doit être autorisée. Sur un appareil qui
// ne la produit pas, les stats restent vides : `ingest` retombe alors
// sur la vitesse moyenne dérivée de la distance.
let speedType = Self.speedType(for: activityType)
let read: Set<HKObjectType> = [hr, energy, distanceType, speedType]
let share: Set<HKSampleType> = [HKQuantityType.workoutType(), hr, energy, distanceType]
do {
@@ -142,10 +149,12 @@ final class WorkoutManager: NSObject, ObservableObject {
self.activeEnergyKcal = kcal
self.distanceMeters = dist
self.elapsedSec = elapsed
self.speedKmh = 2.6 * 3.6 // la simulation avance à 2,6 m/s
ConnectivityManager.shared.sendSample([
"heartRate": hr,
"activeEnergyKcal": kcal,
"distanceMeters": dist,
"speedKmh": self.speedKmh,
"elapsedSec": elapsed,
"ts": Date().timeIntervalSince1970,
"sim": true
@@ -156,11 +165,19 @@ final class WorkoutManager: NSObject, ObservableObject {
}
/// Appelé sur le main depuis le callback de collecte (hors-main).
private func ingest(hr: Double?, kcal: Double?, dist: Double?, elapsed: TimeInterval) {
private func ingest(hr: Double?, kcal: Double?, dist: Double?, speedMs: Double?,
elapsed: TimeInterval) {
if let hr { heartRate = hr }
if let kcal { activeEnergyKcal = kcal }
if let dist { distanceMeters = dist }
elapsedSec = elapsed
// Vitesse instantanée de la montre si elle la mesure, sinon moyenne
// depuis le départ (distance / temps). Les deux en km/h.
if let speedMs {
speedKmh = speedMs * 3.6
} else if elapsed > 5, distanceMeters > 0 {
speedKmh = (distanceMeters / elapsed) * 3.6
}
let now = Date()
// Nouvelle FC -> envoi immédiat (temps réel, comme Apple). Sinon (mise à
@@ -172,6 +189,7 @@ final class WorkoutManager: NSObject, ObservableObject {
"heartRate": heartRate,
"activeEnergyKcal": activeEnergyKcal,
"distanceMeters": distanceMeters,
"speedKmh": speedKmh,
"elapsedSec": elapsed,
"ts": now.timeIntervalSince1970
])
@@ -182,6 +200,14 @@ final class WorkoutManager: NSObject, ObservableObject {
? HKQuantityType(.distanceCycling)
: HKQuantityType(.distanceWalkingRunning)
}
/// La marche n'a pas de type de vitesse dédié : on lui donne celui de la
/// course, qui restera vide la vitesse moyenne dérivée prend le relais.
private static func speedType(for activity: HKWorkoutActivityType) -> HKQuantityType {
activity == .cycling
? HKQuantityType(.cyclingSpeed)
: HKQuantityType(.runningSpeed)
}
}
extension WorkoutManager: HKLiveWorkoutBuilderDelegate {
@@ -191,10 +217,13 @@ extension WorkoutManager: HKLiveWorkoutBuilderDelegate {
let energyType = HKQuantityType(.activeEnergyBurned)
let walkRun = HKQuantityType(.distanceWalkingRunning)
let cycling = HKQuantityType(.distanceCycling)
let runSpeed = HKQuantityType(.runningSpeed)
let cycleSpeed = HKQuantityType(.cyclingSpeed)
var hr: Double?
var kcal: Double?
var dist: Double?
var speedMs: Double?
for type in collectedTypes {
guard let qType = type as? HKQuantityType,
@@ -206,11 +235,16 @@ extension WorkoutManager: HKLiveWorkoutBuilderDelegate {
kcal = stats.sumQuantity()?.doubleValue(for: .kilocalorie())
} else if qType == walkRun || qType == cycling {
dist = stats.sumQuantity()?.doubleValue(for: .meter())
} else if qType == runSpeed || qType == cycleSpeed {
// mostRecent : c'est bien la vitesse à l'instant t qu'on veut
// afficher, pas la moyenne de la séance.
let ms = HKUnit.meter().unitDivided(by: .second())
speedMs = stats.mostRecentQuantity()?.doubleValue(for: ms)
}
}
let elapsed = workoutBuilder.elapsedTime
Task { @MainActor in
self.ingest(hr: hr, kcal: kcal, dist: dist, elapsed: elapsed)
self.ingest(hr: hr, kcal: kcal, dist: dist, speedMs: speedMs, elapsed: elapsed)
}
}