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>
This commit is contained in:
Sylvain Bettinelli
2026-08-19 20:34:31 +00:00
parent 4cff1d4603
commit 852e12526a
2 changed files with 304 additions and 1 deletions

View File

@@ -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)
}
}