| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589 |
- import Foundation
- import SwiftUI
- import WatchConnectivity
- /// WatchState manages the communication between the Watch app and the iPhone app using WatchConnectivity.
- /// It handles glucose data synchronization and sending treatment requests (bolus, carbs) to the phone.
- @Observable final class WatchState: NSObject, WCSessionDelegate {
- // MARK: - Properties
- /// The WatchConnectivity session instance used for communication
- var session: WCSession?
- /// Indicates if the paired iPhone is currently reachable
- var isReachable = false
- var lastWatchStateUpdate: TimeInterval?
- /// main view relevant metrics
- var currentGlucose: String = "--"
- var currentGlucoseColorString: String = "#ffffff"
- var trend: String? = ""
- var delta: String? = "--"
- var glucoseValues: [(date: Date, glucose: Double, color: Color)] = []
- var cob: String? = "--"
- var iob: String? = "--"
- var lastLoopTime: String? = "--"
- var overridePresets: [OverridePresetWatch] = []
- var tempTargetPresets: [TempTargetPresetWatch] = []
- /// treatments inputs
- /// used to store carbs for combined meal-bolus-treatments
- var carbsAmount: Int = 0
- var fatAmount: Int = 0
- var proteinAmount: Int = 0
- var bolusAmount: Double = 0.0
- var confirmationProgress: Double = 0.0
- var bolusProgress: Double = 0.0
- var activeBolusAmount: Double = 0.0
- var deliveredAmount: Double = 0.0
- var isBolusCanceled = false
- // Safety limits
- var maxBolus: Decimal = 10
- var maxCarbs: Decimal = 250
- var maxFat: Decimal = 250
- var maxProtein: Decimal = 250
- var maxIOB: Decimal = 0
- var maxCOB: Decimal = 120
- // Pump specific dosing increment
- var bolusIncrement: Decimal = 0.05
- var confirmBolusFaster: Bool = false
- // Acknowlegement handling
- var showCommsAnimation: Bool = false
- var showAcknowledgmentBanner: Bool = false
- var acknowledgementStatus: AcknowledgementStatus = .pending
- var acknowledgmentMessage: String = ""
- var shouldNavigateToRoot: Bool = true
- // Bolus calculation progress
- var showBolusCalculationProgress: Bool = false
- // Meal bolus-specific properties
- var mealBolusStep: MealBolusStep = .savingCarbs
- var isMealBolusCombo: Bool = false
- var showBolusProgressOverlay: Bool {
- (!showAcknowledgmentBanner || !showCommsAnimation) && bolusProgress > 0 && bolusProgress < 1.0 && !isBolusCanceled
- }
-
- var recommendedBolus: Decimal = 0
- // Debouncing and batch processing helpers
- /// Temporary storage for new data arriving via WatchConnectivity.
- private var pendingData: [String: Any] = [:]
- /// Work item to schedule finalizing the pending data.
- private var finalizeWorkItem: DispatchWorkItem?
- /// A flag to tell the UI we’re still updating.
- var showSyncingAnimation: Bool = false
- var deviceType = WatchSize.current
- override init() {
- super.init()
- setupSession()
- }
- /// Configures the WatchConnectivity session if supported on the device
- private func setupSession() {
- if WCSession.isSupported() {
- let session = WCSession.default
- session.delegate = self
- session.activate()
- self.session = session
- } else {
- print("⌚️ WCSession is not supported on this device")
- }
- }
- // MARK: – Handle Acknowledgement Messages FROM Phone
- func handleAcknowledgment(success: Bool, message: String, isFinal: Bool = true) {
- if success {
- print("⌚️ Acknowledgment received: \(message)")
- acknowledgementStatus = .success
- acknowledgmentMessage = "\(message)"
- } else {
- print("⌚️ Acknowledgment failed: \(message)")
- acknowledgementStatus = .failure
- acknowledgmentMessage = "\(message)"
- }
- DispatchQueue.main.async {
- self.showCommsAnimation = false // Hide progress animation
- self.showSyncingAnimation = false // Just ensure this is 100% set to false
- }
- if isFinal {
- showAcknowledgmentBanner = true
- DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
- self.showAcknowledgmentBanner = false
- self.showSyncingAnimation = false // Just ensure this is 100% set to false
- }
- }
- }
- // MARK: - WCSessionDelegate
- /// Called when the session has completed activation
- /// Updates the reachability status and logs the activation state
- func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
- DispatchQueue.main.async {
- if let error = error {
- print("⌚️ Watch session activation failed: \(error.localizedDescription)")
- return
- }
- if activationState == .activated {
- print("⌚️ Watch session activated with state: \(activationState.rawValue)")
- self.forceConditionalWatchStateUpdate()
- self.isReachable = session.isReachable
- print("⌚️ Watch isReachable after activation: \(session.isReachable)")
- }
- }
- }
- /// Handles incoming messages from the paired iPhone when Phone is in the foreground
- func session(_: WCSession, didReceiveMessage message: [String: Any]) {
- print("⌚️ Watch received data: \(message)")
- // If the message has a nested "watchState" dictionary with date as TimeInterval
- if let watchStateDict = message[WatchMessageKeys.watchState] as? [String: Any],
- let timestamp = watchStateDict[WatchMessageKeys.date] as? TimeInterval
- {
- let date = Date(timeIntervalSince1970: timestamp)
- // Check if it's not older than 15 min
- if date >= Date().addingTimeInterval(-15 * 60) {
- print("⌚️ Handling watchState from \(date)")
- processWatchMessage(message)
- } else {
- print("⌚️ Received outdated watchState data (\(date))")
- DispatchQueue.main.async {
- self.showSyncingAnimation = false
- }
- }
- return
- }
- // Else if the message is an "ack" at the top level
- // e.g. { "acknowledged": true, "message": "Started Temp Target...", "date": Date(...) }
- else if
- let acknowledged = message[WatchMessageKeys.acknowledged] as? Bool,
- let ackMessage = message[WatchMessageKeys.message] as? String
- {
- print("⌚️ Handling ack with message: \(ackMessage), success: \(acknowledged)")
- DispatchQueue.main.async {
- // For ack messages, we do NOT show “Syncing...”
- self.showSyncingAnimation = false
- }
- processWatchMessage(message)
- return
- // Recommended bolus is also not part of the WatchState message, hence the extra condition here
- } else if
- let recommendedBolus = message[WatchMessageKeys.recommendedBolus] as? NSNumber
- {
- print("⌚️ Received recommended bolus: \(recommendedBolus)")
- DispatchQueue.main.async {
- self.recommendedBolus = recommendedBolus.decimalValue
- self.showBolusCalculationProgress = false
- }
- return
- // Handle bolus progress updates
- } else if
- let progress = message[WatchMessageKeys.bolusProgress] as? Double,
- let activeBolusAmount = message[WatchMessageKeys.activeBolusAmount] as? Double,
- let deliveredAmount = message[WatchMessageKeys.deliveredAmount] as? Double
- {
- DispatchQueue.main.async {
- if !self.isBolusCanceled {
- self.bolusProgress = progress
- // we only need to grab the active bolus amount from the phone if it is a phone-invoked bolus
- // when it comes from the watch, we already have it stored and available
- if self.activeBolusAmount == 0 {
- self.activeBolusAmount = activeBolusAmount
- }
- self.deliveredAmount = deliveredAmount
- }
- }
- return
- // Handle bolus cancellation
- } else if
- message[WatchMessageKeys.bolusCanceled] as? Bool == true
- {
- DispatchQueue.main.async {
- self.bolusProgress = 0
- self.activeBolusAmount = 0
- }
- return
- } else {
- print("⌚️ Faulty data. Skipping...")
- DispatchQueue.main.async {
- self.showSyncingAnimation = false
- }
- }
- }
- /// Handles incoming messages from the paired iPhone when Phone is in the background
- func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
- print("⌚️ Watch received data: \(userInfo)")
- // If the message has a nested "watchState" dictionary with date as TimeInterval
- if let watchStateDict = userInfo[WatchMessageKeys.watchState] as? [String: Any],
- let timestamp = watchStateDict[WatchMessageKeys.date] as? TimeInterval
- {
- let date = Date(timeIntervalSince1970: timestamp)
- // Check if it's not older than 15 min
- if date >= Date().addingTimeInterval(-15 * 60) {
- print("⌚️ Handling watchState from \(date)")
- processWatchMessage(userInfo)
- } else {
- print("⌚️ Received outdated watchState data (\(date))")
- DispatchQueue.main.async {
- self.showSyncingAnimation = false
- }
- }
- return
- }
- // Else if the message is an "ack" at the top level
- // e.g. { "acknowledged": true, "message": "Started Temp Target...", "date": Date(...) }
- else if
- let acknowledged = userInfo[WatchMessageKeys.acknowledged] as? Bool,
- let ackMessage = userInfo[WatchMessageKeys.message] as? String
- {
- print("⌚️ Handling ack with message: \(ackMessage), success: \(acknowledged)")
- DispatchQueue.main.async {
- // For ack messages, we do NOT show “Syncing...”
- self.showSyncingAnimation = false
- }
- processWatchMessage(userInfo)
- return
- // Recommended bolus is also not part of the WatchState message, hence the extra condition here
- } else if
- let recommendedBolus = userInfo[WatchMessageKeys.recommendedBolus] as? NSNumber
- {
- print("⌚️ Received recommended bolus: \(recommendedBolus)")
- self.recommendedBolus = recommendedBolus.decimalValue
- showBolusCalculationProgress = false
- return
- // Handle bolus progress updates
- } else if
- let progress = userInfo[WatchMessageKeys.bolusProgress] as? Double,
- let activeBolusAmount = userInfo[WatchMessageKeys.activeBolusAmount] as? Double
- {
- DispatchQueue.main.async {
- if !self.isBolusCanceled {
- self.bolusProgress = progress
- // we only need to grab the active bolus amount from the phone if it is a phone-invoked bolus
- // when it comes from the watch, we already have it stored and available
- if self.activeBolusAmount == 0 {
- self.activeBolusAmount = activeBolusAmount
- }
- }
- }
- return
- // Handle bolus cancellation
- } else if
- userInfo[WatchMessageKeys.bolusCanceled] as? Bool == true
- {
- DispatchQueue.main.async {
- self.bolusProgress = 0
- self.activeBolusAmount = 0
- }
- return
- } else {
- print("⌚️ Faulty data. Skipping...")
- DispatchQueue.main.async {
- self.showSyncingAnimation = false
- }
- }
- }
- /// Called when the reachability status of the paired iPhone changes
- /// Updates the local reachability status
- func sessionReachabilityDidChange(_ session: WCSession) {
- DispatchQueue.main.async {
- print("⌚️ Watch reachability changed: \(session.isReachable)")
- if session.isReachable {
- self.forceConditionalWatchStateUpdate()
-
- // reset bolus progress visibility
- self.showBolusProgressOverlay = false
- // reset input amounts
- self.bolusAmount = 0
- self.carbsAmount = 0
-
- // reset auth progress
- self.confirmationProgress = 0
- }
- }
- }
- /// Conditionally triggers a watch state update if the last known update was too long ago or has never occurred.
- ///
- /// This method checks the `lastWatchStateUpdate` timestamp to determine how many seconds
- /// have elapsed since the last update under the following conditions
- /// - If `lastWatchStateUpdate` is `nil` (meaning there has never been an update), or
- /// - If more than 15 seconds have passed,
- ///
- /// it will show a syncing animation and request a new watch state update from the iPhone app.
- private func forceConditionalWatchStateUpdate() {
- guard let lastUpdateTimestamp = lastWatchStateUpdate else {
- // If there's no recorded timestamp, we must force a fresh update immediately.
- showSyncingAnimation = true
- requestWatchStateUpdate()
- return
- }
- let now = Date().timeIntervalSince1970
- let secondsSinceUpdate = now - lastUpdateTimestamp
- // If more than 15 seconds have elapsed since the last update, force an(other) update.
- if secondsSinceUpdate > 15 {
- showSyncingAnimation = true
- requestWatchStateUpdate()
- return
- }
- }
- /// Handles incoming messages that either contain an acknowledgement or fresh watchState data (<15 min)
- private func processWatchMessage(_ message: [String: Any]) {
- DispatchQueue.main.async {
- // 1) Acknowledgment logic
- if let acknowledged = message[WatchMessageKeys.acknowledged] as? Bool,
- let ackMessage = message[WatchMessageKeys.message] as? String
- {
- DispatchQueue.main.async {
- self.showSyncingAnimation = false
- }
- print("⌚️ Received acknowledgment: \(ackMessage), success: \(acknowledged)")
- switch ackMessage {
- case "Saving carbs...":
- self.isMealBolusCombo = true
- self.mealBolusStep = .savingCarbs
- self.showCommsAnimation = true
- self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: false)
- case "Enacting bolus...":
- self.isMealBolusCombo = true
- self.mealBolusStep = .enactingBolus
- self.showCommsAnimation = true
- self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: false)
- case "Carbs and bolus logged successfully":
- self.isMealBolusCombo = false
- self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: true)
- default:
- self.isMealBolusCombo = false
- self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: true)
- }
- }
- // 2) Raw watchState data
- if let watchStateData = message[WatchMessageKeys.watchState] as? [String: Any] {
- self.scheduleUIUpdate(with: watchStateData)
- }
- }
- }
- /// Accumulate new data, set isSyncing, and debounce final update
- private func scheduleUIUpdate(with newData: [String: Any]) {
- // 1) Mark as syncing
- DispatchQueue.main.async {
- self.showSyncingAnimation = true
- }
- // 2) Merge data into our pendingData
- pendingData.merge(newData) { _, newVal in newVal }
- // 3) Cancel any previous finalization
- finalizeWorkItem?.cancel()
- // 4) Create and schedule a new finalization
- let workItem = DispatchWorkItem { [self] in
- self.finalizePendingData()
- }
- finalizeWorkItem = workItem
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: workItem)
- }
- /// Applies all pending data to the watch state in one shot
- private func finalizePendingData() {
- guard !pendingData.isEmpty else {
- // If we have no actual data, just end syncing
- DispatchQueue.main.async {
- self.showSyncingAnimation = false
- }
- return
- }
- print("⌚️ Finalizing pending data: \(pendingData)")
- // Actually set your main UI properties here
- processRawDataForWatchState(pendingData)
- // Clear
- pendingData.removeAll()
- // Done - hide sync animation
- DispatchQueue.main.async {
- self.showSyncingAnimation = false
- }
- }
- /// Updates the UI properties
- private func processRawDataForWatchState(_ message: [String: Any]) {
- if let timestamp = message[WatchMessageKeys.date] as? TimeInterval {
- lastWatchStateUpdate = timestamp
- }
- if let currentGlucose = message[WatchMessageKeys.currentGlucose] as? String {
- self.currentGlucose = currentGlucose
- }
- if let currentGlucoseColorString = message[WatchMessageKeys.currentGlucoseColorString] as? String {
- self.currentGlucoseColorString = currentGlucoseColorString
- }
- if let trend = message[WatchMessageKeys.trend] as? String {
- self.trend = trend
- }
- if let delta = message[WatchMessageKeys.delta] as? String {
- self.delta = delta
- }
- if let iob = message[WatchMessageKeys.iob] as? String {
- self.iob = iob
- }
- if let cob = message[WatchMessageKeys.cob] as? String {
- self.cob = cob
- }
- if let lastLoopTime = message[WatchMessageKeys.lastLoopTime] as? String {
- self.lastLoopTime = lastLoopTime
- }
- if let glucoseData = message[WatchMessageKeys.glucoseValues] as? [[String: Any]] {
- glucoseValues = glucoseData.compactMap { data in
- guard let glucose = data["glucose"] as? Double,
- let timestamp = data["date"] as? TimeInterval,
- let colorString = data["color"] as? String
- else { return nil }
- return (
- Date(timeIntervalSince1970: timestamp),
- glucose,
- colorString.toColor() // Convert colorString to Color
- )
- }
- .sorted { $0.date < $1.date }
- }
- if let overrideData = message[WatchMessageKeys.overridePresets] as? [[String: Any]] {
- overridePresets = overrideData.compactMap { data in
- guard let name = data["name"] as? String,
- let isEnabled = data["isEnabled"] as? Bool
- else { return nil }
- return OverridePresetWatch(name: name, isEnabled: isEnabled)
- }
- }
- if let tempTargetData = message[WatchMessageKeys.tempTargetPresets] as? [[String: Any]] {
- tempTargetPresets = tempTargetData.compactMap { data in
- guard let name = data["name"] as? String,
- let isEnabled = data["isEnabled"] as? Bool
- else { return nil }
- return TempTargetPresetWatch(name: name, isEnabled: isEnabled)
- }
- }
- if let bolusProgress = message[WatchMessageKeys.bolusProgress] as? Double {
- if !isBolusCanceled {
- self.bolusProgress = bolusProgress
- }
- }
- if let bolusWasCanceled = message[WatchMessageKeys.bolusCanceled] as? Bool, bolusWasCanceled {
- bolusProgress = 0
- activeBolusAmount = 0
- }
- if let maxBolusValue = message[WatchMessageKeys.maxBolus] {
- print("⌚️ Received maxBolus: \(maxBolusValue) of type \(type(of: maxBolusValue))")
- if let decimalValue = (maxBolusValue as? NSNumber)?.decimalValue {
- maxBolus = decimalValue
- print("⌚️ Converted maxBolus to: \(decimalValue)")
- }
- }
- if let maxCarbsValue = message[WatchMessageKeys.maxCarbs] {
- if let decimalValue = (maxCarbsValue as? NSNumber)?.decimalValue {
- maxCarbs = decimalValue
- }
- }
- if let maxFatValue = message[WatchMessageKeys.maxFat] {
- if let decimalValue = (maxFatValue as? NSNumber)?.decimalValue {
- maxFat = decimalValue
- }
- }
- if let maxProteinValue = message[WatchMessageKeys.maxProtein] {
- if let decimalValue = (maxProteinValue as? NSNumber)?.decimalValue {
- maxProtein = decimalValue
- }
- }
- if let maxIOBValue = message[WatchMessageKeys.maxIOB] {
- if let decimalValue = (maxIOBValue as? NSNumber)?.decimalValue {
- maxIOB = decimalValue
- }
- }
- if let maxCOBValue = message[WatchMessageKeys.maxCOB] {
- if let decimalValue = (maxCOBValue as? NSNumber)?.decimalValue {
- maxCOB = decimalValue
- }
- }
- if let bolusIncrement = message[WatchMessageKeys.bolusIncrement] {
- if let decimalValue = (bolusIncrement as? NSNumber)?.decimalValue {
- self.bolusIncrement = decimalValue
- }
- }
- if let confirmBolusFaster = message[WatchMessageKeys.confirmBolusFaster] {
- if let booleanValue = confirmBolusFaster as? Bool {
- self.confirmBolusFaster = booleanValue
- }
- }
- }
- }
|