Files
haptika/ios/HaptikaWatch/Views/WatchCoherenceView.swift
T
2026-07-31 16:20:10 +02:00

139 lines
5.7 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}
}