WatchState.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  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. private 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. override init() {
  58. super.init()
  59. setupSession()
  60. }
  61. /// Configures the WatchConnectivity session if supported on the device
  62. private func setupSession() {
  63. if WCSession.isSupported() {
  64. let session = WCSession.default
  65. session.delegate = self
  66. session.activate()
  67. self.session = session
  68. } else {
  69. print("⌚️ WCSession is not supported on this device")
  70. }
  71. }
  72. // MARK: - Send Data to Phone
  73. /// Sends a bolus insulin request to the paired iPhone
  74. /// - Parameters:
  75. /// - amount: The insulin amount to be delivered
  76. func sendBolusRequest(_ amount: Decimal) {
  77. guard let session = session, session.isReachable else { return }
  78. isBolusCanceled = false // Reset canceled state when starting new bolus
  79. activeBolusAmount = Double(truncating: amount as NSNumber) // Set active bolus amount
  80. let message: [String: Any] = [
  81. "bolus": amount
  82. ]
  83. session.sendMessage(message, replyHandler: nil) { error in
  84. print("Error sending bolus request: \(error.localizedDescription)")
  85. }
  86. // Display pending communication animation
  87. showCommsAnimation = true
  88. }
  89. /// Sends a carbohydrate entry request to the paired iPhone
  90. /// - Parameters:
  91. /// - amount: The amount of carbs in grams
  92. /// - date: The timestamp for the carb entry (defaults to current time)
  93. func sendCarbsRequest(_ amount: Int, _ date: Date = Date()) {
  94. guard let session = session, session.isReachable else { return }
  95. let message: [String: Any] = [
  96. "carbs": amount,
  97. "date": date.timeIntervalSince1970
  98. ]
  99. session.sendMessage(message, replyHandler: nil) { error in
  100. print("Error sending carbs request: \(error.localizedDescription)")
  101. }
  102. // Display pending communication animation
  103. showCommsAnimation = true
  104. }
  105. /// Sends a meal and bolus insulin combo request to the paired iPhone
  106. /// - Parameters:
  107. /// - amount: The insulin amount to be delivered
  108. /// - isExternal: Indicates if the bolus is from an external source
  109. func sendMealBolusComboRequest(carbsAmount _: Decimal, bolusAmount: Decimal, _ date: Date = Date()) {
  110. guard let session = session, session.isReachable else { return }
  111. let message: [String: Any] = [
  112. "bolus": bolusAmount,
  113. "carbs": bolusAmount,
  114. "date": date.timeIntervalSince1970
  115. ]
  116. session.sendMessage(message, replyHandler: nil) { error in
  117. print("Error sending meal bolus combo request: \(error.localizedDescription)")
  118. }
  119. // Display pending communication animation
  120. showCommsAnimation = true
  121. isMealBolusCombo = true
  122. }
  123. func sendCancelOverrideRequest() {
  124. guard let session = session, session.isReachable else { return }
  125. let message: [String: Any] = [
  126. "cancelOverride": true
  127. ]
  128. session.sendMessage(message, replyHandler: nil) { error in
  129. print("⌚️ Error sending cancel override request: \(error.localizedDescription)")
  130. }
  131. // Display pending communication animation
  132. showCommsAnimation = true
  133. }
  134. func sendActivateOverrideRequest(presetName: String) {
  135. guard let session = session, session.isReachable else { return }
  136. let message: [String: Any] = [
  137. "activateOverride": presetName
  138. ]
  139. session.sendMessage(message, replyHandler: nil) { error in
  140. print("⌚️ Error sending activate override request: \(error.localizedDescription)")
  141. }
  142. // Display pending communication animation
  143. showCommsAnimation = true
  144. }
  145. func sendCancelTempTargetRequest() {
  146. guard let session = session, session.isReachable else { return }
  147. let message: [String: Any] = [
  148. "cancelTempTarget": true
  149. ]
  150. session.sendMessage(message, replyHandler: nil) { error in
  151. print("⌚️ Error sending cancel temp target request: \(error.localizedDescription)")
  152. }
  153. // Display pending communication animation
  154. showCommsAnimation = true
  155. }
  156. func sendActivateTempTargetRequest(presetName: String) {
  157. guard let session = session, session.isReachable else { return }
  158. let message: [String: Any] = [
  159. "activateTempTarget": presetName
  160. ]
  161. session.sendMessage(message, replyHandler: nil) { error in
  162. print("⌚️ Error sending activate temp target request: \(error.localizedDescription)")
  163. }
  164. // Display pending communication animation
  165. showCommsAnimation = true
  166. }
  167. func sendCancelBolusRequest() {
  168. isBolusCanceled = true
  169. guard let session = session, session.isReachable else { return }
  170. let message: [String: Any] = [
  171. "cancelBolus": true
  172. ]
  173. session.sendMessage(message, replyHandler: nil) { error in
  174. print("Error sending cancel bolus request: \(error.localizedDescription)")
  175. }
  176. // Reset when cancelled
  177. bolusProgress = 0
  178. activeBolusAmount = 0
  179. // Display pending communication animation
  180. showCommsAnimation = true
  181. }
  182. func requestBolusRecommendation() {
  183. guard let session = session, session.isReachable else { return }
  184. let message: [String: Any] = [
  185. WatchMessageKeys.requestBolusRecommendation: true,
  186. WatchMessageKeys.carbs: carbsAmount
  187. ]
  188. session.sendMessage(message, replyHandler: nil) { error in
  189. print("Error requesting bolus recommendation: \(error.localizedDescription)")
  190. }
  191. if bolusAmount == 0 {
  192. showBolusCalculationProgress = true
  193. }
  194. }
  195. // MARK: – Handle Acknowledgement Messages FROM Phone
  196. func handleAcknowledgment(success: Bool, message: String, isFinal: Bool = true) {
  197. if success {
  198. print("⌚️ Acknowledgment received: \(message)")
  199. acknowledgementStatus = .success
  200. acknowledgmentMessage = "\(message)"
  201. } else {
  202. print("⌚️ Acknowledgment failed: \(message)")
  203. acknowledgementStatus = .failure
  204. acknowledgmentMessage = "\(message)"
  205. }
  206. showCommsAnimation = false // Hide progress animation
  207. if isFinal {
  208. showAcknowledgmentBanner = true
  209. DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
  210. self.showAcknowledgmentBanner = false
  211. }
  212. }
  213. }
  214. func requestWatchStateUpdate() {
  215. guard let session = session, session.activationState == .activated else {
  216. print("⌚️ Session not activated, activating...")
  217. session?.activate()
  218. return
  219. }
  220. if session.isReachable {
  221. print("⌚️ Request an update for watch state from Trio iPhone app...")
  222. let message = [WatchMessageKeys.requestWatchUpdate: WatchMessageKeys.watchState]
  223. session.sendMessage(message, replyHandler: nil) { error in
  224. print("⌚️ Update request for fresh watch state data: \(error.localizedDescription)")
  225. }
  226. } else {
  227. print("⌚️ Phone not reachable for watch state update")
  228. }
  229. }
  230. private func processRawDataForWatchState(_ message: [String: Any]) {
  231. if let currentGlucose = message[WatchMessageKeys.currentGlucose] as? String {
  232. self.currentGlucose = currentGlucose
  233. }
  234. if let currentGlucoseColorString = message[WatchMessageKeys.currentGlucoseColorString] as? String {
  235. self.currentGlucoseColorString = currentGlucoseColorString
  236. }
  237. if let trend = message[WatchMessageKeys.trend] as? String {
  238. self.trend = trend
  239. }
  240. if let delta = message[WatchMessageKeys.delta] as? String {
  241. self.delta = delta
  242. }
  243. if let iob = message[WatchMessageKeys.iob] as? String {
  244. self.iob = iob
  245. }
  246. if let cob = message[WatchMessageKeys.cob] as? String {
  247. self.cob = cob
  248. }
  249. if let lastLoopTime = message[WatchMessageKeys.lastLoopTime] as? String {
  250. self.lastLoopTime = lastLoopTime
  251. }
  252. if let glucoseData = message[WatchMessageKeys.glucoseValues] as? [[String: Any]] {
  253. glucoseValues = glucoseData.compactMap { data in
  254. guard let glucose = data["glucose"] as? Double,
  255. let timestamp = data["date"] as? TimeInterval,
  256. let colorString = data["color"] as? String
  257. else { return nil }
  258. return (
  259. Date(timeIntervalSince1970: timestamp),
  260. glucose,
  261. colorString.toColor() // Convert colorString to Color
  262. )
  263. }
  264. .sorted { $0.date < $1.date }
  265. }
  266. if let overrideData = message[WatchMessageKeys.overridePresets] as? [[String: Any]] {
  267. overridePresets = overrideData.compactMap { data in
  268. guard let name = data["name"] as? String,
  269. let isEnabled = data["isEnabled"] as? Bool
  270. else { return nil }
  271. return OverridePresetWatch(name: name, isEnabled: isEnabled)
  272. }
  273. }
  274. if let tempTargetData = message[WatchMessageKeys.tempTargetPresets] as? [[String: Any]] {
  275. tempTargetPresets = tempTargetData.compactMap { data in
  276. guard let name = data["name"] as? String,
  277. let isEnabled = data["isEnabled"] as? Bool
  278. else { return nil }
  279. return TempTargetPresetWatch(name: name, isEnabled: isEnabled)
  280. }
  281. }
  282. if let bolusProgress = message[WatchMessageKeys.bolusProgress] as? Double {
  283. if !isBolusCanceled {
  284. self.bolusProgress = bolusProgress
  285. }
  286. }
  287. if let bolusWasCanceled = message[WatchMessageKeys.bolusCanceled] as? Bool, bolusWasCanceled {
  288. bolusProgress = 0
  289. activeBolusAmount = 0
  290. return
  291. }
  292. if let maxBolusValue = message[WatchMessageKeys.maxBolus] {
  293. print("⌚️ Received maxBolus: \(maxBolusValue) of type \(type(of: maxBolusValue))")
  294. if let decimalValue = (maxBolusValue as? NSNumber)?.decimalValue {
  295. maxBolus = decimalValue
  296. print("⌚️ Converted maxBolus to: \(decimalValue)")
  297. }
  298. }
  299. if let maxCarbsValue = message[WatchMessageKeys.maxCarbs] {
  300. if let decimalValue = (maxCarbsValue as? NSNumber)?.decimalValue {
  301. maxCarbs = decimalValue
  302. }
  303. }
  304. if let maxFatValue = message[WatchMessageKeys.maxFat] {
  305. if let decimalValue = (maxFatValue as? NSNumber)?.decimalValue {
  306. maxFat = decimalValue
  307. }
  308. }
  309. if let maxProteinValue = message[WatchMessageKeys.maxProtein] {
  310. if let decimalValue = (maxProteinValue as? NSNumber)?.decimalValue {
  311. maxProtein = decimalValue
  312. }
  313. }
  314. if let maxIOBValue = message[WatchMessageKeys.maxIOB] {
  315. if let decimalValue = (maxIOBValue as? NSNumber)?.decimalValue {
  316. maxIOB = decimalValue
  317. }
  318. }
  319. if let maxCOBValue = message[WatchMessageKeys.maxCOB] {
  320. if let decimalValue = (maxCOBValue as? NSNumber)?.decimalValue {
  321. maxCOB = decimalValue
  322. }
  323. }
  324. if let bolusIncrement = message[WatchMessageKeys.bolusIncrement] {
  325. if let decimalValue = (bolusIncrement as? NSNumber)?.decimalValue {
  326. self.bolusIncrement = decimalValue
  327. }
  328. }
  329. }
  330. // MARK: - WCSessionDelegate
  331. /// Called when the session has completed activation
  332. /// Updates the reachability status and logs the activation state
  333. func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
  334. DispatchQueue.main.async {
  335. if let error = error {
  336. print("⌚️ Watch session activation failed: \(error.localizedDescription)")
  337. return
  338. }
  339. // the order here is probably not perfect and needsto be re-arranged
  340. if activationState == .activated {
  341. self.requestWatchStateUpdate()
  342. }
  343. print("⌚️ Watch session activated with state: \(activationState.rawValue)")
  344. self.isReachable = session.isReachable
  345. print("⌚️ Watch isReachable after activation: \(session.isReachable)")
  346. }
  347. }
  348. /// Handles incoming messages from the paired iPhone
  349. /// Updates local glucose data, trend, and delta information
  350. func session(_: WCSession, didReceiveMessage message: [String: Any]) {
  351. print("⌚️ Watch received data from message: \(message)")
  352. // The WatchState message does not contain the ackmessages
  353. // We need to handle them separately and outside of an if condition that only treats watchState data like the if condition below
  354. if let acknowledged = message[WatchMessageKeys.acknowledged] as? Bool,
  355. let ackMessage = message[WatchMessageKeys.message] as? String
  356. {
  357. DispatchQueue.main.async {
  358. switch ackMessage {
  359. case "Saving carbs...":
  360. self.isMealBolusCombo = true
  361. self.mealBolusStep = .savingCarbs
  362. self.showCommsAnimation = true
  363. self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: false)
  364. case "Enacting bolus...":
  365. self.isMealBolusCombo = true
  366. self.mealBolusStep = .enactingBolus
  367. self.showCommsAnimation = true
  368. self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: false)
  369. case "Carbs and bolus logged successfully":
  370. self.isMealBolusCombo = false
  371. self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: true)
  372. default:
  373. self.isMealBolusCombo = false
  374. self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: true)
  375. }
  376. }
  377. return
  378. }
  379. // Recommended bolus is also not part of the WatchState message, hence the extra condition here
  380. if let recommendedBolus = message[WatchMessageKeys.recommendedBolus] as? NSNumber {
  381. print("⌚️ Received recommended bolus: \(recommendedBolus)")
  382. self.recommendedBolus = recommendedBolus.decimalValue
  383. showBolusCalculationProgress = false
  384. return
  385. }
  386. // Handle bolus progress updates
  387. if let progress = message[WatchMessageKeys.bolusProgress] as? Double {
  388. DispatchQueue.main.async {
  389. if !self.isBolusCanceled {
  390. self.bolusProgress = progress
  391. }
  392. }
  393. return
  394. }
  395. // Handle bolus cancellation
  396. if message[WatchMessageKeys.bolusCanceled] as? Bool == true {
  397. DispatchQueue.main.async {
  398. self.bolusProgress = 0
  399. self.activeBolusAmount = 0
  400. }
  401. return
  402. }
  403. if let dataFromMessage = message[WatchMessageKeys.watchState] as? [String: Any] {
  404. DispatchQueue.main.async {
  405. self.processRawDataForWatchState(dataFromMessage)
  406. }
  407. } else {
  408. print("⌚️ Received message without valid state or ack data: \(message)")
  409. }
  410. }
  411. func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
  412. print("⌚️ Watch received data from userInfo: \(userInfo)")
  413. if let dataFromUserInfo = userInfo["watchState"] as? [String: Any] {
  414. DispatchQueue.main.async {
  415. self.processRawDataForWatchState(dataFromUserInfo)
  416. }
  417. } else {
  418. print("⌚️ Warning: Received userInfo without valid watchState data")
  419. }
  420. }
  421. /// Called when the reachability status of the paired iPhone changes
  422. /// Updates the local reachability status
  423. func sessionReachabilityDidChange(_ session: WCSession) {
  424. DispatchQueue.main.async {
  425. print("⌚️ Watch reachability changed: \(session.isReachable)")
  426. if session.isReachable {
  427. // request fresh data from watch
  428. self.requestWatchStateUpdate()
  429. // reset input amounts
  430. self.bolusAmount = 0
  431. self.carbsAmount = 0
  432. // reset auth progress
  433. self.confirmationProgress = 0
  434. }
  435. }
  436. }
  437. }