WatchState.swift 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  1. import Foundation
  2. import SwiftUI
  3. import WatchConnectivity
  4. /// WatchState manages the communication between the Watch app and the iPhone app using WatchConnectivity.
  5. /// It handles glucose data synchronization and sending treatment requests (bolus, carbs) to the phone.
  6. @Observable final class WatchState: NSObject, WCSessionDelegate {
  7. // MARK: - Properties
  8. /// The WatchConnectivity session instance used for communication
  9. var session: WCSession?
  10. /// Indicates if the paired iPhone is currently reachable
  11. var isReachable = false
  12. var lastWatchStateUpdate: TimeInterval?
  13. /// main view relevant metrics
  14. var currentGlucose: String = "--"
  15. var currentGlucoseColorString: String = "#ffffff"
  16. var trend: String? = ""
  17. var delta: String? = "--"
  18. var glucoseValues: [(date: Date, glucose: Double, color: Color)] = []
  19. var minYAxisValue: Decimal = 39
  20. var maxYAxisValue: Decimal = 200
  21. var cob: String? = "--"
  22. var iob: String? = "--"
  23. var lastLoopTime: String? = "--"
  24. var overridePresets: [OverridePresetWatch] = []
  25. var tempTargetPresets: [TempTargetPresetWatch] = []
  26. /// treatments inputs
  27. /// used to store carbs for combined meal-bolus-treatments
  28. var carbsAmount: Int = 0
  29. var fatAmount: Int = 0
  30. var proteinAmount: Int = 0
  31. var bolusAmount: Double = 0.0
  32. var confirmationProgress: Double = 0.0
  33. // Safety limits
  34. var maxBolus: Decimal = 10
  35. var maxCarbs: Decimal = 250
  36. var maxFat: Decimal = 250
  37. var maxProtein: Decimal = 250
  38. // Pump specific dosing increment
  39. var bolusIncrement: Decimal = 0.05
  40. var confirmBolusFaster: Bool = false
  41. // Acknowlegement handling
  42. var showCommsAnimation: Bool = false
  43. var showAcknowledgmentBanner: Bool = false
  44. var acknowledgementStatus: AcknowledgementStatus = .pending
  45. var acknowledgmentMessage: String = ""
  46. var shouldNavigateToRoot: Bool = true
  47. // Bolus calculation progress
  48. var showBolusCalculationProgress: Bool = false
  49. // Meal bolus-specific properties
  50. var mealBolusStep: MealBolusStep = .savingCarbs
  51. var isMealBolusCombo: Bool = false
  52. var recommendedBolus: Decimal = 0
  53. /// Snapshots older than this are dropped at the top of the WC delegate
  54. /// methods. Single source of truth for both `didReceiveMessage` and
  55. /// `didReceiveUserInfo`.
  56. private static let maxAcceptableMessageAgeInMinutes: TimeInterval = 15 * 60
  57. // MARK: - Debouncing and batch processing helpers
  58. /// Temporary storage for new data arriving via WatchConnectivity.
  59. private var pendingData: [String: Any] = [:]
  60. /// Work item to schedule finalizing the pending data.
  61. private var finalizeWorkItem: DispatchWorkItem?
  62. /// A flag to tell the UI we’re still updating.
  63. var showSyncingAnimation: Bool = false
  64. var deviceType = WatchSize.current
  65. override init() {
  66. super.init()
  67. setupSession()
  68. }
  69. /// Configures the WatchConnectivity session if supported on the device
  70. private func setupSession() {
  71. if WCSession.isSupported() {
  72. let session = WCSession.default
  73. session.delegate = self
  74. session.activate()
  75. self.session = session
  76. Task {
  77. await WatchLogger.shared.log("⌚️ WCSession setup complete.")
  78. }
  79. } else {
  80. Task {
  81. await WatchLogger.shared.log("⌚️ WCSession is not supported on this device")
  82. }
  83. }
  84. }
  85. // MARK: – Handle Acknowledgement Messages FROM Phone
  86. func handleAcknowledgment(success: Bool, message: String, isFinal: Bool = true) {
  87. Task {
  88. await WatchLogger.shared.log("Handling acknowledgment: \(message), success: \(success), isFinal: \(isFinal)")
  89. }
  90. if success {
  91. Task {
  92. await WatchLogger.shared.log("⌚️ Acknowledgment received: \(message)")
  93. }
  94. acknowledgementStatus = .success
  95. acknowledgmentMessage = message
  96. // Hide progress animation
  97. DispatchQueue.main.async {
  98. self.showCommsAnimation = false
  99. }
  100. } else {
  101. Task {
  102. await WatchLogger.shared.log("⌚️ Acknowledgment failed: \(message)")
  103. }
  104. // Hide progress animation
  105. DispatchQueue.main.async {
  106. self.showCommsAnimation = false
  107. }
  108. acknowledgementStatus = .failure
  109. acknowledgmentMessage = "\(message)"
  110. }
  111. if isFinal {
  112. DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
  113. self.showAcknowledgmentBanner = true
  114. }
  115. DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
  116. self.showAcknowledgmentBanner = false
  117. self.showSyncingAnimation = false // Just ensure this is 100% set to false
  118. Task {
  119. await WatchLogger.shared.log("Cleared ack banner and syncing animation")
  120. }
  121. }
  122. }
  123. }
  124. // MARK: - WCSessionDelegate
  125. /// Called when the session has completed activation
  126. /// Updates the reachability status and logs the activation state
  127. func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
  128. DispatchQueue.main.async {
  129. if let error = error {
  130. Task {
  131. await WatchLogger.shared.log("⌚️ Watch session activation failed: \(error)", force: true)
  132. await WatchLogger.shared.log("⌚️ Saving logs to disk as fallback!")
  133. await WatchLogger.shared.persistLogsLocally()
  134. }
  135. return
  136. }
  137. if activationState == .activated {
  138. Task {
  139. await WatchLogger.shared.log("⌚️ Watch session activated with state: \(activationState.rawValue)")
  140. }
  141. self.forceConditionalWatchStateUpdate()
  142. self.isReachable = session.isReachable
  143. Task {
  144. await WatchLogger.shared.log("⌚️ Watch isReachable after activation: \(session.isReachable)")
  145. }
  146. }
  147. }
  148. }
  149. /// Handles incoming messages from the paired iPhone when Phone is in the foreground
  150. func session(_: WCSession, didReceiveMessage message: [String: Any]) {
  151. Task {
  152. await WatchLogger.shared.log("⌚️ Watch received data: \(message)")
  153. }
  154. // Ack at top level — no `watchState` wrapper, no staleness check.
  155. if let acknowledged = message[WatchMessageKeys.acknowledged] as? Bool,
  156. let ackMessage = message[WatchMessageKeys.message] as? String,
  157. let ackCodeRaw = message[WatchMessageKeys.ackCode] as? String
  158. {
  159. Task {
  160. await WatchLogger.shared
  161. .log("⌚️ Handling ack with message: \(ackMessage), success: \(acknowledged), ackCode: \(ackCodeRaw)")
  162. }
  163. DispatchQueue.main.async {
  164. self.showSyncingAnimation = false
  165. }
  166. processWatchMessage(message)
  167. return
  168. }
  169. // Recommended bolus is also not part of the WatchState message.
  170. if let recommendedBolus = message[WatchMessageKeys.recommendedBolus] as? NSNumber {
  171. Task {
  172. await WatchLogger.shared.log("⌚️ Received recommended bolus: \(recommendedBolus)")
  173. }
  174. DispatchQueue.main.async {
  175. self.recommendedBolus = recommendedBolus.decimalValue
  176. self.showBolusCalculationProgress = false
  177. }
  178. return
  179. }
  180. handleIncomingWatchStatePayload(message)
  181. }
  182. func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
  183. handleIncomingWatchStatePayload(userInfo)
  184. }
  185. /// Shared path for watch-state payloads from either delegate method.
  186. /// Enforces the freshness contract in one place so the two delivery paths
  187. /// can't drift.
  188. private func handleIncomingWatchStatePayload(_ dictionary: [String: Any]) {
  189. guard let payload = dictionary[WatchMessageKeys.watchState] as? [String: Any],
  190. let timestamp = payload[WatchMessageKeys.date] as? TimeInterval
  191. else {
  192. Task { await WatchLogger.shared.log("⌚️ Faulty watch state payload — skipping", force: true) }
  193. DispatchQueue.main.async { self.showSyncingAnimation = false }
  194. return
  195. }
  196. let date = Date(timeIntervalSince1970: timestamp)
  197. // Wall-clock staleness gate. Drops the queued backlog cheaply when
  198. // the watch app wakes after long disuse; without it, every payload
  199. // schedules merge + UI work.
  200. guard date >= Date().addingTimeInterval(-Self.maxAcceptableMessageAgeInMinutes) else {
  201. Task { await WatchLogger.shared.log("⌚️ Skipping stale watch state (\(date))") }
  202. DispatchQueue.main.async { self.showSyncingAnimation = false }
  203. return
  204. }
  205. // Monotonicity dedup.
  206. let lastProcessed = WatchStateSnapshot.loadLatestDateFromDisk()
  207. guard date > lastProcessed else {
  208. Task { await WatchLogger.shared.log("⌚️ Skipping duplicate watch state (\(date))") }
  209. return
  210. }
  211. WatchStateSnapshot.saveLatestDateToDisk(date)
  212. DispatchQueue.main.async {
  213. self.scheduleUIUpdate(with: payload)
  214. }
  215. }
  216. func session(_: WCSession, didFinish _: WCSessionUserInfoTransfer, error: (any Error)?) {
  217. if let error = error {
  218. Task {
  219. await WatchLogger.shared.log("⌚️ transferUserInfo failed with error: \(error)")
  220. await WatchLogger.shared.log("⌚️ Saving logs to disk as fallback!")
  221. await WatchLogger.shared.persistLogsLocally()
  222. }
  223. }
  224. }
  225. /// Called when the reachability status of the paired iPhone changes
  226. /// Updates the local reachability status
  227. func sessionReachabilityDidChange(_ session: WCSession) {
  228. DispatchQueue.main.async {
  229. Task {
  230. await WatchLogger.shared.log("⌚️ Watch reachability changed: \(session.isReachable)")
  231. }
  232. if session.isReachable {
  233. self.forceConditionalWatchStateUpdate()
  234. // reset input amounts
  235. self.bolusAmount = 0
  236. self.carbsAmount = 0
  237. // reset auth progress
  238. self.confirmationProgress = 0
  239. }
  240. }
  241. }
  242. /// Conditionally triggers a watch state update if the last known update was too long ago or has never occurred.
  243. ///
  244. /// This method checks the `lastWatchStateUpdate` timestamp to determine how many seconds
  245. /// have elapsed since the last update under the following conditions
  246. /// - If `lastWatchStateUpdate` is `nil` (meaning there has never been an update), or
  247. /// - If more than 15 seconds have passed,
  248. ///
  249. /// it will show a syncing animation and request a new watch state update from the iPhone app.
  250. private func forceConditionalWatchStateUpdate() {
  251. guard let lastUpdateTimestamp = lastWatchStateUpdate else {
  252. Task {
  253. await WatchLogger.shared.log("Forcing initial WatchState update")
  254. }
  255. // If there's no recorded timestamp, we must force a fresh update immediately.
  256. showSyncingAnimation = true
  257. requestWatchStateUpdate()
  258. return
  259. }
  260. let now = Date().timeIntervalSince1970
  261. let secondsSinceUpdate = now - lastUpdateTimestamp
  262. Task {
  263. await WatchLogger.shared.log("Time since last update: \(secondsSinceUpdate) seconds")
  264. }
  265. // If more than 15 seconds have elapsed since the last update, force an(other) update.
  266. if secondsSinceUpdate > 15 {
  267. showSyncingAnimation = true
  268. requestWatchStateUpdate()
  269. return
  270. }
  271. }
  272. /// Handles incoming messages that either contain an acknowledgement or fresh watchState data (<15 min)
  273. private func processWatchMessage(_ message: [String: Any]) {
  274. DispatchQueue.main.async {
  275. // 1) Acknowledgment logic
  276. if let acknowledged = message[WatchMessageKeys.acknowledged] as? Bool,
  277. let ackMessage = message[WatchMessageKeys.message] as? String,
  278. let ackCodeRaw = message[WatchMessageKeys.ackCode] as? String,
  279. let ackCode = AcknowledgmentCode(rawValue: ackCodeRaw)
  280. {
  281. DispatchQueue.main.async {
  282. self.showSyncingAnimation = false
  283. }
  284. Task {
  285. await WatchLogger.shared.log("⌚️ Received acknowledgment: \(ackMessage), success: \(acknowledged)")
  286. }
  287. switch ackCode {
  288. case .savingCarbs:
  289. self.isMealBolusCombo = true
  290. self.mealBolusStep = .savingCarbs
  291. self.showCommsAnimation = true
  292. self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: false)
  293. case .enactingBolus:
  294. self.isMealBolusCombo = true
  295. self.mealBolusStep = .enactingBolus
  296. self.showCommsAnimation = true
  297. self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: false)
  298. case .comboComplete:
  299. self.isMealBolusCombo = false
  300. self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: true)
  301. default:
  302. self.isMealBolusCombo = false
  303. self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: true)
  304. }
  305. }
  306. // 2) Raw watchState data
  307. if let watchStateData = message[WatchMessageKeys.watchState] as? [String: Any] {
  308. self.scheduleUIUpdate(with: watchStateData)
  309. }
  310. }
  311. }
  312. /// Accumulate new data, set isSyncing, and debounce final update
  313. private func scheduleUIUpdate(with newData: [String: Any]) {
  314. if let incomingTimestamp = newData[WatchMessageKeys.date] as? TimeInterval,
  315. let lastTimestamp = lastWatchStateUpdate,
  316. incomingTimestamp <= lastTimestamp
  317. {
  318. Task {
  319. await WatchLogger.shared.log("Skipping UI update — outdated WatchState (\(incomingTimestamp))")
  320. }
  321. return
  322. }
  323. // 1) Mark as syncing
  324. DispatchQueue.main.async {
  325. self.showSyncingAnimation = true
  326. }
  327. Task {
  328. await WatchLogger.shared.log("Merging new WatchState data with keys: \(newData.keys.joined(separator: ", "))")
  329. }
  330. // 2) Merge data into our pendingData
  331. pendingData.merge(newData) { _, newVal in newVal }
  332. // 3) Cancel any previous finalization
  333. finalizeWorkItem?.cancel()
  334. // 4) Create and schedule a new finalization
  335. let workItem = DispatchWorkItem { [self] in
  336. Task {
  337. await WatchLogger.shared.log("⏳ Debounced update fired")
  338. }
  339. self.finalizePendingData()
  340. }
  341. finalizeWorkItem = workItem
  342. DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: workItem)
  343. }
  344. /// Applies all pending data to the watch state in one shot
  345. private func finalizePendingData() {
  346. guard !pendingData.isEmpty else {
  347. Task {
  348. await WatchLogger.shared.log("⚠️ finalizePendingData called with empty data")
  349. }
  350. // If we have no actual data, just end syncing
  351. DispatchQueue.main.async {
  352. self.showSyncingAnimation = false
  353. }
  354. return
  355. }
  356. Task {
  357. await WatchLogger.shared.log("⌚️ Finalizing pending data")
  358. }
  359. // Actually set your main UI properties here
  360. processRawDataForWatchState(pendingData)
  361. // Clear
  362. pendingData.removeAll()
  363. // Done - hide sync animation
  364. DispatchQueue.main.async {
  365. self.showSyncingAnimation = false
  366. }
  367. Task {
  368. await WatchLogger.shared.log("✅ Watch UI update complete")
  369. }
  370. }
  371. /// Updates the UI properties
  372. private func processRawDataForWatchState(_ message: [String: Any]) {
  373. Task {
  374. await WatchLogger.shared.log("Processing raw WatchState data with keys: \(message.keys.joined(separator: ", "))")
  375. }
  376. if let timestamp = message[WatchMessageKeys.date] as? TimeInterval {
  377. lastWatchStateUpdate = timestamp
  378. Task {
  379. await WatchLogger.shared.log("Updated lastWatchStateUpdate: \(timestamp)")
  380. }
  381. }
  382. if let currentGlucose = message[WatchMessageKeys.currentGlucose] as? String {
  383. self.currentGlucose = currentGlucose
  384. }
  385. if let currentGlucoseColorString = message[WatchMessageKeys.currentGlucoseColorString] as? String {
  386. self.currentGlucoseColorString = currentGlucoseColorString
  387. }
  388. if let trend = message[WatchMessageKeys.trend] as? String {
  389. self.trend = trend
  390. }
  391. if let delta = message[WatchMessageKeys.delta] as? String {
  392. self.delta = delta
  393. }
  394. if let iob = message[WatchMessageKeys.iob] as? String {
  395. self.iob = iob
  396. }
  397. if let cob = message[WatchMessageKeys.cob] as? String {
  398. self.cob = cob
  399. }
  400. if let lastLoopTime = message[WatchMessageKeys.lastLoopTime] as? String {
  401. self.lastLoopTime = lastLoopTime
  402. }
  403. if let glucoseData = message[WatchMessageKeys.glucoseValues] as? [[String: Any]] {
  404. glucoseValues = glucoseData.compactMap { data in
  405. guard let glucose = data["glucose"] as? Double,
  406. let timestamp = data["date"] as? TimeInterval,
  407. let colorString = data["color"] as? String
  408. else { return nil }
  409. return (
  410. Date(timeIntervalSince1970: timestamp),
  411. glucose,
  412. colorString.toColor() // Convert colorString to Color
  413. )
  414. }
  415. .sorted { $0.date < $1.date }
  416. }
  417. if let minYAxisValue = message[WatchMessageKeys.minYAxisValue] {
  418. if let decimalValue = (minYAxisValue as? NSNumber)?.decimalValue {
  419. self.minYAxisValue = decimalValue
  420. }
  421. }
  422. if let maxYAxisValue = message[WatchMessageKeys.maxYAxisValue] {
  423. if let decimalValue = (maxYAxisValue as? NSNumber)?.decimalValue {
  424. self.maxYAxisValue = decimalValue
  425. }
  426. }
  427. if let overrideData = message[WatchMessageKeys.overridePresets] as? [[String: Any]] {
  428. overridePresets = overrideData.compactMap { data in
  429. guard let name = data["name"] as? String,
  430. let isEnabled = data["isEnabled"] as? Bool
  431. else { return nil }
  432. return OverridePresetWatch(name: name, isEnabled: isEnabled)
  433. }
  434. }
  435. if let tempTargetData = message[WatchMessageKeys.tempTargetPresets] as? [[String: Any]] {
  436. tempTargetPresets = tempTargetData.compactMap { data in
  437. guard let name = data["name"] as? String,
  438. let isEnabled = data["isEnabled"] as? Bool
  439. else { return nil }
  440. return TempTargetPresetWatch(name: name, isEnabled: isEnabled)
  441. }
  442. }
  443. if let maxBolusValue = message[WatchMessageKeys.maxBolus] {
  444. if let decimalValue = (maxBolusValue as? NSNumber)?.decimalValue {
  445. maxBolus = decimalValue
  446. }
  447. }
  448. if let maxCarbsValue = message[WatchMessageKeys.maxCarbs] {
  449. if let decimalValue = (maxCarbsValue as? NSNumber)?.decimalValue {
  450. maxCarbs = decimalValue
  451. }
  452. }
  453. if let maxFatValue = message[WatchMessageKeys.maxFat] {
  454. if let decimalValue = (maxFatValue as? NSNumber)?.decimalValue {
  455. maxFat = decimalValue
  456. }
  457. }
  458. if let maxProteinValue = message[WatchMessageKeys.maxProtein] {
  459. if let decimalValue = (maxProteinValue as? NSNumber)?.decimalValue {
  460. maxProtein = decimalValue
  461. }
  462. }
  463. if let bolusIncrement = message[WatchMessageKeys.bolusIncrement] {
  464. if let decimalValue = (bolusIncrement as? NSNumber)?.decimalValue {
  465. // limit minimum to 0.05 to avoid dealing with 0.025 increments
  466. self.bolusIncrement = max(decimalValue, 0.05)
  467. }
  468. }
  469. if let confirmBolusFaster = message[WatchMessageKeys.confirmBolusFaster] {
  470. if let booleanValue = confirmBolusFaster as? Bool {
  471. self.confirmBolusFaster = booleanValue
  472. }
  473. }
  474. }
  475. }