WatchState.swift 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  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. var showBolusProgressOverlay: Bool {
  56. (!showAcknowledgmentBanner || !showCommsAnimation || !showCommsAnimation) && bolusProgress > 0 && bolusProgress < 1.0 &&
  57. !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. override init() {
  68. super.init()
  69. setupSession()
  70. }
  71. /// Configures the WatchConnectivity session if supported on the device
  72. private func setupSession() {
  73. if WCSession.isSupported() {
  74. let session = WCSession.default
  75. session.delegate = self
  76. session.activate()
  77. self.session = session
  78. } else {
  79. print("⌚️ WCSession is not supported on this device")
  80. }
  81. }
  82. // MARK: – Handle Acknowledgement Messages FROM Phone
  83. func handleAcknowledgment(success: Bool, message: String, isFinal: Bool = true) {
  84. if success {
  85. print("⌚️ Acknowledgment received: \(message)")
  86. acknowledgementStatus = .success
  87. acknowledgmentMessage = "\(message)"
  88. } else {
  89. print("⌚️ Acknowledgment failed: \(message)")
  90. acknowledgementStatus = .failure
  91. acknowledgmentMessage = "\(message)"
  92. }
  93. DispatchQueue.main.async {
  94. self.showCommsAnimation = false // Hide progress animation
  95. self.showSyncingAnimation = false // Just ensure this is 100% set to false
  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. // the order here is probably not perfect and needs to be re-arranged
  115. if activationState == .activated,
  116. self.lastWatchStateUpdate == nil || self.lastWatchStateUpdate! < Date().timeIntervalSince1970 - 15
  117. {
  118. self.showSyncingAnimation = true
  119. self.requestWatchStateUpdate()
  120. }
  121. print("⌚️ Watch session activated with state: \(activationState.rawValue)")
  122. self.isReachable = session.isReachable
  123. print("⌚️ Watch isReachable after activation: \(session.isReachable)")
  124. }
  125. }
  126. /// Handles incoming messages from the paired iPhone when Phone is in the foreground
  127. func session(_: WCSession, didReceiveMessage message: [String: Any]) {
  128. print("⌚️ Watch received data: \(message)")
  129. // If the message has a nested "watchState" dictionary with date as TimeInterval
  130. if let watchStateDict = message[WatchMessageKeys.watchState] as? [String: Any],
  131. let timestamp = watchStateDict[WatchMessageKeys.date] as? TimeInterval
  132. {
  133. let date = Date(timeIntervalSince1970: timestamp)
  134. // Check if it's not older than 15 min
  135. if date >= Date().addingTimeInterval(-15 * 60) {
  136. print("⌚️ Handling watchState from \(date)")
  137. processWatchMessage(message)
  138. } else {
  139. print("⌚️ Received outdated watchState data (\(date))")
  140. DispatchQueue.main.async {
  141. self.showSyncingAnimation = false
  142. }
  143. }
  144. return
  145. }
  146. // Else if the message is an "ack" at the top level
  147. // e.g. { "acknowledged": true, "message": "Started Temp Target...", "date": Date(...) }
  148. else if
  149. let acknowledged = message[WatchMessageKeys.acknowledged] as? Bool,
  150. let ackMessage = message[WatchMessageKeys.message] as? String
  151. {
  152. print("⌚️ Handling ack with message: \(ackMessage), success: \(acknowledged)")
  153. DispatchQueue.main.async {
  154. // For ack messages, we do NOT show “Syncing...”
  155. self.showSyncingAnimation = false
  156. }
  157. processWatchMessage(message)
  158. return
  159. // Recommended bolus is also not part of the WatchState message, hence the extra condition here
  160. } else if
  161. let recommendedBolus = message[WatchMessageKeys.recommendedBolus] as? NSNumber
  162. {
  163. print("⌚️ Received recommended bolus: \(recommendedBolus)")
  164. DispatchQueue.main.async {
  165. self.recommendedBolus = recommendedBolus.decimalValue
  166. self.showBolusCalculationProgress = false
  167. }
  168. return
  169. // Handle bolus progress updates
  170. } else if
  171. let progress = message[WatchMessageKeys.bolusProgress] as? Double,
  172. let activeBolusAmount = message[WatchMessageKeys.activeBolusAmount] as? Double
  173. {
  174. DispatchQueue.main.async {
  175. if !self.isBolusCanceled {
  176. self.bolusProgress = progress
  177. // we only need to grab the active bolus amount from the phone if it is a phone-invoked bolus
  178. // when it comes from the watch, we already have it stored and available
  179. if self.activeBolusAmount == 0 {
  180. self.activeBolusAmount = activeBolusAmount
  181. }
  182. }
  183. }
  184. return
  185. // Handle bolus cancellation
  186. } else if
  187. message[WatchMessageKeys.bolusCanceled] as? Bool == true
  188. {
  189. DispatchQueue.main.async {
  190. self.bolusProgress = 0
  191. self.activeBolusAmount = 0
  192. }
  193. return
  194. } else {
  195. print("⌚️ Faulty data. Skipping...")
  196. DispatchQueue.main.async {
  197. self.showSyncingAnimation = false
  198. }
  199. }
  200. }
  201. /// Handles incoming messages from the paired iPhone when Phone is in the background
  202. func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
  203. print("⌚️ Watch received data: \(userInfo)")
  204. // If the message has a nested "watchState" dictionary with date as TimeInterval
  205. if let watchStateDict = userInfo[WatchMessageKeys.watchState] as? [String: Any],
  206. let timestamp = watchStateDict[WatchMessageKeys.date] as? TimeInterval
  207. {
  208. let date = Date(timeIntervalSince1970: timestamp)
  209. // Check if it's not older than 15 min
  210. if date >= Date().addingTimeInterval(-15 * 60) {
  211. print("⌚️ Handling watchState from \(date)")
  212. processWatchMessage(userInfo)
  213. } else {
  214. print("⌚️ Received outdated watchState data (\(date))")
  215. DispatchQueue.main.async {
  216. self.showSyncingAnimation = false
  217. }
  218. }
  219. return
  220. }
  221. // Else if the message is an "ack" at the top level
  222. // e.g. { "acknowledged": true, "message": "Started Temp Target...", "date": Date(...) }
  223. else if
  224. let acknowledged = userInfo[WatchMessageKeys.acknowledged] as? Bool,
  225. let ackMessage = userInfo[WatchMessageKeys.message] as? String
  226. {
  227. print("⌚️ Handling ack with message: \(ackMessage), success: \(acknowledged)")
  228. DispatchQueue.main.async {
  229. // For ack messages, we do NOT show “Syncing...”
  230. self.showSyncingAnimation = false
  231. }
  232. processWatchMessage(userInfo)
  233. return
  234. // Recommended bolus is also not part of the WatchState message, hence the extra condition here
  235. } else if
  236. let recommendedBolus = userInfo[WatchMessageKeys.recommendedBolus] as? NSNumber
  237. {
  238. print("⌚️ Received recommended bolus: \(recommendedBolus)")
  239. self.recommendedBolus = recommendedBolus.decimalValue
  240. showBolusCalculationProgress = false
  241. return
  242. // Handle bolus progress updates
  243. } else if
  244. let progress = userInfo[WatchMessageKeys.bolusProgress] as? Double,
  245. let activeBolusAmount = userInfo[WatchMessageKeys.activeBolusAmount] as? Double
  246. {
  247. DispatchQueue.main.async {
  248. if !self.isBolusCanceled {
  249. self.bolusProgress = progress
  250. // we only need to grab the active bolus amount from the phone if it is a phone-invoked bolus
  251. // when it comes from the watch, we already have it stored and available
  252. if self.activeBolusAmount == 0 {
  253. self.activeBolusAmount = activeBolusAmount
  254. }
  255. }
  256. }
  257. return
  258. // Handle bolus cancellation
  259. } else if
  260. userInfo[WatchMessageKeys.bolusCanceled] as? Bool == true
  261. {
  262. DispatchQueue.main.async {
  263. self.bolusProgress = 0
  264. self.activeBolusAmount = 0
  265. }
  266. return
  267. } else {
  268. print("⌚️ Faulty data. Skipping...")
  269. DispatchQueue.main.async {
  270. self.showSyncingAnimation = false
  271. }
  272. }
  273. }
  274. /// Called when the reachability status of the paired iPhone changes
  275. /// Updates the local reachability status
  276. func sessionReachabilityDidChange(_ session: WCSession) {
  277. DispatchQueue.main.async {
  278. print("⌚️ Watch reachability changed: \(session.isReachable)")
  279. if session.isReachable {
  280. if let timestamp = self.lastWatchStateUpdate, timestamp < Date().timeIntervalSince1970 - 15 {
  281. // request fresh data from watch
  282. self.requestWatchStateUpdate()
  283. }
  284. // reset input amounts
  285. self.bolusAmount = 0
  286. self.carbsAmount = 0
  287. // reset auth progress
  288. self.confirmationProgress = 0
  289. }
  290. }
  291. }
  292. /// Handles incoming messages that either contain an acknowledgement or fresh watchState data (<15 min)
  293. private func processWatchMessage(_ message: [String: Any]) {
  294. DispatchQueue.main.async {
  295. // 1) Acknowledgment logic
  296. if let acknowledged = message[WatchMessageKeys.acknowledged] as? Bool,
  297. let ackMessage = message[WatchMessageKeys.message] as? String
  298. {
  299. DispatchQueue.main.async {
  300. self.showSyncingAnimation = false
  301. }
  302. print("⌚️ Received acknowledgment: \(ackMessage), success: \(acknowledged)")
  303. switch ackMessage {
  304. case "Saving carbs...":
  305. self.isMealBolusCombo = true
  306. self.mealBolusStep = .savingCarbs
  307. self.showCommsAnimation = true
  308. self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: false)
  309. case "Enacting bolus...":
  310. self.isMealBolusCombo = true
  311. self.mealBolusStep = .enactingBolus
  312. self.showCommsAnimation = true
  313. self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: false)
  314. case "Carbs and bolus logged successfully":
  315. self.isMealBolusCombo = false
  316. self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: true)
  317. default:
  318. self.isMealBolusCombo = false
  319. self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: true)
  320. }
  321. }
  322. // 2) Raw watchState data
  323. if let watchStateData = message[WatchMessageKeys.watchState] as? [String: Any] {
  324. self.scheduleUIUpdate(with: watchStateData)
  325. }
  326. }
  327. }
  328. /// Accumulate new data, set isSyncing, and debounce final update
  329. private func scheduleUIUpdate(with newData: [String: Any]) {
  330. // 1) Mark as syncing
  331. DispatchQueue.main.async {
  332. self.showSyncingAnimation = true
  333. }
  334. // 2) Merge data into our pendingData
  335. pendingData.merge(newData) { _, newVal in newVal }
  336. // 3) Cancel any previous finalization
  337. finalizeWorkItem?.cancel()
  338. // 4) Create and schedule a new finalization
  339. let workItem = DispatchWorkItem { [self] in
  340. self.finalizePendingData()
  341. }
  342. finalizeWorkItem = workItem
  343. DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: workItem)
  344. }
  345. /// Applies all pending data to the watch state in one shot
  346. private func finalizePendingData() {
  347. guard !pendingData.isEmpty else {
  348. // If we have no actual data, just end syncing
  349. DispatchQueue.main.async {
  350. self.showSyncingAnimation = false
  351. }
  352. return
  353. }
  354. print("⌚️ Finalizing pending data: \(pendingData)")
  355. // Actually set your main UI properties here
  356. processRawDataForWatchState(pendingData)
  357. // Clear
  358. pendingData.removeAll()
  359. // Done - but ensure this runs at least 2 sec, to avoid flickering
  360. DispatchQueue.main.async {
  361. self.showSyncingAnimation = false
  362. }
  363. }
  364. /// Updates the UI properties
  365. private func processRawDataForWatchState(_ message: [String: Any]) {
  366. if let timestamp = message[WatchMessageKeys.date] as? TimeInterval {
  367. lastWatchStateUpdate = timestamp
  368. }
  369. if let currentGlucose = message[WatchMessageKeys.currentGlucose] as? String {
  370. self.currentGlucose = currentGlucose
  371. }
  372. if let currentGlucoseColorString = message[WatchMessageKeys.currentGlucoseColorString] as? String {
  373. self.currentGlucoseColorString = currentGlucoseColorString
  374. }
  375. if let trend = message[WatchMessageKeys.trend] as? String {
  376. self.trend = trend
  377. }
  378. if let delta = message[WatchMessageKeys.delta] as? String {
  379. self.delta = delta
  380. }
  381. if let iob = message[WatchMessageKeys.iob] as? String {
  382. self.iob = iob
  383. }
  384. if let cob = message[WatchMessageKeys.cob] as? String {
  385. self.cob = cob
  386. }
  387. if let lastLoopTime = message[WatchMessageKeys.lastLoopTime] as? String {
  388. self.lastLoopTime = lastLoopTime
  389. }
  390. if let glucoseData = message[WatchMessageKeys.glucoseValues] as? [[String: Any]] {
  391. glucoseValues = glucoseData.compactMap { data in
  392. guard let glucose = data["glucose"] as? Double,
  393. let timestamp = data["date"] as? TimeInterval,
  394. let colorString = data["color"] as? String
  395. else { return nil }
  396. return (
  397. Date(timeIntervalSince1970: timestamp),
  398. glucose,
  399. colorString.toColor() // Convert colorString to Color
  400. )
  401. }
  402. .sorted { $0.date < $1.date }
  403. }
  404. if let overrideData = message[WatchMessageKeys.overridePresets] as? [[String: Any]] {
  405. overridePresets = overrideData.compactMap { data in
  406. guard let name = data["name"] as? String,
  407. let isEnabled = data["isEnabled"] as? Bool
  408. else { return nil }
  409. return OverridePresetWatch(name: name, isEnabled: isEnabled)
  410. }
  411. }
  412. if let tempTargetData = message[WatchMessageKeys.tempTargetPresets] as? [[String: Any]] {
  413. tempTargetPresets = tempTargetData.compactMap { data in
  414. guard let name = data["name"] as? String,
  415. let isEnabled = data["isEnabled"] as? Bool
  416. else { return nil }
  417. return TempTargetPresetWatch(name: name, isEnabled: isEnabled)
  418. }
  419. }
  420. if let bolusProgress = message[WatchMessageKeys.bolusProgress] as? Double {
  421. if !isBolusCanceled {
  422. self.bolusProgress = bolusProgress
  423. }
  424. }
  425. if let bolusWasCanceled = message[WatchMessageKeys.bolusCanceled] as? Bool, bolusWasCanceled {
  426. bolusProgress = 0
  427. activeBolusAmount = 0
  428. }
  429. if let maxBolusValue = message[WatchMessageKeys.maxBolus] {
  430. print("⌚️ Received maxBolus: \(maxBolusValue) of type \(type(of: maxBolusValue))")
  431. if let decimalValue = (maxBolusValue as? NSNumber)?.decimalValue {
  432. maxBolus = decimalValue
  433. print("⌚️ Converted maxBolus to: \(decimalValue)")
  434. }
  435. }
  436. if let maxCarbsValue = message[WatchMessageKeys.maxCarbs] {
  437. if let decimalValue = (maxCarbsValue as? NSNumber)?.decimalValue {
  438. maxCarbs = decimalValue
  439. }
  440. }
  441. if let maxFatValue = message[WatchMessageKeys.maxFat] {
  442. if let decimalValue = (maxFatValue as? NSNumber)?.decimalValue {
  443. maxFat = decimalValue
  444. }
  445. }
  446. if let maxProteinValue = message[WatchMessageKeys.maxProtein] {
  447. if let decimalValue = (maxProteinValue as? NSNumber)?.decimalValue {
  448. maxProtein = decimalValue
  449. }
  450. }
  451. if let maxIOBValue = message[WatchMessageKeys.maxIOB] {
  452. if let decimalValue = (maxIOBValue as? NSNumber)?.decimalValue {
  453. maxIOB = decimalValue
  454. }
  455. }
  456. if let maxCOBValue = message[WatchMessageKeys.maxCOB] {
  457. if let decimalValue = (maxCOBValue as? NSNumber)?.decimalValue {
  458. maxCOB = decimalValue
  459. }
  460. }
  461. if let bolusIncrement = message[WatchMessageKeys.bolusIncrement] {
  462. if let decimalValue = (bolusIncrement as? NSNumber)?.decimalValue {
  463. self.bolusIncrement = decimalValue
  464. }
  465. }
  466. if let confirmBolusFaster = message[WatchMessageKeys.confirmBolusFaster] {
  467. if let booleanValue = confirmBolusFaster as? Bool {
  468. self.confirmBolusFaster = booleanValue
  469. }
  470. }
  471. }
  472. }