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,178 @@
import SwiftUI
struct WatchBeforeAfterFlowView: View {
enum Stage { case intro, before, beforeFeeling, exercise, after, afterFeeling, result }
@EnvironmentObject private var model: WatchAppModel
let regulation: WatchRegulationProtocol
@State private var stage: Stage = .intro
@State private var before: WatchMeasurementSummaryV1?
@State private var after: WatchMeasurementSummaryV1?
@State private var exerciseStartedAt: Date?
@State private var flowError: String?
@State private var selectedFeeling = 3
@State private var feelingBefore: Int?
@State private var feelingAfter: Int?
var body: some View {
ScrollView {
VStack(spacing: 10) {
switch stage {
case .intro:
Image(systemName: "arrow.left.arrow.right.heart.fill").font(.title).foregroundStyle(WatchTheme.accent)
Text("Avant · Exercice · Après").font(.headline).multilineTextAlignment(.center)
Text("Deux mesures identiques encadrent l’exercice pour comparer le BPM et la qualité, sans score de stress.")
.font(.caption2).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
Button("Mesurer avant") { startMeasurement(stage: .before) }.buttonStyle(.borderedProminent)
case .before, .after:
measurementContent
case .beforeFeeling:
feelingContent(title: "Comment vous sentez-vous avant ?") {
feelingBefore = selectedFeeling
if var measurement = before {
measurement.feeling = selectedFeeling
measurement.feelingRecordedAt = Date()
measurement.updatedAt = Date()
before = measurement
model.enqueueMeasurement(measurement)
}
stage = .exercise
exerciseStartedAt = Date()
Task {
do { try await model.haptics.start(protocol: regulation) }
catch { flowError = error.localizedDescription }
}
}
case .exercise:
if model.haptics.isRunning, let exerciseStartedAt {
BreathOrb(regulation: regulation, startedAt: exerciseStartedAt)
ProgressView(value: model.haptics.elapsedSeconds, total: regulation.durationSeconds)
Button("Arrêter", role: .cancel) { model.haptics.cancel() }
} else {
Text("Exercice terminé").font(.headline)
Button("Mesurer après") { startMeasurement(stage: .after) }.buttonStyle(.borderedProminent)
}
case .afterFeeling:
feelingContent(title: "Comment vous sentez-vous après ?") {
feelingAfter = selectedFeeling
if var measurement = after {
measurement.feeling = selectedFeeling
measurement.feelingRecordedAt = Date()
measurement.updatedAt = Date()
after = measurement
model.enqueueMeasurement(measurement)
}
saveCompletedSession()
stage = .result
}
case .result:
resultContent
}
if let flowError { Text(flowError).font(.caption2).foregroundStyle(.orange) }
}
.padding(.horizontal, 4)
}
.navigationTitle("Comparaison")
.haptikaScreen()
.onChange(of: model.measurement.state) { _, newState in handleMeasurementState(newState) }
}
@ViewBuilder
private var measurementContent: some View {
switch model.measurement.state {
case let .waitingForFirstSample(seconds):
ProgressView()
Text("Recherche du signal · \(seconds) s").font(.caption2)
case let .collecting(progress):
Text(model.measurement.currentBPM.map { "\(Int($0.rounded())) BPM" } ?? "— BPM").font(.title2.bold())
ProgressView(value: progress)
Text(stage == .before ? "Mesure avant" : "Mesure après").font(.caption2)
case .finishing:
ProgressView("Analyse…")
default:
ProgressView()
}
Button("Annuler", role: .cancel) { model.measurement.cancel(); stage = .intro }
}
private var resultContent: some View {
VStack(spacing: 9) {
Text("Votre comparaison").font(.headline)
WatchCard {
VStack(alignment: .leading, spacing: 5) {
metricRow("Avant", before?.averageBPM)
metricRow("Après", after?.averageBPM)
if let beforeBPM = before?.averageBPM, let afterBPM = after?.averageBPM {
Divider()
Text(String(format: "Écart %+.0f BPM", afterBPM - beforeBPM)).foregroundStyle(WatchTheme.accent)
}
Text("Qualité : \(Int(((before?.quality ?? 0) * 100).rounded())) % → \(Int(((after?.quality ?? 0) * 100).rounded())) %")
if let feelingBefore, let feelingAfter {
Text("Ressenti : \(feelingBefore)/5 → \(feelingAfter)/5")
}
}
.font(.caption)
}
Text("Une variation ponctuelle n’est pas un diagnostic.").font(.caption2).foregroundStyle(WatchTheme.secondary)
}
}
private func metricRow(_ label: String, _ bpm: Double?) -> some View {
HStack {
Text(label)
Spacer()
Text(bpm.map { "\(Int($0.rounded())) BPM" } ?? "—")
}
}
private func startMeasurement(stage: Stage) {
self.stage = stage
model.measurement.resetAfterResult()
Task {
do {
try await model.measurement.requestAuthorization()
try await model.measurement.start()
} catch { flowError = error.localizedDescription }
}
}
private func feelingContent(title: String, onConfirm: @escaping () -> Void) -> some View {
VStack(spacing: 10) {
Text(title).font(.headline).multilineTextAlignment(.center)
Picker("Ressenti", selection: $selectedFeeling) {
ForEach(1...5, id: \.self) { score in Text("\(score) / 5").tag(score) }
}
Button("Continuer", action: onConfirm).buttonStyle(.borderedProminent)
}
}
private func saveCompletedSession() {
let endedAt = Date()
model.enqueueSession(.init(
protocolID: regulation.id,
startedAt: exerciseStartedAt ?? endedAt,
endedAt: endedAt,
durationSeconds: regulation.durationSeconds,
state: .completed,
beforeMeasurementID: before?.id,
afterMeasurementID: after?.id,
feelingBefore: feelingBefore,
feelingAfter: feelingAfter
))
}
private func handleMeasurementState(_ state: WatchMeasurementState) {
guard case let .completed(summary) = state else { return }
if stage == .before {
before = summary
model.measurement.resetAfterResult()
selectedFeeling = 3
stage = .beforeFeeling
} else if stage == .after {
after = summary
model.measurement.resetAfterResult()
selectedFeeling = 3
stage = .afterFeeling
}
}
}