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 showsLabel = true @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { TimelineView(.animation(minimumInterval: reduceMotion ? 1 : 1 / 20)) { context in let elapsed = WatchDesignMode.frozen ? 0 : context.date.timeIntervalSince(startedAt) let position = RegulationPhaseEngine.position(in: regulation, elapsedSeconds: elapsed) let fraction = position?.progress ?? 0 let scale = position?.phase.kind == .inhale ? 0.65 + fraction * 0.35 : position?.phase.kind == .exhale ? 1 - fraction * 0.35 : 1 VStack(spacing: 20) { Text(position?.phase.kind == .exhale ? "Expirez" : position?.phase.kind == .inhale ? "Inspirez" : position?.phase.label ?? "Inspirez") .opacity(showsLabel ? 1 : 0) .font(WatchTheme.font(20, weight: .semibold)).multilineTextAlignment(.center) .minimumScaleFactor(0.75).lineLimit(1).fixedSize(horizontal: false, vertical: true) ZStack { Circle().fill(WatchTheme.accent.opacity(0.2)).blur(radius: 6) Circle().fill(WatchTheme.accent.opacity(0.8)) .overlay(Circle().stroke(WatchTheme.accent.opacity(0.3))) Circle().fill(.white.opacity(0.8)).frame(width: 8, height: 8) } .frame(width: 68, height: 68).scaleEffect(reduceMotion || WatchDesignMode.frozen ? 1 : scale) .frame(height: 68).accessibilityHidden(true) }.frame(maxWidth: .infinity).background { WatchGlow() } } } }