182 lines
6.8 KiB
Swift
182 lines
6.8 KiB
Swift
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
|
||
}
|
||
}
|