Files
coach-ios/ios/App/CoachLiveActivity/CoachWidgets.swift
Sylvain Bettinelli 3202e03a63 Trois widgets d'ecran verrouille et une complication Routine
Les familles accessory* n'etaient declarees que cote Watch, alors que ce sont
exactement celles de l'ecran verrouille iOS 16+. Les vues existaient donc
deja : il ne manquait que la declaration cote iPhone.

Ajoutes sur l'ecran verrouille : Forme (circulaire/rectangulaire/inline),
Seance (rectangulaire/inline, avec la bascule doneToday du commit precedent)
et Routine du jour. Sur la Watch : complication Routine, et la seance gagne la
famille circulaire (icone du sport, verte quand elle est faite).

Le snapshot gagne RoutineProgress {done, total} au grain EXERCICE — celui de
la page web et de l'app Watch depuis le 03/08. `fraction` borne le ratio et
protege de la division par zero ; un total nul ne doit pas remplir la jauge.
Meme garde du jour que doneToday : la routine repart a zero au reveil, un
report aveugle afficherait 24/24 des le matin.

Vues dupliquees plutot que partagees entre les deux extensions : elles ne
partagent aucun fichier et l'App Group n'est pas commun entre appareils. Trois
petites vues coutent moins qu'un fichier de plus a cabler dans deux cibles —
et pour la meme raison tout est ajoute a CoachWidgets.swift, deja membre de sa
cible : AUCUN fichier nouveau, donc rien a cabler dans le pbxproj.

⚠️ NON COMPILE — a builder au prochain passage sur le Mac, avec le correctif
« JOUR OFF » (12b56a4).

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

487 lines
19 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])
}
}
// 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])
}
}