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

@@ -97,6 +97,14 @@ struct CoachLiveView: View {
color: .purple, color: .purple,
isPulsing: false, isPulsing: false,
pulsePhase: pulse) pulsePhase: pulse)
// L'allure reste (référence en course), la vitesse
// s'ajoute : Apple ne donne jamais de km/h à pied.
MetricCard(label: "Vitesse",
value: store.speedKmhText,
unit: "km/h",
color: .cyan,
isPulsing: false,
pulsePhase: pulse)
} }
} }

View File

@@ -20,6 +20,7 @@ final class LiveStore: ObservableObject {
@Published var heartRate: Double = 0 @Published var heartRate: Double = 0
@Published var activeEnergyKcal: Double = 0 @Published var activeEnergyKcal: Double = 0
@Published var distanceMeters: Double = 0 @Published var distanceMeters: Double = 0
@Published var speedKmh: Double = 0
@Published var elapsedSec: TimeInterval = 0 @Published var elapsedSec: TimeInterval = 0
@Published var activityType: String? = nil @Published var activityType: String? = nil
@Published var status: Status = .waiting @Published var status: Status = .waiting
@@ -69,6 +70,7 @@ final class LiveStore: ObservableObject {
let hr = sample["heartRate"] as? Double let hr = sample["heartRate"] as? Double
let kcal = sample["activeEnergyKcal"] as? Double let kcal = sample["activeEnergyKcal"] as? Double
let dist = sample["distanceMeters"] as? Double let dist = sample["distanceMeters"] as? Double
let spd = sample["speedKmh"] as? Double
let elap = sample["elapsedSec"] as? Double let elap = sample["elapsedSec"] as? Double
let act = sample["activityType"] as? String let act = sample["activityType"] as? String
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
@@ -77,6 +79,7 @@ final class LiveStore: ObservableObject {
if let v = hr { self.heartRate = v } if let v = hr { self.heartRate = v }
if let v = kcal { self.activeEnergyKcal = v } if let v = kcal { self.activeEnergyKcal = v }
if let v = dist { self.distanceMeters = v } if let v = dist { self.distanceMeters = v }
if let v = spd { self.speedKmh = v }
if let v = elap { self.elapsedSec = v } if let v = elap { self.elapsedSec = v }
if let v = act { self.activityType = v } if let v = act { self.activityType = v }
self.lastSampleAt = Date() self.lastSampleAt = Date()
@@ -137,6 +140,7 @@ final class LiveStore: ObservableObject {
heartRate: heartRate, heartRate: heartRate,
activeEnergyKcal: activeEnergyKcal, activeEnergyKcal: activeEnergyKcal,
distanceMeters: distanceMeters, distanceMeters: distanceMeters,
speedKmh: speedKmh,
elapsedSec: elapsedSec, elapsedSec: elapsedSec,
isLive: isLive, isLive: isLive,
isPaused: isPaused isPaused: isPaused
@@ -150,6 +154,9 @@ final class LiveStore: ObservableObject {
var distanceKmText: String { var distanceKmText: String {
distanceMeters > 0 ? String(format: "%.2f", distanceMeters / 1000) : "" distanceMeters > 0 ? String(format: "%.2f", distanceMeters / 1000) : ""
} }
var speedKmhText: String {
speedKmh > 0 ? String(format: "%.1f", speedKmh) : ""
}
var paceText: String { var paceText: String {
guard distanceMeters > 50, elapsedSec > 10 else { return "" } guard distanceMeters > 50, elapsedSec > 10 else { return "" }
let minPerKm = (elapsedSec / 60) / (distanceMeters / 1000) let minPerKm = (elapsedSec / 60) / (distanceMeters / 1000)

View File

@@ -23,6 +23,10 @@ struct CoachLiveActivityAttributes: ActivityAttributes {
var heartRate: Double var heartRate: Double
var activeEnergyKcal: Double var activeEnergyKcal: Double
var distanceMeters: Double var distanceMeters: Double
/// Vitesse instantanée en km/h (mesurée par la montre, sinon moyenne
/// dérivée). Apple n'affiche jamais de km/h en course : c'est le
/// principal apport de cette Live Activity face à l'app Exercice.
var speedKmh: Double = 0
var elapsedSec: TimeInterval var elapsedSec: TimeInterval
var isLive: Bool // true = direct ; false = perdu/en pause var isLive: Bool // true = direct ; false = perdu/en pause
/// Séance mise en pause sur la montre. Pilote le libellé du bouton /// Séance mise en pause sur la montre. Pilote le libellé du bouton

View File

@@ -48,6 +48,11 @@ struct CoachLiveActivityWidget: Widget {
unit: "km", unit: "km",
color: .orange) color: .orange)
Divider().frame(height: 28) Divider().frame(height: 28)
metricInline(label: "Vitesse",
value: speedText(context.state),
unit: "km/h",
color: .cyan)
Divider().frame(height: 28)
metricInline(label: "Durée", metricInline(label: "Durée",
value: elapsedText(context.state.elapsedSec), value: elapsedText(context.state.elapsedSec),
unit: "", unit: "",
@@ -102,6 +107,7 @@ struct CoachLiveActivityWidget: Widget {
metricBlock(value: hrText(context.state), unit: "bpm", color: .red) metricBlock(value: hrText(context.state), unit: "bpm", color: .red)
metricBlock(value: kcalText(context.state), unit: "kcal", color: .green) metricBlock(value: kcalText(context.state), unit: "kcal", color: .green)
metricBlock(value: distKmText(context.state), unit: "km", color: .orange) metricBlock(value: distKmText(context.state), unit: "km", color: .orange)
metricBlock(value: speedText(context.state), unit: "km/h", color: .cyan)
metricBlock(value: elapsedText(context.state.elapsedSec), unit: "", color: .white) metricBlock(value: elapsedText(context.state.elapsedSec), unit: "", color: .white)
} }
controls(state: context.state) controls(state: context.state)
@@ -186,6 +192,9 @@ struct CoachLiveActivityWidget: Widget {
private func distKmText(_ s: CoachLiveActivityAttributes.ContentState) -> String { private func distKmText(_ s: CoachLiveActivityAttributes.ContentState) -> String {
s.distanceMeters > 0 ? String(format: "%.2f", s.distanceMeters / 1000) : "" s.distanceMeters > 0 ? String(format: "%.2f", s.distanceMeters / 1000) : ""
} }
private func speedText(_ s: CoachLiveActivityAttributes.ContentState) -> String {
s.speedKmh > 0 ? String(format: "%.1f", s.speedKmh) : ""
}
private func elapsedText(_ sec: TimeInterval) -> String { private func elapsedText(_ sec: TimeInterval) -> String {
let total = Int(sec) let total = Int(sec)
let h = total / 3600 let h = total / 3600

View File

@@ -98,6 +98,12 @@ struct LiveWorkoutView: View {
Metric(label: "Distance", Metric(label: "Distance",
value: String(format: "%.2f", workout.distanceMeters / 1000), value: String(format: "%.2f", workout.distanceMeters / 1000),
unit: "km", color: .blue) unit: "km", color: .blue)
// km/h plutôt que l'allure min/km : l'app Exercice d'Apple ne
// propose la vitesse qu'à vélo, jamais en course.
Metric(label: "Vitesse",
value: workout.speedKmh > 0
? String(format: "%.1f", workout.speedKmh) : "",
unit: "km/h", color: .cyan)
Metric(label: "Durée", value: Self.timeString(workout.elapsedSec), unit: "", color: .green) Metric(label: "Durée", value: Self.timeString(workout.elapsedSec), unit: "", color: .green)
HStack(spacing: 4) { HStack(spacing: 4) {

View File

@@ -14,6 +14,7 @@ final class WorkoutManager: NSObject, ObservableObject {
@Published private(set) var heartRate = 0.0 @Published private(set) var heartRate = 0.0
@Published private(set) var activeEnergyKcal = 0.0 @Published private(set) var activeEnergyKcal = 0.0
@Published private(set) var distanceMeters = 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 elapsedSec = 0.0
@Published private(set) var isRunning = false @Published private(set) var isRunning = false
@Published private(set) var isPaused = false @Published private(set) var isPaused = false
@@ -42,7 +43,13 @@ final class WorkoutManager: NSObject, ObservableObject {
let distanceType = Self.distanceType(for: activityType) let distanceType = Self.distanceType(for: activityType)
let hr = HKQuantityType(.heartRate) let hr = HKQuantityType(.heartRate)
let energy = HKQuantityType(.activeEnergyBurned) 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] let share: Set<HKSampleType> = [HKQuantityType.workoutType(), hr, energy, distanceType]
do { do {
@@ -142,10 +149,12 @@ final class WorkoutManager: NSObject, ObservableObject {
self.activeEnergyKcal = kcal self.activeEnergyKcal = kcal
self.distanceMeters = dist self.distanceMeters = dist
self.elapsedSec = elapsed self.elapsedSec = elapsed
self.speedKmh = 2.6 * 3.6 // la simulation avance à 2,6 m/s
ConnectivityManager.shared.sendSample([ ConnectivityManager.shared.sendSample([
"heartRate": hr, "heartRate": hr,
"activeEnergyKcal": kcal, "activeEnergyKcal": kcal,
"distanceMeters": dist, "distanceMeters": dist,
"speedKmh": self.speedKmh,
"elapsedSec": elapsed, "elapsedSec": elapsed,
"ts": Date().timeIntervalSince1970, "ts": Date().timeIntervalSince1970,
"sim": true "sim": true
@@ -156,11 +165,19 @@ final class WorkoutManager: NSObject, ObservableObject {
} }
/// Appelé sur le main depuis le callback de collecte (hors-main). /// 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 hr { heartRate = hr }
if let kcal { activeEnergyKcal = kcal } if let kcal { activeEnergyKcal = kcal }
if let dist { distanceMeters = dist } if let dist { distanceMeters = dist }
elapsedSec = elapsed 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() let now = Date()
// Nouvelle FC -> envoi immédiat (temps réel, comme Apple). Sinon (mise à // Nouvelle FC -> envoi immédiat (temps réel, comme Apple). Sinon (mise à
@@ -172,6 +189,7 @@ final class WorkoutManager: NSObject, ObservableObject {
"heartRate": heartRate, "heartRate": heartRate,
"activeEnergyKcal": activeEnergyKcal, "activeEnergyKcal": activeEnergyKcal,
"distanceMeters": distanceMeters, "distanceMeters": distanceMeters,
"speedKmh": speedKmh,
"elapsedSec": elapsed, "elapsedSec": elapsed,
"ts": now.timeIntervalSince1970 "ts": now.timeIntervalSince1970
]) ])
@@ -182,6 +200,14 @@ final class WorkoutManager: NSObject, ObservableObject {
? HKQuantityType(.distanceCycling) ? HKQuantityType(.distanceCycling)
: HKQuantityType(.distanceWalkingRunning) : 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 { extension WorkoutManager: HKLiveWorkoutBuilderDelegate {
@@ -191,10 +217,13 @@ extension WorkoutManager: HKLiveWorkoutBuilderDelegate {
let energyType = HKQuantityType(.activeEnergyBurned) let energyType = HKQuantityType(.activeEnergyBurned)
let walkRun = HKQuantityType(.distanceWalkingRunning) let walkRun = HKQuantityType(.distanceWalkingRunning)
let cycling = HKQuantityType(.distanceCycling) let cycling = HKQuantityType(.distanceCycling)
let runSpeed = HKQuantityType(.runningSpeed)
let cycleSpeed = HKQuantityType(.cyclingSpeed)
var hr: Double? var hr: Double?
var kcal: Double? var kcal: Double?
var dist: Double? var dist: Double?
var speedMs: Double?
for type in collectedTypes { for type in collectedTypes {
guard let qType = type as? HKQuantityType, guard let qType = type as? HKQuantityType,
@@ -206,11 +235,16 @@ extension WorkoutManager: HKLiveWorkoutBuilderDelegate {
kcal = stats.sumQuantity()?.doubleValue(for: .kilocalorie()) kcal = stats.sumQuantity()?.doubleValue(for: .kilocalorie())
} else if qType == walkRun || qType == cycling { } else if qType == walkRun || qType == cycling {
dist = stats.sumQuantity()?.doubleValue(for: .meter()) 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 let elapsed = workoutBuilder.elapsedTime
Task { @MainActor in 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)
} }
} }