113 lines
4.8 KiB
Swift
113 lines
4.8 KiB
Swift
import SwiftUI
|
||
|
||
struct WatchCardiacMirrorView: View {
|
||
enum Stage { case intro, measuring, ready, mirroring, completed }
|
||
|
||
@EnvironmentObject private var model: WatchAppModel
|
||
@State private var stage: Stage = .intro
|
||
@State private var initialBPM: Double?
|
||
@State private var errorMessage: String?
|
||
@State private var mirrorStartedAt: Date?
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(spacing: 10) {
|
||
switch stage {
|
||
case .intro:
|
||
Image(systemName: "heart.circle.fill").font(.system(size: 40)).foregroundStyle(.pink)
|
||
Text("Miroir cardiaque").font(.headline)
|
||
Text(model.connectivity.isReachable ? "Mode connecté : la Watch mesure, l’iPhone peut reconstruire le rythme visuel et haptique." : "Mode Estimation Watch : mesure initiale, arrêt du capteur, puis rythme reconstruit et ralenti.")
|
||
.font(.caption2).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
|
||
Button("Mesurer le rythme") { startMeasurement() }.buttonStyle(.borderedProminent)
|
||
case .measuring:
|
||
Text(model.measurement.currentBPM.map { "\(Int($0.rounded())) BPM" } ?? "— BPM").font(.title2.bold())
|
||
measurementProgress
|
||
Text("Aucune vibration pendant la mesure").font(.caption2).foregroundStyle(.orange)
|
||
Button("Annuler", role: .cancel) { model.measurement.cancel(); stage = .intro }
|
||
case .ready:
|
||
Text(initialBPM.map { "Repère initial \(Int($0.rounded())) BPM" } ?? "Repère indisponible").font(.headline)
|
||
Text("Le rythme suivant est une estimation reconstruite, pas chaque battement biologique.")
|
||
.font(.caption2).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
|
||
if model.connectivity.isReachable {
|
||
Label("iPhone connecté", systemImage: "iphone.and.arrow.forward").font(.caption).foregroundStyle(WatchTheme.mint)
|
||
Button("Terminer") { stage = .completed }
|
||
} else {
|
||
Button("Lancer le miroir") { startMirror() }.buttonStyle(.borderedProminent)
|
||
}
|
||
case .mirroring:
|
||
estimatedPulse
|
||
ProgressView(value: model.haptics.elapsedSeconds, total: 120)
|
||
Text("Ralentissement progressif · −1,5 BPM/min").font(.caption2).foregroundStyle(WatchTheme.secondary)
|
||
Button("Arrêter", role: .cancel) { model.haptics.cancel(); stage = .completed }
|
||
case .completed:
|
||
Image(systemName: "checkmark.circle.fill").font(.title).foregroundStyle(WatchTheme.mint)
|
||
Text("Expérience terminée").font(.headline)
|
||
Button("Recommencer") { stage = .intro; model.measurement.resetAfterResult() }
|
||
}
|
||
if let errorMessage { Text(errorMessage).font(.caption2).foregroundStyle(.orange) }
|
||
}
|
||
.padding(.horizontal, 4)
|
||
}
|
||
.navigationTitle("Miroir")
|
||
.haptikaScreen()
|
||
.onChange(of: model.measurement.state) { _, state in
|
||
if case let .completed(summary) = state {
|
||
initialBPM = summary.averageBPM
|
||
stage = .ready
|
||
}
|
||
}
|
||
.onChange(of: model.haptics.isRunning) { wasRunning, isRunning in
|
||
if stage == .mirroring, wasRunning, !isRunning { stage = .completed }
|
||
}
|
||
.onDisappear {
|
||
if stage == .measuring { model.measurement.cancel() }
|
||
if stage == .mirroring { model.haptics.cancel() }
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var measurementProgress: some View {
|
||
switch model.measurement.state {
|
||
case let .waitingForFirstSample(seconds):
|
||
ProgressView(); Text("Signal · \(seconds) s").font(.caption2)
|
||
case let .collecting(progress): ProgressView(value: progress)
|
||
case .finishing: ProgressView("Analyse…")
|
||
default: EmptyView()
|
||
}
|
||
}
|
||
|
||
private var estimatedPulse: some View {
|
||
TimelineView(.animation(minimumInterval: 1 / 20)) { context in
|
||
let elapsed = mirrorStartedAt.map { context.date.timeIntervalSince($0) } ?? 0
|
||
let bpm = max(35, (initialBPM ?? 60) - 1.5 * elapsed / 60)
|
||
let phase = (elapsed * bpm / 60).truncatingRemainder(dividingBy: 1)
|
||
let scale = 0.78 + 0.22 * max(0, 1 - phase * 4)
|
||
VStack {
|
||
Image(systemName: "heart.fill").font(.system(size: 62)).foregroundStyle(.pink).scaleEffect(scale)
|
||
Text("≈ \(Int(bpm.rounded())) BPM").font(.headline.monospacedDigit())
|
||
}
|
||
}
|
||
}
|
||
|
||
private func startMeasurement() {
|
||
stage = .measuring
|
||
model.measurement.resetAfterResult()
|
||
Task {
|
||
do {
|
||
try await model.measurement.requestAuthorization()
|
||
try await model.measurement.start()
|
||
} catch { errorMessage = error.localizedDescription }
|
||
}
|
||
}
|
||
|
||
private func startMirror() {
|
||
guard let initialBPM else { return }
|
||
mirrorStartedAt = Date()
|
||
stage = .mirroring
|
||
Task {
|
||
do { try await model.haptics.startCardiacMirror(initialBPM: initialBPM) }
|
||
catch { errorMessage = error.localizedDescription; stage = .ready }
|
||
}
|
||
}
|
||
}
|