Comme l'app Exercice : deux boutons sur la bannière de l'écran verrouillé et dans l'île dépliée. iOS n'expose aucun geste de balayage pour révéler des actions — depuis iOS 17, ce sont des boutons intégrés (App Intents), et c'est le seul mécanisme public. - Intents `CoachTogglePauseIntent` / `CoachEndWorkoutIntent`, conformes à LiveActivityIntent (sans quoi `perform()` n'est jamais appelé). - Ils vivent dans CoachLiveActivityAttributes.swift, seul fichier déjà membre des deux targets : un fichier neuf imposerait une manip Target Membership dans Xcode, source d'erreurs répétées ici. - Commandes relayées à la montre par WCSession, avec repli transferUserInfo : isReachable retombe à false quand la séance tourne en arrière-plan profond, la commande est alors différée au réveil de l'app montre. - L'état de pause remonte depuis HKWorkoutSession (seule source fiable : la montre peut mettre en pause d'elle-même) et bascule le libellé du bouton. - Le watchdog de LiveStore est neutralisé pendant la pause : sans ça, l'absence de samples aurait affiché « connexion perdue » puis terminé l'activité. - L'app watchOS gagne le bouton Pause qui lui manquait, aligné sur le même état. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
176 lines
6.2 KiB
Swift
176 lines
6.2 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)
|
|
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)
|
|
}
|