feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user