feat(live): vue native SwiftUI avec vrai Liquid Glass iOS 26

- LiveStore.swift : bus singleton main-actor, alimenté par CoachLiveBridge,
  observable par SwiftUI. Watchdog 8s pour status .lost.
- CoachLiveView.swift : vue SwiftUI @available(iOS 26.0,*) avec
  .glassEffect()/GlassEffectContainer (vrai Liquid Glass), aurora background,
  4 cartes métriques teintées, pulse heartbeat sur FC en direct.
- CoachLiveBridge : nouvelle méthode plugin openNativeLive() qui présente
  CoachLiveView en plein écran (fallback web pour iOS < 26). emit() alimente
  aussi LiveStore.shared.
- pbxproj : registration des 2 nouveaux .swift dans la target App.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain
2026-05-26 18:05:21 +02:00
parent 3c0e266cdc
commit f65d96e908
4 changed files with 318 additions and 1 deletions

View File

@@ -22,7 +22,9 @@
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */; };
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */; };
88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88F28EB02FBB330E00E8306E /* CoachWorkoutObserver.swift */; };
97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */; };
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A420239FC6D065C8A599B61C /* ConnectivityManager.swift */; };
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4A07811751FB78AA0002 /* CoachAuth.swift */; };
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; };
@@ -59,6 +61,7 @@
2517374227A4072A4D6AFC15 /* CoachWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CoachWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
3342ACCE085010828CF63565 /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveView.swift; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@@ -67,6 +70,7 @@
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
6030BD533EBC2D1D26DB9479 /* LiveStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveStore.swift; sourceTree = "<group>"; };
82122ACF7E5A608AD1D55D24 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
88A837392FAA741C00DDD144 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
88F28EB02FBB330E00E8306E /* CoachWorkoutObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutObserver.swift; sourceTree = "<group>"; };
@@ -149,6 +153,8 @@
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */,
6030BD533EBC2D1D26DB9479 /* LiveStore.swift */,
340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */,
);
path = App;
sourceTree = "<group>";
@@ -292,6 +298,8 @@
88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */,
E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */,
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */,
97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */,
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@@ -14,6 +14,8 @@
import Foundation
import Capacitor
import WatchConnectivity
import SwiftUI
import UIKit
import os
private let liveLog = Logger(subsystem: "ch.hypnotruck.coach", category: "live")
@@ -23,7 +25,8 @@ public class CoachLiveBridgePlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "CoachLiveBridgePlugin"
public let jsName = "CoachLiveBridge"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "getStatus", returnType: CAPPluginReturnPromise)
CAPPluginMethod(name: "getStatus", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "openNativeLive", returnType: CAPPluginReturnPromise)
]
override public func load() {
@@ -55,6 +58,35 @@ public class CoachLiveBridgePlugin: CAPPlugin, CAPBridgedPlugin {
DispatchQueue.main.async { [weak self] in
self?.notifyListeners("liveSample", data: sample)
}
// Bus interne pour la vue native CoachLiveView (Liquid Glass iOS 26).
LiveStore.shared.ingest(sample)
}
/// Présente la vue SwiftUI native (vrai Liquid Glass iOS 26) en plein écran
/// par-dessus la WebView. Appelé depuis le JS de /live au chargement.
/// Sur iOS < 26, no-op (la WebView affiche la version web glassmorphism).
@objc func openNativeLive(_ call: CAPPluginCall) {
guard #available(iOS 26.0, *) else {
call.resolve(["presented": false, "reason": "iOS < 26"])
return
}
DispatchQueue.main.async { [weak self] in
guard let vc = self?.bridge?.viewController else {
call.resolve(["presented": false, "reason": "no host vc"])
return
}
// Évite de re-présenter si une autre vue est déjà au-dessus.
if vc.presentedViewController != nil {
call.resolve(["presented": false, "reason": "already presented"])
return
}
let host = UIHostingController(rootView: CoachLiveView())
host.modalPresentationStyle = .fullScreen
host.view.backgroundColor = .black
vc.present(host, animated: true) {
call.resolve(["presented": true])
}
}
}
}

