feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
import Foundation
|
||||
|
||||
public enum RegulationFamily: String, Codable, Sendable {
|
||||
case breathe
|
||||
case ground
|
||||
case recenter
|
||||
}
|
||||
|
||||
public enum RegulationMode: String, Codable, Sendable {
|
||||
case haptic
|
||||
case liveHeartRate
|
||||
}
|
||||
|
||||
public enum RegulationPhaseKind: String, Codable, Sendable {
|
||||
case inhale
|
||||
case hold
|
||||
case exhale
|
||||
case anchor
|
||||
}
|
||||
|
||||
public struct RegulationPhase: Codable, Equatable, Sendable, Identifiable {
|
||||
public var id: String { "\(kind.rawValue)-\(durationSeconds)-\(label)" }
|
||||
public let kind: RegulationPhaseKind
|
||||
public let durationSeconds: TimeInterval
|
||||
public let label: String
|
||||
|
||||
public init(kind: RegulationPhaseKind, durationSeconds: TimeInterval, label: String) {
|
||||
self.kind = kind
|
||||
self.durationSeconds = durationSeconds
|
||||
self.label = label
|
||||
}
|
||||
}
|
||||
|
||||
public struct WatchRegulationProtocol: Codable, Equatable, Sendable, Identifiable {
|
||||
public let id: String
|
||||
public let version: Int
|
||||
public let family: RegulationFamily
|
||||
public let mode: RegulationMode
|
||||
public let eyebrow: String
|
||||
public let title: String
|
||||
public let summary: String
|
||||
public let durationSeconds: TimeInterval
|
||||
public let phases: [RegulationPhase]
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
version: Int = 1,
|
||||
family: RegulationFamily,
|
||||
mode: RegulationMode,
|
||||
eyebrow: String,
|
||||
title: String,
|
||||
summary: String,
|
||||
durationSeconds: TimeInterval,
|
||||
phases: [RegulationPhase]
|
||||
) {
|
||||
self.id = id
|
||||
self.version = version
|
||||
self.family = family
|
||||
self.mode = mode
|
||||
self.eyebrow = eyebrow
|
||||
self.title = title
|
||||
self.summary = summary
|
||||
self.durationSeconds = durationSeconds
|
||||
self.phases = phases
|
||||
}
|
||||
}
|
||||
|
||||
public enum WatchProtocolCatalog {
|
||||
private struct Manifest: Decodable {
|
||||
let schemaVersion: Int
|
||||
let protocols: [ManifestProtocol]
|
||||
}
|
||||
|
||||
private struct ManifestProtocol: Decodable {
|
||||
let kind: String
|
||||
let family: RegulationFamily
|
||||
let mode: String
|
||||
let eyebrow: String
|
||||
let title: String
|
||||
let description: String
|
||||
let durationSeconds: TimeInterval
|
||||
let phases: [RegulationPhase]
|
||||
|
||||
var watchProtocol: WatchRegulationProtocol {
|
||||
WatchRegulationProtocol(
|
||||
id: kind,
|
||||
version: 1,
|
||||
family: family,
|
||||
mode: mode == "haptic" ? .haptic : .liveHeartRate,
|
||||
eyebrow: eyebrow,
|
||||
title: title,
|
||||
summary: description,
|
||||
durationSeconds: durationSeconds,
|
||||
phases: phases
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public static let protocols: [WatchRegulationProtocol] = loadBundledManifest() ?? fallbackProtocols
|
||||
|
||||
private static let fallbackProtocols: [WatchRegulationProtocol] = [
|
||||
.init(id: "myCoherence", family: .breathe, mode: .liveHeartRate, eyebrow: "BIOFEEDBACK", title: "Ma Cohérence", summary: "Respiration visuelle 5–5 avec tendance cardiaque.", durationSeconds: 180, phases: [
|
||||
.init(kind: .inhale, durationSeconds: 5, label: "Inspirez"),
|
||||
.init(kind: .exhale, durationSeconds: 5, label: "Expirez"),
|
||||
]),
|
||||
.init(id: "coherence55", family: .breathe, mode: .haptic, eyebrow: "ÉQUILIBRE", title: "Cohérence 5–5", summary: "Une respiration régulière pour retrouver un rythme calme.", durationSeconds: 300, phases: [
|
||||
.init(kind: .inhale, durationSeconds: 5, label: "Inspirez"),
|
||||
.init(kind: .exhale, durationSeconds: 5, label: "Expirez lentement"),
|
||||
]),
|
||||
.init(id: "box4444", family: .breathe, mode: .haptic, eyebrow: "STABILITÉ", title: "Respiration carrée", summary: "Quatre temps égaux pour structurer l’attention.", durationSeconds: 240, phases: [
|
||||
.init(kind: .inhale, durationSeconds: 4, label: "Inspirez"),
|
||||
.init(kind: .hold, durationSeconds: 4, label: "Gardez l’air"),
|
||||
.init(kind: .exhale, durationSeconds: 4, label: "Expirez"),
|
||||
.init(kind: .hold, durationSeconds: 4, label: "Restez vide"),
|
||||
]),
|
||||
.init(id: "breathing478", family: .breathe, mode: .haptic, eyebrow: "RALENTISSEMENT", title: "Respiration 4–7–8", summary: "Un cycle court avec une expiration longue.", durationSeconds: 76, phases: [
|
||||
.init(kind: .inhale, durationSeconds: 4, label: "Inspirez"),
|
||||
.init(kind: .hold, durationSeconds: 7, label: "Gardez l’air"),
|
||||
.init(kind: .exhale, durationSeconds: 8, label: "Expirez lentement"),
|
||||
]),
|
||||
.init(id: "relax46", family: .breathe, mode: .haptic, eyebrow: "RELÂCHEMENT", title: "Expiration 4–6", summary: "Une expiration plus longue pour accompagner la détente.", durationSeconds: 180, phases: [
|
||||
.init(kind: .inhale, durationSeconds: 4, label: "Inspirez"),
|
||||
.init(kind: .exhale, durationSeconds: 6, label: "Expirez lentement"),
|
||||
]),
|
||||
.init(id: "sosStress", family: .recenter, mode: .haptic, eyebrow: "RETOUR AU CALME", title: "SOS tension", summary: "Trois impulsions puis un repère toutes les dix secondes.", durationSeconds: 62, phases: [
|
||||
.init(kind: .anchor, durationSeconds: 62, label: "Restez avec la sensation"),
|
||||
]),
|
||||
.init(id: "microAnchor", family: .ground, mode: .haptic, eyebrow: "FORMAT COURT", title: "Micro-ancrage", summary: "Un repère tactile discret pour revenir aux sensations.", durationSeconds: 37, phases: [
|
||||
.init(kind: .anchor, durationSeconds: 37, label: "Sentez les points de contact"),
|
||||
]),
|
||||
]
|
||||
|
||||
public static func protocolWithID(_ id: String) -> WatchRegulationProtocol? {
|
||||
protocols.first { $0.id == id }
|
||||
}
|
||||
|
||||
private static func loadBundledManifest(bundle: Bundle = .main) -> [WatchRegulationProtocol]? {
|
||||
guard let url = bundle.url(forResource: "regulation-protocols.v1", withExtension: "json"),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let manifest = try? JSONDecoder().decode(Manifest.self, from: data),
|
||||
manifest.schemaVersion == 1,
|
||||
manifest.protocols.count == 7 else {
|
||||
return nil
|
||||
}
|
||||
return manifest.protocols.map(\.watchProtocol)
|
||||
}
|
||||
}
|
||||
|
||||
public struct RegulationPhasePosition: Equatable, Sendable {
|
||||
public let phaseIndex: Int
|
||||
public let phase: RegulationPhase
|
||||
public let progress: Double
|
||||
public let cycleIndex: Int
|
||||
}
|
||||
|
||||
public enum RegulationPhaseEngine {
|
||||
public static func position(
|
||||
in regulation: WatchRegulationProtocol,
|
||||
elapsedSeconds: TimeInterval
|
||||
) -> RegulationPhasePosition? {
|
||||
guard !regulation.phases.isEmpty else { return nil }
|
||||
let cycleDuration = regulation.phases.reduce(0) { $0 + $1.durationSeconds }
|
||||
guard cycleDuration > 0 else { return nil }
|
||||
let clamped = max(0, min(elapsedSeconds, max(0, regulation.durationSeconds - 0.001)))
|
||||
let cycleElapsed = clamped.truncatingRemainder(dividingBy: cycleDuration)
|
||||
var cursor: TimeInterval = 0
|
||||
for (index, phase) in regulation.phases.enumerated() {
|
||||
let end = cursor + phase.durationSeconds
|
||||
if cycleElapsed < end || index == regulation.phases.count - 1 {
|
||||
return .init(
|
||||
phaseIndex: index,
|
||||
phase: phase,
|
||||
progress: min(1, max(0, (cycleElapsed - cursor) / phase.durationSeconds)),
|
||||
cycleIndex: Int(clamped / cycleDuration)
|
||||
)
|
||||
}
|
||||
cursor = end
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user