diff --git a/ios/App/App/CoachLiveView.swift b/ios/App/App/CoachLiveView.swift index 52ff7dd..7916bbf 100644 --- a/ios/App/App/CoachLiveView.swift +++ b/ios/App/App/CoachLiveView.swift @@ -97,6 +97,14 @@ struct CoachLiveView: View { color: .purple, isPulsing: false, 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) } } diff --git a/ios/App/App/LiveStore.swift b/ios/App/App/LiveStore.swift index 576687d..690b1ea 100644 --- a/ios/App/App/LiveStore.swift +++ b/ios/App/App/LiveStore.swift @@ -20,6 +20,7 @@ final class LiveStore: ObservableObject { @Published var heartRate: Double = 0 @Published var activeEnergyKcal: Double = 0 @Published var distanceMeters: Double = 0 + @Published var speedKmh: Double = 0 @Published var elapsedSec: TimeInterval = 0 @Published var activityType: String? = nil @Published var status: Status = .waiting @@ -69,6 +70,7 @@ final class LiveStore: ObservableObject { let hr = sample["heartRate"] as? Double let kcal = sample["activeEnergyKcal"] as? Double let dist = sample["distanceMeters"] as? Double + let spd = sample["speedKmh"] as? Double let elap = sample["elapsedSec"] as? Double let act = sample["activityType"] as? String Task { @MainActor [weak self] in @@ -77,6 +79,7 @@ final class LiveStore: ObservableObject { if let v = hr { self.heartRate = v } if let v = kcal { self.activeEnergyKcal = 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 = act { self.activityType = v } self.lastSampleAt = Date() @@ -137,6 +140,7 @@ final class LiveStore: ObservableObject { heartRate: heartRate, activeEnergyKcal: activeEnergyKcal, distanceMeters: distanceMeters, + speedKmh: speedKmh, elapsedSec: elapsedSec, isLive: isLive, isPaused: isPaused @@ -150,6 +154,9 @@ final class LiveStore: ObservableObject { var distanceKmText: String { distanceMeters > 0 ? String(format: "%.2f", distanceMeters / 1000) : "—" } + var speedKmhText: String { + speedKmh > 0 ? String(format: "%.1f", speedKmh) : "—" + } var paceText: String { guard distanceMeters > 50, elapsedSec > 10 else { return "—" } let minPerKm = (elapsedSec / 60) / (distanceMeters / 1000) diff --git a/ios/App/CoachLiveActivity/CoachLiveActivityAttributes.swift b/ios/App/CoachLiveActivity/CoachLiveActivityAttributes.swift index 9b5906a..dca8825 100644 --- a/ios/App/CoachLiveActivity/CoachLiveActivityAttributes.swift +++ b/ios/App/CoachLiveActivity/CoachLiveActivityAttributes.swift @@ -23,6 +23,10 @@ struct CoachLiveActivityAttributes: ActivityAttributes { var heartRate: Double var activeEnergyKcal: 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 isLive: Bool // true = direct ; false = perdu/en pause /// Séance mise en pause sur la montre. Pilote le libellé du bouton diff --git a/ios/App/CoachLiveActivity/CoachLiveActivityWidget.swift b/ios/App/CoachLiveActivity/CoachLiveActivityWidget.swift index 06d99dd..75ef799 100644 --- a/ios/App/CoachLiveActivity/CoachLiveActivityWidget.swift +++ b/ios/App/CoachLiveActivity/CoachLiveActivityWidget.swift @@ -48,6 +48,11 @@ struct CoachLiveActivityWidget: Widget { unit: "km", color: .orange) Divider().frame(height: 28) + metricInline(label: "Vitesse", + value: speedText(context.state), + unit: "km/h", + color: .cyan) + Divider().frame(height: 28) metricInline(label: "Durée", value: elapsedText(context.state.elapsedSec), unit: "", @@ -102,6 +107,7 @@ struct CoachLiveActivityWidget: Widget { metricBlock(value: hrText(context.state), unit: "bpm", color: .red) metricBlock(value: kcalText(context.state), unit: "kcal", color: .green) 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) } controls(state: context.state) @@ -186,6 +192,9 @@ struct CoachLiveActivityWidget: Widget { private func distKmText(_ s: CoachLiveActivityAttributes.ContentState) -> String { 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 { let total = Int(sec) let h = total / 3600 diff --git a/ios/App/CoachWatch/ContentView.swift b/ios/App/CoachWatch/ContentView.swift index 887ada7..28c47ba 100644 --- a/ios/App/CoachWatch/ContentView.swift +++ b/ios/App/CoachWatch/ContentView.swift @@ -98,6 +98,12 @@ struct LiveWorkoutView: View { Metric(label: "Distance", value: String(format: "%.2f", workout.distanceMeters / 1000), 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) HStack(spacing: 4) { diff --git a/ios/App/CoachWatch/WorkoutManager.swift b/ios/App/CoachWatch/WorkoutManager.swift index 6eb241f..68e71df 100644 --- a/ios/App/CoachWatch/WorkoutManager.swift +++ b/ios/App/CoachWatch/WorkoutManager.swift @@ -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 = [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 = [hr, energy, distanceType, speedType] let share: Set = [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) } }