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 */; };
|
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||||
543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */; };
|
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 */; };
|
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 */; };
|
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A420239FC6D065C8A599B61C /* ConnectivityManager.swift */; };
|
||||||
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4A07811751FB78AA0002 /* CoachAuth.swift */; };
|
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4A07811751FB78AA0002 /* CoachAuth.swift */; };
|
||||||
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; 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>"; };
|
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>"; };
|
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>"; };
|
88F28EB02FBB330E00E8306E /* CoachWorkoutObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutObserver.swift; sourceTree = "<group>"; };
|
||||||
@@ -149,6 +153,8 @@
|
|||||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||||
50B271D01FEDC1A000F3C39B /* public */,
|
50B271D01FEDC1A000F3C39B /* public */,
|
||||||
A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */,
|
A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */,
|
||||||
|
6030BD533EBC2D1D26DB9479 /* LiveStore.swift */,
|
||||||
|
340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */,
|
||||||
);
|
);
|
||||||
path = App;
|
path = App;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -292,6 +298,8 @@
|
|||||||
88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */,
|
88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */,
|
||||||
E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */,
|
E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */,
|
||||||
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */,
|
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */,
|
||||||
|
97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */,
|
||||||
|
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Capacitor
|
import Capacitor
|
||||||
import WatchConnectivity
|
import WatchConnectivity
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
import os
|
import os
|
||||||
|
|
||||||
private let liveLog = Logger(subsystem: "ch.hypnotruck.coach", category: "live")
|
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 identifier = "CoachLiveBridgePlugin"
|
||||||
public let jsName = "CoachLiveBridge"
|
public let jsName = "CoachLiveBridge"
|
||||||
public let pluginMethods: [CAPPluginMethod] = [
|
public let pluginMethods: [CAPPluginMethod] = [
|
||||||
CAPPluginMethod(name: "getStatus", returnType: CAPPluginReturnPromise)
|
CAPPluginMethod(name: "getStatus", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "openNativeLive", returnType: CAPPluginReturnPromise)
|
||||||
]
|
]
|
||||||
|
|
||||||
override public func load() {
|
override public func load() {
|
||||||
@@ -55,6 +58,35 @@ public class CoachLiveBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
self?.notifyListeners("liveSample", data: sample)
|
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