WatchState.swift 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
  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. // Safety limits
  34. var maxBolus: Decimal = 10
  35. var maxCarbs: Decimal = 250
  36. var maxFat: Decimal = 250
  37. var maxProtein: Decimal = 250
  38. // Pump specific dosing increment
  39. var bolusIncrement: Decimal = 0.05
  40. var confirmBolusFaster: Bool = false
  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 recommendedBolus: Decimal = 0
  53. // MARK: - Debouncing and batch processing helpers
  54. /// Temporary storage for new data arriving via WatchConnectivity.
  55. private var pendingData: [String: Any] = [:]
  56. /// Work item to schedule finalizing the pending data.
  57. private var finalizeWorkItem: DispatchWorkItem?
  58. /// A flag to tell the UI we’re still updating.
  59. var showSyncingAnimation: Bool = false
  60. var deviceType = WatchSize.current
  61. override init() {
  62. super.init()
  63. setupSession()
  64. }
  65. /// Configures the WatchConnectivity session if supported on the device
  66. private func setupSession() {
  67. if WCSession.isSupported() {
  68. let session = WCSession.default
  69. session.delegate = self
  70. session.activate()
  71. self.session = session
  72. Task {
  73. await WatchLogger.shared.log("⌚️ WCSession setup complete.")
  74. }
  75. } else {
  76. Task {
  77. await WatchLogger.shared.log("⌚️ WCSession is not supported on this device")
  78. }
  79. }
  80. }
  81. // MARK: – Handle Acknowledgement Messages FROM Phone
  82. func handleAcknowledgment(success: Bool, message: String, isFinal: Bool = true) {
  83. Task {
  84. await WatchLogger.shared.log("Handling acknowledgment: \(message), success: \(success), isFinal: \(isFinal)")
  85. }
  86. if success {
  87. Task {
  88. await WatchLogger.shared.log("⌚️ Acknowledgment received: \(message)")
  89. }
  90. acknowledgementStatus = .success
  91. acknowledgmentMessage = message
  92. // Hide progress animation
  93. DispatchQueue.main.async {
  94. self.showCommsAnimation = false
  95. }
  96. } else {
  97. Task {
  98. await WatchLogger.shared.log("⌚️ Acknowledgment failed: \(message)")
  99. }
  100. // Hide progress animation
  101. DispatchQueue.main.async {
  102. self.showCommsAnimation = false
  103. }
  104. acknowledgementStatus = .failure
  105. acknowledgmentMessage = "\(message)"
  106. }
  107. if isFinal {
  108. DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
  109. self.showAcknowledgmentBanner = true
  110. }
  111. DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
  112. self.showAcknowledgmentBanner = false
  113. self.showSyncingAnimation = false // Just ensure this is 100% set to false
  114. Task {
  115. await WatchLogger.shared.log("Cleared ack banner and syncing animation")
  116. }
  117. }
  118. }
  119. }
  120. // MARK: - WCSessionDelegate
  121. /// Called when the session has completed activation
  122. /// Updates the reachability status and logs the activation state
  123. func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
  124. DispatchQueue.main.async {
  125. if let error = error {
  126. Task {
  127. await WatchLogger.shared.log("⌚️ Watch session activation failed: \(error)", force: true)
  128. await WatchLogger.shared.log("⌚️ Saving logs to disk as fallback!")
  129. await WatchLogger.shared.persistLogsLocally()
  130. }
  131. return
  132. }
  133. if activationState == .activated {
  134. Task {
  135. await WatchLogger.shared.log("⌚️ Watch session activated with state: \(activationState.rawValue)")
  136. }
  137. self.forceConditionalWatchStateUpdate()
  138. self.isReachable = session.isReachable
  139. Task {
  140. await WatchLogger.shared.log("⌚️ Watch isReachable after activation: \(session.isReachable)")
  141. }
  142. }
  143. }
  144. }
  145. /// Handles incoming messages from the paired iPhone when Phone is in the foreground
  146. func session(_: WCSession, didReceiveMessage message: [String: Any]) {
  147. Task {
  148. await WatchLogger.shared.log("⌚️ Watch received data: \(message)")
  149. }
  150. // If the message has a nested "watchState" dictionary with date as TimeInterval
  151. if let watchStateDict = message[WatchMessageKeys.watchState] as? [String: Any],
  152. let timestamp = watchStateDict[WatchMessageKeys.date] as? TimeInterval
  153. {
  154. let date = Date(timeIntervalSince1970: timestamp)
  155. // Check if it's not older than 15 min
  156. if date >= Date().addingTimeInterval(-15 * 60) {
  157. Task {
  158. await WatchLogger.shared.log("⌚️ Handling watchState from \(date)")
  159. }
  160. processWatchMessage(message)
  161. } else {
  162. Task {
  163. await WatchLogger.shared.log("⌚️ Received outdated watchState data (\(date))")
  164. }
  165. DispatchQueue.main.async {
  166. self.showSyncingAnimation = false
  167. }
  168. }
  169. return
  170. }
  171. // Else if the message is an "ack" at the top level
  172. // e.g. { "acknowledged": true, "message": "Started Temp Target...", "date": Date(...) }
  173. else if
  174. let acknowledged = message[WatchMessageKeys.acknowledged] as? Bool,
  175. let ackMessage = message[WatchMessageKeys.message] as? String,
  176. let ackCodeRaw = message[WatchMessageKeys.ackCode] as? String
  177. {
  178. Task {
  179. await WatchLogger.shared
  180. .log("⌚️ Handling ack with message: \(ackMessage), success: \(acknowledged), ackCode: \(ackCodeRaw)")
  181. }
  182. DispatchQueue.main.async {
  183. // For ack messages, we do NOT show “Syncing...”
  184. self.showSyncingAnimation = false
  185. }
  186. processWatchMessage(message)
  187. return
  188. // Recommended bolus is also not part of the WatchState message, hence the extra condition here
  189. } else if
  190. let recommendedBolus = message[WatchMessageKeys.recommendedBolus] as? NSNumber
  191. {
  192. Task {
  193. await WatchLogger.shared.log("⌚️ Received recommended bolus: \(recommendedBolus)")
  194. }
  195. DispatchQueue.main.async {
  196. self.recommendedBolus = recommendedBolus.decimalValue
  197. self.showBolusCalculationProgress = false
  198. }
  199. return
  200. } else {
  201. Task {
  202. await WatchLogger.shared.log("⌚️ Faulty data. Skipping...")
  203. }
  204. DispatchQueue.main.async {
  205. self.showSyncingAnimation = false
  206. }
  207. }
  208. }
  209. func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
  210. guard let snapshot = WatchStateSnapshot(from: userInfo) else {
  211. Task {
  212. await WatchLogger.shared.log("⌚️ Invalid snapshot received", force: true)
  213. }
  214. return
  215. }
  216. let lastProcessed = WatchStateSnapshot.loadLatestDateFromDisk()
  217. guard snapshot.date > lastProcessed else {
  218. Task {
  219. await WatchLogger.shared.log("⌚️ Ignoring outdated or duplicate WatchState snapshot", force: true)
  220. }
  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. Task {
  231. await WatchLogger.shared.log("⌚️ transferUserInfo failed with error: \(error)")
  232. await WatchLogger.shared.log("⌚️ Saving logs to disk as fallback!")
  233. await WatchLogger.shared.persistLogsLocally()
  234. }
  235. }
  236. }
  237. /// Called when the reachability status of the paired iPhone changes
  238. /// Updates the local reachability status
  239. func sessionReachabilityDidChange(_ session: WCSession) {
  240. DispatchQueue.main.async {
  241. Task {
  242. await WatchLogger.shared.log("⌚️ Watch reachability changed: \(session.isReachable)")
  243. }
  244. if session.isReachable {
  245. self.forceConditionalWatchStateUpdate()
  246. // reset input amounts
  247. self.bolusAmount = 0
  248. self.carbsAmount = 0
  249. // reset auth progress
  250. self.confirmationProgress = 0
  251. }
  252. }
  253. }
  254. /// Conditionally triggers a watch state update if the last known update was too long ago or has never occurred.
  255. ///
  256. /// This method checks the `lastWatchStateUpdate` timestamp to determine how many seconds
  257. /// have elapsed since the last update under the following conditions
  258. /// - If `lastWatchStateUpdate` is `nil` (meaning there has never been an update), or
  259. /// - If more than 15 seconds have passed,
  260. ///
  261. /// it will show a syncing animation and request a new watch state update from the iPhone app.
  262. private func forceConditionalWatchStateUpdate() {
  263. guard let lastUpdateTimestamp = lastWatchStateUpdate else {
  264. Task {
  265. await WatchLogger.shared.log("Forcing initial WatchState update")
  266. }
  267. // If there's no recorded timestamp, we must force a fresh update immediately.
  268. showSyncingAnimation = true
  269. requestWatchStateUpdate()
  270. return
  271. }
  272. let now = Date().timeIntervalSince1970
  273. let secondsSinceUpdate = now - lastUpdateTimestamp
  274. Task {
  275. await WatchLogger.shared.log("Time since last update: \(secondsSinceUpdate) seconds")
  276. }
  277. // If more than 15 seconds have elapsed since the last update, force an(other) update.
  278. if secondsSinceUpdate > 15 {
  279. showSyncingAnimation = true
  280. requestWatchStateUpdate()
  281. return
  282. }
  283. }
  284. /// Handles incoming messages that either contain an acknowledgement or fresh watchState data (<15 min)
  285. private func processWatchMessage(_ message: [String: Any]) {
  286. DispatchQueue.main.async {
  287. // 1) Acknowledgment logic
  288. if let acknowledged = message[WatchMessageKeys.acknowledged] as? Bool,
  289. let ackMessage = message[WatchMessageKeys.message] as? String,
  290. let ackCodeRaw = message[WatchMessageKeys.ackCode] as? String,
  291. let ackCode = AcknowledgmentCode(rawValue: ackCodeRaw)
  292. {
  293. DispatchQueue.main.async {
  294. self.showSyncingAnimation = false
  295. }
  296. Task {
  297. await WatchLogger.shared.log("⌚️ Received acknowledgment: \(ackMessage), success: \(acknowledged)")
  298. }
  299. switch ackCode {
  300. case .savingCarbs:
  301. self.isMealBolusCombo = true
  302. self.mealBolusStep = .savingCarbs
  303. self.showCommsAnimation = true
  304. self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: false)
  305. case .enactingBolus:
  306. self.isMealBolusCombo = true
  307. self.mealBolusStep = .enactingBolus
  308. self.showCommsAnimation = true
  309. self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: false)
  310. case .comboComplete:
  311. self.isMealBolusCombo = false
  312. self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: true)
  313. default:
  314. self.isMealBolusCombo = false
  315. self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: true)
  316. }
  317. }
  318. // 2) Raw watchState data
  319. if let watchStateData = message[WatchMessageKeys.watchState] as? [String: Any] {
  320. self.scheduleUIUpdate(with: watchStateData)
  321. }
  322. }
  323. }
  324. /// Accumulate new data, set isSyncing, and debounce final update
  325. private func scheduleUIUpdate(with newData: [String: Any]) {
  326. if let incomingTimestamp = newData[WatchMessageKeys.date] as? TimeInterval,
  327. let lastTimestamp = lastWatchStateUpdate,
  328. incomingTimestamp <= lastTimestamp
  329. {
  330. Task {
  331. await WatchLogger.shared.log("Skipping UI update — outdated WatchState (\(incomingTimestamp))")
  332. }
  333. return
  334. }
  335. // 1) Mark as syncing
  336. DispatchQueue.main.async {
  337. self.showSyncingAnimation = true
  338. }
  339. Task {
  340. await WatchLogger.shared.log("Merging new WatchState data with keys: \(newData.keys.joined(separator: ", "))")
  341. }
  342. // 2) Merge data into our pendingData
  343. pendingData.merge(newData) { _, newVal in newVal }
  344. // 3) Cancel any previous finalization
  345. finalizeWorkItem?.cancel()
  346. // 4) Create and schedule a new finalization
  347. let workItem = DispatchWorkItem { [self] in
  348. Task {
  349. await WatchLogger.shared.log("⏳ Debounced update fired")
  350. }
  351. self.finalizePendingData()
  352. }
  353. finalizeWorkItem = workItem
  354. DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: workItem)
  355. }
  356. /// Applies all pending data to the watch state in one shot
  357. private func finalizePendingData() {
  358. guard !pendingData.isEmpty else {
  359. Task {
  360. await WatchLogger.shared.log("⚠️ finalizePendingData called with empty data")
  361. }
  362. // If we have no actual data, just end syncing
  363. DispatchQueue.main.async {
  364. self.showSyncingAnimation = false
  365. }
  366. return
  367. }
  368. Task {
  369. await WatchLogger.shared.log("⌚️ Finalizing pending data")
  370. }
  371. // Actually set your main UI properties here
  372. processRawDataForWatchState(pendingData)
  373. // Clear
  374. pendingData.removeAll()
  375. // Done - hide sync animation
  376. DispatchQueue.main.async {
  377. self.showSyncingAnimation = false
  378. }
  379. Task {
  380. await WatchLogger.shared.log("✅ Watch UI update complete")
  381. }
  382. }
  383. /// Updates the UI properties
  384. private func processRawDataForWatchState(_ message: [String: Any]) {
  385. Task {
  386. await WatchLogger.shared.log("Processing raw WatchState data with keys: \(message.keys.joined(separator: ", "))")
  387. }
  388. if let timestamp = message[WatchMessageKeys.date] as? TimeInterval {
  389. lastWatchStateUpdate = timestamp
  390. Task {
  391. await WatchLogger.shared.log("Updated lastWatchStateUpdate: \(timestamp)")
  392. }
  393. }
  394. if let currentGlucose = message[WatchMessageKeys.currentGlucose] as? String {
  395. self.currentGlucose = currentGlucose
  396. }
  397. if let currentGlucoseColorString = message[WatchMessageKeys.currentGlucoseColorString] as? String {
  398. self.currentGlucoseColorString = currentGlucoseColorString
  399. }
  400. if let trend = message[WatchMessageKeys.trend] as? String {
  401. self.trend = trend
  402. }
  403. if let delta = message[WatchMessageKeys.delta] as? String {
  404. self.delta = delta
  405. }
  406. if let iob = message[WatchMessageKeys.iob] as? String {
  407. self.iob = iob
  408. }
  409. if let cob = message[WatchMessageKeys.cob] as? String {
  410. self.cob = cob
  411. }
  412. if let lastLoopTime = message[WatchMessageKeys.lastLoopTime] as? String {
  413. self.lastLoopTime = lastLoopTime
  414. }
  415. if let glucoseData = message[WatchMessageKeys.glucoseValues] as? [[String: Any]] {
  416. glucoseValues = glucoseData.compactMap { data in
  417. guard let glucose = data["glucose"] as? Double,
  418. let timestamp = data["date"] as? TimeInterval,
  419. let colorString = data["color"] as? String
  420. else { return nil }
  421. return (
  422. Date(timeIntervalSince1970: timestamp),
  423. glucose,
  424. colorString.toColor() // Convert colorString to Color
  425. )
  426. }
  427. .sorted { $0.date < $1.date }
  428. }
  429. if let minYAxisValue = message[WatchMessageKeys.minYAxisValue] {
  430. if let decimalValue = (minYAxisValue as? NSNumber)?.decimalValue {
  431. self.minYAxisValue = decimalValue
  432. }
  433. }
  434. if let maxYAxisValue = message[WatchMessageKeys.maxYAxisValue] {
  435. if let decimalValue = (maxYAxisValue as? NSNumber)?.decimalValue {
  436. self.maxYAxisValue = decimalValue
  437. }
  438. }
  439. if let overrideData = message[WatchMessageKeys.overridePresets] as? [[String: Any]] {
  440. overridePresets = overrideData.compactMap { data in
  441. guard let name = data["name"] as? String,
  442. let isEnabled = data["isEnabled"] as? Bool
  443. else { return nil }
  444. return OverridePresetWatch(name: name, isEnabled: isEnabled)
  445. }
  446. }
  447. if let tempTargetData = message[WatchMessageKeys.tempTargetPresets] as? [[String: Any]] {
  448. tempTargetPresets = tempTargetData.compactMap { data in
  449. guard let name = data["name"] as? String,
  450. let isEnabled = data["isEnabled"] as? Bool
  451. else { return nil }
  452. return TempTargetPresetWatch(name: name, isEnabled: isEnabled)
  453. }
  454. }
  455. if let maxBolusValue = message[WatchMessageKeys.maxBolus] {
  456. if let decimalValue = (maxBolusValue as? NSNumber)?.decimalValue {
  457. maxBolus = decimalValue
  458. }
  459. }
  460. if let maxCarbsValue = message[WatchMessageKeys.maxCarbs] {
  461. if let decimalValue = (maxCarbsValue as? NSNumber)?.decimalValue {
  462. maxCarbs = decimalValue
  463. }
  464. }
  465. if let maxFatValue = message[WatchMessageKeys.maxFat] {
  466. if let decimalValue = (maxFatValue as? NSNumber)?.decimalValue {
  467. maxFat = decimalValue
  468. }
  469. }
  470. if let maxProteinValue = message[WatchMessageKeys.maxProtein] {
  471. if let decimalValue = (maxProteinValue as? NSNumber)?.decimalValue {
  472. maxProtein = decimalValue
  473. }
  474. }
  475. if let bolusIncrement = message[WatchMessageKeys.bolusIncrement] {
  476. if let decimalValue = (bolusIncrement as? NSNumber)?.decimalValue {
  477. // limit minimum to 0.05 to avoid dealing with 0.025 increments
  478. self.bolusIncrement = max(decimalValue, 0.05)
  479. }
  480. }
  481. if let confirmBolusFaster = message[WatchMessageKeys.confirmBolusFaster] {
  482. if let booleanValue = confirmBolusFaster as? Bool {
  483. self.confirmBolusFaster = booleanValue
  484. }
  485. }
  486. }
  487. }