Files
coach-ios/ios/App/CoachLiveActivity/CoachWidgets.swift
Sylvain Bettinelli 4cff1d4603 Les widgets reçoivent la bande et les préréglages au lieu de les inventer
La couleur du score de forme était décidée ici, avec un barème périmé
(vert >= 75, jaune >= 50). Le serveur avait recalé le sien sur la
distribution réelle (65/55/40) parce que le score est borné à [25, 75] :
une bande haute à 75 est inatteignable. Mesuré sur 115 jours de
production, le vert n'est jamais sorti et le rouge couvrait 68 jours.
La correction n'avait pas atteint le binaire.

`FormeScore` porte désormais la bande servie par le serveur, et
`resolvedBand` ne sert que de repli — aligné sur les seuils serveur, pas
sur les anciens.

Le widget de saisie rapide enregistrait un café à 100 ml, volume qui ne
correspond à aucun préréglage : le serveur avait séparé l'expresso (60)
du mug (250) parce qu'un volume unique faussait le journal, et ces
préréglages sont personnalisables. Les boutons viennent maintenant du
snapshot ; `fallbackQuickDrinks` reprend les préréglages par défaut du
serveur.

15 tests ajoutés au banc d'essai Linux, qui compile réellement ces deux
fichiers : 25 tests verts. Le reste (SwiftUI, WidgetKit) n'est pas
compilable ici et n'a été que relu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:42:19 +00:00

514 lines
21 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
/// Couleur de la bande servie par le serveur.
///
/// Les seuils vivaient ici (75 / 50), périmés face à ceux du serveur
/// (65 / 55 / 40). Le score étant borné à [25, 75] par construction, le vert
/// était inatteignable 0 jour sur 115 en production. Voir
/// `CoachWidgetSnapshot.FormeScore.resolvedBand`, qui porte le repli.
private func ringColor(_ band: CoachWidgetSnapshot.FormeBand?) -> Color {
switch band {
case .great: return Color(red: 0.20, green: 0.78, blue: 0.35)
case .good: return Color(red: 0.30, green: 0.85, blue: 0.60)
case .medium: return Color(red: 1.00, green: 0.80, blue: 0.0)
case .low: return Color(red: 1.00, green: 0.27, blue: 0.23)
case nil: return Color.gray
}
}
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(forme?.resolvedBand), 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])
}
}