feat: implement Haptika PPG and Watch V1

This commit is contained in:
Sacha
2026-07-31 16:19:42 +02:00
parent 6b7d4e0728
commit b9930b7378
249 changed files with 46475 additions and 122 deletions
@@ -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")"
}