feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WatchCardiacMirrorView: View {
|
||||
enum Stage { case intro, measuring, ready, mirroring, completed }
|
||||
|
||||
@EnvironmentObject private var model: WatchAppModel
|
||||
@State private var stage: Stage = .intro
|
||||
@State private var initialBPM: Double?
|
||||
@State private var errorMessage: String?
|
||||
@State private var mirrorStartedAt: Date?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 10) {
|
||||
switch stage {
|
||||
case .intro:
|
||||
Image(systemName: "heart.circle.fill").font(.system(size: 40)).foregroundStyle(.pink)
|
||||
Text("Miroir cardiaque").font(.headline)
|
||||
Text(model.connectivity.isReachable ? "Mode connecté : la Watch mesure, l’iPhone peut reconstruire le rythme visuel et haptique." : "Mode Estimation Watch : mesure initiale, arrêt du capteur, puis rythme reconstruit et ralenti.")
|
||||
.font(.caption2).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
|
||||
Button("Mesurer le rythme") { startMeasurement() }.buttonStyle(.borderedProminent)
|
||||
case .measuring:
|
||||
Text(model.measurement.currentBPM.map { "\(Int($0.rounded())) BPM" } ?? "— BPM").font(.title2.bold())
|
||||
measurementProgress
|
||||
Text("Aucune vibration pendant la mesure").font(.caption2).foregroundStyle(.orange)
|
||||
Button("Annuler", role: .cancel) { model.measurement.cancel(); stage = .intro }
|
||||
case .ready:
|
||||
Text(initialBPM.map { "Repère initial \(Int($0.rounded())) BPM" } ?? "Repère indisponible").font(.headline)
|
||||
Text("Le rythme suivant est une estimation reconstruite, pas chaque battement biologique.")
|
||||
.font(.caption2).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
|
||||
if model.connectivity.isReachable {
|
||||
Label("iPhone connecté", systemImage: "iphone.and.arrow.forward").font(.caption).foregroundStyle(WatchTheme.mint)
|
||||
Button("Terminer") { stage = .completed }
|
||||
} else {
|
||||
Button("Lancer le miroir") { startMirror() }.buttonStyle(.borderedProminent)
|
||||
}
|
||||
case .mirroring:
|
||||
estimatedPulse
|
||||
ProgressView(value: model.haptics.elapsedSeconds, total: 120)
|
||||
Text("Ralentissement progressif · −1,5 BPM/min").font(.caption2).foregroundStyle(WatchTheme.secondary)
|
||||
Button("Arrêter", role: .cancel) { model.haptics.cancel(); stage = .completed }
|
||||
case .completed:
|
||||
Image(systemName: "checkmark.circle.fill").font(.title).foregroundStyle(WatchTheme.mint)
|
||||
Text("Expérience terminée").font(.headline)
|
||||
Button("Recommencer") { stage = .intro; model.measurement.resetAfterResult() }
|
||||
}
|
||||
if let errorMessage { Text(errorMessage).font(.caption2).foregroundStyle(.orange) }
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
.navigationTitle("Miroir")
|
||||
.haptikaScreen()
|
||||
.onChange(of: model.measurement.state) { _, state in
|
||||
if case let .completed(summary) = state {
|
||||
initialBPM = summary.averageBPM
|
||||
stage = .ready
|
||||
}
|
||||
}
|
||||
.onChange(of: model.haptics.isRunning) { wasRunning, isRunning in
|
||||
if stage == .mirroring, wasRunning, !isRunning { stage = .completed }
|
||||
}
|
||||
.onDisappear {
|
||||
if stage == .measuring { model.measurement.cancel() }
|
||||
if stage == .mirroring { model.haptics.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var measurementProgress: some View {
|
||||
switch model.measurement.state {
|
||||
case let .waitingForFirstSample(seconds):
|
||||
ProgressView(); Text("Signal · \(seconds) s").font(.caption2)
|
||||
case let .collecting(progress): ProgressView(value: progress)
|
||||
case .finishing: ProgressView("Analyse…")
|
||||
default: EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private var estimatedPulse: some View {
|
||||
TimelineView(.animation(minimumInterval: 1 / 20)) { context in
|
||||
let elapsed = mirrorStartedAt.map { context.date.timeIntervalSince($0) } ?? 0
|
||||
let bpm = max(35, (initialBPM ?? 60) - 1.5 * elapsed / 60)
|
||||
let phase = (elapsed * bpm / 60).truncatingRemainder(dividingBy: 1)
|
||||
let scale = 0.78 + 0.22 * max(0, 1 - phase * 4)
|
||||
VStack {
|
||||
Image(systemName: "heart.fill").font(.system(size: 62)).foregroundStyle(.pink).scaleEffect(scale)
|
||||
Text("≈ \(Int(bpm.rounded())) BPM").font(.headline.monospacedDigit())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startMeasurement() {
|
||||
stage = .measuring
|
||||
model.measurement.resetAfterResult()
|
||||
Task {
|
||||
do {
|
||||
try await model.measurement.requestAuthorization()
|
||||
try await model.measurement.start()
|
||||
} catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
|
||||
private func startMirror() {
|
||||
guard let initialBPM else { return }
|
||||
mirrorStartedAt = Date()
|
||||
stage = .mirroring
|
||||
Task {
|
||||
do { try await model.haptics.startCardiacMirror(initialBPM: initialBPM) }
|
||||
catch { errorMessage = error.localizedDescription; stage = .ready }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user