feat: implement Haptika PPG and Watch V1

This commit is contained in:
Sacha
2026-07-31 16:19:42 +02:00
parent 6b7d4e0728
commit b9930b7378
249 changed files with 46475 additions and 122 deletions
@@ -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 }
}
}
}