WatchState.swift 19 KB

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