WatchState.swift 22 KB

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