diff --git a/ios/App/CoachWatch/ConnectivityManager.swift b/ios/App/CoachWatch/ConnectivityManager.swift index e2437bb..99a2617 100644 --- a/ios/App/CoachWatch/ConnectivityManager.swift +++ b/ios/App/CoachWatch/ConnectivityManager.swift @@ -21,6 +21,15 @@ final class ConnectivityManager: NSObject, ObservableObject { /// rafraîchi à chaque battement coûterait pour rien. @Published private(set) var zones: HeartRateZones? + /// Dernier snapshot du jour poussé par l'iPhone : séance prévue, score de + /// forme, hydratation, énergie, routine. + /// + /// Ces données arrivaient déjà sur la montre — les complications les lisent + /// depuis le cadran — mais rien ne les publiait, donc l'app elle-même n'en + /// montrait rien. Publié pour la même raison que `zones` : décoder le JSON + /// de l'App Group à chaque rendu coûterait pour rien. + @Published private(set) var snapshot: CoachWidgetSnapshot? + private let queue = DispatchQueue(label: "ch.hypnotruck.coach.connectivity") private var buffer: [[String: Any]] = [] @@ -32,7 +41,9 @@ final class ConnectivityManager: NSObject, ObservableObject { session.activate() // La montre a pu redémarrer depuis le dernier envoi de l'iPhone : on // repart du dernier snapshot connu plutôt que d'attendre le suivant. - zones = CoachWidgetStore.load()?.zones + let stored = CoachWidgetStore.load() + zones = stored?.zones + snapshot = stored } func sendSample(_ payload: [String: Any]) { @@ -238,6 +249,7 @@ extension ConnectivityManager: WCSessionDelegate { // Des zones absentes d'un snapshot partiel ne doivent pas effacer // celles en place : la séance en cours perdrait son repère. if let received = snapshot.zones { self.zones = received } + self.snapshot = snapshot WidgetCenter.shared.reloadAllTimelines() } } diff --git a/ios/App/CoachWatch/ContentView.swift b/ios/App/CoachWatch/ContentView.swift index f595bdc..f62cad4 100644 --- a/ios/App/CoachWatch/ContentView.swift +++ b/ios/App/CoachWatch/ContentView.swift @@ -24,8 +24,13 @@ struct ContentView: View { 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() @@ -45,6 +50,15 @@ struct ActivityPickerView: View { 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 { @@ -95,6 +109,283 @@ struct ActivityPickerView: View { .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) } }