Files
coach-ios/ios/App/CoachWatch/ContentView.swift
Sylvain Bettinelli 4f8699fd37 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>
2026-08-11 13:31:13 +00:00

182 lines
6.6 KiB
Swift

import SwiftUI
import HealthKit
struct ContentView: View {
@EnvironmentObject private var workout: WorkoutManager
var body: some View {
Group {
if workout.isRunning {
LiveWorkoutView()
} else {
ActivityPickerView()
}
}
.task {
// Déclenchement auto du mode test (validation simulateur) :
// SIMCTL_CHILD_AUTOSIM=1 xcrun simctl launch
if ProcessInfo.processInfo.environment["AUTOSIM"] == "1" {
workout.startSimulation()
}
}
}
}
struct ActivityPickerView: View {
@EnvironmentObject private var workout: WorkoutManager
@State private var showRoutine = false
private struct Activity: Identifiable {
let id = UUID()
let label: String
let symbol: String
let type: HKWorkoutActivityType
let location: HKWorkoutSessionLocationType
}
private let activities: [Activity] = [
.init(label: "Course", symbol: "figure.run", type: .running, location: .outdoor),
.init(label: "Vélo", symbol: "figure.outdoor.cycle", type: .cycling, location: .outdoor),
.init(label: "Marche", symbol: "figure.walk", type: .walking, location: .outdoor),
.init(label: "Renfo", symbol: "dumbbell", type: .traditionalStrengthTraining, location: .indoor)
]
var body: some View {
ScrollView {
VStack(spacing: 8) {
ForEach(activities) { activity in
Button {
Task {
await workout.startWorkout(activityType: activity.type,
locationType: activity.location)
}
} label: {
Label(activity.label, systemImage: activity.symbol)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
Button {
showRoutine = true
} label: {
Label("Routine du jour", systemImage: "figure.cooldown")
.frame(maxWidth: .infinity, alignment: .leading)
}
.tint(.cyan)
Button {
workout.startSimulation()
} label: {
Label("Test (FC simulée)", systemImage: "waveform.path.ecg")
.frame(maxWidth: .infinity, alignment: .leading)
}
.tint(.gray)
if let message = workout.statusMessage {
Text(message)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 4)
}
.navigationTitle("Coach")
.sheet(isPresented: $showRoutine) {
RoutineView()
}
}
}
struct LiveWorkoutView: View {
@EnvironmentObject private var workout: WorkoutManager
@EnvironmentObject private var connectivity: ConnectivityManager
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
Metric(label: "FC", value: "\(Int(workout.heartRate))", unit: "bpm", color: .red)
Metric(label: "Calories", value: "\(Int(workout.activeEnergyKcal))", unit: "kcal", color: .orange)
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) {
Image(systemName: connectivity.isReachable
? "iphone.radiowaves.left.and.right" : "iphone.slash")
.foregroundStyle(connectivity.isReachable ? .green : .secondary)
Text(connectivity.isReachable ? "iPhone connecté" : "iPhone hors de portée")
.font(.caption2)
.foregroundStyle(.secondary)
}
// Pause/reprise : la Live Activity de l'iPhone pilote les mêmes
// appels, l'état affiché ici doit donc suivre `isPaused` et non
// un état local au bouton.
Button {
workout.isPaused ? workout.resume() : workout.pause()
} label: {
Label(workout.isPaused ? "Reprendre" : "Pause",
systemImage: workout.isPaused ? "play.fill" : "pause.fill")
.frame(maxWidth: .infinity)
}
.tint(workout.isPaused ? .green : .yellow)
Button(role: .destructive) {
workout.end()
} label: {
Label("Terminer", systemImage: "stop.fill")
.frame(maxWidth: .infinity)
}
}
.padding(.horizontal, 4)
}
}
private static func timeString(_ seconds: Double) -> String {
let total = Int(seconds)
let h = total / 3600
let m = (total % 3600) / 60
let s = total % 60
return h > 0
? String(format: "%d:%02d:%02d", h, m, s)
: String(format: "%02d:%02d", m, s)
}
}
private struct Metric: View {
let label: String
let value: String
let unit: String
let color: Color
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
HStack(alignment: .firstTextBaseline, spacing: 2) {
Text(value)
.font(.system(size: 30, weight: .semibold, design: .rounded))
.foregroundStyle(color)
if !unit.isEmpty {
Text(unit)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
#Preview {
ContentView()
.environmentObject(WorkoutManager.shared)
.environmentObject(ConnectivityManager.shared)
}