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 // Réinjecté explicitement dans la feuille « Aujourd'hui » : une sheet est // une scène à part, et un EnvironmentObject manquant y est un crash, pas // une vue vide. @EnvironmentObject private var connectivity: ConnectivityManager @State private var showRoutine = false @State private var showVma = false @State private var showToday = 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) { // Les données du jour d'abord : ouvrir l'app pour savoir où on // en est ne doit pas obliger à traverser un lanceur d'activité. Button { showToday = true } label: { TodaySummaryCard() } .buttonStyle(.plain) 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 { showVma = true } label: { Label("Test VMA", systemImage: "speedometer") .frame(maxWidth: .infinity, alignment: .leading) } .tint(.orange) 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() } .sheet(isPresented: $showVma) { VmaTestView() } .sheet(isPresented: $showToday) { TodayView() .environmentObject(connectivity) } } } // MARK: - Les données du jour au poignet /// Traduit un sport du plan en symbole. Les libellés viennent du serveur /// (`TodaySession.sport`) : tout sport inconnu retombe sur un symbole neutre /// plutôt que sur un symbole faux. private func sportSymbol(_ sport: String) -> String { switch sport.lowercased() { case "running": return "figure.run" case "cycling": return "figure.outdoor.cycle" case "walking": return "figure.walk" case "hiking": return "figure.hiking" case "strength", "core": return "dumbbell" case "mobility", "yoga", "flexibility": return "figure.cooldown" case "rest": return "moon.zzz" default: return "figure.mixed.cardio" } } /// Couleur de la bande de forme, telle que le serveur la nomme. Même table que /// la complication : le natif traduit la bande, il ne la décide pas. private func formeColor(_ band: CoachWidgetSnapshot.FormeBand?) -> Color { switch band { case .great: return .green case .good: return .mint case .medium: return .yellow case .low: return .red case nil: return .gray } } /// Le snapshot ramené au jour affiché. /// /// ⚠️ Toujours passer par `asOf(_:)` : un snapshot d'hier remet ses compteurs à /// zéro et vide la séance. Sans lui, la montre afficherait au réveil les /// chiffres de la veille comme étant ceux du jour — faux, pas seulement périmé. private func snapshotDuJour(_ stored: CoachWidgetSnapshot?) -> CoachWidgetSnapshot? { stored?.asOf(Date()) } /// Résumé compact en tête de l'écran d'accueil : la séance du jour et la forme. struct TodaySummaryCard: View { @EnvironmentObject private var connectivity: ConnectivityManager private var snapshot: CoachWidgetSnapshot? { snapshotDuJour(connectivity.snapshot) } var body: some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 4) { Text("Aujourd'hui") .font(.caption2) .foregroundStyle(.secondary) Spacer() if let forme = snapshot?.forme, let score = forme.score { // Le chiffre porte l'information ; la couleur ne fait que // l'accompagner (même règle que la FC en séance). Text("\(score)") .font(.caption.weight(.semibold)) .foregroundStyle(formeColor(forme.resolvedBand)) } } if let session = snapshot?.today { HStack(spacing: 6) { Image(systemName: sportSymbol(session.sport)) .foregroundStyle(.cyan) VStack(alignment: .leading, spacing: 0) { Text(session.title) .font(.footnote.weight(.medium)) .lineLimit(1) if let subtitle = session.subtitle { Text(subtitle) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) } } Spacer(minLength: 0) if session.done { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) } } } else if connectivity.snapshot == nil { Text("Ouvre l'app sur l'iPhone") .font(.caption2) .foregroundStyle(.secondary) } else { Text("Pas de séance prévue") .font(.footnote) .foregroundStyle(.secondary) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(8) .background(Color.gray.opacity(0.22), in: RoundedRectangle(cornerRadius: 10)) .accessibilityElement(children: .combine) .accessibilityHint("Ouvre le détail de la journée") } } /// Le détail de la journée : séance, ce qui a été fait, forme, hydratation, /// énergie, routine, séance de demain. struct TodayView: View { @EnvironmentObject private var connectivity: ConnectivityManager private var snapshot: CoachWidgetSnapshot? { snapshotDuJour(connectivity.snapshot) } /// L'heure du dernier envoi de l'iPhone, jamais l'heure courante : c'est la /// seule façon de distinguer « rien bu » d'« iPhone pas synchronisé ». private var freshness: String { guard let updated = connectivity.snapshot?.updatedAt else { return "jamais synchronisé" } if Calendar.current.isDateInToday(updated) { return "à jour " + updated.formatted(date: .omitted, time: .shortened) } return "dernier envoi " + updated.formatted(date: .abbreviated, time: .shortened) } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { if connectivity.snapshot == nil { Text("Aucune donnée reçue de l'iPhone. Ouvre l'app Coach sur le téléphone pour envoyer la journée.") .font(.footnote) .foregroundStyle(.secondary) } else { sessionSection doneSection formeSection hydrationSection energySection routineSection tomorrowSection } Text(freshness) .font(.caption2) .foregroundStyle(.secondary) } .padding(.horizontal, 4) .frame(maxWidth: .infinity, alignment: .leading) } .navigationTitle("Aujourd'hui") } @ViewBuilder private var sessionSection: some View { if let session = snapshot?.today { TodayRow(icon: sportSymbol(session.sport), tint: .cyan, label: session.done ? "Séance · faite" : "Séance prévue", value: session.title, detail: session.subtitle) } else { TodayRow(icon: "moon.zzz", tint: .indigo, label: "Séance", value: "Jour off", detail: nil) } } @ViewBuilder private var doneSection: some View { if let done = snapshot?.doneToday { TodayRow(icon: sportSymbol(done.sport), tint: .green, label: "Fait aujourd'hui", value: done.title ?? done.sport.capitalized, detail: done.subtitle) } } @ViewBuilder private var formeSection: some View { if let forme = snapshot?.forme, let score = forme.score { TodayRow(icon: "heart.text.square", tint: formeColor(forme.resolvedBand), label: "Forme", value: "\(score)", detail: forme.label) } } /// « 1,3 L » : le litre est l'unité lisible d'un coup d'œil, et /// `formatted` met la virgule des locales francophones. private static func litres(_ millilitres: Double) -> String { (millilitres / 1000).formatted(.number.precision(.fractionLength(1))) + " L" } private var waterDetail: String? { guard let objectif = snapshot?.waterGoalMl else { return nil } return "objectif " + Self.litres(objectif) } private var energyDetail: String? { guard let objectif = snapshot?.kcalGoal else { return nil } return "objectif \(Int(objectif)) kcal" } @ViewBuilder private var hydrationSection: some View { if let water = snapshot?.waterMlToday { TodayRow(icon: "drop.fill", tint: .blue, label: "Hydratation", value: Self.litres(water), detail: waterDetail) } if let cafes = snapshot?.coffeeCountToday, cafes > 0 { TodayRow(icon: "cup.and.saucer.fill", tint: .brown, label: "Cafés", value: "\(cafes)", detail: nil) } } @ViewBuilder private var energySection: some View { if let kcal = snapshot?.kcalToday { TodayRow(icon: "fork.knife", tint: .orange, label: "Énergie", value: "\(Int(kcal)) kcal", detail: energyDetail) } } @ViewBuilder private var routineSection: some View { if let routine = snapshot?.routine, routine.total > 0 { TodayRow(icon: "checklist", tint: .teal, label: "Routine", value: "\(routine.done)/\(routine.total)", detail: routine.done >= routine.total ? "terminée" : nil) } } @ViewBuilder private var tomorrowSection: some View { if let demain = snapshot?.tomorrow { TodayRow(icon: sportSymbol(demain.sport), tint: .gray, label: "Demain", value: demain.title, detail: demain.subtitle) } } } /// Une ligne du détail : intitulé, valeur, précision facultative. private struct TodayRow: View { let icon: String let tint: Color let label: String let value: String let detail: String? var body: some View { HStack(alignment: .top, spacing: 8) { Image(systemName: icon) .foregroundStyle(tint) .frame(width: 18) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 0) { Text(label) .font(.caption2) .foregroundStyle(.secondary) Text(value) .font(.footnote.weight(.medium)) if let detail { Text(detail) .font(.caption2) .foregroundStyle(.secondary) } } Spacer(minLength: 0) } .accessibilityElement(children: .combine) .accessibilityLabel(label) .accessibilityValue(detail.map { "\(value), \($0)" } ?? value) } } struct LiveWorkoutView: View { @EnvironmentObject private var workout: WorkoutManager @EnvironmentObject private var connectivity: ConnectivityManager var body: some View { ScrollView { VStack(alignment: .leading, spacing: 10) { HeartRateMetric(bpm: Int(workout.heartRate), zones: connectivity.zones) 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) } } /// FC de la séance, accompagnée de sa zone quand les zones sont connues. /// /// ⚠️ La couleur ne porte jamais l'information seule : « Z3 » est écrit à côté. /// Une pastille nue serait illisible pour un daltonien, et le contraste d'un /// écran de montre en plein soleil ne se prête pas aux nuances. private struct HeartRateMetric: View { let bpm: Int let zones: HeartRateZones? private var zone: HeartRateZone? { guard bpm > 0 else { return nil } return zones?.zone(for: bpm) } var body: some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 6) { Text("FC") .font(.caption2) .foregroundStyle(.secondary) if let zone { Text(zone.label) .font(.caption2.weight(.bold)) .padding(.horizontal, 6) .padding(.vertical, 1) .background(zone.color.opacity(0.25), in: Capsule()) .foregroundStyle(zone.color) } } HStack(alignment: .firstTextBaseline, spacing: 2) { Text("\(bpm)") .font(.system(size: 30, weight: .semibold, design: .rounded)) // La valeur prend la couleur de la zone : d'un coup d'œil, // sans lire, on sait si l'on est trop haut. .foregroundStyle(zone?.color ?? .red) .contentTransition(.numericText()) Text("bpm") .font(.caption) .foregroundStyle(.secondary) } if let zone, let bounds = zones?[zone], bounds.count == 2 { HStack(spacing: 4) { Text("\(zone.name) · \(bounds[0])–\(bounds[1])") // Sous bêtabloquant, le repère qui compte n'est pas le bas // de Z4 mais HRKarv0,60 (Díaz-Buschmann 2014, ESC) : la // Karvonen standard sous-évalue le seuil chez le patient // traité. L'écran iPhone l'affiche déjà. if zones?.isUseful(bpm: bpm) == true { Image(systemName: "checkmark.seal.fill") .foregroundStyle(.green) .accessibilityHidden(true) } } .font(.caption2) .foregroundStyle(.secondary) } } .accessibilityElement(children: .ignore) .accessibilityLabel("Fréquence cardiaque") .accessibilityValue(accessibilityDescription) } private var accessibilityDescription: String { guard let zone else { return "\(bpm) battements par minute" } let useful = zones?.isUseful(bpm: bpm) == true ? ", zone d'entraînement utile" : "" return "\(bpm) battements par minute, zone \(zone.label), \(zone.name)\(useful)" } } 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) } // Zones réelles de production, figées le 12/08/2026 — pour voir le rendu sans // lancer de séance ni brancher la montre. Ouvre ce fichier dans Xcode et // active le canevas (⌥⌘↩) : les cinq états s'affichent d'un coup. private let previewZones = HeartRateZones( z1: [100, 109], z2: [109, 118], z3: [118, 126], z4: [126, 134], z5: [134, 143], fcmaxUsed: 143, clinicalTargetBpm: 109 ) #Preview("FC · les 5 zones") { ScrollView { VStack(alignment: .leading, spacing: 14) { // 104 → Z1, 115 → Z2 (la cible des séances CdC), 122 → Z3, // 130 → Z4, 141 → Z5. 109 vérifie la règle du chevauchement : // c'est le plancher de Z2, il ne doit PAS s'afficher Z1. ForEach([104, 109, 115, 122, 130, 141], id: \.self) { bpm in HeartRateMetric(bpm: bpm, zones: previewZones) } } .padding(.horizontal, 4) } } #Preview("FC · zones inconnues") { // Repli quand l'iPhone n'a jamais poussé de snapshot : la FC reste // lisible, en rouge, sans zone inventée. HeartRateMetric(bpm: 128, zones: nil) }