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:
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user