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