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

113 lines
4.8 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 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 }
}
}
}