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,138 @@
import Charts
import SwiftUI
struct WatchCoherenceView: View {
@EnvironmentObject private var model: WatchAppModel
@State private var selectedDuration = 180
@State private var startedAt: Date?
@State private var savedCompletion = false
private let regulation = WatchProtocolCatalog.protocolWithID("myCoherence")!
var body: some View {
ScrollView {
VStack(spacing: 10) {
switch model.measurement.state {
case .idle, .authorizing:
Text("Respirez 5 s / 5 s").font(.headline)
Text("Le guide reste visuel : aucune vibration ne peut interrompre la mesure cardiaque.")
.font(.caption2).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
Picker("Durée", selection: $selectedDuration) {
Text("3 min").tag(180)
Text("5 min").tag(300)
}
Button("Démarrer") { start() }.buttonStyle(.borderedProminent)
case let .waitingForFirstSample(seconds):
BreathOrb(regulation: regulation, startedAt: startedAt ?? Date())
Text("Signal cardiaque… \(seconds) s").font(.caption2)
cancelButton
case let .collecting(progress):
BreathOrb(regulation: regulation, startedAt: startedAt ?? Date())
Text(model.measurement.currentBPM.map { "\(Int($0.rounded())) BPM" } ?? "— BPM").font(.title3.bold())
heartRateTrend(samples: Array(model.measurement.samples.suffix(30)))
ProgressView(value: progress)
Text("Couverture \(Int(model.measurement.samples.last?.date.timeIntervalSince(model.measurement.samples.first?.date ?? Date()) ?? 0)) s")
.font(.caption2).foregroundStyle(WatchTheme.secondary)
cancelButton
case .finishing:
ProgressView("Sauvegarde…")
case let .completed(summary):
Image(systemName: "checkmark.circle.fill").font(.title).foregroundStyle(WatchTheme.mint)
Text("Séance terminée").font(.headline)
Text(summary.averageBPM.map { "Pouls moyen \(Int($0.rounded())) BPM" } ?? "Pouls non disponible")
.font(.caption)
heartRateTrend(samples: summary.samples)
Text("Couverture \(Int(summary.coverageSeconds)) s · tendance \(trendLabel(summary.samples))")
.font(.caption2).foregroundStyle(WatchTheme.secondary)
Text("Aucun score de cohérence ou de PRV n’est inventé.").font(.caption2).foregroundStyle(WatchTheme.secondary)
Button("Terminer") { model.measurement.resetAfterResult() }.buttonStyle(.borderedProminent)
case let .failed(message):
Text("Séance interrompue").font(.headline)
Text(message).font(.caption2).foregroundStyle(WatchTheme.secondary)
Button("Retour") { model.measurement.resetAfterResult() }
}
}
.padding(.horizontal, 4)
}
.navigationTitle("Ma Cohérence")
.haptikaScreen()
.onChange(of: model.measurement.state) { _, newState in
guard case let .completed(summary) = newState, !savedCompletion, let startedAt else { return }
savedCompletion = true
model.enqueueSession(.init(
protocolID: regulation.id,
startedAt: startedAt,
endedAt: summary.endedAt,
durationSeconds: summary.endedAt.timeIntervalSince(startedAt),
state: .completed,
afterMeasurementID: summary.id,
healthKitWorkoutID: model.measurement.lastWorkoutUUID
))
}
}
private func start() {
startedAt = Date()
savedCompletion = false
Task {
do {
try await model.measurement.requestAuthorization()
try await model.measurement.start(
mode: .guidedMindAndBody(saveToHealth: model.store.preferences.saveMindAndBodyWorkouts),
collectionDurationSeconds: TimeInterval(selectedDuration)
)
} catch {}
}
}
private var cancelButton: some View {
Button("Arrêter", role: .cancel) { model.measurement.cancel() }.buttonStyle(.bordered)
}
private func heartRateTrend(samples: [WatchHeartRateSampleV1]) -> some View {
Chart(samples) { sample in
LineMark(x: .value("Temps", sample.date), y: .value("BPM", sample.bpm))
.foregroundStyle(WatchTheme.mint)
.interpolationMethod(.catmullRom)
}
.chartXAxis(.hidden)
.chartYAxis(.hidden)
.frame(height: 44)
}
private func trendLabel(_ samples: [WatchHeartRateSampleV1]) -> String {
guard samples.count >= 4 else { return "en construction" }
let midpoint = samples.count / 2
let first = samples.prefix(midpoint).map(\.bpm)
let last = samples.suffix(from: midpoint).map(\.bpm)
let delta = (last.reduce(0, +) / Double(last.count)) - (first.reduce(0, +) / Double(first.count))
if delta < -2 { return "en baisse" }
if delta > 2 { return "en hausse" }
return "stable"
}
}
struct BreathOrb: View {
let regulation: WatchRegulationProtocol
let startedAt: Date
var body: some View {
TimelineView(.animation(minimumInterval: 1 / 20)) { context in
let elapsed = context.date.timeIntervalSince(startedAt)
let position = RegulationPhaseEngine.position(in: regulation, elapsedSeconds: elapsed)
let isExpanding = position?.phase.kind == .inhale
let fraction = position?.progress ?? 0
let scale = isExpanding ? 0.6 + fraction * 0.4 : 1 - fraction * 0.4
VStack(spacing: 6) {
ZStack {
Circle().fill(WatchTheme.accent.opacity(0.12)).frame(width: 108, height: 108)
Circle()
.fill(RadialGradient(colors: [WatchTheme.mint, WatchTheme.accent.opacity(0.25)], center: .center, startRadius: 2, endRadius: 52))
.frame(width: 88, height: 88)
.scaleEffect(scale)
}
Text(position?.phase.label ?? "Respirez").font(.headline)
}
}
}
}