feat(widgets): widgets natifs iPhone (Séance du jour + Score de forme)
Pont WebView→App Group→WidgetKit : - CoachWidgetSnapshot.swift : modèle partagé App↔extension (App Group group.ch.hypnotruck.coach) - CoachWidgetBridge.swift : plugin Capacitor setSnapshot/clear + WidgetCenter.reloadAllTimelines - CoachWidgets.swift : 2 widgets (TimelineProvider + vues SwiftUI, small/medium) - bundle + enregistrement plugin + entitlements App Group (App + CoachLiveActivity) ⚠️ Reste câblage Mac (App Group portail + capability + Target Membership) : docs/widgets-runbook-mac.md. Hook web côté coach_sportif. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,5 +12,9 @@
|
||||
<array>
|
||||
<string>Default</string>
|
||||
</array>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.ch.hypnotruck.coach</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
55
ios/App/App/CoachWidgetBridge.swift
Normal file
55
ios/App/App/CoachWidgetBridge.swift
Normal file
@@ -0,0 +1,55 @@
|
||||
// CoachWidgetBridge.swift
|
||||
// Plugin Capacitor (cible App) : la WebView pousse les données du jour
|
||||
// (séance + score de forme) vers l'App Group, puis on rafraîchit les widgets
|
||||
// d'écran d'accueil. Aucun accès réseau côté widget : tout passe par ce pont.
|
||||
//
|
||||
// Enregistrement : MainViewController.capacitorDidLoad() ->
|
||||
// bridge?.registerPluginInstance(CoachWidgetBridgePlugin())
|
||||
//
|
||||
// Usage JS (no-op gracieux hors app native) :
|
||||
// const W = window.Capacitor?.Plugins?.CoachWidgetBridge;
|
||||
// await W?.setSnapshot({
|
||||
// today: { sport:"running", title:"Sortie longue", subtitle:"1h30 · Z2", done:false },
|
||||
// forme: { score: 72, label:"Prêt" }
|
||||
// });
|
||||
import Foundation
|
||||
import Capacitor
|
||||
import WidgetKit
|
||||
|
||||
@objc(CoachWidgetBridgePlugin)
|
||||
public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let identifier = "CoachWidgetBridgePlugin"
|
||||
public let jsName = "CoachWidgetBridge"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "setSnapshot", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
@objc func setSnapshot(_ call: CAPPluginCall) {
|
||||
var today: CoachWidgetSnapshot.TodaySession?
|
||||
if let t = call.getObject("today") {
|
||||
today = CoachWidgetSnapshot.TodaySession(
|
||||
sport: t["sport"] as? String ?? "rest",
|
||||
title: t["title"] as? String ?? "",
|
||||
subtitle: t["subtitle"] as? String,
|
||||
done: t["done"] as? Bool ?? false
|
||||
)
|
||||
}
|
||||
var forme: CoachWidgetSnapshot.FormeScore?
|
||||
if let f = call.getObject("forme") {
|
||||
forme = CoachWidgetSnapshot.FormeScore(
|
||||
score: f["score"] as? Int,
|
||||
label: f["label"] as? String
|
||||
)
|
||||
}
|
||||
CoachWidgetStore.save(CoachWidgetSnapshot(updatedAt: Date(), today: today, forme: forme))
|
||||
if #available(iOS 14.0, *) { WidgetCenter.shared.reloadAllTimelines() }
|
||||
call.resolve(["ok": true])
|
||||
}
|
||||
|
||||
@objc func clear(_ call: CAPPluginCall) {
|
||||
CoachWidgetStore.clear()
|
||||
if #available(iOS 14.0, *) { WidgetCenter.shared.reloadAllTimelines() }
|
||||
call.resolve(["ok": true])
|
||||
}
|
||||
}
|
||||
56
ios/App/App/CoachWidgetSnapshot.swift
Normal file
56
ios/App/App/CoachWidgetSnapshot.swift
Normal file
@@ -0,0 +1,56 @@
|
||||
// CoachWidgetSnapshot.swift
|
||||
// Modèle de données PARTAGÉ entre l'app (cible App) et l'extension widget
|
||||
// (cible CoachLiveActivity). L'app écrit le snapshot du jour dans l'App Group
|
||||
// via CoachWidgetBridge ; les widgets d'écran d'accueil le lisent.
|
||||
//
|
||||
// ⚠️ Ce fichier doit appartenir aux DEUX cibles (App + CoachLiveActivity) —
|
||||
// cf. docs/widgets-runbook-mac.md (étape « Target Membership »).
|
||||
//
|
||||
// App Group requis : group.ch.hypnotruck.coach (à créer sur le portail Apple
|
||||
// et activer comme capability sur les deux cibles).
|
||||
import Foundation
|
||||
|
||||
enum CoachWidgetStore {
|
||||
/// Identifiant App Group partagé app ↔ extension. Doit correspondre
|
||||
/// EXACTEMENT à la capability "App Groups" des deux cibles.
|
||||
static let appGroup = "group.ch.hypnotruck.coach"
|
||||
static let snapshotKey = "coach_widget_snapshot"
|
||||
|
||||
static var defaults: UserDefaults? { UserDefaults(suiteName: appGroup) }
|
||||
|
||||
static func save(_ snapshot: CoachWidgetSnapshot) {
|
||||
guard let d = defaults, let data = try? JSONEncoder().encode(snapshot) else { return }
|
||||
d.set(data, forKey: snapshotKey)
|
||||
}
|
||||
|
||||
static func load() -> CoachWidgetSnapshot? {
|
||||
guard let d = defaults, let data = d.data(forKey: snapshotKey) else { return nil }
|
||||
return try? JSONDecoder().decode(CoachWidgetSnapshot.self, from: data)
|
||||
}
|
||||
|
||||
static func clear() {
|
||||
defaults?.removeObject(forKey: snapshotKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Instantané des données affichées par les widgets. Volontairement minimal :
|
||||
/// le widget n'a pas d'accès réseau, tout vient de ce snapshot poussé par l'app.
|
||||
struct CoachWidgetSnapshot: Codable {
|
||||
var updatedAt: Date
|
||||
var today: TodaySession?
|
||||
var forme: FormeScore?
|
||||
|
||||
/// 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…
|
||||
var title: String // "Sortie longue", "Renfo bas du corps"…
|
||||
var subtitle: String? // objectif court : "1h30 · Z2", "4×12"…
|
||||
var done: Bool // séance déjà réalisée aujourd'hui
|
||||
}
|
||||
|
||||
/// Score de forme / récupération (nil = pas encore de score).
|
||||
struct FormeScore: Codable {
|
||||
var score: Int? // 0–100
|
||||
var label: String? // "Prêt", "Récup", "Fatigue"…
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,9 @@ class MainViewController: CAPBridgeViewController {
|
||||
// Plugin natif Sign in with Google — signIn() via SDK GoogleSignIn
|
||||
// → id_token POSTé sur /auth/google/web (cf. CoachGoogleAuth.swift).
|
||||
bridge?.registerPluginInstance(CoachGoogleAuthPlugin())
|
||||
// Pont widgets : la WebView pousse les données du jour vers l'App Group
|
||||
// et rafraîchit les widgets d'écran d'accueil (cf. CoachWidgetBridge.swift).
|
||||
bridge?.registerPluginInstance(CoachWidgetBridgePlugin())
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
|
||||
10
ios/App/CoachLiveActivity/CoachLiveActivity.entitlements
Normal file
10
ios/App/CoachLiveActivity/CoachLiveActivity.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.ch.hypnotruck.coach</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,6 +1,7 @@
|
||||
// CoachLiveActivityBundle.swift
|
||||
// Entry point @main de l'extension WidgetKit. Contient le ou les Widgets
|
||||
// exposés par cette extension. Ici juste la Live Activity du live workout.
|
||||
// Entry point @main de l'extension WidgetKit. Contient les Widgets exposés par
|
||||
// cette extension : la Live Activity du live workout + les widgets d'écran
|
||||
// d'accueil (séance du jour, score de forme).
|
||||
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
@@ -9,5 +10,7 @@ import SwiftUI
|
||||
struct CoachLiveActivityBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
CoachLiveActivityWidget()
|
||||
CoachTodaySessionWidget()
|
||||
CoachFormeScoreWidget()
|
||||
}
|
||||
}
|
||||
|
||||
258
ios/App/CoachLiveActivity/CoachWidgets.swift
Normal file
258
ios/App/CoachLiveActivity/CoachWidgets.swift
Normal file
@@ -0,0 +1,258 @@
|
||||
// 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)
|
||||
}
|
||||
|
||||
private var emptyView: some View {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user