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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
179
ios/App/App/CoachLiveView.swift
Normal file
179
ios/App/App/CoachLiveView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
98
ios/App/App/LiveStore.swift
Normal file
98
ios/App/App/LiveStore.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user