diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 76c687e..10664c4 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -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 = ""; }; 3342ACCE085010828CF63565 /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveView.swift; sourceTree = ""; }; 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; 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 = ""; }; @@ -67,6 +70,7 @@ 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; + 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveStore.swift; sourceTree = ""; }; 82122ACF7E5A608AD1D55D24 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 88A837392FAA741C00DDD144 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = ""; }; 88F28EB02FBB330E00E8306E /* CoachWorkoutObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutObserver.swift; sourceTree = ""; }; @@ -149,6 +153,8 @@ 2FAD9762203C412B000D30F8 /* config.xml */, 50B271D01FEDC1A000F3C39B /* public */, A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */, + 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */, + 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */, ); path = App; sourceTree = ""; @@ -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; }; diff --git a/ios/App/App/CoachLiveBridge.swift b/ios/App/App/CoachLiveBridge.swift index 2d35013..f74eb35 100644 --- a/ios/App/App/CoachLiveBridge.swift +++ b/ios/App/App/CoachLiveBridge.swift @@ -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]) + } + } } } diff --git a/ios/App/App/CoachLiveView.swift b/ios/App/App/CoachLiveView.swift new file mode 100644 index 0000000..d739750 --- /dev/null +++ b/ios/App/App/CoachLiveView.swift @@ -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) + } +} diff --git a/ios/App/App/LiveStore.swift b/ios/App/App/LiveStore.swift new file mode 100644 index 0000000..604c366 --- /dev/null +++ b/ios/App/App/LiveStore.swift @@ -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 + } + } +}