diff --git a/ios/App/App/CoachWidgetBridge.swift b/ios/App/App/CoachWidgetBridge.swift index a44b336..e189f41 100644 --- a/ios/App/App/CoachWidgetBridge.swift +++ b/ios/App/App/CoachWidgetBridge.swift @@ -67,6 +67,15 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin { ) } + var routine: CoachWidgetSnapshot.RoutineProgress? + if let r = call.getObject("routine"), + let total = (r["total"] as? NSNumber)?.intValue, total > 0 { + routine = CoachWidgetSnapshot.RoutineProgress( + done: (r["done"] as? NSNumber)?.intValue ?? 0, + total: total + ) + } + var snapshot = CoachWidgetSnapshot(updatedAt: Date(), today: today, forme: forme) /* `doneToday` suit la même règle que la nutrition : absent de l'appel → on garde le précédent. Un appel partiel effacerait sinon l'activité @@ -76,6 +85,10 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin { veille resterait affichée indéfiniment. */ snapshot.doneToday = done ?? (Calendar.current.isDateInToday(previous?.updatedAt ?? .distantPast) ? previous?.doneToday : nil) + // Même garde du jour que `doneToday` : la routine se remet à zéro au + // réveil, un report aveugle afficherait 24/24 dès le matin. + snapshot.routine = routine ?? (Calendar.current.isDateInToday(previous?.updatedAt ?? .distantPast) + ? previous?.routine : nil) snapshot.waterMlToday = number("waterMlToday") ?? previous?.waterMlToday snapshot.coffeeCountToday = (call.getValue("coffeeCountToday") as? NSNumber)?.intValue ?? previous?.coffeeCountToday diff --git a/ios/App/App/CoachWidgetSnapshot.swift b/ios/App/App/CoachWidgetSnapshot.swift index 900729a..ab818e8 100644 --- a/ios/App/App/CoachWidgetSnapshot.swift +++ b/ios/App/App/CoachWidgetSnapshot.swift @@ -70,6 +70,11 @@ struct CoachWidgetSnapshot: Codable { /// Optionnel : un snapshot écrit par une version antérieure reste lisible. var doneToday: DoneActivity? = nil + /// Avancement de la routine quotidienne (nil = non poussée). + /// Optionnel, comme les autres ajouts : un snapshot d'une version + /// antérieure reste décodable. + var routine: RoutineProgress? = nil + /// Séance planifiée du jour (nil = jour OFF / pas de séance). struct TodaySession: Codable { var sport: String // running, cycling, strength, mobility, hiking, rest… @@ -78,6 +83,22 @@ struct CoachWidgetSnapshot: Codable { var done: Bool // séance déjà réalisée aujourd'hui } + /// Exercices de la routine cochés aujourd'hui, sur le total du jour. + /// Le grain est l'EXERCICE, pas le bloc : c'est celui de la page web et + /// de l'app Watch depuis le 2026-08-03, et deux grains concurrents + /// donneraient deux comptes différents du même geste. + struct RoutineProgress: Codable { + var done: Int + var total: Int + + /// 0…1, borné. `total` nul ne doit jamais produire de division par zéro + /// ni une jauge pleine par accident. + var fraction: Double { + guard total > 0 else { return 0 } + return min(1, max(0, Double(done) / Double(total))) + } + } + /// Ce qui a été fait dans la journée : sport, et de quoi le qualifier. struct DoneActivity: Codable { var sport: String // running, cycling, walking, strength… diff --git a/ios/App/CoachLiveActivity/CoachLiveActivityBundle.swift b/ios/App/CoachLiveActivity/CoachLiveActivityBundle.swift index 70adbb7..24476cb 100644 --- a/ios/App/CoachLiveActivity/CoachLiveActivityBundle.swift +++ b/ios/App/CoachLiveActivity/CoachLiveActivityBundle.swift @@ -14,5 +14,13 @@ struct CoachLiveActivityBundle: WidgetBundle { CoachFormeScoreWidget() // Saisie rapide : App Intents (iOS 17+) pour l'eau et le café. if #available(iOS 17.0, *) { CoachQuickWidget() } + // Écran verrouillé (iOS 16+) : mêmes familles que les complications + // Watch. Trois widgets distincts plutôt qu'un seul multi-familles, + // pour qu'ils soient choisissables séparément dans chaque emplacement. + if #available(iOS 16.0, *) { + CoachLockFormeWidget() + CoachLockSessionWidget() + CoachRoutineWidget() + } } } diff --git a/ios/App/CoachLiveActivity/CoachWidgets.swift b/ios/App/CoachLiveActivity/CoachWidgets.swift index 63ac695..fd1618c 100644 --- a/ios/App/CoachLiveActivity/CoachWidgets.swift +++ b/ios/App/CoachLiveActivity/CoachWidgets.swift @@ -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]) + } +} diff --git a/ios/App/CoachWatchWidgets/CoachWatchComplications.swift b/ios/App/CoachWatchWidgets/CoachWatchComplications.swift index 9c6d203..0ada56c 100644 --- a/ios/App/CoachWatchWidgets/CoachWatchComplications.swift +++ b/ios/App/CoachWatchWidgets/CoachWatchComplications.swift @@ -146,7 +146,16 @@ struct CoachWatchSessionView: View { let today = entry.snapshot?.today Group { if let s = today { - if family == .accessoryInline { + if family == .accessoryCircular { + // Le cadran ne tient pas un titre : l'icône du sport porte + // l'information, la pastille verte dit qu'elle est faite. + ZStack { + AccessoryWidgetBackground() + Image(systemName: watchSportSymbol(s.sport)) + .font(.title3) + .foregroundStyle(s.done ? .green : .primary) + } + } else if family == .accessoryInline { Label(s.title, systemImage: watchSportSymbol(s.sport)) } else { // accessoryRectangular VStack(alignment: .leading, spacing: 2) { @@ -206,7 +215,59 @@ struct CoachWatchSessionWidget: Widget { } .configurationDisplayName("Séance du jour") .description("La séance prévue aujourd'hui.") - .supportedFamilies([.accessoryRectangular, .accessoryInline]) + .supportedFamilies([.accessoryRectangular, .accessoryInline, .accessoryCircular]) + } +} + +// MARK: - Complication « Routine du jour » (circular / rectangular / inline) + +/// Avancement au grain EXERCICE (14/24) — le même que la page web et l'app +/// Watch depuis le 2026-08-03. Un grain « bloc » donnerait un second compte +/// du même geste, et les deux finiraient par diverger. +struct CoachWatchRoutineView: View { + @Environment(\.widgetFamily) private var family + var entry: WatchEntry + + 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: 2) { + 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) + } + } + default: // accessoryCircular + Gauge(value: r?.fraction ?? 0, in: 0...1) { + Image(systemName: "figure.cooldown") + } currentValueLabel: { + Text(r.map { String($0.done) } ?? "—") + } + .gaugeStyle(.accessoryCircular) + .tint(.mint) + } + } + .watchWidgetBackground() + .widgetURL(URL(string: "https://coach.hypnotruck.ch/routine")) + } +} + +struct CoachWatchRoutineWidget: Widget { + let kind = "CoachWatchRoutineWidget" + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: WatchProvider()) { entry in + CoachWatchRoutineView(entry: entry) + } + .configurationDisplayName("Routine") + .description("Exercices de la routine cochés aujourd'hui.") + .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline]) } } @@ -217,5 +278,6 @@ struct CoachWatchWidgetsBundle: WidgetBundle { var body: some Widget { CoachWatchFormeWidget() CoachWatchSessionWidget() + CoachWatchRoutineWidget() } }