Files
coach-ios/ios/App/CoachLiveActivity/CoachWidgets.swift
Sylvain Bettinelli 12b56a4d39 Le widget disait « JOUR OFF » les jours de sortie non planifiee
Pendant du commit web 25f53b5. Quand la seance prevue est remplacee par une
autre activite, le plan du jour se vide : le widget iPhone affichait « JOUR
OFF / Pas de seance prevue » et la complication Watch « Jour OFF » — le jour
meme d'une sortie de 20 km. Le snapshot ne portait que le PLAN.

CoachWidgetSnapshot gagne doneToday (sport, titre, sous-titre), optionnel :
un snapshot ecrit par une version anterieure reste decodable. today decrit le
plan, doneToday decrit la journee ; les deux peuvent coexister.

CoachWidgetBridge le conserve quand l'appel l'omet, comme la nutrition — mais
seulement si le snapshot precedent date d'aujourd'hui, sinon la sortie de la
veille resterait affichee indefiniment.

Les deux vues basculent sur l'activite realisee, avec la pastille verte
« Realisee » deja utilisee pour une seance planifiee faite. Le sous-titre
(« 20,3 km · 56 min ») passe devant le titre : le titre d'une seance importee
est souvent vide.

⚠️ NON COMPILE — a builder au prochain passage sur le Mac. Aucun fichier
nouveau, donc rien a cabler dans le pbxproj : les quatre fichiers touches sont
deja membres de leurs cibles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 17:56:53 +00:00

291 lines
11 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) {
completion(CoachSnapshotEntry(date: Date(), snapshot: CoachWidgetStore.load()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<CoachSnapshotEntry>) -> Void) {
let entry = CoachSnapshotEntry(date: Date(), snapshot: CoachWidgetStore.load())
// L'app rafraîchit explicitement (WidgetCenter.reloadAllTimelines) à
// chaque setSnapshot. On planifie en plus un refresh horaire de courtoisie.
let next = Calendar.current.date(byAdding: .hour, value: 1, to: Date())
?? Date().addingTimeInterval(3600)
completion(Timeline(entries: [entry], policy: .after(next)))
}
}
// 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])
}
}