Files
coach-ios/ios/App/CoachLiveActivity/CoachWidgets.swift
Sylvain Bettinelli b8eb1e1b1e Le widget ne passait pas minuit : personne ne lisait updatedAt
Signale par Sylvain le 2026-08-18 : passe minuit, l'hydratation, les calories
et la seance de la veille restaient affichees comme celles du jour. Il fallait
ouvrir l'app pour les voir retomber a zero.

Le widget n'a AUCUN acces reseau : il rend ce que l'app lui a pousse la
derniere fois qu'elle a tourne. Le snapshot portait deja `updatedAt` — rien
ne le lisait. CoachQuickWidget programmait meme un reveil a minuit, avec le
bon commentaire, mais relisait ensuite les memes chiffres sans regarder leur
date : le rechargement avait lieu, la remise a zero non.

- `CoachWidgetSnapshot.asOf(_:)` : vide ce qui decrit UNE journee (eau, cafes,
  kcal, routine, activite faite, score de forme) quand le snapshot date d'un
  autre jour. Conserve ce qui traverse les jours — objectifs et zones
  cardiaques. Les compteurs repartent a 0 et non a nil : au matin, « rien bu »
  est vrai, « je ne sais pas » ne l'est pas.
- Les trois providers emettent une 2e entree datee de minuit. WidgetKit
  bascule seul, sans reveiller l'app.
- `tomorrow` : la seance du lendemain, poussee par le pont web. Sans elle,
  vider la seance ferait annoncer JOUR OFF chaque nuit — l'agacement d'un
  incident precedent, repete tous les jours.

⚠️ Deuxieme occurrence trouvee en verifiant, et pire : CoachQuickSync.credit()
ajoutait au total EXISTANT puis rehorodatait a maintenant. Sur un snapshot de
la veille, 200 ml bus le matin donnaient « 2 200 ml » estampilles du jour — un
total faux, que la peremption ne pouvait plus rattraper.

⚠️ NON COMPILE : pas de toolchain Swift ici. Build Xcode requis sur le Mac
mini pour que le correctif arrive sur l'iPhone et la Watch.
2026-08-18 07:11:15 +00:00