View File

@@ -0,0 +1,179 @@
// CoachLiveView.swift
// Vue SwiftUI native pour le live workout, avec le VRAI Liquid Glass d'iOS 26
// (.glassEffect / GlassEffectContainer). Présentée en sheet par-dessus la
// WebView quand /live se charge (cf. CoachLiveBridge.openNativeLive). Source
// de données : LiveStore.shared (alimenté par CoachLiveBridge via WCSession).
//
// Fallback : si iOS < 26, openNativeLive ne présente rien et la WebView affiche
// /live en CSS glassmorphism (cf. live_workout.html).
import SwiftUI
@available(iOS 26.0, *)
struct CoachLiveView: View {
@StateObject private var store = LiveStore.shared
@Environment(\.dismiss) private var dismiss
@State private var pulse: Bool = false
var body: some View {
ZStack {
auroraBackground.ignoresSafeArea()
VStack(spacing: 18) {
header
statusRow
GlassEffectContainer(spacing: 14) {
LazyVGrid(columns: [GridItem(.flexible(), spacing: 14),
GridItem(.flexible(), spacing: 14)],
spacing: 14) {
MetricCard(label: "Fréquence",
value: store.heartRateText,
unit: "bpm",
color: .red,
isPulsing: store.status == .live,
pulsePhase: pulse)
MetricCard(label: "Calories",
value: store.kcalText,
unit: "kcal",
color: .green,
isPulsing: false,
pulsePhase: pulse)
MetricCard(label: "Distance",
value: store.distanceKmText,
unit: "km",
color: .orange,
isPulsing: false,
pulsePhase: pulse)
MetricCard(label: "Allure",
value: store.paceText,
unit: "min/km",
color: .purple,
isPulsing: false,
pulsePhase: pulse)
}
}
if let activity = store.activityLabel {
Label(activity, systemImage: "figure.run")
.font(.callout.weight(.medium))
.padding(.horizontal, 16).padding(.vertical, 8)
.glassEffect(.regular, in: .capsule)
}
Spacer(minLength: 0)
Text(store.elapsedText)
.font(.system(size: 56, weight: .heavy, design: .rounded).monospacedDigit())
.foregroundStyle(.white)
.padding(.horizontal, 36).padding(.vertical, 14)
.glassEffect(.regular.tint(.white.opacity(0.10)), in: .capsule)
}
.padding(20)
}
.preferredColorScheme(.dark)
.onAppear {
withAnimation(.easeInOut(duration: 0.55).repeatForever(autoreverses: true)) {
pulse = true
}
}
}
// MARK: - Sub-views
private var header: some View {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 2) {
Text("Live workout").font(.largeTitle.bold())
Text("Apple Watch → iPhone")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: 15, weight: .semibold))
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
.glassEffect(.regular.interactive(), in: .circle)
}
}
private var statusRow: some View {
HStack(spacing: 10) {
Circle()
.fill(statusColor)
.frame(width: 10, height: 10)
.scaleEffect(store.status == .live && pulse ? 1.45 : 1.0)
Text(statusText).font(.callout)
Spacer()
}
.padding(.horizontal, 14).padding(.vertical, 10)
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
}
private var auroraBackground: some View {
ZStack {
Color.black
RadialGradient(colors: [Color.red.opacity(0.55), .clear],
center: .init(x: 0.18, y: 0.12),
startRadius: 5, endRadius: 480)
RadialGradient(colors: [Color.orange.opacity(0.45), .clear],
center: .init(x: 0.88, y: 0.88),
startRadius: 5, endRadius: 520)
RadialGradient(colors: [Color.green.opacity(0.30), .clear],
center: .init(x: 0.65, y: 0.42),
startRadius: 5, endRadius: 420)
}
.blur(radius: 24)
}
private var statusColor: Color {
switch store.status {
case .waiting: return .orange
case .live: return .green
case .lost: return .red
}
}
private var statusText: String {
switch store.status {
case .waiting: return "En attente de la Watch…"
case .live: return "En direct"
case .lost: return "Connexion perdue — Bluetooth ?"
}
}
}
// MARK: - MetricCard (extrait pour clarté + scaleEffect isolé)
@available(iOS 26.0, *)
private struct MetricCard: View {
let label: String
let value: String
let unit: String
let color: Color
let isPulsing: Bool
let pulsePhase: Bool
var body: some View {
VStack(spacing: 6) {
Text(label.uppercased())
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.tracking(0.6)
Text(value)
.font(.system(size: 48, weight: .heavy, design: .rounded).monospacedDigit())
.foregroundStyle(color)
.shadow(color: color.opacity(0.55), radius: 18, x: 0, y: 0)
.contentTransition(.numericText())
Text(unit)
.font(.caption2)
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity, minHeight: 130)
.padding(.vertical, 14)
.glassEffect(.regular.tint(color.opacity(0.18)),
in: RoundedRectangle(cornerRadius: 22, style: .continuous))
.scaleEffect(isPulsing && pulsePhase ? 1.025 : 1.0)
}
}

