Files
coach-ios/ios/App/CoachWatch/ContentView.swift
Sylvain Bettinelli 852e12526a La montre avait les données du jour et n'en montrait rien
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>
2026-08-19 20:34:31 +00:00

583 lines
22 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}