WatchState.swift 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562
  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 minYAxisValue: Decimal = 39
  20. var maxYAxisValue: Decimal = 200
  21. var cob: String? = "--"
  22. var iob: String? = "--"
  23. var lastLoopTime: String? = "--"
  24. var overridePresets: [OverridePresetWatch] = []
  25. var tempTargetPresets: [TempTargetPresetWatch] = []
  26. /// treatments inputs
  27. /// used to store carbs for combined meal-bolus-treatments
  28. var carbsAmount: Int = 0
  29. var fatAmount: Int = 0
  30. var proteinAmount: Int = 0
  31. var bolusAmount: Double = 0.0
  32. var confirmationProgress: Double = 0.0
  33. var bolusProgress: Double = 0.0
  34. var activeBolusAmount: Double = 0.0
  35. var deliveredAmount: Double = 0.0
  36. var isBolusCanceled = false
  37. // Safety limits
  38. var maxBolus: Decimal = 10
  39. var maxCarbs: Decimal = 250
  40. var maxFat: Decimal = 250
  41. var maxProtein: Decimal = 250
  42. // Pump specific dosing increment
  43. var bolusIncrement: Decimal = 0.05
  44. var confirmBolusFaster: Bool = false
  45. // Acknowlegement handling
  46. var showCommsAnimation: Bool = false
  47. var showAcknowledgmentBanner: Bool = false
  48. var acknowledgementStatus: AcknowledgementStatus = .pending
  49. var acknowledgmentMessage: String = ""
  50. var shouldNavigateToRoot: Bool = true
  51. // Bolus calculation progress
  52. var showBolusCalculationProgress: Bool = false
  53. // Meal bolus-specific properties
  54. var mealBolusStep: MealBolusStep = .savingCarbs
  55. var isMealBolusCombo: Bool = false
  56. var showBolusProgressOverlay: Bool {
  57. (!showAcknowledgmentBanner || !showCommsAnimation) && bolusProgress > 0 && bolusProgress < 1.0 && !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. var deviceType = WatchSize.current
  68. override init() {
  69. super.init()
  70. setupSession()
  71. }
  72. /// Configures the WatchConnectivity session if supported on the device
  73. private func setupSession() {
  74. if WCSession.isSupported() {
  75. let session = WCSession.default
  76. session.delegate = self
  77. session.activate()
  78. self.session = session
  79. WatchLogger.shared.log("⌚️ WCSession setup complete.")
  80. } else {
  81. WatchLogger.shared.log("⌚️ WCSession is not supported on this device")
  82. }
  83. }
  84. // MARK: – Handle Acknowledgement Messages FROM Phone
  85. func handleAcknowledgment(success: Bool, message: String, isFinal: Bool = true) {
  86. WatchLogger.shared.log("Handling acknowledgment: \(message), success: \(success), isFinal: \(isFinal)")
  87. if success {
  88. WatchLogger.shared.log("⌚️ Acknowledgment received: \(message)")
  89. acknowledgementStatus = .success
  90. acknowledgmentMessage = "\(message)"
  91. } else {
  92. WatchLogger.shared.log("⌚️ Acknowledgment failed: \(message)")
  93. DispatchQueue.main.async {
  94. self.showCommsAnimation = false // Hide progress animation
  95. }
  96. acknowledgementStatus = .failure
  97. acknowledgmentMessage = "\(message)"
  98. }
  99. if isFinal {
  100. showAcknowledgmentBanner = true
  101. DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
  102. self.showAcknowledgmentBanner = false
  103. self.showSyncingAnimation = false // Just ensure this is 100% set to false
  104. WatchLogger.shared.log("Cleared ack banner and syncing animation")
  105. }
  106. }
  107. }
  108. // MARK: - WCSessionDelegate
  109. /// Called when the session has completed activation
  110. /// Updates the reachability status and logs the activation state
  111. func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
  112. DispatchQueue.main.async {
  113. if let error = error {
  114. WatchLogger.shared.log("⌚️ Watch session activation failed: \(error.localizedDescription)")
  115. return
  116. }
  117. if activationState == .activated {
  118. WatchLogger.shared.log("⌚️ Watch session activated with state: \(activationState.rawValue)")
  119. self.forceConditionalWatchStateUpdate()
  120. self.isReachable = session.isReachable
  121. WatchLogger.shared.log("⌚️ Watch isReachable after activation: \(session.isReachable)")
  122. }
  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. WatchLogger.shared.log("⌚️ 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. WatchLogger.shared.log("⌚️ Handling watchState from \(date)")
  136. processWatchMessage(message)
  137. } else {
  138. WatchLogger.shared.log("⌚️ 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. WatchLogger.shared.log("⌚️ 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. WatchLogger.shared.log("⌚️ 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 timestamp = message[WatchMessageKeys.bolusProgressTimestamp] as? TimeInterval,
  171. let progress = message[WatchMessageKeys.bolusProgress] as? Double,
  172. let activeBolusAmount = message[WatchMessageKeys.activeBolusAmount] as? Double,
  173. let deliveredAmount = message[WatchMessageKeys.deliveredAmount] as? Double
  174. {
  175. let date = Date(timeIntervalSince1970: timestamp)
  176. // Check if it's not older than 5 min
  177. if date >= Date().addingTimeInterval(-5 * 60) {
  178. WatchLogger.shared.log("⌚️ Handling bolusProgress (sent at \(date))")
  179. DispatchQueue.main.async {
  180. if !self.isBolusCanceled {
  181. self.bolusProgress = progress
  182. self.activeBolusAmount = activeBolusAmount
  183. self.deliveredAmount = deliveredAmount
  184. }
  185. }
  186. } else {
  187. WatchLogger.shared.log("⌚️ Received outdated bolus progress (sent at \(date))")
  188. DispatchQueue.main.async {
  189. self.bolusProgress = 0
  190. self.activeBolusAmount = 0
  191. }
  192. }
  193. return
  194. // Handle bolus cancellation
  195. } else if
  196. message[WatchMessageKeys.bolusCanceled] as? Bool == true
  197. {
  198. DispatchQueue.main.async {
  199. self.bolusProgress = 0
  200. self.activeBolusAmount = 0
  201. self
  202. .isBolusCanceled =
  203. false /// Reset flag to ensure a bolus progress is also shown after canceling bolus from watch
  204. }
  205. return
  206. } else {
  207. WatchLogger.shared.log("⌚️ Faulty data. Skipping...")
  208. DispatchQueue.main.async {
  209. self.showSyncingAnimation = false
  210. }
  211. }
  212. }
  213. func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
  214. guard let snapshot = WatchStateSnapshot(from: userInfo) else {
  215. print("⌚️ Invalid snapshot received")
  216. return
  217. }
  218. let lastProcessed = WatchStateSnapshot.loadLatestDateFromDisk()
  219. guard snapshot.date > lastProcessed else {
  220. print("⌚️ Ignoring outdated or duplicate WatchState snapshot")
  221. return
  222. }
  223. WatchStateSnapshot.saveLatestDateToDisk(snapshot.date)
  224. DispatchQueue.main.async {
  225. self.scheduleUIUpdate(with: snapshot.payload)
  226. }
  227. }
  228. func session(_: WCSession, didFinish _: WCSessionUserInfoTransfer, error: (any Error)?) {
  229. if let error = error {
  230. WatchLogger.shared.log("⌚️ transferUserInfo failed with error: \(error.localizedDescription)")
  231. WatchLogger.shared.log("⌚️ Saving logs to disk as fallback!")
  232. WatchLogger.shared.persistLogsLocally()
  233. }
  234. }
  235. /// Called when the reachability status of the paired iPhone changes
  236. /// Updates the local reachability status
  237. func sessionReachabilityDidChange(_ session: WCSession) {
  238. DispatchQueue.main.async {
  239. WatchLogger.shared.log("⌚️ Watch reachability changed: \(session.isReachable)")
  240. if session.isReachable {
  241. self.forceConditionalWatchStateUpdate()
  242. // reset input amounts
  243. self.bolusAmount = 0
  244. self.carbsAmount = 0
  245. // reset auth progress
  246. self.confirmationProgress = 0
  247. }
  248. }
  249. }
  250. /// Conditionally triggers a watch state update if the last known update was too long ago or has never occurred.
  251. ///
  252. /// This method checks the `lastWatchStateUpdate` timestamp to determine how many seconds
  253. /// have elapsed since the last update under the following conditions
  254. /// - If `lastWatchStateUpdate` is `nil` (meaning there has never been an update), or
  255. /// - If more than 15 seconds have passed,
  256. ///
  257. /// it will show a syncing animation and request a new watch state update from the iPhone app.
  258. private func forceConditionalWatchStateUpdate() {
  259. guard let lastUpdateTimestamp = lastWatchStateUpdate else {
  260. WatchLogger.shared.log("Forcing initial WatchState update")
  261. // If there's no recorded timestamp, we must force a fresh update immediately.
  262. showSyncingAnimation = true
  263. requestWatchStateUpdate()
  264. return
  265. }
  266. let now = Date().timeIntervalSince1970
  267. let secondsSinceUpdate = now - lastUpdateTimestamp
  268. WatchLogger.shared.log("Time since last update: \(secondsSinceUpdate) seconds")
  269. // If more than 15 seconds have elapsed since the last update, force an(other) update.
  270. if secondsSinceUpdate > 15 {
  271. showSyncingAnimation = true
  272. requestWatchStateUpdate()
  273. return
  274. }
  275. }
  276. /// Handles incoming messages that either contain an acknowledgement or fresh watchState data (<15 min)
  277. private func processWatchMessage(_ message: [String: Any]) {
  278. DispatchQueue.main.async {
  279. // 1) Acknowledgment logic
  280. if let acknowledged = message[WatchMessageKeys.acknowledged] as? Bool,
  281. let ackMessage = message[WatchMessageKeys.message] as? String
  282. {
  283. DispatchQueue.main.async {
  284. self.showSyncingAnimation = false
  285. }
  286. WatchLogger.shared.log("⌚️ Received acknowledgment: \(ackMessage), success: \(acknowledged)")
  287. switch ackMessage {
  288. case "Saving carbs...":
  289. self.isMealBolusCombo = true
  290. self.mealBolusStep = .savingCarbs
  291. self.showCommsAnimation = true
  292. self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: false)
  293. case "Enacting bolus...":
  294. self.isMealBolusCombo = true
  295. self.mealBolusStep = .enactingBolus
  296. self.showCommsAnimation = true
  297. self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: false)
  298. case "Carbs and bolus logged successfully":
  299. self.isMealBolusCombo = false
  300. self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: true)
  301. default:
  302. self.isMealBolusCombo = false
  303. self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: true)
  304. }
  305. }
  306. // 2) Raw watchState data
  307. if let watchStateData = message[WatchMessageKeys.watchState] as? [String: Any] {
  308. self.scheduleUIUpdate(with: watchStateData)
  309. }
  310. }
  311. }
  312. /// Accumulate new data, set isSyncing, and debounce final update
  313. private func scheduleUIUpdate(with newData: [String: Any]) {
  314. if let incomingTimestamp = newData[WatchMessageKeys.date] as? TimeInterval,
  315. let lastTimestamp = lastWatchStateUpdate,
  316. incomingTimestamp <= lastTimestamp
  317. {
  318. WatchLogger.shared.log("Skipping UI update — outdated WatchState (\(incomingTimestamp))")
  319. return
  320. }
  321. // 1) Mark as syncing
  322. DispatchQueue.main.async {
  323. self.showSyncingAnimation = true
  324. }
  325. WatchLogger.shared.log("Merging new WatchState data with keys: \(newData.keys.joined(separator: ", "))")
  326. // 2) Merge data into our pendingData
  327. pendingData.merge(newData) { _, newVal in newVal }
  328. // 3) Cancel any previous finalization
  329. finalizeWorkItem?.cancel()
  330. // 4) Create and schedule a new finalization
  331. let workItem = DispatchWorkItem { [self] in
  332. WatchLogger.shared.log("⏳ Debounced update fired")
  333. self.finalizePendingData()
  334. }
  335. finalizeWorkItem = workItem
  336. DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: workItem)
  337. }
  338. /// Applies all pending data to the watch state in one shot
  339. private func finalizePendingData() {
  340. guard !pendingData.isEmpty else {
  341. WatchLogger.shared.log("⚠️ finalizePendingData called with empty data")
  342. // If we have no actual data, just end syncing
  343. DispatchQueue.main.async {
  344. self.showSyncingAnimation = false
  345. }
  346. return
  347. }
  348. WatchLogger.shared.log("⌚️ Finalizing pending data")
  349. // Actually set your main UI properties here
  350. processRawDataForWatchState(pendingData)
  351. // Clear
  352. pendingData.removeAll()
  353. // Done - hide sync animation
  354. DispatchQueue.main.async {
  355. self.showSyncingAnimation = false
  356. }
  357. WatchLogger.shared.log("✅ Watch UI update complete")
  358. }
  359. /// Updates the UI properties
  360. private func processRawDataForWatchState(_ message: [String: Any]) {
  361. WatchLogger.shared.log("Processing raw WatchState data with keys: \(message.keys.joined(separator: ", "))")
  362. if let timestamp = message[WatchMessageKeys.date] as? TimeInterval {
  363. lastWatchStateUpdate = timestamp
  364. WatchLogger.shared.log("Updated lastWatchStateUpdate: \(timestamp)")
  365. }
  366. if let currentGlucose = message[WatchMessageKeys.currentGlucose] as? String {
  367. self.currentGlucose = currentGlucose
  368. }
  369. if let currentGlucoseColorString = message[WatchMessageKeys.currentGlucoseColorString] as? String {
  370. self.currentGlucoseColorString = currentGlucoseColorString
  371. }
  372. if let trend = message[WatchMessageKeys.trend] as? String {
  373. self.trend = trend
  374. }
  375. if let delta = message[WatchMessageKeys.delta] as? String {
  376. self.delta = delta
  377. }
  378. if let iob = message[WatchMessageKeys.iob] as? String {
  379. self.iob = iob
  380. }
  381. if let cob = message[WatchMessageKeys.cob] as? String {
  382. self.cob = cob
  383. }
  384. if let lastLoopTime = message[WatchMessageKeys.lastLoopTime] as? String {
  385. self.lastLoopTime = lastLoopTime
  386. }
  387. if let glucoseData = message[WatchMessageKeys.glucoseValues] as? [[String: Any]] {
  388. glucoseValues = glucoseData.compactMap { data in
  389. guard let glucose = data["glucose"] as? Double,
  390. let timestamp = data["date"] as? TimeInterval,
  391. let colorString = data["color"] as? String
  392. else { return nil }
  393. return (
  394. Date(timeIntervalSince1970: timestamp),
  395. glucose,
  396. colorString.toColor() // Convert colorString to Color
  397. )
  398. }
  399. .sorted { $0.date < $1.date }
  400. }
  401. if let minYAxisValue = message[WatchMessageKeys.minYAxisValue] {
  402. if let decimalValue = (minYAxisValue as? NSNumber)?.decimalValue {
  403. self.minYAxisValue = decimalValue
  404. }
  405. }
  406. if let maxYAxisValue = message[WatchMessageKeys.maxYAxisValue] {
  407. if let decimalValue = (maxYAxisValue as? NSNumber)?.decimalValue {
  408. self.maxYAxisValue = decimalValue
  409. }
  410. }
  411. if let overrideData = message[WatchMessageKeys.overridePresets] as? [[String: Any]] {
  412. overridePresets = overrideData.compactMap { data in
  413. guard let name = data["name"] as? String,
  414. let isEnabled = data["isEnabled"] as? Bool
  415. else { return nil }
  416. return OverridePresetWatch(name: name, isEnabled: isEnabled)
  417. }
  418. }
  419. if let tempTargetData = message[WatchMessageKeys.tempTargetPresets] as? [[String: Any]] {
  420. tempTargetPresets = tempTargetData.compactMap { data in
  421. guard let name = data["name"] as? String,
  422. let isEnabled = data["isEnabled"] as? Bool
  423. else { return nil }
  424. return TempTargetPresetWatch(name: name, isEnabled: isEnabled)
  425. }
  426. }
  427. if let bolusProgress = message[WatchMessageKeys.bolusProgress] as? Double {
  428. if !isBolusCanceled {
  429. self.bolusProgress = bolusProgress
  430. }
  431. }
  432. if let bolusWasCanceled = message[WatchMessageKeys.bolusCanceled] as? Bool, bolusWasCanceled {
  433. bolusProgress = 0
  434. activeBolusAmount = 0
  435. }
  436. if let maxBolusValue = message[WatchMessageKeys.maxBolus] {
  437. if let decimalValue = (maxBolusValue as? NSNumber)?.decimalValue {
  438. maxBolus = decimalValue
  439. }
  440. }
  441. if let maxCarbsValue = message[WatchMessageKeys.maxCarbs] {
  442. if let decimalValue = (maxCarbsValue as? NSNumber)?.decimalValue {
  443. maxCarbs = decimalValue
  444. }
  445. }
  446. if let maxFatValue = message[WatchMessageKeys.maxFat] {
  447. if let decimalValue = (maxFatValue as? NSNumber)?.decimalValue {
  448. maxFat = decimalValue
  449. }
  450. }
  451. if let maxProteinValue = message[WatchMessageKeys.maxProtein] {
  452. if let decimalValue = (maxProteinValue as? NSNumber)?.decimalValue {
  453. maxProtein = decimalValue
  454. }
  455. }
  456. if let bolusIncrement = message[WatchMessageKeys.bolusIncrement] {
  457. if let decimalValue = (bolusIncrement as? NSNumber)?.decimalValue {
  458. self.bolusIncrement = decimalValue
  459. }
  460. }
  461. if let confirmBolusFaster = message[WatchMessageKeys.confirmBolusFaster] {
  462. if let booleanValue = confirmBolusFaster as? Bool {
  463. self.confirmBolusFaster = booleanValue
  464. }
  465. }
  466. }
  467. }