View File

@@ -0,0 +1,98 @@
// LiveStore.swift
// Bus interne main-actor pour les samples de séance live. Alimenté par
// CoachLiveBridge (WCSession) et consommé par la vue SwiftUI native
// CoachLiveView (Liquid Glass iOS 26). Le bridge continue aussi à publier
// vers le JS pour la page web /live (fallback / debug).
import Foundation
import Combine
@MainActor
final class LiveStore: ObservableObject {
static let shared = LiveStore()
enum Status: Equatable {
case waiting
case live
case lost
}
@Published var heartRate: Double = 0
@Published var activeEnergyKcal: Double = 0
@Published var distanceMeters: Double = 0
@Published var elapsedSec: TimeInterval = 0
@Published var activityType: String? = nil
@Published var status: Status = .waiting
@Published var lastSampleAt: Date? = nil
private var watchdog: Timer?
private let lostThreshold: TimeInterval = 8
private init() {
watchdog = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in
guard let self else { return }
guard self.status == .live, let ts = self.lastSampleAt else { return }
if Date().timeIntervalSince(ts) > self.lostThreshold {
self.status = .lost
}
}
}
}
/// Appelé depuis CoachLiveBridge à chaque sample reçu (WCSession).
/// nonisolated : peut être appelé depuis n'importe quel thread (WCSession
/// callbacks viennent hors main).
nonisolated func ingest(_ sample: [String: Any]) {
let hr = sample["heartRate"] as? Double
let kcal = sample["activeEnergyKcal"] as? Double
let dist = sample["distanceMeters"] as? Double
let elap = sample["elapsedSec"] as? Double
let act = sample["activityType"] as? String
Task { @MainActor [weak self] in
guard let self else { return }
if let v = hr { self.heartRate = v }
if let v = kcal { self.activeEnergyKcal = v }
if let v = dist { self.distanceMeters = v }
if let v = elap { self.elapsedSec = v }
if let v = act { self.activityType = v }
self.lastSampleAt = Date()
self.status = .live
}
}
// MARK: - Formatted accessors (utilisés par CoachLiveView)
var heartRateText: String { heartRate > 0 ? String(Int(heartRate.rounded())) : "" }
var kcalText: String { activeEnergyKcal > 0 ? String(Int(activeEnergyKcal.rounded())) : "" }
var distanceKmText: String {
distanceMeters > 0 ? String(format: "%.2f", distanceMeters / 1000) : ""
}
var paceText: String {
guard distanceMeters > 50, elapsedSec > 10 else { return "" }
let minPerKm = (elapsedSec / 60) / (distanceMeters / 1000)
guard minPerKm > 2, minPerKm < 30 else { return "" }
let m = Int(minPerKm)
let s = Int(((minPerKm - Double(m)) * 60).rounded())
return "\(m):\(String(format: "%02d", s))"
}
var elapsedText: String {
let sec = Int(elapsedSec)
let h = sec / 3600
let m = (sec % 3600) / 60
let s = sec % 60
if h > 0 { return "\(h)h\(String(format: "%02d", m))" }
return "\(m):\(String(format: "%02d", s))"
}
var activityLabel: String? {
switch activityType {
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é"
default: return nil
}
}
}