WatchState.swift 22 KB

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