116 lines
4.3 KiB
Swift
116 lines
4.3 KiB
Swift
import SwiftUI
|
||
|
||
struct WatchMeasurementView: View {
|
||
@EnvironmentObject private var model: WatchAppModel
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(spacing: 10) {
|
||
switch model.measurement.state {
|
||
case .idle, .authorizing:
|
||
preparation
|
||
case let .waitingForFirstSample(secondsRemaining):
|
||
liveHeader(bpm: nil, caption: "Recherche du signal · \(secondsRemaining) s")
|
||
ProgressView()
|
||
cancelButton
|
||
case let .collecting(progress):
|
||
liveHeader(bpm: model.measurement.currentBPM, caption: "Mesure en cours")
|
||
ProgressView(value: progress)
|
||
Text("Restez immobile, bras détendu").font(.caption2).foregroundStyle(WatchTheme.secondary)
|
||
cancelButton
|
||
case .finishing:
|
||
ProgressView("Analyse…")
|
||
case let .completed(summary):
|
||
WatchMeasurementResultView(summary: summary)
|
||
case let .failed(message):
|
||
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
|
||
Text("Mesure interrompue").font(.headline)
|
||
Text(message).font(.caption2).foregroundStyle(WatchTheme.secondary)
|
||
Button("Recommencer") { model.measurement.resetAfterResult() }
|
||
}
|
||
}
|
||
.padding(.horizontal, 4)
|
||
}
|
||
.navigationTitle("Mesurer")
|
||
.haptikaScreen()
|
||
}
|
||
|
||
private var preparation: some View {
|
||
VStack(spacing: 10) {
|
||
Image(systemName: "applewatch.radiowaves.left.and.right").font(.system(size: 38)).foregroundStyle(WatchTheme.mint)
|
||
Text("60 secondes au repos").font(.headline)
|
||
Text("Serrez le bracelet sans gêne et posez l’avant-bras. Haptika attendra jusqu’à 30 s le premier BPM.")
|
||
.font(.caption2)
|
||
.foregroundStyle(WatchTheme.secondary)
|
||
.multilineTextAlignment(.center)
|
||
Button("Commencer") {
|
||
Task {
|
||
do {
|
||
try await model.measurement.requestAuthorization()
|
||
try await model.measurement.start()
|
||
} catch {}
|
||
}
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
}
|
||
|
||
private func liveHeader(bpm: Double?, caption: String) -> some View {
|
||
VStack(spacing: 3) {
|
||
Image(systemName: "heart.fill").foregroundStyle(.pink).symbolEffect(.pulse, options: .repeating)
|
||
Text(bpm.map { "\(Int($0.rounded()))" } ?? "—")
|
||
.font(.system(size: 42, weight: .bold, design: .rounded))
|
||
Text(bpm == nil ? caption : "BPM · \(caption)").font(.caption2).foregroundStyle(WatchTheme.secondary)
|
||
}
|
||
}
|
||
|
||
private var cancelButton: some View {
|
||
Button("Arrêter", role: .cancel) { model.measurement.cancel() }
|
||
.buttonStyle(.bordered)
|
||
}
|
||
}
|
||
|
||
struct WatchMeasurementResultView: View {
|
||
@EnvironmentObject private var model: WatchAppModel
|
||
let summary: WatchMeasurementSummaryV1
|
||
|
||
private var comparison: WatchBaselineComparison {
|
||
model.store.baseline.compare(bpm: summary.averageBPM)
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: 9) {
|
||
Image(systemName: summary.isValid ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
|
||
.font(.title)
|
||
.foregroundStyle(summary.isValid ? WatchTheme.mint : .orange)
|
||
Text(summary.averageBPM.map { "\(Int($0.rounded())) BPM" } ?? "Signal incomplet")
|
||
.font(.title3.bold())
|
||
WatchCard {
|
||
VStack(alignment: .leading, spacing: 5) {
|
||
Label("Qualité \(Int((summary.quality * 100).rounded())) %", systemImage: "checkmark.seal")
|
||
Text("\(Int(summary.minimumBPM ?? 0))–\(Int(summary.maximumBPM ?? 0)) BPM")
|
||
Text("Couverture \(Int(summary.coverageSeconds)) s")
|
||
Divider()
|
||
Text(comparison.frenchLabel).foregroundStyle(WatchTheme.accent)
|
||
}
|
||
.font(.caption)
|
||
}
|
||
Text("Mesure optique de bien-être, non médicale. Aucun indice PRV n’est calculé sur la Watch.")
|
||
.font(.caption2)
|
||
.foregroundStyle(WatchTheme.secondary)
|
||
if summary.isValid {
|
||
Text("Ressenti").font(.caption2).foregroundStyle(WatchTheme.secondary)
|
||
HStack(spacing: 4) {
|
||
ForEach(1...5, id: \.self) { value in
|
||
Button("\(value)") { model.saveFeeling(value, for: summary) }
|
||
.buttonStyle(.borderless)
|
||
.foregroundStyle(summary.feeling == value ? WatchTheme.mint : .white)
|
||
}
|
||
}
|
||
}
|
||
Button("Terminer") { model.measurement.resetAfterResult() }
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
}
|
||
}
|