feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
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 body: some View {
|
||||
TimelineView(.animation(minimumInterval: 1 / 20)) { context in
|
||||
let elapsed = context.date.timeIntervalSince(startedAt)
|
||||
let position = RegulationPhaseEngine.position(in: regulation, elapsedSeconds: elapsed)
|
||||
let isExpanding = position?.phase.kind == .inhale
|
||||
let fraction = position?.progress ?? 0
|
||||
let scale = isExpanding ? 0.6 + fraction * 0.4 : 1 - fraction * 0.4
|
||||
VStack(spacing: 6) {
|
||||
ZStack {
|
||||
Circle().fill(WatchTheme.accent.opacity(0.12)).frame(width: 108, height: 108)
|
||||
Circle()
|
||||
.fill(RadialGradient(colors: [WatchTheme.mint, WatchTheme.accent.opacity(0.25)], center: .center, startRadius: 2, endRadius: 52))
|
||||
.frame(width: 88, height: 88)
|
||||
.scaleEffect(scale)
|
||||
}
|
||||
Text(position?.phase.label ?? "Respirez").font(.headline)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WatchExerciseListView: View {
|
||||
private var hapticProtocols: [WatchRegulationProtocol] {
|
||||
WatchProtocolCatalog.protocols.filter { $0.mode == .haptic }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List(hapticProtocols) { regulation in
|
||||
NavigationLink {
|
||||
WatchExercisePlayerView(regulation: regulation)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(regulation.title).font(.subheadline.bold())
|
||||
Text(formatDuration(regulation.durationSeconds)).font(.caption2).foregroundStyle(WatchTheme.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Exercices")
|
||||
.haptikaScreen()
|
||||
}
|
||||
}
|
||||
|
||||
struct WatchExercisePlayerView: View {
|
||||
@EnvironmentObject private var model: WatchAppModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let regulation: WatchRegulationProtocol
|
||||
@State private var startedAt: Date?
|
||||
@State private var errorMessage: String?
|
||||
@State private var sessionSaved = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 10) {
|
||||
if model.haptics.isRunning, let startedAt {
|
||||
BreathOrb(regulation: regulation, startedAt: startedAt)
|
||||
ProgressView(value: model.haptics.elapsedSeconds, total: regulation.durationSeconds)
|
||||
Text("\(Int(max(0, regulation.durationSeconds - model.haptics.elapsedSeconds))) s")
|
||||
.font(.caption.monospacedDigit()).foregroundStyle(WatchTheme.secondary)
|
||||
Button("Arrêter", role: .cancel) {
|
||||
saveSession(state: .cancelled)
|
||||
model.haptics.cancel()
|
||||
}
|
||||
} else if startedAt != nil {
|
||||
Image(systemName: "checkmark.circle.fill").font(.title).foregroundStyle(WatchTheme.mint)
|
||||
Text("Exercice terminé").font(.headline)
|
||||
Button("Terminer") { dismiss() }.buttonStyle(.borderedProminent)
|
||||
.onAppear { saveSession(state: .completed) }
|
||||
} else {
|
||||
Text(regulation.eyebrow).font(.caption2.bold()).foregroundStyle(WatchTheme.accent)
|
||||
Text(regulation.title).font(.headline).multilineTextAlignment(.center)
|
||||
Text(regulation.summary).font(.caption2).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
|
||||
Text("Aucune mesure cardiaque pendant les vibrations.").font(.caption2.bold()).foregroundStyle(.orange)
|
||||
Button("Commencer") { start() }.buttonStyle(.borderedProminent)
|
||||
NavigationLink("Parcours avant / après") {
|
||||
WatchBeforeAfterFlowView(regulation: regulation)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
if let errorMessage { Text(errorMessage).font(.caption2).foregroundStyle(.orange) }
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
.navigationTitle(regulation.title)
|
||||
.haptikaScreen()
|
||||
.onDisappear {
|
||||
if model.haptics.isRunning {
|
||||
saveSession(state: .cancelled)
|
||||
model.haptics.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func start() {
|
||||
let now = Date()
|
||||
Task {
|
||||
do {
|
||||
try await model.haptics.start(protocol: regulation)
|
||||
startedAt = now
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveSession(state: WatchRegulationSessionState) {
|
||||
guard !sessionSaved, let startedAt else { return }
|
||||
sessionSaved = true
|
||||
model.enqueueSession(.init(
|
||||
protocolID: regulation.id,
|
||||
startedAt: startedAt,
|
||||
endedAt: Date(),
|
||||
durationSeconds: model.haptics.elapsedSeconds,
|
||||
state: state
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDuration(_ duration: TimeInterval) -> String {
|
||||
let seconds = Int(duration)
|
||||
return seconds < 60 ? "\(seconds) s" : "\(seconds / 60) min \(seconds % 60 == 0 ? "" : "\(seconds % 60) s")"
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import Charts
|
||||
import SwiftUI
|
||||
|
||||
struct WatchHistoryView: View {
|
||||
@EnvironmentObject private var model: WatchAppModel
|
||||
|
||||
private var recentSevenDays: [WatchMeasurementSummaryV1] {
|
||||
let cutoff = Date().addingTimeInterval(-7 * 86_400)
|
||||
return model.store.measurements.filter { $0.startedAt >= cutoff && $0.isValid }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if !recentSevenDays.isEmpty {
|
||||
Section("Tendance 7 jours") {
|
||||
Chart(recentSevenDays) { measurement in
|
||||
LineMark(
|
||||
x: .value("Date", measurement.startedAt),
|
||||
y: .value("BPM", measurement.averageBPM ?? 0)
|
||||
)
|
||||
.foregroundStyle(WatchTheme.accent)
|
||||
PointMark(
|
||||
x: .value("Date", measurement.startedAt),
|
||||
y: .value("BPM", measurement.averageBPM ?? 0)
|
||||
)
|
||||
.foregroundStyle(WatchTheme.mint)
|
||||
}
|
||||
.chartYAxis(.hidden)
|
||||
.frame(height: 90)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Mesures") {
|
||||
if model.store.measurements.isEmpty {
|
||||
Text("Aucune mesure synchronisée").font(.caption).foregroundStyle(WatchTheme.secondary)
|
||||
}
|
||||
ForEach(model.store.measurements) { measurement in
|
||||
NavigationLink {
|
||||
WatchMeasurementDetailView(measurement: measurement)
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(measurement.averageBPM.map { "\(Int($0.rounded())) BPM" } ?? "Incomplète").font(.subheadline.bold())
|
||||
Text(measurement.startedAt, style: .date).font(.caption2).foregroundStyle(WatchTheme.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text("\(Int((measurement.quality * 100).rounded())) %").font(.caption2).foregroundStyle(measurement.isValid ? WatchTheme.mint : .orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Séances") {
|
||||
if model.store.sessions.isEmpty {
|
||||
Text("Aucune séance").font(.caption).foregroundStyle(WatchTheme.secondary)
|
||||
}
|
||||
ForEach(model.store.sessions) { session in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(WatchProtocolCatalog.protocolWithID(session.protocolID)?.title ?? session.protocolID).font(.subheadline.bold())
|
||||
Text(session.startedAt, style: .relative).font(.caption2).foregroundStyle(WatchTheme.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Historique")
|
||||
.haptikaScreen()
|
||||
}
|
||||
}
|
||||
|
||||
struct WatchMeasurementDetailView: View {
|
||||
@EnvironmentObject private var model: WatchAppModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let measurement: WatchMeasurementSummaryV1
|
||||
@State private var confirmDelete = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Mesure Watch") {
|
||||
metric("Moyenne", measurement.averageBPM.map { "\(Int($0.rounded())) BPM" } ?? "—")
|
||||
metric("Min / max", "\(Int(measurement.minimumBPM ?? 0)) / \(Int(measurement.maximumBPM ?? 0))")
|
||||
metric("Qualité", "\(Int((measurement.quality * 100).rounded())) %")
|
||||
metric("Couverture", "\(Int(measurement.coverageSeconds)) s")
|
||||
metric("Appareil", measurement.device.name)
|
||||
if let feeling = measurement.feeling { metric("Ressenti", "\(feeling) / 5") }
|
||||
}
|
||||
Section {
|
||||
Text("Pouls optique Apple Watch. Pas de PRV ni de diagnostic médical.")
|
||||
.font(.caption2).foregroundStyle(WatchTheme.secondary)
|
||||
}
|
||||
Button("Supprimer", role: .destructive) { confirmDelete = true }
|
||||
}
|
||||
.navigationTitle(measurement.startedAt.formatted(date: .abbreviated, time: .shortened))
|
||||
.haptikaScreen()
|
||||
.confirmationDialog("Supprimer cette mesure ?", isPresented: $confirmDelete) {
|
||||
Button("Supprimer", role: .destructive) {
|
||||
model.deleteMeasurement(measurement)
|
||||
dismiss()
|
||||
}
|
||||
Button("Annuler", role: .cancel) {}
|
||||
}
|
||||
}
|
||||
|
||||
private func metric(_ label: String, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(label).foregroundStyle(WatchTheme.secondary)
|
||||
Spacer()
|
||||
Text(value).multilineTextAlignment(.trailing)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WatchRootView: View {
|
||||
@EnvironmentObject private var model: WatchAppModel
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if model.hasCompletedOnboarding {
|
||||
WatchHomeView()
|
||||
} else {
|
||||
WatchOnboardingView()
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
|
||||
struct WatchOnboardingView: View {
|
||||
@EnvironmentObject private var model: WatchAppModel
|
||||
@State private var page = 0
|
||||
@State private var authorizationError: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
TabView(selection: $page) {
|
||||
onboardingPage(icon: "hand.raised.fill", title: "Vos données", text: "Les mesures restent sur vos appareils et se synchronisent avec votre iPhone appairé.").tag(0)
|
||||
onboardingPage(icon: "heart.text.square.fill", title: "Bien-être, pas diagnostic", text: "Haptika n’est pas un dispositif médical. Consultez un professionnel en cas de symptôme.").tag(1)
|
||||
onboardingPage(icon: "waveform.path.ecg", title: "Mesurer sans vibrer", text: "La Watch mesure le pouls optique. Les vibrations sont toujours arrêtées pendant cette phase.").tag(2)
|
||||
}
|
||||
.tabViewStyle(.verticalPage)
|
||||
|
||||
if let authorizationError {
|
||||
Text(authorizationError).font(.caption2).foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
Button(page < 2 ? "Continuer" : "Autoriser Santé") {
|
||||
if page < 2 {
|
||||
withAnimation { page += 1 }
|
||||
} else {
|
||||
Task {
|
||||
do {
|
||||
try await model.measurement.requestAuthorization()
|
||||
model.hasCompletedOnboarding = true
|
||||
} catch {
|
||||
authorizationError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.background(WatchTheme.background.ignoresSafeArea())
|
||||
}
|
||||
|
||||
private func onboardingPage(icon: String, title: String, text: String) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: icon).font(.system(size: 34)).foregroundStyle(WatchTheme.accent)
|
||||
Text(title).font(.headline).multilineTextAlignment(.center)
|
||||
Text(text).font(.caption).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WatchHomeView: View {
|
||||
@EnvironmentObject private var model: WatchAppModel
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
NavigationLink(value: WatchHomeDestination.measure) { homeRow("Mesurer", "waveform.path.ecg", WatchTheme.mint) }
|
||||
NavigationLink(value: WatchHomeDestination.coherence) { homeRow("Ma Cohérence", "circle.dotted", WatchTheme.accent) }
|
||||
NavigationLink(value: WatchHomeDestination.exercises) { homeRow("Exercices", "wind", .cyan) }
|
||||
NavigationLink(value: WatchHomeDestination.cardiacMirror) { homeRow("Miroir cardiaque", "heart.circle", .pink) }
|
||||
NavigationLink(value: WatchHomeDestination.history) { homeRow("Historique", "chart.xyaxis.line", .indigo) }
|
||||
} header: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("HAPTIKA").font(.caption2).foregroundStyle(WatchTheme.accent)
|
||||
Text("Prendre un temps").font(.headline).foregroundStyle(.white)
|
||||
}
|
||||
.textCase(nil)
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Circle().fill(model.connectivity.isReachable ? WatchTheme.mint : .gray).frame(width: 7, height: 7)
|
||||
Text(model.connectivity.isReachable ? "iPhone joignable" : "Mode autonome")
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(WatchTheme.secondary)
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: WatchHomeDestination.self) { destination in
|
||||
switch destination {
|
||||
case .measure: WatchMeasurementView()
|
||||
case .coherence: WatchCoherenceView()
|
||||
case .exercises: WatchExerciseListView()
|
||||
case .cardiacMirror: WatchCardiacMirrorView()
|
||||
case .history: WatchHistoryView()
|
||||
}
|
||||
}
|
||||
.haptikaScreen()
|
||||
}
|
||||
}
|
||||
|
||||
private func homeRow(_ title: String, _ icon: String, _ color: Color) -> some View {
|
||||
Label {
|
||||
Text(title).font(.subheadline.weight(.semibold))
|
||||
} icon: {
|
||||
Image(systemName: icon).foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import SwiftUI
|
||||
|
||||
enum WatchTheme {
|
||||
static let background = Color(red: 0.015, green: 0.04, blue: 0.10)
|
||||
static let card = Color(red: 0.035, green: 0.12, blue: 0.22)
|
||||
static let accent = Color(red: 0.37, green: 0.79, blue: 0.96)
|
||||
static let mint = Color(red: 0.38, green: 0.88, blue: 0.78)
|
||||
static let secondary = Color(red: 0.66, green: 0.73, blue: 0.84)
|
||||
}
|
||||
|
||||
struct WatchCard<Content: View>: View {
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(WatchTheme.card.opacity(0.82), in: RoundedRectangle(cornerRadius: 18))
|
||||
.overlay(RoundedRectangle(cornerRadius: 18).stroke(WatchTheme.accent.opacity(0.22)))
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func haptikaScreen() -> some View {
|
||||
scrollContentBackground(.hidden)
|
||||
.background(WatchTheme.background.ignoresSafeArea())
|
||||
.tint(WatchTheme.accent)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user