179 lines
6.6 KiB
Swift
179 lines
6.6 KiB
Swift
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
|
||
}
|
||
}
|
||
}
|