feat: implement Haptika PPG and Watch V1

This commit is contained in:
Sacha
2026-07-31 16:19:42 +02:00
parent 6b7d4e0728
commit b9930b7378
249 changed files with 46475 additions and 122 deletions
+128
View File
@@ -0,0 +1,128 @@
import Combine
import Foundation
import WatchKit
@MainActor
public final class WatchAppModel: ObservableObject {
@Published public var hasCompletedOnboarding: Bool {
didSet { defaults.set(hasCompletedOnboarding, forKey: onboardingKey) }
}
@Published public var selectedTab: WatchHomeDestination?
public let store: WatchStore
public let measurement: WatchHealthMeasurementService
public let haptics: WatchHapticService
public let connectivity: WatchConnectivityService
private let defaults: UserDefaults
private let onboardingKey = "haptika.watch.onboarding.completed.v1"
private let deviceIDKey = "haptika.watch.device.id.v1"
private var sequence: UInt64
public var deviceID: String {
if let value = defaults.string(forKey: deviceIDKey) { return value }
let value = UUID().uuidString
defaults.set(value, forKey: deviceIDKey)
return value
}
public init(
defaults: UserDefaults = .standard,
store: WatchStore? = nil,
measurement: WatchHealthMeasurementService? = nil,
haptics: WatchHapticService? = nil,
connectivity: WatchConnectivityService? = nil
) {
self.defaults = defaults
self.store = store ?? WatchStore(defaults: defaults)
self.measurement = measurement ?? WatchHealthMeasurementService()
self.haptics = haptics ?? WatchHapticService()
self.connectivity = connectivity ?? WatchConnectivityService()
hasCompletedOnboarding = defaults.bool(forKey: onboardingKey)
sequence = UInt64(defaults.integer(forKey: "haptika.watch.sequence.v1"))
wireServices()
}
public func start() {
connectivity.activate()
connectivity.flush(outbox: store.outbox)
}
public func enqueueMeasurement(_ measurement: WatchMeasurementSummaryV1) {
guard let envelope = makeEnvelope(kind: .measurementUpsert, payload: measurement) else {
store.upsert(measurement)
return
}
store.upsert(measurement, enqueue: envelope)
connectivity.transfer(envelope)
}
public func enqueueSession(_ session: WatchRegulationSessionV1) {
guard let envelope = makeEnvelope(kind: .regulationSessionUpsert, payload: session) else {
store.upsert(session)
return
}
store.upsert(session, enqueue: envelope)
connectivity.transfer(envelope)
}
public func deleteMeasurement(_ measurement: WatchMeasurementSummaryV1) {
let tombstone = WatchDeletionTombstoneV1(entityID: measurement.id, entityKind: "measurement")
let envelope = makeEnvelope(kind: .tombstone, payload: tombstone)
_ = store.deleteMeasurement(id: measurement.id, envelope: envelope)
if let envelope { connectivity.transfer(envelope) }
}
public func saveFeeling(_ feeling: Int, for measurement: WatchMeasurementSummaryV1) {
var updated = measurement
updated.feeling = min(5, max(1, feeling))
updated.feelingRecordedAt = Date()
updated.updatedAt = Date()
enqueueMeasurement(updated)
}
private func wireServices() {
measurement.onCompleted = { [weak self] summary in self?.enqueueMeasurement(summary) }
measurement.onLiveHeartRate = { [weak self] sample in
guard let self,
let envelope = self.makeEnvelope(
kind: .liveHeartRate,
payload: WatchLiveHeartRateV1(bpm: sample.bpm, sampledAt: sample.date)
) else { return }
self.connectivity.sendLive(envelope)
}
connectivity.onAcknowledgement = { [weak self] ids in self?.store.acknowledge(eventIDs: ids) }
connectivity.onEnvelope = { [weak self] envelope in
guard let self else { return }
if envelope.kind == .preferences,
let preferences = try? envelope.decodePayload(WatchPreferencesV1.self) {
self.store.preferences = preferences
} else if envelope.kind == .measurementUpsert,
let measurement = try? envelope.decodePayload(WatchMeasurementSummaryV1.self) {
self.store.upsert(measurement)
} else if envelope.kind == .regulationSessionUpsert,
let session = try? envelope.decodePayload(WatchRegulationSessionV1.self) {
self.store.upsert(session)
} else if envelope.kind == .tombstone,
let tombstone = try? envelope.decodePayload(WatchDeletionTombstoneV1.self) {
self.store.apply(tombstone)
}
}
}
private func makeEnvelope<Payload: Encodable>(kind: WatchSyncKind, payload: Payload) -> WatchSyncEnvelopeV1? {
sequence += 1
defaults.set(Int(sequence), forKey: "haptika.watch.sequence.v1")
return try? .init(deviceID: deviceID, sequence: sequence, kind: kind, payload: payload)
}
}
public enum WatchHomeDestination: String, Hashable, Identifiable {
case measure
case coherence
case exercises
case cardiacMirror
case history
public var id: String { rawValue }
}