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 }
|
||||
}
|
||||
Reference in New Issue
Block a user