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,14 @@
import SwiftUI
@main
struct HaptikaWatchApp: App {
@StateObject private var model = WatchAppModel()
var body: some Scene {
WindowGroup {
WatchRootView()
.environmentObject(model)
.task { model.start() }
}
}
}
+128
View File
@@ -0,0 +1,128 @@
import Combine
import Foundation
import WatchKit
@MainActor
public final class WatchAppModel: ObservableObject {
@Published public var hasCompletedOnboarding: Bool {
didSet { defaults.set(hasCompletedOnboarding, forKey: onboardingKey) }
}
@Published public var selectedTab: WatchHomeDestination?
public let store: WatchStore
public let measurement: WatchHealthMeasurementService
public let haptics: WatchHapticService
public let connectivity: WatchConnectivityService
private let defaults: UserDefaults
private let onboardingKey = "haptika.watch.onboarding.completed.v1"
private let deviceIDKey = "haptika.watch.device.id.v1"
private var sequence: UInt64
public var deviceID: String {
if let value = defaults.string(forKey: deviceIDKey) { return value }
let value = UUID().uuidString
defaults.set(value, forKey: deviceIDKey)
return value
}
public init(
defaults: UserDefaults = .standard,
store: WatchStore? = nil,
measurement: WatchHealthMeasurementService? = nil,
haptics: WatchHapticService? = nil,
connectivity: WatchConnectivityService? = nil
) {
self.defaults = defaults
self.store = store ?? WatchStore(defaults: defaults)
self.measurement = measurement ?? WatchHealthMeasurementService()
self.haptics = haptics ?? WatchHapticService()
self.connectivity = connectivity ?? WatchConnectivityService()
hasCompletedOnboarding = defaults.bool(forKey: onboardingKey)
sequence = UInt64(defaults.integer(forKey: "haptika.watch.sequence.v1"))
wireServices()
}
public func start() {
connectivity.activate()
connectivity.flush(outbox: store.outbox)
}
public func enqueueMeasurement(_ measurement: WatchMeasurementSummaryV1) {
guard let envelope = makeEnvelope(kind: .measurementUpsert, payload: measurement) else {
store.upsert(measurement)
return
}
store.upsert(measurement, enqueue: envelope)
connectivity.transfer(envelope)
}
public func enqueueSession(_ session: WatchRegulationSessionV1) {
guard let envelope = makeEnvelope(kind: .regulationSessionUpsert, payload: session) else {
store.upsert(session)
return
}
store.upsert(session, enqueue: envelope)
connectivity.transfer(envelope)
}
public func deleteMeasurement(_ measurement: WatchMeasurementSummaryV1) {
let tombstone = WatchDeletionTombstoneV1(entityID: measurement.id, entityKind: "measurement")
let envelope = makeEnvelope(kind: .tombstone, payload: tombstone)
_ = store.deleteMeasurement(id: measurement.id, envelope: envelope)
if let envelope { connectivity.transfer(envelope) }
}
public func saveFeeling(_ feeling: Int, for measurement: WatchMeasurementSummaryV1) {
var updated = measurement
updated.feeling = min(5, max(1, feeling))
updated.feelingRecordedAt = Date()
updated.updatedAt = Date()
enqueueMeasurement(updated)
}
private func wireServices() {
measurement.onCompleted = { [weak self] summary in self?.enqueueMeasurement(summary) }
measurement.onLiveHeartRate = { [weak self] sample in
guard let self,
let envelope = self.makeEnvelope(
kind: .liveHeartRate,
payload: WatchLiveHeartRateV1(bpm: sample.bpm, sampledAt: sample.date)
) else { return }
self.connectivity.sendLive(envelope)
}
connectivity.onAcknowledgement = { [weak self] ids in self?.store.acknowledge(eventIDs: ids) }
connectivity.onEnvelope = { [weak self] envelope in
guard let self else { return }
if envelope.kind == .preferences,
let preferences = try? envelope.decodePayload(WatchPreferencesV1.self) {
self.store.preferences = preferences
} else if envelope.kind == .measurementUpsert,
let measurement = try? envelope.decodePayload(WatchMeasurementSummaryV1.self) {
self.store.upsert(measurement)
} else if envelope.kind == .regulationSessionUpsert,
let session = try? envelope.decodePayload(WatchRegulationSessionV1.self) {
self.store.upsert(session)
} else if envelope.kind == .tombstone,
let tombstone = try? envelope.decodePayload(WatchDeletionTombstoneV1.self) {
self.store.apply(tombstone)
}
}
}
private func makeEnvelope<Payload: Encodable>(kind: WatchSyncKind, payload: Payload) -> WatchSyncEnvelopeV1? {
sequence += 1
defaults.set(Int(sequence), forKey: "haptika.watch.sequence.v1")
return try? .init(deviceID: deviceID, sequence: sequence, kind: kind, payload: payload)
}
}
public enum WatchHomeDestination: String, Hashable, Identifiable {
case measure
case coherence
case exercises
case cardiacMirror
case history
public var id: String { rawValue }
}
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.922",
"green" : "0.741",
"red" : "0.314"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "watch-marketing",
"scale" : "1x",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -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
}
}
+72
View File
@@ -0,0 +1,72 @@
import Foundation
public enum WatchBaselineMaturity: String, Codable, Sendable {
case unavailable
case provisional
case personalized
}
public enum WatchBaselineComparison: String, Codable, Sendable {
case unavailable
case usual
case slightlyDifferent
case clearlyDifferent
}
public struct WatchBaselineSnapshot: Codable, Equatable, Sendable {
public let maturity: WatchBaselineMaturity
public let comparableMeasurements: Int
public let medianBPM: Double?
public let medianAbsoluteDeviation: Double?
public init(measurements: [WatchMeasurementSummaryV1]) {
let values = measurements
.filter { $0.source == .appleWatchOptical && $0.isValid && $0.quality >= 0.65 && $0.context.isBaselineComparable }
.compactMap(\.averageBPM)
.filter(\.isFinite)
comparableMeasurements = values.count
medianBPM = Self.median(values)
medianAbsoluteDeviation = medianBPM.map { median in
Self.median(values.map { abs($0 - median) }) ?? 0
}
if values.count >= 10 {
maturity = .personalized
} else if values.count >= 5 {
maturity = .provisional
} else {
maturity = .unavailable
}
}
public func compare(bpm: Double?) -> WatchBaselineComparison {
guard maturity != .unavailable, let bpm, bpm.isFinite, let medianBPM else {
return .unavailable
}
let scale = max(medianAbsoluteDeviation ?? 0, 2)
let robustDistance = abs(bpm - medianBPM) / scale
if robustDistance < 2 { return .usual }
if robustDistance < 4 { return .slightlyDifferent }
return .clearlyDifferent
}
private static func median(_ values: [Double]) -> Double? {
guard !values.isEmpty else { return nil }
let sorted = values.sorted()
let middle = sorted.count / 2
return sorted.count.isMultiple(of: 2)
? (sorted[middle - 1] + sorted[middle]) / 2
: sorted[middle]
}
}
public extension WatchBaselineComparison {
var frenchLabel: String {
switch self {
case .unavailable: "Baseline en construction"
case .usual: "Habituel"
case .slightlyDifferent: "Légèrement différent"
case .clearlyDifferent: "Clairement différent"
}
}
}
@@ -0,0 +1,73 @@
import Foundation
public enum WatchMeasurementInvalidReason: String, Codable, CaseIterable, Sendable {
case noValidHeartRate
case insufficientSamples
case insufficientCoverage
case staleData
case interrupted
}
public struct WatchMeasurementValidation: Equatable, Sendable {
public let validSamples: [WatchHeartRateSampleV1]
public let currentBPM: Double?
public let averageBPM: Double?
public let minimumBPM: Double?
public let maximumBPM: Double?
public let coverageSeconds: TimeInterval
public let maximumGapSeconds: TimeInterval
public let quality: Double
public let invalidReasons: [WatchMeasurementInvalidReason]
public var isValid: Bool { invalidReasons.isEmpty }
}
public enum WatchMeasurementValidator {
public static let minimumBPM = 35.0
public static let maximumBPM = 220.0
public static let minimumSamples = 8
public static let minimumCoverageSeconds = 45.0
public static let maximumSilenceSeconds = 15.0
public static let targetDurationSeconds = 60.0
public static func validate(
samples: [WatchHeartRateSampleV1],
interrupted: Bool = false
) -> WatchMeasurementValidation {
let filtered = samples
.filter { $0.bpm.isFinite && (minimumBPM ... maximumBPM).contains($0.bpm) }
.sorted { $0.date < $1.date }
let coverage = max(0, (filtered.last?.date.timeIntervalSince(filtered.first?.date ?? Date())) ?? 0)
let gaps = zip(filtered, filtered.dropFirst()).map { $1.date.timeIntervalSince($0.date) }
let maximumGap = max(0, gaps.max() ?? 0)
let bpms = filtered.map(\.bpm)
let average = bpms.isEmpty ? nil : bpms.reduce(0, +) / Double(bpms.count)
var reasons: [WatchMeasurementInvalidReason] = []
if filtered.isEmpty { reasons.append(.noValidHeartRate) }
if filtered.count < minimumSamples { reasons.append(.insufficientSamples) }
if coverage < minimumCoverageSeconds { reasons.append(.insufficientCoverage) }
if maximumGap > maximumSilenceSeconds { reasons.append(.staleData) }
if interrupted { reasons.append(.interrupted) }
let sampleScore = min(1, Double(filtered.count) / Double(minimumSamples))
let coverageScore = min(1, coverage / targetDurationSeconds)
let continuityScore = maximumGap <= maximumSilenceSeconds
? 1
: max(0, maximumSilenceSeconds / maximumGap)
let quality = min(1, max(0, sampleScore * coverageScore * continuityScore))
return WatchMeasurementValidation(
validSamples: filtered,
currentBPM: filtered.last?.bpm,
averageBPM: average,
minimumBPM: bpms.min(),
maximumBPM: bpms.max(),
coverageSeconds: coverage,
maximumGapSeconds: maximumGap,
quality: quality,
invalidReasons: reasons
)
}
}
+377
View File
@@ -0,0 +1,377 @@
import Foundation
public enum MeasurementSource: String, Codable, CaseIterable, Sendable {
case iphoneCameraPPG
case appleWatchOptical
}
public struct WatchHeartRateSampleV1: Codable, Equatable, Sendable, Identifiable {
public var id: UUID
public var date: Date
public var bpm: Double
public init(id: UUID = UUID(), date: Date, bpm: Double) {
self.id = id
self.date = date
self.bpm = bpm
}
}
public struct WatchDeviceIdentityV1: Codable, Equatable, Sendable {
public var id: String
public var name: String
public var model: String?
public var systemVersion: String?
public init(id: String, name: String, model: String?, systemVersion: String?) {
self.id = id
self.name = name
self.model = model
self.systemVersion = systemVersion
}
private enum CodingKeys: String, CodingKey { case id, name, model, systemVersion }
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
if let model { try container.encode(model, forKey: .model) } else { try container.encodeNil(forKey: .model) }
if let systemVersion { try container.encode(systemVersion, forKey: .systemVersion) } else { try container.encodeNil(forKey: .systemVersion) }
}
}
public enum WatchMeasurementPosture: String, Codable, CaseIterable, Sendable {
case seated
case standing
case lying
case unknown
}
public struct WatchMeasurementContextV1: Codable, Equatable, Sendable {
public var posture: WatchMeasurementPosture
public var isAtRest: Bool
public var recentCaffeine: Bool
public var recentMeal: Bool
public var recentExercise: Bool
public var note: String?
public init(
posture: WatchMeasurementPosture = .unknown,
isAtRest: Bool = true,
recentCaffeine: Bool = false,
recentMeal: Bool = false,
recentExercise: Bool = false,
note: String? = nil
) {
self.posture = posture
self.isAtRest = isAtRest
self.recentCaffeine = recentCaffeine
self.recentMeal = recentMeal
self.recentExercise = recentExercise
self.note = note
}
public var isBaselineComparable: Bool {
isAtRest && !recentCaffeine && !recentMeal && !recentExercise
}
}
public struct WatchMeasurementSummaryV1: Codable, Equatable, Sendable, Identifiable {
public static let schemaVersion = 1
public var id: UUID
public var updatedAt: Date
public var startedAt: Date
public var endedAt: Date
public var source: MeasurementSource
public var device: WatchDeviceIdentityV1
public var currentBPM: Double?
public var averageBPM: Double?
public var minimumBPM: Double?
public var maximumBPM: Double?
public var samples: [WatchHeartRateSampleV1]
public var coverageSeconds: TimeInterval
public var quality: Double
public var isValid: Bool
public var invalidReasons: [WatchMeasurementInvalidReason]
public var context: WatchMeasurementContextV1
public var feeling: Int?
public var feelingRecordedAt: Date?
public init(
id: UUID = UUID(),
updatedAt: Date = Date(),
startedAt: Date,
endedAt: Date,
source: MeasurementSource = .appleWatchOptical,
device: WatchDeviceIdentityV1,
currentBPM: Double?,
averageBPM: Double?,
minimumBPM: Double?,
maximumBPM: Double?,
samples: [WatchHeartRateSampleV1],
coverageSeconds: TimeInterval,
quality: Double,
isValid: Bool,
invalidReasons: [WatchMeasurementInvalidReason] = [],
context: WatchMeasurementContextV1 = .init(),
feeling: Int? = nil,
feelingRecordedAt: Date? = nil
) {
self.id = id
self.updatedAt = updatedAt
self.startedAt = startedAt
self.endedAt = endedAt
self.source = source
self.device = device
self.currentBPM = currentBPM
self.averageBPM = averageBPM
self.minimumBPM = minimumBPM
self.maximumBPM = maximumBPM
self.samples = samples
self.coverageSeconds = coverageSeconds
self.quality = quality
self.isValid = isValid
self.invalidReasons = invalidReasons
self.context = context
self.feeling = feeling
self.feelingRecordedAt = feelingRecordedAt
}
private enum CodingKeys: String, CodingKey {
case schemaVersion, id, updatedAt, startedAt, endedAt, source, device
case currentBpm, averageBpm, minimumBpm, maximumBpm, samples, coverage, quality
case valid, invalidReason, context, feeling
}
private struct WireSample: Codable {
let offsetSeconds: TimeInterval
let bpm: Double
}
private struct WireContext: Codable {
let atRest: Bool
let posture: String?
let recentExercise: Bool?
let recentCaffeine: Bool?
let recentMeal: Bool?
}
private struct WireFeeling: Codable {
let score: Int
let recordedAt: Date
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(Self.schemaVersion, forKey: .schemaVersion)
try container.encode(id, forKey: .id)
try container.encode(updatedAt, forKey: .updatedAt)
try container.encode(startedAt, forKey: .startedAt)
try container.encode(endedAt, forKey: .endedAt)
try container.encode(source, forKey: .source)
try container.encode(device, forKey: .device)
if let currentBPM { try container.encode(currentBPM, forKey: .currentBpm) }
else { try container.encodeNil(forKey: .currentBpm) }
try container.encodeIfPresent(averageBPM, forKey: .averageBpm)
try container.encodeIfPresent(minimumBPM, forKey: .minimumBpm)
try container.encodeIfPresent(maximumBPM, forKey: .maximumBpm)
try container.encode(samples.map { WireSample(offsetSeconds: max(0, $0.date.timeIntervalSince(startedAt)), bpm: $0.bpm) }, forKey: .samples)
try container.encode(min(1, max(0, coverageSeconds / 60)), forKey: .coverage)
try container.encode(quality, forKey: .quality)
try container.encode(isValid, forKey: .valid)
if let invalidReason = invalidReasons.first?.rawValue { try container.encode(invalidReason, forKey: .invalidReason) }
else { try container.encodeNil(forKey: .invalidReason) }
try container.encode(WireContext(
atRest: context.isAtRest,
posture: context.posture == .unknown ? nil : context.posture.rawValue,
recentExercise: context.recentExercise,
recentCaffeine: context.recentCaffeine,
recentMeal: context.recentMeal
), forKey: .context)
if let feeling {
try container.encode(WireFeeling(score: feeling, recordedAt: feelingRecordedAt ?? updatedAt), forKey: .feeling)
} else {
try container.encodeNil(forKey: .feeling)
}
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
guard try container.decode(Int.self, forKey: .schemaVersion) == Self.schemaVersion else {
throw DecodingError.dataCorruptedError(forKey: .schemaVersion, in: container, debugDescription: "Unsupported Watch measurement schema")
}
id = try container.decode(UUID.self, forKey: .id)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
startedAt = try container.decode(Date.self, forKey: .startedAt)
endedAt = try container.decode(Date.self, forKey: .endedAt)
source = try container.decode(MeasurementSource.self, forKey: .source)
device = try container.decode(WatchDeviceIdentityV1.self, forKey: .device)
currentBPM = try container.decodeIfPresent(Double.self, forKey: .currentBpm)
averageBPM = try container.decodeIfPresent(Double.self, forKey: .averageBpm)
minimumBPM = try container.decodeIfPresent(Double.self, forKey: .minimumBpm)
maximumBPM = try container.decodeIfPresent(Double.self, forKey: .maximumBpm)
let wireSamples = try container.decode([WireSample].self, forKey: .samples)
let decodedStartedAt = startedAt
samples = wireSamples.map { .init(date: decodedStartedAt.addingTimeInterval($0.offsetSeconds), bpm: $0.bpm) }
coverageSeconds = try container.decode(Double.self, forKey: .coverage) * 60
quality = try container.decode(Double.self, forKey: .quality)
isValid = try container.decode(Bool.self, forKey: .valid)
invalidReasons = (try container.decodeIfPresent(String.self, forKey: .invalidReason))
.flatMap(WatchMeasurementInvalidReason.init(rawValue:)).map { [$0] } ?? []
let wireContext = try container.decode(WireContext.self, forKey: .context)
context = .init(
posture: wireContext.posture.flatMap(WatchMeasurementPosture.init(rawValue:)) ?? .unknown,
isAtRest: wireContext.atRest,
recentCaffeine: wireContext.recentCaffeine ?? false,
recentMeal: wireContext.recentMeal ?? false,
recentExercise: wireContext.recentExercise ?? false
)
let wireFeeling = try container.decodeIfPresent(WireFeeling.self, forKey: .feeling)
feeling = wireFeeling?.score
feelingRecordedAt = wireFeeling?.recordedAt
}
}
public enum WatchRegulationSessionState: String, Codable, Sendable {
case inProgress
case awaitingAfter
case completed
case cancelled
}
public struct WatchRegulationSessionV1: Codable, Equatable, Sendable, Identifiable {
public static let currentSchemaVersion = 1
public let schemaVersion = 1
public var id: UUID
public var protocolID: String
public var protocolVersion: Int = 1
public var startedAt: Date
public var endedAt: Date
public var durationSeconds: TimeInterval
public var state: WatchRegulationSessionState
public var beforeMeasurementID: UUID?
public var afterMeasurementID: UUID?
public var feelingBefore: Int?
public var feelingAfter: Int?
public var healthKitWorkoutID: UUID?
public var updatedAt: Date
public init(
id: UUID = UUID(),
protocolID: String,
protocolVersion: Int = 1,
startedAt: Date,
endedAt: Date,
durationSeconds: TimeInterval,
state: WatchRegulationSessionState,
beforeMeasurementID: UUID? = nil,
afterMeasurementID: UUID? = nil,
feelingBefore: Int? = nil,
feelingAfter: Int? = nil,
healthKitWorkoutID: UUID? = nil,
updatedAt: Date = Date()
) {
self.id = id
self.protocolID = protocolID
self.protocolVersion = protocolVersion
self.startedAt = startedAt
self.endedAt = endedAt
self.durationSeconds = durationSeconds
self.state = state
self.beforeMeasurementID = beforeMeasurementID
self.afterMeasurementID = afterMeasurementID
self.feelingBefore = feelingBefore
self.feelingAfter = feelingAfter
self.healthKitWorkoutID = healthKitWorkoutID
self.updatedAt = updatedAt
}
private enum CodingKeys: String, CodingKey {
case schemaVersion, id, protocolID = "protocolId", startedAt, endedAt, durationSeconds, state
case beforeMeasurementID = "beforeMeasurementId", afterMeasurementID = "afterMeasurementId"
case feelingBefore, feelingAfter, healthKitWorkoutID = "workoutUUID", updatedAt
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(schemaVersion, forKey: .schemaVersion)
try container.encode(id, forKey: .id)
try container.encode(protocolID, forKey: .protocolID)
try container.encode(startedAt, forKey: .startedAt)
try container.encode(endedAt, forKey: .endedAt)
try container.encode(durationSeconds, forKey: .durationSeconds)
try container.encode(state, forKey: .state)
try container.encode(updatedAt, forKey: .updatedAt)
if let beforeMeasurementID { try container.encode(beforeMeasurementID, forKey: .beforeMeasurementID) }
else { try container.encodeNil(forKey: .beforeMeasurementID) }
if let afterMeasurementID { try container.encode(afterMeasurementID, forKey: .afterMeasurementID) }
else { try container.encodeNil(forKey: .afterMeasurementID) }
if let feelingBefore { try container.encode(feelingBefore, forKey: .feelingBefore) }
else { try container.encodeNil(forKey: .feelingBefore) }
if let feelingAfter { try container.encode(feelingAfter, forKey: .feelingAfter) }
else { try container.encodeNil(forKey: .feelingAfter) }
if let healthKitWorkoutID { try container.encode(healthKitWorkoutID, forKey: .healthKitWorkoutID) }
else { try container.encodeNil(forKey: .healthKitWorkoutID) }
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
guard try container.decode(Int.self, forKey: .schemaVersion) == 1 else {
throw DecodingError.dataCorruptedError(forKey: .schemaVersion, in: container, debugDescription: "Unsupported Watch session schema")
}
id = try container.decode(UUID.self, forKey: .id)
protocolID = try container.decode(String.self, forKey: .protocolID)
protocolVersion = 1
startedAt = try container.decode(Date.self, forKey: .startedAt)
endedAt = try container.decode(Date.self, forKey: .endedAt)
durationSeconds = try container.decode(TimeInterval.self, forKey: .durationSeconds)
state = try container.decode(WatchRegulationSessionState.self, forKey: .state)
beforeMeasurementID = try container.decodeIfPresent(UUID.self, forKey: .beforeMeasurementID)
afterMeasurementID = try container.decodeIfPresent(UUID.self, forKey: .afterMeasurementID)
feelingBefore = try container.decodeIfPresent(Int.self, forKey: .feelingBefore)
feelingAfter = try container.decodeIfPresent(Int.self, forKey: .feelingAfter)
healthKitWorkoutID = try container.decodeIfPresent(UUID.self, forKey: .healthKitWorkoutID)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
}
}
public struct WatchDeletionTombstoneV1: Codable, Equatable, Sendable, Identifiable {
public let schemaVersion = 1
public let id: UUID = UUID()
public let entityID: UUID
public let entityKind: String
public let deletedAt: Date
public let updatedAt: Date
public init(entityID: UUID, entityKind: String, deletedAt: Date = Date(), updatedAt: Date = Date()) {
self.entityID = entityID
self.entityKind = entityKind
self.deletedAt = deletedAt
self.updatedAt = updatedAt
}
private enum CodingKeys: String, CodingKey {
case schemaVersion, entityID = "entityId", entityKind, deletedAt, updatedAt
}
}
public struct WatchPreferencesV1: Codable, Equatable, Sendable {
public var schemaVersion = 1
public var saveMindAndBodyWorkouts: Bool
public var protocolCatalogVersion: String
public var updatedAt: Date
public init(
saveMindAndBodyWorkouts: Bool = true,
protocolCatalogVersion: String = "1.0.0",
updatedAt: Date = Date()
) {
self.saveMindAndBodyWorkouts = saveMindAndBodyWorkouts
self.protocolCatalogVersion = protocolCatalogVersion
self.updatedAt = updatedAt
}
}
@@ -0,0 +1,40 @@
import Foundation
public enum WatchExclusiveResource: String, Codable, Sendable {
case heartRateMeasurement
case hapticRegulation
}
public enum WatchResourceError: LocalizedError, Equatable {
case busy(WatchExclusiveResource)
case releaseMismatch
public var errorDescription: String? {
switch self {
case let .busy(resource): "Une activité \(resource.rawValue) est déjà en cours."
case .releaseMismatch: "La ressource libérée ne correspond pas à l’activité en cours."
}
}
}
public actor WatchResourceArbiter {
public static let shared = WatchResourceArbiter()
public private(set) var current: WatchExclusiveResource?
public init() {}
public func acquire(_ resource: WatchExclusiveResource) throws {
if let current { throw WatchResourceError.busy(current) }
current = resource
}
public func release(_ resource: WatchExclusiveResource) throws {
guard current == resource else { throw WatchResourceError.releaseMismatch }
current = nil
}
public func forceRelease() {
current = nil
}
}
+134
View File
@@ -0,0 +1,134 @@
import Combine
import Foundation
@MainActor
public final class WatchStore: ObservableObject {
@Published public private(set) var measurements: [WatchMeasurementSummaryV1] = []
@Published public private(set) var sessions: [WatchRegulationSessionV1] = []
@Published public private(set) var outbox: [WatchSyncEnvelopeV1] = []
@Published public private(set) var tombstones: [WatchDeletionTombstoneV1] = []
@Published public var preferences: WatchPreferencesV1 {
didSet { persist() }
}
public var baseline: WatchBaselineSnapshot { .init(measurements: measurements) }
private let defaults: UserDefaults
private let storageKey: String
private struct Snapshot: Codable {
var measurements: [WatchMeasurementSummaryV1]
var sessions: [WatchRegulationSessionV1]
var outbox: [WatchSyncEnvelopeV1]
var tombstones: [WatchDeletionTombstoneV1]
var preferences: WatchPreferencesV1
}
public init(defaults: UserDefaults = .standard, storageKey: String = "haptika.watch.store.v1") {
self.defaults = defaults
self.storageKey = storageKey
if let data = defaults.data(forKey: storageKey),
let snapshot = try? WatchCoding.decoder.decode(Snapshot.self, from: data) {
measurements = snapshot.measurements
sessions = snapshot.sessions
outbox = snapshot.outbox
tombstones = snapshot.tombstones
preferences = snapshot.preferences
} else {
preferences = .init()
}
}
public func upsert(_ measurement: WatchMeasurementSummaryV1, enqueue envelope: WatchSyncEnvelopeV1? = nil) {
if let tombstone = tombstones.first(where: { $0.entityID == measurement.id }),
tombstone.updatedAt >= measurement.updatedAt { return }
measurements.removeAll { $0.id == measurement.id && $0.updatedAt <= measurement.updatedAt }
if !measurements.contains(where: { $0.id == measurement.id }) {
measurements.append(measurement)
}
measurements.sort { $0.startedAt > $1.startedAt }
measurements = Array(measurements.prefix(30))
if let envelope { enqueue(envelope) }
persist()
}
public func upsert(_ session: WatchRegulationSessionV1, enqueue envelope: WatchSyncEnvelopeV1? = nil) {
if let tombstone = tombstones.first(where: { $0.entityID == session.id }),
tombstone.updatedAt >= session.updatedAt { return }
sessions.removeAll { $0.id == session.id && $0.updatedAt <= session.updatedAt }
if !sessions.contains(where: { $0.id == session.id }) {
sessions.append(session)
}
sessions.sort { $0.startedAt > $1.startedAt }
sessions = Array(sessions.prefix(20))
if let envelope { enqueue(envelope) }
persist()
}
@discardableResult
public func deleteMeasurement(id: UUID, envelope: WatchSyncEnvelopeV1? = nil) -> WatchDeletionTombstoneV1 {
measurements.removeAll { $0.id == id }
let tombstone = WatchDeletionTombstoneV1(entityID: id, entityKind: "measurement")
tombstones.removeAll { $0.entityID == id }
tombstones.append(tombstone)
if let envelope { enqueue(envelope) }
persist()
return tombstone
}
@discardableResult
public func deleteSession(id: UUID, envelope: WatchSyncEnvelopeV1? = nil) -> WatchDeletionTombstoneV1 {
sessions.removeAll { $0.id == id }
let tombstone = WatchDeletionTombstoneV1(entityID: id, entityKind: "regulationSession")
tombstones.removeAll { $0.entityID == id }
tombstones.append(tombstone)
if let envelope { enqueue(envelope) }
persist()
return tombstone
}
public func apply(_ tombstone: WatchDeletionTombstoneV1) {
if tombstone.entityKind == "measurement" {
measurements.removeAll { $0.id == tombstone.entityID }
} else if tombstone.entityKind == "regulationSession" {
sessions.removeAll { $0.id == tombstone.entityID }
}
if let existing = tombstones.first(where: { $0.entityID == tombstone.entityID }),
existing.updatedAt >= tombstone.updatedAt { return }
tombstones.removeAll { $0.entityID == tombstone.entityID }
tombstones.append(tombstone)
persist()
}
public func enqueue(_ envelope: WatchSyncEnvelopeV1) {
guard !outbox.contains(where: { $0.eventID == envelope.eventID }) else { return }
outbox.append(envelope)
persist()
}
public func acknowledge(eventIDs: Set<UUID>) {
outbox.removeAll { eventIDs.contains($0.eventID) }
persist()
}
public func replaceFromCompanion(
measurements incomingMeasurements: [WatchMeasurementSummaryV1],
sessions incomingSessions: [WatchRegulationSessionV1]
) {
for measurement in incomingMeasurements { upsert(measurement) }
for session in incomingSessions { upsert(session) }
}
private func persist() {
let snapshot = Snapshot(
measurements: measurements,
sessions: sessions,
outbox: outbox,
tombstones: tombstones,
preferences: preferences
)
if let data = try? WatchCoding.encoder.encode(snapshot) {
defaults.set(data, forKey: storageKey)
}
}
}
@@ -0,0 +1,169 @@
import Foundation
public enum WatchSyncKind: String, Codable, Sendable {
case liveHeartRate
case measurementUpsert
case regulationSessionUpsert
case feelingUpsert
case tombstone
case preferences
case acknowledgement = "ack"
}
/// The payload is encoded JSON data. Keeping the transport wrapper non-generic lets the
/// offline outbox contain measurements, sessions and tombstones in a single queue.
public struct WatchSyncEnvelopeV1: Codable, Equatable, Sendable, Identifiable {
public static let schemaVersion = 1
public var id: UUID { eventID }
public let eventID: UUID
public let deviceID: String
public let sequence: UInt64
public let sentAt: Date
public let kind: WatchSyncKind
public let payload: Data
public init(
eventID: UUID = UUID(),
deviceID: String,
sequence: UInt64,
sentAt: Date = Date(),
kind: WatchSyncKind,
payload: Data
) {
self.eventID = eventID
self.deviceID = deviceID
self.sequence = sequence
self.sentAt = sentAt
self.kind = kind
self.payload = payload
}
public init<Payload: Encodable>(
eventID: UUID = UUID(),
deviceID: String,
sequence: UInt64,
sentAt: Date = Date(),
kind: WatchSyncKind,
payload: Payload,
encoder: JSONEncoder = WatchCoding.encoder
) throws {
self.init(
eventID: eventID,
deviceID: deviceID,
sequence: sequence,
sentAt: sentAt,
kind: kind,
payload: try encoder.encode(payload)
)
}
public func decodePayload<Payload: Decodable>(
_ type: Payload.Type,
decoder: JSONDecoder = WatchCoding.decoder
) throws -> Payload {
try decoder.decode(type, from: payload)
}
private enum CodingKeys: String, CodingKey {
case schemaVersion, eventID = "eventId", deviceID = "deviceId", sequence, sentAt, kind, payload
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(Self.schemaVersion, forKey: .schemaVersion)
try container.encode(eventID, forKey: .eventID)
try container.encode(deviceID, forKey: .deviceID)
try container.encode(sequence, forKey: .sequence)
try container.encode(sentAt, forKey: .sentAt)
try container.encode(kind, forKey: .kind)
try container.encode(try WatchJSONValue(data: payload), forKey: .payload)
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
guard try container.decode(Int.self, forKey: .schemaVersion) == Self.schemaVersion else {
throw DecodingError.dataCorruptedError(forKey: .schemaVersion, in: container, debugDescription: "Unsupported Watch envelope schema")
}
eventID = try container.decode(UUID.self, forKey: .eventID)
deviceID = try container.decode(String.self, forKey: .deviceID)
sequence = try container.decode(UInt64.self, forKey: .sequence)
sentAt = try container.decode(Date.self, forKey: .sentAt)
kind = try container.decode(WatchSyncKind.self, forKey: .kind)
payload = try container.decode(WatchJSONValue.self, forKey: .payload).data()
}
}
private enum WatchJSONValue: Codable, Equatable {
case object([String: WatchJSONValue])
case array([WatchJSONValue])
case string(String)
case number(Double)
case bool(Bool)
case null
init(data: Data) throws {
self = try WatchCoding.decoder.decode(Self.self, from: data)
}
func data() throws -> Data { try WatchCoding.encoder.encode(self) }
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() { self = .null }
else if let value = try? container.decode(Bool.self) { self = .bool(value) }
else if let value = try? container.decode(Double.self) { self = .number(value) }
else if let value = try? container.decode(String.self) { self = .string(value) }
else if let value = try? container.decode([WatchJSONValue].self) { self = .array(value) }
else { self = .object(try container.decode([String: WatchJSONValue].self)) }
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case let .object(value): try container.encode(value)
case let .array(value): try container.encode(value)
case let .string(value): try container.encode(value)
case let .number(value): try container.encode(value)
case let .bool(value): try container.encode(value)
case .null: try container.encodeNil()
}
}
}
public struct WatchAcknowledgementV1: Codable, Equatable, Sendable {
public let eventIDs: [UUID]
public let receivedAt: Date
public init(eventIDs: [UUID], receivedAt: Date = Date()) {
self.eventIDs = eventIDs
self.receivedAt = receivedAt
}
}
public struct WatchLiveHeartRateV1: Codable, Equatable, Sendable {
public let bpm: Double
public let sampledAt: Date
public let measurementID: UUID?
public init(bpm: Double, sampledAt: Date, measurementID: UUID? = nil) {
self.bpm = bpm
self.sampledAt = sampledAt
self.measurementID = measurementID
}
}
public enum WatchCoding {
public static var encoder: JSONEncoder {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.sortedKeys]
return encoder
}
public static var decoder: JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.healthkit</key>
<true/>
</dict>
</plist>
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Haptika</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSHealthShareUsageDescription</key>
<string>Haptika lit votre fréquence cardiaque pendant une mesure ou une séance guidée afin d’afficher votre évolution en direct.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>Haptika peut enregistrer dans Santé les séances guidées Corps et esprit que vous terminez, si vous activez cette option.</string>
<key>WKApplication</key>
<true/>
<key>WKBackgroundModes</key>
<array>
<string>workout-processing</string>
</array>
<key>WKCompanionAppBundleIdentifier</key>
<string>com.sachagaulin.haptika.r537r56465.dev</string>
</dict>
</plist>
@@ -0,0 +1,129 @@
import Combine
import Foundation
import WatchConnectivity
@MainActor
public final class WatchConnectivityService: NSObject, ObservableObject {
@Published public private(set) var activationState: WCSessionActivationState = .notActivated
@Published public private(set) var isReachable = false
@Published public private(set) var lastSynchronizedAt: Date?
@Published public private(set) var lastError: String?
public var onEnvelope: ((WatchSyncEnvelopeV1) -> Void)?
public var onAcknowledgement: ((Set<UUID>) -> Void)?
private let session: WCSession?
private let encoder = WatchCoding.encoder
private let decoder = WatchCoding.decoder
public override init() {
session = WCSession.isSupported() ? .default : nil
super.init()
}
public func activate() {
guard let session else { return }
session.delegate = self
session.activate()
activationState = session.activationState
isReachable = session.isReachable
}
public func sendLive(_ envelope: WatchSyncEnvelopeV1) {
guard let session, session.isReachable else { return }
do {
let data = try encoder.encode(envelope)
session.sendMessageData(data, replyHandler: nil) { [weak self] error in
Task { @MainActor in self?.lastError = error.localizedDescription }
}
} catch {
lastError = error.localizedDescription
}
}
/// Guaranteed delivery for durable mutations. WatchConnectivity persists these while offline.
public func transfer(_ envelope: WatchSyncEnvelopeV1) {
guard let session else { return }
do {
session.transferUserInfo(["envelope": try encoder.encode(envelope)])
} catch {
lastError = error.localizedDescription
}
}
public func flush(outbox: [WatchSyncEnvelopeV1]) {
for envelope in outbox { transfer(envelope) }
}
public func updateApplicationContext(preferences: WatchPreferencesV1) {
guard let session else { return }
do {
try session.updateApplicationContext(["preferences": try encoder.encode(preferences)])
} catch {
lastError = error.localizedDescription
}
}
private func receive(_ data: Data) {
do {
let envelope = try decoder.decode(WatchSyncEnvelopeV1.self, from: data)
lastSynchronizedAt = Date()
if envelope.kind == .acknowledgement,
let acknowledgement = try? envelope.decodePayload(WatchAcknowledgementV1.self) {
onAcknowledgement?(Set(acknowledgement.eventIDs))
} else {
onEnvelope?(envelope)
}
} catch {
lastError = error.localizedDescription
}
}
}
extension WatchConnectivityService: WCSessionDelegate {
nonisolated public func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
Task { @MainActor [weak self] in
self?.activationState = activationState
self?.isReachable = session.isReachable
self?.lastError = error?.localizedDescription
}
}
nonisolated public func sessionReachabilityDidChange(_ session: WCSession) {
Task { @MainActor [weak self] in self?.isReachable = session.isReachable }
}
nonisolated public func session(_ session: WCSession, didReceiveMessageData messageData: Data) {
Task { @MainActor [weak self] in self?.receive(messageData) }
}
nonisolated public func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
guard let data = userInfo["envelope"] as? Data else { return }
Task { @MainActor [weak self] in self?.receive(data) }
}
nonisolated public func session(
_ session: WCSession,
didReceiveApplicationContext applicationContext: [String: Any]
) {
guard let data = applicationContext["preferences"] as? Data else { return }
Task { @MainActor [weak self] in
guard let self,
let preferences = try? self.decoder.decode(WatchPreferencesV1.self, from: data) else { return }
let payload = try? self.encoder.encode(preferences)
if let payload {
self.onEnvelope?(.init(
deviceID: "iphone",
sequence: 0,
kind: .preferences,
payload: payload
))
}
self.lastSynchronizedAt = Date()
}
}
}
@@ -0,0 +1,127 @@
import Foundation
import WatchKit
@MainActor
public final class WatchHapticService: ObservableObject {
@Published public private(set) var isRunning = false
@Published public private(set) var elapsedSeconds: TimeInterval = 0
private let arbiter: WatchResourceArbiter
private var task: Task<Void, Never>?
private var activeRunID: UUID?
public init(arbiter: WatchResourceArbiter = .shared) {
self.arbiter = arbiter
}
public func start(protocol regulation: WatchRegulationProtocol) async throws {
task?.cancel()
task = nil
activeRunID = nil
await arbiter.forceRelease()
try await arbiter.acquire(.hapticRegulation)
let runID = UUID()
activeRunID = runID
isRunning = true
elapsedSeconds = 0
WKInterfaceDevice.current().play(.start)
task = Task { @MainActor [weak self] in
guard let self else { return }
defer {
Task { @MainActor in await self.finishRun(runID) }
}
if regulation.id == "sosStress" {
for _ in 0..<3 {
guard !Task.isCancelled else { return }
WKInterfaceDevice.current().play(.click)
try? await Task.sleep(for: .milliseconds(600))
}
}
var previousPhaseIndex = -1
var previousCycleIndex = -1
let startedAt = Date()
while !Task.isCancelled {
let elapsed = Date().timeIntervalSince(startedAt)
self.elapsedSeconds = min(regulation.durationSeconds, elapsed)
if elapsed >= regulation.durationSeconds {
WKInterfaceDevice.current().play(.success)
return
}
if regulation.id == "sosStress" {
let second = Int(elapsed.rounded(.down))
if second > 0 && second.isMultiple(of: 10) {
WKInterfaceDevice.current().play(.click)
try? await Task.sleep(for: .seconds(1))
continue
}
} else if let position = RegulationPhaseEngine.position(in: regulation, elapsedSeconds: elapsed),
position.phaseIndex != previousPhaseIndex || position.cycleIndex != previousCycleIndex {
if previousPhaseIndex >= 0 { WKInterfaceDevice.current().play(.click) }
previousPhaseIndex = position.phaseIndex
previousCycleIndex = position.cycleIndex
}
try? await Task.sleep(for: .milliseconds(100))
}
}
}
public func startCardiacMirror(
initialBPM: Double,
durationSeconds: TimeInterval = 120,
decelerationBPMPerMinute: Double = 1.5
) async throws {
task?.cancel()
task = nil
activeRunID = nil
await arbiter.forceRelease()
try await arbiter.acquire(.hapticRegulation)
let runID = UUID()
activeRunID = runID
isRunning = true
elapsedSeconds = 0
WKInterfaceDevice.current().play(.start)
task = Task { @MainActor [weak self] in
guard let self else { return }
defer {
Task { @MainActor in await self.finishRun(runID) }
}
let startedAt = Date()
while !Task.isCancelled {
let elapsed = Date().timeIntervalSince(startedAt)
self.elapsedSeconds = min(durationSeconds, elapsed)
guard elapsed < durationSeconds else {
WKInterfaceDevice.current().play(.success)
return
}
let targetBPM = max(35, initialBPM - decelerationBPMPerMinute * elapsed / 60)
WKInterfaceDevice.current().play(.click)
try? await Task.sleep(for: .seconds(60 / targetBPM))
}
}
}
public func cancel(playStop: Bool = true) {
guard task != nil || isRunning else { return }
task?.cancel()
task = nil
activeRunID = nil
isRunning = false
if playStop { WKInterfaceDevice.current().play(.stop) }
Task { @MainActor [weak self] in
guard let self, self.activeRunID == nil else { return }
await self.arbiter.forceRelease()
}
}
private func finishRun(_ runID: UUID) async {
guard activeRunID == runID else { return }
activeRunID = nil
task = nil
isRunning = false
try? await arbiter.release(.hapticRegulation)
}
}
@@ -0,0 +1,267 @@
import Combine
import Foundation
import HealthKit
import WatchKit
public enum WatchCollectionMode: Equatable, Sendable {
case technicalMeasurement
case guidedMindAndBody(saveToHealth: Bool)
}
public enum WatchMeasurementState: Equatable, Sendable {
case idle
case authorizing
case waitingForFirstSample(secondsRemaining: Int)
case collecting(progress: Double)
case finishing
case completed(WatchMeasurementSummaryV1)
case failed(String)
}
@MainActor
public final class WatchHealthMeasurementService: NSObject, ObservableObject {
@Published public private(set) var state: WatchMeasurementState = .idle
@Published public private(set) var currentBPM: Double?
@Published public private(set) var samples: [WatchHeartRateSampleV1] = []
@Published public private(set) var lastWorkoutUUID: UUID?
public var onLiveHeartRate: ((WatchHeartRateSampleV1) -> Void)?
public var onCompleted: ((WatchMeasurementSummaryV1) -> Void)?
private let healthStore: HKHealthStore
private let arbiter: WatchResourceArbiter
private var workoutSession: HKWorkoutSession?
private var builder: HKLiveWorkoutBuilder?
private var collectionMode: WatchCollectionMode = .technicalMeasurement
private var startedAt: Date?
private var validCollectionStartedAt: Date?
private var timeoutTask: Task<Void, Never>?
private var finishTask: Task<Void, Never>?
private var collectionDurationSeconds: TimeInterval = 60
public init(
healthStore: HKHealthStore = HKHealthStore(),
arbiter: WatchResourceArbiter = .shared
) {
self.healthStore = healthStore
self.arbiter = arbiter
super.init()
}
public func requestAuthorization() async throws {
guard HKHealthStore.isHealthDataAvailable(),
let heartRate = HKObjectType.quantityType(forIdentifier: .heartRate) else {
throw NSError(domain: "HaptikaWatch.Health", code: 1, userInfo: [NSLocalizedDescriptionKey: "Santé n’est pas disponible sur cette Watch."])
}
state = .authorizing
try await healthStore.requestAuthorization(
toShare: [HKObjectType.workoutType()],
read: [heartRate]
)
state = .idle
}
public func start(
mode: WatchCollectionMode = .technicalMeasurement,
collectionDurationSeconds: TimeInterval = 60
) async throws {
guard case .idle = state else { return }
try await arbiter.acquire(.heartRateMeasurement)
do {
let configuration = HKWorkoutConfiguration()
configuration.activityType = .mindAndBody
configuration.locationType = .unknown
let session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
let builder = session.associatedWorkoutBuilder()
builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)
session.delegate = self
builder.delegate = self
workoutSession = session
self.builder = builder
collectionMode = mode
self.collectionDurationSeconds = max(60, collectionDurationSeconds)
samples = []
currentBPM = nil
lastWorkoutUUID = nil
let now = Date()
startedAt = now
validCollectionStartedAt = nil
state = .waitingForFirstSample(secondsRemaining: 30)
session.startActivity(with: now)
try await builder.beginCollection(at: now)
scheduleFirstSampleTimeout()
} catch {
await arbiter.forceRelease()
state = .failed(error.localizedDescription)
throw error
}
}
public func cancel() {
timeoutTask?.cancel()
finishTask?.cancel()
workoutSession?.end()
let builder = builder
Task { @MainActor [weak self] in
if let builder {
try? await builder.endCollection(at: Date())
builder.discardWorkout()
}
await self?.arbiter.forceRelease()
self?.resetResources()
self?.state = .idle
}
}
public func finish(interrupted: Bool = false) {
guard builder != nil else { return }
state = .finishing
timeoutTask?.cancel()
finishTask?.cancel()
workoutSession?.end()
let endDate = Date()
let startDate = startedAt ?? endDate
let validation = WatchMeasurementValidator.validate(samples: samples, interrupted: interrupted)
let summary = WatchMeasurementSummaryV1(
startedAt: startDate,
endedAt: endDate,
device: WatchDeviceIdentityV1(
id: WKInterfaceDevice.current().identifierForVendor?.uuidString ?? "unknown-watch",
name: WKInterfaceDevice.current().name,
model: WKInterfaceDevice.current().model,
systemVersion: WKInterfaceDevice.current().systemVersion
),
currentBPM: validation.currentBPM,
averageBPM: validation.averageBPM,
minimumBPM: validation.minimumBPM,
maximumBPM: validation.maximumBPM,
samples: validation.validSamples,
coverageSeconds: validation.coverageSeconds,
quality: validation.quality,
isValid: validation.isValid,
invalidReasons: validation.invalidReasons
)
let mode = collectionMode
let builder = self.builder
finishTask = Task { @MainActor [weak self] in
guard let self, let builder else { return }
do {
try await builder.endCollection(at: endDate)
switch mode {
case .technicalMeasurement:
builder.discardWorkout()
case let .guidedMindAndBody(saveToHealth):
if saveToHealth {
self.lastWorkoutUUID = try await builder.finishWorkout()?.uuid
} else {
builder.discardWorkout()
}
}
await self.arbiter.forceRelease()
self.resetResources(keepSamples: true)
self.state = .completed(summary)
self.onCompleted?(summary)
} catch {
builder.discardWorkout()
await self.arbiter.forceRelease()
self.resetResources(keepSamples: true)
self.state = .failed(error.localizedDescription)
}
}
}
public func resetAfterResult() {
guard workoutSession == nil else { return }
state = .idle
samples = []
currentBPM = nil
lastWorkoutUUID = nil
}
private func scheduleFirstSampleTimeout() {
timeoutTask?.cancel()
timeoutTask = Task { @MainActor [weak self] in
for remaining in stride(from: 30, through: 1, by: -1) {
guard let self, self.validCollectionStartedAt == nil, !Task.isCancelled else { return }
self.state = .waitingForFirstSample(secondsRemaining: remaining)
try? await Task.sleep(for: .seconds(1))
}
guard let self, self.validCollectionStartedAt == nil, !Task.isCancelled else { return }
self.finish()
}
}
private func scheduleCollectionFinish(from firstSampleDate: Date) {
timeoutTask?.cancel()
finishTask?.cancel()
finishTask = Task { @MainActor [weak self] in
while let self, !Task.isCancelled {
let elapsed = Date().timeIntervalSince(firstSampleDate)
self.state = .collecting(progress: min(1, elapsed / self.collectionDurationSeconds))
if elapsed >= self.collectionDurationSeconds {
self.finish()
return
}
try? await Task.sleep(for: .milliseconds(250))
}
}
}
private func acceptHeartRate(_ bpm: Double, at date: Date) {
guard (WatchMeasurementValidator.minimumBPM ... WatchMeasurementValidator.maximumBPM).contains(bpm) else { return }
let sample = WatchHeartRateSampleV1(date: date, bpm: bpm)
samples.append(sample)
currentBPM = bpm
onLiveHeartRate?(sample)
if validCollectionStartedAt == nil {
validCollectionStartedAt = date
scheduleCollectionFinish(from: date)
}
}
private func resetResources(keepSamples: Bool = false) {
workoutSession = nil
builder = nil
startedAt = nil
validCollectionStartedAt = nil
timeoutTask = nil
finishTask = nil
if !keepSamples { samples = [] }
}
}
extension WatchHealthMeasurementService: HKWorkoutSessionDelegate {
nonisolated public func workoutSession(
_ workoutSession: HKWorkoutSession,
didChangeTo toState: HKWorkoutSessionState,
from fromState: HKWorkoutSessionState,
date: Date
) {}
nonisolated public func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
Task { @MainActor [weak self] in
self?.finish(interrupted: true)
}
}
}
extension WatchHealthMeasurementService: HKLiveWorkoutBuilderDelegate {
nonisolated public func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
nonisolated public func workoutBuilder(
_ workoutBuilder: HKLiveWorkoutBuilder,
didCollectDataOf collectedTypes: Set<HKSampleType>
) {
guard let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate),
collectedTypes.contains(heartRateType),
let statistics = workoutBuilder.statistics(for: heartRateType),
let quantity = statistics.mostRecentQuantity() else { return }
let unit = HKUnit.count().unitDivided(by: .minute())
let bpm = quantity.doubleValue(for: unit)
Task { @MainActor [weak self] in
self?.acceptHeartRate(bpm, at: Date())
}
}
}
@@ -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)
}
}
}
+114
View File
@@ -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)
}
}
}
+29
View File
@@ -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)
}
}