139 lines
5.7 KiB
Swift
139 lines
5.7 KiB
Swift
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)
|
||
}
|
||
}
|
||
}
|
||
}
|