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>
This commit is contained in:
Sylvain Bettinelli
2026-08-11 18:08:13 +00:00
parent 12b56a4d39
commit 3202e03a63
5 changed files with 302 additions and 2 deletions

View File

@@ -288,3 +288,199 @@ struct CoachFormeScoreWidget: Widget {
.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])
}
}