378 lines
14 KiB
Swift
378 lines
14 KiB
Swift
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
|
|
}
|
|
}
|