WatchState.swift 20 KB

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