Le snapshot poussé par l'iPhone (séance prévue, forme, hydratation, énergie, routine, séance de demain) arrive sur la montre depuis WatchConnectivity et vit dans son App Group. Les complications le lisent ; l'app, elle, ouvrait sur le lanceur d'activité et n'en affichait pas une ligne. Aucun nouveau pont : `ConnectivityManager` publie le snapshot qu'il persistait déjà, l'accueil en montre un résumé, et une feuille « Aujourd'hui » donne le détail. Même `asOf(_:)` que les complications, pour qu'un snapshot d'hier se vide au lieu de mentir. Les vues vivent dans ContentView.swift, déjà membre de la cible CoachWatch : pas de Target Membership à régler dans Xcode. ⚠️ Non compilé — SwiftUI et WatchKit n'existent pas sur Linux. Seuls `swiftc -parse` et les tests Linux existants sont passés. À builder sur le Mac. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
583 lines
22 KiB
Swift
583 lines
22 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
|
||
// 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)
|
||
}
|