WatchState.swift 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601
  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. var bolusProgress: Double = 0.0
  34. var activeBolusAmount: Double = 0.0
  35. var deliveredAmount: Double = 0.0
  36. var isBolusCanceled = false
  37. // Safety limits
  38. var maxBolus: Decimal = 10
  39. var maxCarbs: Decimal = 250
  40. var maxFat: Decimal = 250
  41. var maxProtein: Decimal = 250
  42. // Pump specific dosing increment
  43. var bolusIncrement: Decimal = 0.05
  44. var confirmBolusFaster: Bool = false
  45. // Acknowlegement handling
  46. var showCommsAnimation: Bool = false
  47. var showAcknowledgmentBanner: Bool = false
  48. var acknowledgementStatus: AcknowledgementStatus = .pending
  49. var acknowledgmentMessage: String = ""
  50. var shouldNavigateToRoot: Bool = true
  51. // Bolus calculation progress
  52. var showBolusCalculationProgress: Bool = false
  53. // Meal bolus-specific properties
  54. var mealBolusStep: MealBolusStep = .savingCarbs
  55. var isMealBolusCombo: Bool = false
  56. var showBolusProgressOverlay: Bool {
  57. (!showAcknowledgmentBanner || !showCommsAnimation) && bolusProgress > 0 && bolusProgress < 1.0 && !isBolusCanceled
  58. }
  59. var recommendedBolus: Decimal = 0
  60. // Debouncing and batch processing helpers
  61. /// Temporary storage for new data arriving via WatchConnectivity.
  62. private var pendingData: [String: Any] = [:]
  63. /// Work item to schedule finalizing the pending data.
  64. private var finalizeWorkItem: DispatchWorkItem?
  65. /// A flag to tell the UI we’re still updating.
  66. var showSyncingAnimation: Bool = false
  67. var deviceType = WatchSize.current
  68. override init() {
  69. super.init()
  70. setupSession()
  71. }
  72. /// Configures the WatchConnectivity session if supported on the device
  73. private func setupSession() {
  74. if WCSession.isSupported() {
  75. let session = WCSession.default
  76. session.delegate = self
  77. session.activate()
  78. self.session = session
  79. } else {
  80. print("⌚️ WCSession is not supported on this device")
  81. }
  82. }
  83. // MARK: – Handle Acknowledgement Messages FROM Phone
  84. func handleAcknowledgment(success: Bool, message: String, isFinal: Bool = true) {
  85. if success {
  86. print("⌚️ Acknowledgment received: \(message)")
  87. acknowledgementStatus = .success
  88. acknowledgmentMessage = "\(message)"
  89. } else {
  90. print("⌚️ Acknowledgment failed: \(message)")
  91. DispatchQueue.main.async {
  92. self.showCommsAnimation = false // Hide progress animation
  93. }
  94. acknowledgementStatus = .failure
  95. acknowledgmentMessage = "\(message)"
  96. }
  97. if isFinal {
  98. showAcknowledgmentBanner = true
  99. DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
  100. self.showAcknowledgmentBanner = false
  101. self.showSyncingAnimation = false // Just ensure this is 100% set to false
  102. }
  103. }
  104. }
  105. // MARK: - WCSessionDelegate
  106. /// Called when the session has completed activation
  107. /// Updates the reachability status and logs the activation state
  108. func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
  109. DispatchQueue.main.async {
  110. if let error = error {
  111. print("⌚️ Watch session activation failed: \(error.localizedDescription)")
  112. return
  113. }
  114. if activationState == .activated {
  115. print("⌚️ Watch session activated with state: \(activationState.rawValue)")
  116. self.forceConditionalWatchStateUpdate()
  117. self.isReachable = session.isReachable
  118. print("⌚️ Watch isReachable after activation: \(session.isReachable)")
  119. }
  120. }
  121. }
  122. /// Handles incoming messages from the paired iPhone when Phone is in the foreground
  123. func session(_: WCSession, didReceiveMessage message: [String: Any]) {
  124. print("⌚️ Watch received data: \(message)")
  125. // If the message has a nested "watchState" dictionary with date as TimeInterval
  126. if let watchStateDict = message[WatchMessageKeys.watchState] as? [String: Any],
  127. let timestamp = watchStateDict[WatchMessageKeys.date] as? TimeInterval
  128. {
  129. let date = Date(timeIntervalSince1970: timestamp)
  130. // Check if it's not older than 15 min
  131. if date >= Date().addingTimeInterval(-15 * 60) {
  132. print("⌚️ Handling watchState from \(date)")
  133. processWatchMessage(message)
  134. } else {
  135. print("⌚️ Received outdated watchState data (\(date))")
  136. DispatchQueue.main.async {
  137. self.showSyncingAnimation = false
  138. }
  139. }
  140. return
  141. }
  142. // Else if the message is an "ack" at the top level
  143. // e.g. { "acknowledged": true, "message": "Started Temp Target...", "date": Date(...) }
  144. else if
  145. let acknowledged = message[WatchMessageKeys.acknowledged] as? Bool,
  146. let ackMessage = message[WatchMessageKeys.message] as? String
  147. {
  148. print("⌚️ Handling ack with message: \(ackMessage), success: \(acknowledged)")
  149. DispatchQueue.main.async {
  150. // For ack messages, we do NOT show “Syncing...”
  151. self.showSyncingAnimation = false
  152. }
  153. processWatchMessage(message)
  154. return
  155. // Recommended bolus is also not part of the WatchState message, hence the extra condition here
  156. } else if
  157. let recommendedBolus = message[WatchMessageKeys.recommendedBolus] as? NSNumber
  158. {
  159. print("⌚️ Received recommended bolus: \(recommendedBolus)")
  160. DispatchQueue.main.async {
  161. self.recommendedBolus = recommendedBolus.decimalValue
  162. self.showBolusCalculationProgress = false
  163. }
  164. return
  165. // Handle bolus progress updates
  166. } else if
  167. let timestamp = message[WatchMessageKeys.bolusProgressTimestamp] as? TimeInterval,
  168. let progress = message[WatchMessageKeys.bolusProgress] as? Double,
  169. let activeBolusAmount = message[WatchMessageKeys.activeBolusAmount] as? Double,
  170. let deliveredAmount = message[WatchMessageKeys.deliveredAmount] as? Double
  171. {
  172. let date = Date(timeIntervalSince1970: timestamp)
  173. // Check if it's not older than 5 min
  174. if date >= Date().addingTimeInterval(-5 * 60) {
  175. print("⌚️ Handling bolusProgress (sent at \(date))")
  176. DispatchQueue.main.async {
  177. if !self.isBolusCanceled {
  178. self.bolusProgress = progress
  179. self.activeBolusAmount = activeBolusAmount
  180. self.deliveredAmount = deliveredAmount
  181. }
  182. }
  183. } else {
  184. print("⌚️ Received outdated bolus progress (sent at \(date))")
  185. DispatchQueue.main.async {
  186. self.bolusProgress = 0
  187. self.activeBolusAmount = 0
  188. }
  189. }
  190. return
  191. // Handle bolus cancellation
  192. } else if
  193. message[WatchMessageKeys.bolusCanceled] as? Bool == true
  194. {
  195. DispatchQueue.main.async {
  196. self.bolusProgress = 0
  197. self.activeBolusAmount = 0
  198. self
  199. .isBolusCanceled =
  200. false /// Reset flag to ensure a bolus progress is also shown after canceling bolus from watch
  201. }
  202. return
  203. } else {
  204. print("⌚️ Faulty data. Skipping...")
  205. DispatchQueue.main.async {
  206. self.showSyncingAnimation = false
  207. }
  208. }
  209. }
  210. /// Handles incoming messages from the paired iPhone when Phone is in the background
  211. func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
  212. print("⌚️ Watch received data: \(userInfo)")
  213. // If the message has a nested "watchState" dictionary with date as TimeInterval
  214. if let watchStateDict = userInfo[WatchMessageKeys.watchState] as? [String: Any],
  215. let timestamp = watchStateDict[WatchMessageKeys.date] as? TimeInterval
  216. {
  217. let date = Date(timeIntervalSince1970: timestamp)
  218. // Check if it's not older than 15 min
  219. if date >= Date().addingTimeInterval(-15 * 60) {
  220. print("⌚️ Handling watchState from \(date)")
  221. processWatchMessage(userInfo)
  222. } else {
  223. print("⌚️ Received outdated watchState data (\(date))")
  224. DispatchQueue.main.async {
  225. self.showSyncingAnimation = false
  226. }
  227. }
  228. return
  229. }
  230. // Else if the message is an "ack" at the top level
  231. // e.g. { "acknowledged": true, "message": "Started Temp Target...", "date": Date(...) }
  232. else if
  233. let acknowledged = userInfo[WatchMessageKeys.acknowledged] as? Bool,
  234. let ackMessage = userInfo[WatchMessageKeys.message] as? String
  235. {
  236. print("⌚️ Handling ack with message: \(ackMessage), success: \(acknowledged)")
  237. DispatchQueue.main.async {
  238. // For ack messages, we do NOT show “Syncing...”
  239. self.showSyncingAnimation = false
  240. }
  241. processWatchMessage(userInfo)
  242. return
  243. // Recommended bolus is also not part of the WatchState message, hence the extra condition here
  244. } else if
  245. let recommendedBolus = userInfo[WatchMessageKeys.recommendedBolus] as? NSNumber
  246. {
  247. print("⌚️ Received recommended bolus: \(recommendedBolus)")
  248. self.recommendedBolus = recommendedBolus.decimalValue
  249. showBolusCalculationProgress = false
  250. return
  251. // Handle bolus progress updates
  252. } else if
  253. let timestamp = userInfo[WatchMessageKeys.bolusProgressTimestamp] as? TimeInterval,
  254. let progress = userInfo[WatchMessageKeys.bolusProgress] as? Double,
  255. let activeBolusAmount = userInfo[WatchMessageKeys.activeBolusAmount] as? Double,
  256. let deliveredAmount = userInfo[WatchMessageKeys.deliveredAmount] as? Double
  257. {
  258. let date = Date(timeIntervalSince1970: timestamp)
  259. // Check if it's not older than 5 min
  260. if date >= Date().addingTimeInterval(-5 * 60) {
  261. print("⌚️ Handling bolusProgress (sent at \(date))")
  262. DispatchQueue.main.async {
  263. if !self.isBolusCanceled {
  264. self.bolusProgress = progress
  265. self.activeBolusAmount = activeBolusAmount
  266. self.deliveredAmount = deliveredAmount
  267. }
  268. }
  269. } else {
  270. print("⌚️ Received outdated bolus progress (sent at \(date))")
  271. DispatchQueue.main.async {
  272. self.bolusProgress = 0
  273. self.activeBolusAmount = 0
  274. }
  275. }
  276. return
  277. // Handle bolus cancellation
  278. } else if
  279. userInfo[WatchMessageKeys.bolusCanceled] as? Bool == true
  280. {
  281. DispatchQueue.main.async {
  282. self.bolusProgress = 0
  283. self.activeBolusAmount = 0
  284. }
  285. return
  286. } else {
  287. print("⌚️ Faulty data. Skipping...")
  288. DispatchQueue.main.async {
  289. self.showSyncingAnimation = false
  290. }
  291. }
  292. }
  293. /// Called when the reachability status of the paired iPhone changes
  294. /// Updates the local reachability status
  295. func sessionReachabilityDidChange(_ session: WCSession) {
  296. DispatchQueue.main.async {
  297. print("⌚️ Watch reachability changed: \(session.isReachable)")
  298. if session.isReachable {
  299. self.forceConditionalWatchStateUpdate()
  300. // reset input amounts
  301. self.bolusAmount = 0
  302. self.carbsAmount = 0
  303. // reset auth progress
  304. self.confirmationProgress = 0
  305. }
  306. }
  307. }
  308. /// Conditionally triggers a watch state update if the last known update was too long ago or has never occurred.
  309. ///
  310. /// This method checks the `lastWatchStateUpdate` timestamp to determine how many seconds
  311. /// have elapsed since the last update under the following conditions
  312. /// - If `lastWatchStateUpdate` is `nil` (meaning there has never been an update), or
  313. /// - If more than 15 seconds have passed,
  314. ///
  315. /// it will show a syncing animation and request a new watch state update from the iPhone app.
  316. private func forceConditionalWatchStateUpdate() {
  317. guard let lastUpdateTimestamp = lastWatchStateUpdate else {
  318. // If there's no recorded timestamp, we must force a fresh update immediately.
  319. showSyncingAnimation = true
  320. requestWatchStateUpdate()
  321. return
  322. }
  323. let now = Date().timeIntervalSince1970
  324. let secondsSinceUpdate = now - lastUpdateTimestamp
  325. // If more than 15 seconds have elapsed since the last update, force an(other) update.
  326. if secondsSinceUpdate > 15 {
  327. showSyncingAnimation = true
  328. requestWatchStateUpdate()
  329. return
  330. }
  331. }
  332. /// Handles incoming messages that either contain an acknowledgement or fresh watchState data (<15 min)
  333. private func processWatchMessage(_ message: [String: Any]) {
  334. DispatchQueue.main.async {
  335. // 1) Acknowledgment logic
  336. if let acknowledged = message[WatchMessageKeys.acknowledged] as? Bool,
  337. let ackMessage = message[WatchMessageKeys.message] as? String
  338. {
  339. DispatchQueue.main.async {
  340. self.showSyncingAnimation = false
  341. }
  342. print("⌚️ Received acknowledgment: \(ackMessage), success: \(acknowledged)")
  343. switch ackMessage {
  344. case "Saving carbs...":
  345. self.isMealBolusCombo = true
  346. self.mealBolusStep = .savingCarbs
  347. self.showCommsAnimation = true
  348. self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: false)
  349. case "Enacting bolus...":
  350. self.isMealBolusCombo = true
  351. self.mealBolusStep = .enactingBolus
  352. self.showCommsAnimation = true
  353. self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: false)
  354. case "Carbs and bolus logged successfully":
  355. self.isMealBolusCombo = false
  356. self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: true)
  357. default:
  358. self.isMealBolusCombo = false
  359. self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: true)
  360. }
  361. }
  362. // 2) Raw watchState data
  363. if let watchStateData = message[WatchMessageKeys.watchState] as? [String: Any] {
  364. self.scheduleUIUpdate(with: watchStateData)
  365. }
  366. }
  367. }
  368. /// Accumulate new data, set isSyncing, and debounce final update
  369. private func scheduleUIUpdate(with newData: [String: Any]) {
  370. // 1) Mark as syncing
  371. DispatchQueue.main.async {
  372. self.showSyncingAnimation = true
  373. }
  374. // 2) Merge data into our pendingData
  375. pendingData.merge(newData) { _, newVal in newVal }
  376. // 3) Cancel any previous finalization
  377. finalizeWorkItem?.cancel()
  378. // 4) Create and schedule a new finalization
  379. let workItem = DispatchWorkItem { [self] in
  380. self.finalizePendingData()
  381. }
  382. finalizeWorkItem = workItem
  383. DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: workItem)
  384. }
  385. /// Applies all pending data to the watch state in one shot
  386. private func finalizePendingData() {
  387. guard !pendingData.isEmpty else {
  388. // If we have no actual data, just end syncing
  389. DispatchQueue.main.async {
  390. self.showSyncingAnimation = false
  391. }
  392. return
  393. }
  394. print("⌚️ Finalizing pending data: \(pendingData)")
  395. // Actually set your main UI properties here
  396. processRawDataForWatchState(pendingData)
  397. // Clear
  398. pendingData.removeAll()
  399. // Done - hide sync animation
  400. DispatchQueue.main.async {
  401. self.showSyncingAnimation = false
  402. }
  403. }
  404. /// Updates the UI properties
  405. private func processRawDataForWatchState(_ message: [String: Any]) {
  406. if let timestamp = message[WatchMessageKeys.date] as? TimeInterval {
  407. lastWatchStateUpdate = timestamp
  408. }
  409. if let currentGlucose = message[WatchMessageKeys.currentGlucose] as? String {
  410. self.currentGlucose = currentGlucose
  411. }
  412. if let currentGlucoseColorString = message[WatchMessageKeys.currentGlucoseColorString] as? String {
  413. self.currentGlucoseColorString = currentGlucoseColorString
  414. }
  415. if let trend = message[WatchMessageKeys.trend] as? String {
  416. self.trend = trend
  417. }
  418. if let delta = message[WatchMessageKeys.delta] as? String {
  419. self.delta = delta
  420. }
  421. if let iob = message[WatchMessageKeys.iob] as? String {
  422. self.iob = iob
  423. }
  424. if let cob = message[WatchMessageKeys.cob] as? String {
  425. self.cob = cob
  426. }
  427. if let lastLoopTime = message[WatchMessageKeys.lastLoopTime] as? String {
  428. self.lastLoopTime = lastLoopTime
  429. }
  430. if let glucoseData = message[WatchMessageKeys.glucoseValues] as? [[String: Any]] {
  431. glucoseValues = glucoseData.compactMap { data in
  432. guard let glucose = data["glucose"] as? Double,
  433. let timestamp = data["date"] as? TimeInterval,
  434. let colorString = data["color"] as? String
  435. else { return nil }
  436. return (
  437. Date(timeIntervalSince1970: timestamp),
  438. glucose,
  439. colorString.toColor() // Convert colorString to Color
  440. )
  441. }
  442. .sorted { $0.date < $1.date }
  443. }
  444. if let minYAxisValue = message[WatchMessageKeys.minYAxisValue] {
  445. if let decimalValue = (minYAxisValue as? NSNumber)?.decimalValue {
  446. self.minYAxisValue = decimalValue
  447. }
  448. }
  449. if let maxYAxisValue = message[WatchMessageKeys.maxYAxisValue] {
  450. if let decimalValue = (maxYAxisValue as? NSNumber)?.decimalValue {
  451. self.maxYAxisValue = decimalValue
  452. }
  453. }
  454. if let overrideData = message[WatchMessageKeys.overridePresets] as? [[String: Any]] {
  455. overridePresets = overrideData.compactMap { data in
  456. guard let name = data["name"] as? String,
  457. let isEnabled = data["isEnabled"] as? Bool
  458. else { return nil }
  459. return OverridePresetWatch(name: name, isEnabled: isEnabled)
  460. }
  461. }
  462. if let tempTargetData = message[WatchMessageKeys.tempTargetPresets] as? [[String: Any]] {
  463. tempTargetPresets = tempTargetData.compactMap { data in
  464. guard let name = data["name"] as? String,
  465. let isEnabled = data["isEnabled"] as? Bool
  466. else { return nil }
  467. return TempTargetPresetWatch(name: name, isEnabled: isEnabled)
  468. }
  469. }
  470. if let bolusProgress = message[WatchMessageKeys.bolusProgress] as? Double {
  471. if !isBolusCanceled {
  472. self.bolusProgress = bolusProgress
  473. }
  474. }
  475. if let bolusWasCanceled = message[WatchMessageKeys.bolusCanceled] as? Bool, bolusWasCanceled {
  476. bolusProgress = 0
  477. activeBolusAmount = 0
  478. }
  479. if let maxBolusValue = message[WatchMessageKeys.maxBolus] {
  480. print("⌚️ Received maxBolus: \(maxBolusValue) of type \(type(of: maxBolusValue))")
  481. if let decimalValue = (maxBolusValue as? NSNumber)?.decimalValue {
  482. maxBolus = decimalValue
  483. print("⌚️ Converted maxBolus to: \(decimalValue)")
  484. }
  485. }
  486. if let maxCarbsValue = message[WatchMessageKeys.maxCarbs] {
  487. if let decimalValue = (maxCarbsValue as? NSNumber)?.decimalValue {
  488. maxCarbs = decimalValue
  489. }
  490. }
  491. if let maxFatValue = message[WatchMessageKeys.maxFat] {
  492. if let decimalValue = (maxFatValue as? NSNumber)?.decimalValue {
  493. maxFat = decimalValue
  494. }
  495. }
  496. if let maxProteinValue = message[WatchMessageKeys.maxProtein] {
  497. if let decimalValue = (maxProteinValue as? NSNumber)?.decimalValue {
  498. maxProtein = decimalValue
  499. }
  500. }
  501. if let bolusIncrement = message[WatchMessageKeys.bolusIncrement] {
  502. if let decimalValue = (bolusIncrement as? NSNumber)?.decimalValue {
  503. self.bolusIncrement = decimalValue
  504. }
  505. }
  506. if let confirmBolusFaster = message[WatchMessageKeys.confirmBolusFaster] {
  507. if let booleanValue = confirmBolusFaster as? Bool {
  508. self.confirmBolusFaster = booleanValue
  509. }
  510. }
  511. }
  512. }