506 lines
20 KiB
Swift
Raw 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.
// CoachWidgets.swift
// Widgets d'écran d'accueil (cible CoachLiveActivity). Lisent le snapshot
// poussé par l'app dans l'App Group (CoachWidgetStore) aucun accès réseau.
// Deux widgets : « Séance du jour » et « Score de forme ».
//
// Ce fichier appartient à la cible CoachLiveActivity. Il référence
// CoachWidgetSnapshot.swift, qui doit AUSSI appartenir à cette cible
// (Target Membership) cf. docs/widgets-runbook-mac.md.
import WidgetKit
import SwiftUI
// MARK: - Timeline
struct CoachSnapshotEntry: TimelineEntry {
let date: Date
let snapshot: CoachWidgetSnapshot?
}
struct CoachWidgetProvider: TimelineProvider {
func placeholder(in context: Context) -> CoachSnapshotEntry {
CoachSnapshotEntry(date: Date(), snapshot: nil)
}
func getSnapshot(in context: Context, completion: @escaping (CoachSnapshotEntry) -> Void) {
let now = Date()
completion(CoachSnapshotEntry(date: now, snapshot: CoachWidgetStore.load()?.asOf(now)))
}
/// **DEUX entrées, et c'est le correctif.**
///
/// L'ancienne version n'en produisait qu'une, avec le snapshot brut, et
/// repassait une heure plus tard. Or repasser ne sert à rien : elle relisait
/// le MÊME snapshot périmé dans l'App Group et le réaffichait à l'identique.
/// Les chiffres de la veille restaient donc à l'écran jusqu'à ce que l'app
/// soit rouverte à la main.
///
/// La seconde entrée est datée de minuit et porte le snapshot passé par
/// `asOf(_:)`. WidgetKit bascule dessus tout seul à l'heure dite, **sans
/// réveiller l'app ni accéder au réseau** ce qu'un widget ne peut de
/// toute façon pas faire.
func getTimeline(in context: Context, completion: @escaping (Timeline<CoachSnapshotEntry>) -> Void) {
let now = Date()
let stored = CoachWidgetStore.load()
let midnight = CoachWidgetSnapshot.nextMidnight(after: now)
let entries = [
CoachSnapshotEntry(date: now, snapshot: stored?.asOf(now)),
CoachSnapshotEntry(date: midnight, snapshot: stored?.asOf(midnight)),
]
// L'app rafraîchit explicitement (WidgetCenter.reloadAllTimelines) à
// chaque setSnapshot ; on redemande la main peu après minuit pour que
// le jour d'APRÈS soit à son tour préparé.
completion(Timeline(entries: entries,
policy: .after(midnight.addingTimeInterval(60))))
}
}
// MARK: - Palette / icônes (alignées sur les couleurs sport de l'app)
private func sportColor(_ sport: String) -> Color {
switch sport {
case "running": return Color(red: 1.00, green: 0.45, blue: 0.21)
case "cycling": return Color(red: 0.20, green: 0.78, blue: 0.35)
case "strength": return Color(red: 0.69, green: 0.32, blue: 0.87)
case "mobility": return Color(red: 0.35, green: 0.78, blue: 0.98)
case "hiking", "walking": return Color(red: 0.80, green: 0.62, blue: 0.30)
default: return Color.gray
}
}
private func sportSymbol(_ sport: String) -> String {
switch sport {
case "cycling": return "bicycle"
case "walking": return "figure.walk"
case "hiking": return "figure.hiking"
case "strength": return "dumbbell.fill"
case "mobility": return "figure.yoga"
case "rest": return "moon.zzz.fill"
default: return "figure.run"
}
}
private func sportLabel(_ sport: String) -> String {
switch sport {
case "running": return "Course"
case "cycling": return "Vélo"
case "walking": return "Marche"
case "hiking": return "Rando"
case "strength": return "Renfo"
case "mobility": return "Mobilité"
case "rest": return "Repos"
default: return sport.capitalized
}
}
// Applique le fond container (requis iOS 17+) sans casser le build < 17.
private extension View {
@ViewBuilder
func coachWidgetBackground() -> some View {
if #available(iOS 17.0, *) {
self.containerBackground(.fill.tertiary, for: .widget)
} else {
self.padding()
}
}
}
// MARK: - Widget « Séance du jour »
struct CoachTodaySessionEntryView: View {
var entry: CoachSnapshotEntry
@Environment(\.widgetFamily) private var family
var body: some View {
let today = entry.snapshot?.today
Group {
if let s = today {
if family == .systemMedium {
mediumView(s)
} else {
smallView(s)
}
} else {
emptyView
}
}
.coachWidgetBackground()
.widgetURL(URL(string: "https://coach.hypnotruck.ch/calendar"))
}
private func header(_ s: CoachWidgetSnapshot.TodaySession) -> some View {
HStack(spacing: 6) {
Image(systemName: sportSymbol(s.sport))
.foregroundStyle(sportColor(s.sport))
Text(sportLabel(s.sport).uppercased())
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
Spacer()
if s.done {
Image(systemName: "checkmark.seal.fill")
.foregroundStyle(.green)
.font(.caption)
}
}
}
private func smallView(_ s: CoachWidgetSnapshot.TodaySession) -> some View {
VStack(alignment: .leading, spacing: 6) {
header(s)
Spacer(minLength: 0)
Text(s.title)
.font(.headline)
.lineLimit(2)
.minimumScaleFactor(0.8)
if let sub = s.subtitle, !sub.isEmpty {
Text(sub).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
}
private func mediumView(_ s: CoachWidgetSnapshot.TodaySession) -> some View {
HStack(spacing: 14) {
ZStack {
Circle().fill(sportColor(s.sport).opacity(0.18))
Image(systemName: sportSymbol(s.sport))
.font(.title2)
.foregroundStyle(sportColor(s.sport))
}
.frame(width: 56, height: 56)
VStack(alignment: .leading, spacing: 4) {
Text("Séance du jour")
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
Text(s.title).font(.headline).lineLimit(2)
if let sub = s.subtitle, !sub.isEmpty {
Text(sub).font(.subheadline).foregroundStyle(.secondary).lineLimit(1)
}
if s.done {
Label("Réalisée", systemImage: "checkmark.seal.fill")
.font(.caption2).foregroundStyle(.green)
}
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
}
/// Rien au plan. Deux situations très différentes, longtemps confondues :
/// une vraie journée de repos, et une journée où le plan a été vidé parce
/// que l'activité a été faite autrement. Annoncer « JOUR OFF » à qui vient
/// de rouler 20 km efface son effort d'où la bascule sur `doneToday`.
@ViewBuilder
private var emptyView: some View {
if let d = entry.snapshot?.doneToday {
doneView(d)
} else {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Image(systemName: "moon.zzz.fill").foregroundStyle(.secondary)
Text("JOUR OFF").font(.caption2.weight(.bold)).foregroundStyle(.secondary)
}
Spacer(minLength: 0)
Text("Pas de séance prévue")
.font(.subheadline).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
}
}
private func doneView(_ d: CoachWidgetSnapshot.DoneActivity) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Image(systemName: sportSymbol(d.sport))
.foregroundStyle(sportColor(d.sport))
Text(sportLabel(d.sport).uppercased())
.font(.caption2.weight(.bold)).foregroundStyle(.secondary)
Spacer()
Image(systemName: "checkmark.seal.fill")
.foregroundStyle(.green).font(.caption)
}
Spacer(minLength: 0)
// Le sous-titre porte la substance (« 20,3 km · 56 min ») ; le
// titre d'une séance importée est souvent vide, d'où le repli.
Text(d.subtitle ?? d.title ?? "Séance réalisée")
.font(.headline).lineLimit(2).minimumScaleFactor(0.8)
if d.subtitle != nil, let t = d.title, !t.isEmpty {
Text(t).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
}
}
struct CoachTodaySessionWidget: Widget {
let kind = "CoachTodaySessionWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CoachWidgetProvider()) { entry in
CoachTodaySessionEntryView(entry: entry)
}
.configurationDisplayName("Séance du jour")
.description("La séance prévue aujourd'hui dans ton programme.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
// MARK: - Widget « Score de forme »
struct CoachFormeScoreEntryView: View {
var entry: CoachSnapshotEntry
private func ringColor(_ score: Int) -> Color {
switch score {
case 75...: return Color(red: 0.20, green: 0.78, blue: 0.35)
case 50..<75: return Color(red: 1.00, green: 0.80, blue: 0.0)
default: return Color(red: 1.00, green: 0.27, blue: 0.23)
}
}
var body: some View {
let forme = entry.snapshot?.forme
VStack(spacing: 8) {
Text("FORME")
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: 0)
if let score = forme?.score {
ZStack {
Circle().stroke(Color.secondary.opacity(0.18), lineWidth: 9)
Circle()
.trim(from: 0, to: min(max(Double(score) / 100.0, 0), 1))
.stroke(ringColor(score), style: StrokeStyle(lineWidth: 9, lineCap: .round))
.rotationEffect(.degrees(-90))
VStack(spacing: 0) {
Text("\(score)").font(.system(size: 30, weight: .bold, design: .rounded))
if let l = forme?.label, !l.isEmpty {
Text(l).font(.caption2).foregroundStyle(.secondary)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
VStack(spacing: 4) {
Image(systemName: "bolt.heart")
.font(.title)
.foregroundStyle(.secondary)
Text("Pas de score")
.font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.coachWidgetBackground()
.widgetURL(URL(string: "https://coach.hypnotruck.ch/forme"))
}
}
struct CoachFormeScoreWidget: Widget {
let kind = "CoachFormeScoreWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CoachWidgetProvider()) { entry in
CoachFormeScoreEntryView(entry: entry)
}
.configurationDisplayName("Score de forme")
.description("Ton score de forme / récupération du jour.")
.supportedFamilies([.systemSmall])
}
}
// MARK: - Écran verrouillé (iOS 16+) : familles accessory*
//
// Les mêmes familles que les complications Apple Watch. Les vues sont
// volontairement écrites ici plutôt que partagées avec CoachWatchWidgets :
// les deux extensions ne partagent aucun fichier, et l'App Group n'est PAS
// commun entre appareils (la montre reçoit son snapshot par WatchConnectivity).
// Dupliquer trois petites vues coûte moins qu'un fichier de plus à câbler
// dans deux cibles.
/// Fond des widgets accessory : transparent, la pile de verrouillage fournit
/// le sien. `.fill.tertiary` (celui des widgets d'accueil) y ferait une dalle
/// opaque.
private extension View {
@ViewBuilder
func lockWidgetBackground() -> some View {
if #available(iOS 17.0, *) {
self.containerBackground(.clear, for: .widget)
} else {
self
}
}
}
@available(iOS 16.0, *)
struct CoachLockFormeView: View {
@Environment(\.widgetFamily) private var family
var entry: CoachSnapshotEntry
var body: some View {
let score = entry.snapshot?.forme?.score
let label = entry.snapshot?.forme?.label
Group {
switch family {
case .accessoryInline:
// Une ligne, sans mise en forme : le système impose son style.
Text(score.map { "Forme \($0)" } ?? "Forme —")
case .accessoryRectangular:
VStack(alignment: .leading, spacing: 2) {
Text("FORME").font(.caption2.weight(.bold)).foregroundStyle(.secondary)
Text(score.map(String.init) ?? "").font(.title2.weight(.semibold))
if let l = label, !l.isEmpty {
Text(l).font(.caption2).foregroundStyle(.secondary).lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
default: // .accessoryCircular
// Gauge borné 0-100 : le score est un pourcentage de forme, un
// dépassement d'échelle donnerait un anneau plein trompeur.
Gauge(value: Double(min(100, max(0, score ?? 0))), in: 0...100) {
Text("")
} currentValueLabel: {
Text(score.map(String.init) ?? "")
}
.gaugeStyle(.accessoryCircular)
}
}
.lockWidgetBackground()
.widgetURL(URL(string: "https://coach.hypnotruck.ch/forme"))
}
}
@available(iOS 16.0, *)
struct CoachLockSessionView: View {
@Environment(\.widgetFamily) private var family
var entry: CoachSnapshotEntry
var body: some View {
Group {
if let s = entry.snapshot?.today {
if family == .accessoryInline {
Label(s.title, systemImage: sportSymbol(s.sport))
} else {
VStack(alignment: .leading, spacing: 2) {
Label(sportLabel(s.sport).uppercased(), systemImage: sportSymbol(s.sport))
.font(.caption2.weight(.bold)).foregroundStyle(.secondary)
Text(s.title).font(.headline).lineLimit(1)
if let sub = s.subtitle, !sub.isEmpty {
Text(sub).font(.caption2).foregroundStyle(.secondary).lineLimit(1)
} else if s.done {
Text("Réalisée").font(.caption2).foregroundStyle(.green)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
} else if let d = entry.snapshot?.doneToday {
// Plan vide journée vide cf. CoachWidgetSnapshot.DoneActivity.
if family == .accessoryInline {
Label(d.subtitle ?? sportLabel(d.sport), systemImage: sportSymbol(d.sport))
} else {
VStack(alignment: .leading, spacing: 2) {
Label(sportLabel(d.sport).uppercased(), systemImage: sportSymbol(d.sport))
.font(.caption2.weight(.bold)).foregroundStyle(.secondary)
Text(d.subtitle ?? d.title ?? "Séance réalisée")
.font(.headline).lineLimit(1)
Text("Réalisée").font(.caption2).foregroundStyle(.green)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
} else {
if family == .accessoryInline {
Label("Jour OFF", systemImage: "moon.zzz.fill")
} else {
VStack(alignment: .leading, spacing: 2) {
Label("JOUR OFF", systemImage: "moon.zzz.fill")
.font(.caption2.weight(.bold)).foregroundStyle(.secondary)
Text("Pas de séance").font(.footnote)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.lockWidgetBackground()
.widgetURL(URL(string: "https://coach.hypnotruck.ch/calendar"))
}
}
/// Routine quotidienne : avancement au grain EXERCICE (14/24), le même que la
/// page web et l'app Watch. Un grain « bloc » afficherait un autre compte du
/// même geste.
@available(iOS 16.0, *)
struct CoachRoutineView: View {
@Environment(\.widgetFamily) private var family
var entry: CoachSnapshotEntry
var body: some View {
let r = entry.snapshot?.routine
let text = r.map { "\($0.done)/\($0.total)" } ?? ""
Group {
switch family {
case .accessoryInline:
Label("Routine \(text)", systemImage: "figure.cooldown")
case .accessoryRectangular:
VStack(alignment: .leading, spacing: 3) {
Label("ROUTINE", systemImage: "figure.cooldown")
.font(.caption2.weight(.bold)).foregroundStyle(.secondary)
Text(text).font(.headline)
if let r, r.total > 0 {
ProgressView(value: r.fraction).progressViewStyle(.linear)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
default: // .accessoryCircular
Gauge(value: r?.fraction ?? 0, in: 0...1) {
Text("")
} currentValueLabel: {
// Le total ne tient pas dans l'anneau : on montre le nombre
// d'exercices faits, la jauge porte la proportion.
Text(r.map { String($0.done) } ?? "")
}
.gaugeStyle(.accessoryCircular)
}
}
.lockWidgetBackground()
.widgetURL(URL(string: "https://coach.hypnotruck.ch/routine"))
}
}
@available(iOS 16.0, *)
struct CoachLockFormeWidget: Widget {
let kind = "CoachLockFormeWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CoachWidgetProvider()) { entry in
CoachLockFormeView(entry: entry)
}
.configurationDisplayName("Forme (écran verrouillé)")
.description("Ton score de forme, sur l'écran verrouillé.")
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
}
}
@available(iOS 16.0, *)
struct CoachLockSessionWidget: Widget {
let kind = "CoachLockSessionWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CoachWidgetProvider()) { entry in
CoachLockSessionView(entry: entry)
}
.configurationDisplayName("Séance (écran verrouillé)")
.description("La séance du jour, ou ce que tu as fait.")
.supportedFamilies([.accessoryRectangular, .accessoryInline])
}
}
@available(iOS 16.0, *)
struct CoachRoutineWidget: Widget {
let kind = "CoachRoutineWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CoachWidgetProvider()) { entry in
CoachRoutineView(entry: entry)
}
.configurationDisplayName("Routine du jour")
.description("Exercices de la routine cochés aujourd'hui.")
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
}
}