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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user