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 var serviceSubscriptions = Set() 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() { // Relay nested service changes so all Watch screens receive live updates. for publisher in [store.objectWillChange, measurement.objectWillChange, haptics.objectWillChange, connectivity.objectWillChange] { publisher.sink { [weak self] _ in self?.objectWillChange.send() } .store(in: &serviceSubscriptions) } 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(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 } }