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
@@ -0,0 +1,129 @@
import Combine
import Foundation
import WatchConnectivity
@MainActor
public final class WatchConnectivityService: NSObject, ObservableObject {
@Published public private(set) var activationState: WCSessionActivationState = .notActivated
@Published public private(set) var isReachable = false
@Published public private(set) var lastSynchronizedAt: Date?
@Published public private(set) var lastError: String?
public var onEnvelope: ((WatchSyncEnvelopeV1) -> Void)?
public var onAcknowledgement: ((Set<UUID>) -> Void)?
private let session: WCSession?
private let encoder = WatchCoding.encoder
private let decoder = WatchCoding.decoder
public override init() {
session = WCSession.isSupported() ? .default : nil
super.init()
}
public func activate() {
guard let session else { return }
session.delegate = self
session.activate()
activationState = session.activationState
isReachable = session.isReachable
}
public func sendLive(_ envelope: WatchSyncEnvelopeV1) {
guard let session, session.isReachable else { return }
do {
let data = try encoder.encode(envelope)
session.sendMessageData(data, replyHandler: nil) { [weak self] error in
Task { @MainActor in self?.lastError = error.localizedDescription }
}
} catch {
lastError = error.localizedDescription
}
}
/// Guaranteed delivery for durable mutations. WatchConnectivity persists these while offline.
public func transfer(_ envelope: WatchSyncEnvelopeV1) {
guard let session else { return }
do {
session.transferUserInfo(["envelope": try encoder.encode(envelope)])
} catch {
lastError = error.localizedDescription
}
}
public func flush(outbox: [WatchSyncEnvelopeV1]) {
for envelope in outbox { transfer(envelope) }
}
public func updateApplicationContext(preferences: WatchPreferencesV1) {
guard let session else { return }
do {
try session.updateApplicationContext(["preferences": try encoder.encode(preferences)])
} catch {
lastError = error.localizedDescription
}
}
private func receive(_ data: Data) {
do {
let envelope = try decoder.decode(WatchSyncEnvelopeV1.self, from: data)
lastSynchronizedAt = Date()
if envelope.kind == .acknowledgement,
let acknowledgement = try? envelope.decodePayload(WatchAcknowledgementV1.self) {
onAcknowledgement?(Set(acknowledgement.eventIDs))
} else {
onEnvelope?(envelope)
}
} catch {
lastError = error.localizedDescription
}
}
}
extension WatchConnectivityService: WCSessionDelegate {
nonisolated public func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
Task { @MainActor [weak self] in
self?.activationState = activationState
self?.isReachable = session.isReachable
self?.lastError = error?.localizedDescription
}
}
nonisolated public func sessionReachabilityDidChange(_ session: WCSession) {
Task { @MainActor [weak self] in self?.isReachable = session.isReachable }
}
nonisolated public func session(_ session: WCSession, didReceiveMessageData messageData: Data) {
Task { @MainActor [weak self] in self?.receive(messageData) }
}
nonisolated public func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
guard let data = userInfo["envelope"] as? Data else { return }
Task { @MainActor [weak self] in self?.receive(data) }
}
nonisolated public func session(
_ session: WCSession,
didReceiveApplicationContext applicationContext: [String: Any]
) {
guard let data = applicationContext["preferences"] as? Data else { return }
Task { @MainActor [weak self] in
guard let self,
let preferences = try? self.decoder.decode(WatchPreferencesV1.self, from: data) else { return }
let payload = try? self.encoder.encode(preferences)
if let payload {
self.onEnvelope?(.init(
deviceID: "iphone",
sequence: 0,
kind: .preferences,
payload: payload
))
}
self.lastSynchronizedAt = Date()
}
}
}