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