feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WatchRootView: View {
|
||||
@EnvironmentObject private var model: WatchAppModel
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if model.hasCompletedOnboarding {
|
||||
WatchHomeView()
|
||||
} else {
|
||||
WatchOnboardingView()
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
|
||||
struct WatchOnboardingView: View {
|
||||
@EnvironmentObject private var model: WatchAppModel
|
||||
@State private var page = 0
|
||||
@State private var authorizationError: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
TabView(selection: $page) {
|
||||
onboardingPage(icon: "hand.raised.fill", title: "Vos données", text: "Les mesures restent sur vos appareils et se synchronisent avec votre iPhone appairé.").tag(0)
|
||||
onboardingPage(icon: "heart.text.square.fill", title: "Bien-être, pas diagnostic", text: "Haptika n’est pas un dispositif médical. Consultez un professionnel en cas de symptôme.").tag(1)
|
||||
onboardingPage(icon: "waveform.path.ecg", title: "Mesurer sans vibrer", text: "La Watch mesure le pouls optique. Les vibrations sont toujours arrêtées pendant cette phase.").tag(2)
|
||||
}
|
||||
.tabViewStyle(.verticalPage)
|
||||
|
||||
if let authorizationError {
|
||||
Text(authorizationError).font(.caption2).foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
Button(page < 2 ? "Continuer" : "Autoriser Santé") {
|
||||
if page < 2 {
|
||||
withAnimation { page += 1 }
|
||||
} else {
|
||||
Task {
|
||||
do {
|
||||
try await model.measurement.requestAuthorization()
|
||||
model.hasCompletedOnboarding = true
|
||||
} catch {
|
||||
authorizationError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.background(WatchTheme.background.ignoresSafeArea())
|
||||
}
|
||||
|
||||
private func onboardingPage(icon: String, title: String, text: String) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: icon).font(.system(size: 34)).foregroundStyle(WatchTheme.accent)
|
||||
Text(title).font(.headline).multilineTextAlignment(.center)
|
||||
Text(text).font(.caption).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WatchHomeView: View {
|
||||
@EnvironmentObject private var model: WatchAppModel
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
NavigationLink(value: WatchHomeDestination.measure) { homeRow("Mesurer", "waveform.path.ecg", WatchTheme.mint) }
|
||||
NavigationLink(value: WatchHomeDestination.coherence) { homeRow("Ma Cohérence", "circle.dotted", WatchTheme.accent) }
|
||||
NavigationLink(value: WatchHomeDestination.exercises) { homeRow("Exercices", "wind", .cyan) }
|
||||
NavigationLink(value: WatchHomeDestination.cardiacMirror) { homeRow("Miroir cardiaque", "heart.circle", .pink) }
|
||||
NavigationLink(value: WatchHomeDestination.history) { homeRow("Historique", "chart.xyaxis.line", .indigo) }
|
||||
} header: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("HAPTIKA").font(.caption2).foregroundStyle(WatchTheme.accent)
|
||||
Text("Prendre un temps").font(.headline).foregroundStyle(.white)
|
||||
}
|
||||
.textCase(nil)
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Circle().fill(model.connectivity.isReachable ? WatchTheme.mint : .gray).frame(width: 7, height: 7)
|
||||
Text(model.connectivity.isReachable ? "iPhone joignable" : "Mode autonome")
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(WatchTheme.secondary)
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: WatchHomeDestination.self) { destination in
|
||||
switch destination {
|
||||
case .measure: WatchMeasurementView()
|
||||
case .coherence: WatchCoherenceView()
|
||||
case .exercises: WatchExerciseListView()
|
||||
case .cardiacMirror: WatchCardiacMirrorView()
|
||||
case .history: WatchHistoryView()
|
||||
}
|
||||
}
|
||||
.haptikaScreen()
|
||||
}
|
||||
}
|
||||
|
||||
private func homeRow(_ title: String, _ icon: String, _ color: Color) -> some View {
|
||||
Label {
|
||||
Text(title).font(.subheadline.weight(.semibold))
|
||||
} icon: {
|
||||
Image(systemName: icon).foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import SwiftUI
|
||||
|
||||
enum WatchTheme {
|
||||
static let background = Color(red: 0.015, green: 0.04, blue: 0.10)
|
||||
static let card = Color(red: 0.035, green: 0.12, blue: 0.22)
|
||||
static let accent = Color(red: 0.37, green: 0.79, blue: 0.96)
|
||||
static let mint = Color(red: 0.38, green: 0.88, blue: 0.78)
|
||||
static let secondary = Color(red: 0.66, green: 0.73, blue: 0.84)
|
||||
}
|
||||
|
||||
struct WatchCard<Content: View>: View {
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(WatchTheme.card.opacity(0.82), in: RoundedRectangle(cornerRadius: 18))
|
||||
.overlay(RoundedRectangle(cornerRadius: 18).stroke(WatchTheme.accent.opacity(0.22)))
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func haptikaScreen() -> some View {
|
||||
scrollContentBackground(.hidden)
|
||||
.background(WatchTheme.background.ignoresSafeArea())
|
||||
.tint(WatchTheme.accent)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user