Quellcode durchsuchen

Introduce a watch logger WIP

Deniz Cengiz vor 1 Jahr
Ursprung
Commit
027c50859c

+ 133 - 0
Trio Watch App Extension/WatchLogger.swift

@@ -0,0 +1,133 @@
+//
+//  WatchLogger.swift
+//  Trio
+//
+//  Created by Cengiz Deniz on 18.04.25.
+//
+import Foundation
+import WatchConnectivity
+
+final class WatchLogger {
+    static let shared = WatchLogger()
+
+    private var logs: [String] = []
+    private let maxEntries = 300
+    private let flushInterval: TimeInterval = 15 * 60
+    private let flushSizeThreshold = 150
+
+    private var lastFlush = Date()
+    private let session = WCSession.default
+
+    private init() {
+        Timer.scheduledTimer(withTimeInterval: flushInterval, repeats: true) { _ in
+            self.flushIfNeeded(force: false)
+        }
+    }
+
+    func log(_ message: String) {
+        let timestamp = ISO8601DateFormatter().string(from: Date())
+        let entry = "[\(timestamp)] \(message)"
+        logs.append(entry)
+
+        if logs.count > maxEntries {
+            logs.removeFirst()
+        }
+
+        print(entry)
+        flushIfNeeded(force: false)
+    }
+
+    func flushIfNeeded(force: Bool = false) {
+        let now = Date()
+        let shouldFlush = force || logs.count >= flushSizeThreshold || now.timeIntervalSince(lastFlush) >= flushInterval
+
+        if shouldFlush {
+            flushToPhone()
+        }
+    }
+
+    private func flushToPhone() {
+        guard !logs.isEmpty else { return }
+
+        let payload: [String: Any] = [
+            "watchLogs": logs.joined(separator: "\n")
+        ]
+
+        if session.activationState != .activated {
+            session.activate()
+        }
+
+        if session.isReachable {
+            session.sendMessage(payload, replyHandler: nil) { error in
+                print("⌚️ Failed to flush logs to phone via sendMessage: \(error.localizedDescription)")
+                self.persistLogsLocally()
+            }
+        }
+
+        lastFlush = Date()
+        logs.removeAll()
+    }
+
+    func persistLogsLocally() {
+        let logDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
+            .appendingPathComponent("logs", isDirectory: true)
+
+        try? FileManager.default.createDirectory(at: logDir, withIntermediateDirectories: true)
+
+        let logFile = logDir.appendingPathComponent("watch_log.txt")
+        let previousLogFile = logDir.appendingPathComponent("watch_log_prev.txt")
+        let startOfDay = Calendar.current.startOfDay(for: Date())
+
+        // Rotate if necessary
+        if let attributes = try? FileManager.default.attributesOfItem(atPath: logFile.path),
+           let creationDate = attributes[.creationDate] as? Date,
+           creationDate < startOfDay
+        {
+            try? FileManager.default.removeItem(at: previousLogFile)
+            try? FileManager.default.moveItem(at: logFile, to: previousLogFile)
+            FileManager.default.createFile(atPath: logFile.path, contents: nil, attributes: [.creationDate: startOfDay])
+        }
+
+        let fullLog = logs.joined(separator: "\n") + "\n"
+        if let data = fullLog.data(using: .utf8) {
+            if let handle = try? FileHandle(forWritingTo: logFile) {
+                _ = try? handle.seekToEnd()
+                handle.write(data)
+                try? handle.close()
+            } else {
+                try? data.write(to: logFile)
+            }
+        }
+    }
+
+    /// Optional recovery mechanism:
+    /// Use this on startup to attempt flushing local file logs to phone
+    func flushPersistedLogs() {
+        let logDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
+            .appendingPathComponent("logs", isDirectory: true)
+        let logFile = logDir.appendingPathComponent("watch_log.txt")
+
+        guard let data = try? Data(contentsOf: logFile),
+              let logString = String(data: data, encoding: .utf8),
+              !logString.isEmpty
+        else { return }
+
+        let payload: [String: Any] = [
+            "watchLogs": logString
+        ]
+
+        if session.activationState != .activated {
+            session.activate()
+        }
+
+        if session.isReachable {
+            session.sendMessage(payload, replyHandler: nil) { error in
+                print("⌚️ Failed to resend persisted logs: \(error.localizedDescription)")
+            }
+            try? FileManager.default.removeItem(at: logFile)
+        } else {
+            _ = session.transferUserInfo(payload)
+            try? FileManager.default.removeItem(at: logFile)
+        }
+    }
+}

+ 143 - 97
Trio Watch App Extension/WatchState.swift

@@ -97,7 +97,7 @@ import WatchConnectivity
             session.activate()
             self.session = session
         } else {
-            print("⌚️ WCSession is not supported on this device")
+            WatchLogger.shared.log("⌚️ WCSession is not supported on this device")
         }
     }
 
@@ -105,11 +105,11 @@ import WatchConnectivity
 
     func handleAcknowledgment(success: Bool, message: String, isFinal: Bool = true) {
         if success {
-            print("⌚️ Acknowledgment received: \(message)")
+            WatchLogger.shared.log("⌚️ Acknowledgment received: \(message)")
             acknowledgementStatus = .success
             acknowledgmentMessage = "\(message)"
         } else {
-            print("⌚️ Acknowledgment failed: \(message)")
+            WatchLogger.shared.log("⌚️ Acknowledgment failed: \(message)")
             DispatchQueue.main.async {
                 self.showCommsAnimation = false // Hide progress animation
             }
@@ -133,25 +133,25 @@ import WatchConnectivity
     func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
         DispatchQueue.main.async {
             if let error = error {
-                print("⌚️ Watch session activation failed: \(error.localizedDescription)")
+                WatchLogger.shared.log("⌚️ Watch session activation failed: \(error.localizedDescription)")
                 return
             }
 
             if activationState == .activated {
-                print("⌚️ Watch session activated with state: \(activationState.rawValue)")
+                WatchLogger.shared.log("⌚️ Watch session activated with state: \(activationState.rawValue)")
 
                 self.forceConditionalWatchStateUpdate()
 
                 self.isReachable = session.isReachable
 
-                print("⌚️ Watch isReachable after activation: \(session.isReachable)")
+                WatchLogger.shared.log("⌚️ 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)")
+        WatchLogger.shared.log("⌚️ 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],
@@ -161,10 +161,10 @@ import WatchConnectivity
 
             // Check if it's not older than 15 min
             if date >= Date().addingTimeInterval(-15 * 60) {
-                print("⌚️ Handling watchState from \(date)")
+                WatchLogger.shared.log("⌚️ Handling watchState from \(date)")
                 processWatchMessage(message)
             } else {
-                print("⌚️ Received outdated watchState data (\(date))")
+                WatchLogger.shared.log("⌚️ Received outdated watchState data (\(date))")
                 DispatchQueue.main.async {
                     self.showSyncingAnimation = false
                 }
@@ -178,7 +178,7 @@ import WatchConnectivity
             let acknowledged = message[WatchMessageKeys.acknowledged] as? Bool,
             let ackMessage = message[WatchMessageKeys.message] as? String
         {
-            print("⌚️ Handling ack with message: \(ackMessage), success: \(acknowledged)")
+            WatchLogger.shared.log("⌚️ Handling ack with message: \(ackMessage), success: \(acknowledged)")
             DispatchQueue.main.async {
                 // For ack messages, we do NOT show “Syncing...”
                 self.showSyncingAnimation = false
@@ -190,7 +190,7 @@ import WatchConnectivity
         } else if
             let recommendedBolus = message[WatchMessageKeys.recommendedBolus] as? NSNumber
         {
-            print("⌚️ Received recommended bolus: \(recommendedBolus)")
+            WatchLogger.shared.log("⌚️ Received recommended bolus: \(recommendedBolus)")
 
             DispatchQueue.main.async {
                 self.recommendedBolus = recommendedBolus.decimalValue
@@ -210,7 +210,7 @@ import WatchConnectivity
 
             // Check if it's not older than 5 min
             if date >= Date().addingTimeInterval(-5 * 60) {
-                print("⌚️ Handling bolusProgress (sent at \(date))")
+                WatchLogger.shared.log("⌚️ Handling bolusProgress (sent at \(date))")
                 DispatchQueue.main.async {
                     if !self.isBolusCanceled {
                         self.bolusProgress = progress
@@ -219,7 +219,7 @@ import WatchConnectivity
                     }
                 }
             } else {
-                print("⌚️ Received outdated bolus progress (sent at \(date))")
+                WatchLogger.shared.log("⌚️ Received outdated bolus progress (sent at \(date))")
                 DispatchQueue.main.async {
                     self.bolusProgress = 0
                     self.activeBolusAmount = 0
@@ -240,7 +240,7 @@ import WatchConnectivity
             }
             return
         } else {
-            print("⌚️ Faulty data. Skipping...")
+            WatchLogger.shared.log("⌚️ Faulty data. Skipping...")
             DispatchQueue.main.async {
                 self.showSyncingAnimation = false
             }
@@ -248,93 +248,133 @@ import WatchConnectivity
     }
 
     /// Handles incoming messages from the paired iPhone when Phone is in the background
+//    func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
+//        WatchLogger.shared.log("⌚️ Watch received data: \(userInfo)")
+//
+//        if let stateDict = userInfo[WatchMessageKeys.watchState] as? [String: Any] {
+//                WatchLogger.shared.log("📥 Found WatchState in userInfo")
+//
+//                if let snapshot = WatchStateSnapshot(from: stateDict) {
+//                    WatchLogger.shared.log("📥 Parsed snapshot: \(snapshot.date)")
+//
+//                    Task {
+//                        await WatchStateModel.shared.handleIncomingSnapshot(snapshot)
+//                    }
+//                } else {
+//                    WatchLogger.shared.log("❌ Failed to parse WatchState snapshot from 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) {
+//                WatchLogger.shared.log("⌚️ Handling watchState from \(date)")
+//                processWatchMessage(userInfo)
+//            } else {
+//                WatchLogger.shared.log("⌚️ 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
+//        {
+//            WatchLogger.shared.log("⌚️ 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
+//        {
+//            WatchLogger.shared.log("⌚️ Received recommended bolus: \(recommendedBolus)")
+//            self.recommendedBolus = recommendedBolus.decimalValue
+//            showBolusCalculationProgress = false
+//            return
+//
+//                    // Handle bolus progress updates
+//        } else if
+//            let timestamp = userInfo[WatchMessageKeys.bolusProgressTimestamp] as? TimeInterval,
+//            let progress = userInfo[WatchMessageKeys.bolusProgress] as? Double,
+//            let activeBolusAmount = userInfo[WatchMessageKeys.activeBolusAmount] as? Double,
+//            let deliveredAmount = userInfo[WatchMessageKeys.deliveredAmount] as? Double
+//        {
+//            let date = Date(timeIntervalSince1970: timestamp)
+//
+//            // Check if it's not older than 5 min
+//            if date >= Date().addingTimeInterval(-5 * 60) {
+//                WatchLogger.shared.log("⌚️ Handling bolusProgress (sent at \(date))")
+//                DispatchQueue.main.async {
+//                    if !self.isBolusCanceled {
+//                        self.bolusProgress = progress
+//                        self.activeBolusAmount = activeBolusAmount
+//                        self.deliveredAmount = deliveredAmount
+//                    }
+//                }
+//            } else {
+//                WatchLogger.shared.log("⌚️ Received outdated bolus progress (sent at \(date))")
+//                DispatchQueue.main.async {
+//                    self.bolusProgress = 0
+//                    self.activeBolusAmount = 0
+//                }
+//            }
+//            return
+//
+//                    // Handle bolus cancellation
+//        } else if
+//            userInfo[WatchMessageKeys.bolusCanceled] as? Bool == true
+//        {
+//            DispatchQueue.main.async {
+//                self.bolusProgress = 0
+//                self.activeBolusAmount = 0
+//            }
+//            return
+//        } else {
+//            WatchLogger.shared.log("⌚️ Faulty data. Skipping...")
+//            DispatchQueue.main.async {
+//                self.showSyncingAnimation = false
+//            }
+//        }
+//    }
     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
-                }
-            }
+        guard let snapshot = WatchStateSnapshot(from: userInfo) else {
+            print("⌚️ Invalid snapshot received")
             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
+        let lastProcessed = WatchStateSnapshot.loadLatestDateFromDisk()
 
-                    // 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
+        guard snapshot.date > lastProcessed else {
+            print("⌚️ Ignoring outdated or duplicate WatchState snapshot")
             return
+        }
 
-                    // Handle bolus progress updates
-        } else if
-            let timestamp = userInfo[WatchMessageKeys.bolusProgressTimestamp] as? TimeInterval,
-            let progress = userInfo[WatchMessageKeys.bolusProgress] as? Double,
-            let activeBolusAmount = userInfo[WatchMessageKeys.activeBolusAmount] as? Double,
-            let deliveredAmount = userInfo[WatchMessageKeys.deliveredAmount] as? Double
-        {
-            let date = Date(timeIntervalSince1970: timestamp)
+        WatchStateSnapshot.saveLatestDateToDisk(snapshot.date)
 
-            // Check if it's not older than 5 min
-            if date >= Date().addingTimeInterval(-5 * 60) {
-                print("⌚️ Handling bolusProgress (sent at \(date))")
-                DispatchQueue.main.async {
-                    if !self.isBolusCanceled {
-                        self.bolusProgress = progress
-                        self.activeBolusAmount = activeBolusAmount
-                        self.deliveredAmount = deliveredAmount
-                    }
-                }
-            } else {
-                print("⌚️ Received outdated bolus progress (sent at \(date))")
-                DispatchQueue.main.async {
-                    self.bolusProgress = 0
-                    self.activeBolusAmount = 0
-                }
-            }
-            return
+        DispatchQueue.main.async {
+            self.scheduleUIUpdate(with: snapshot.payload)
+        }
+    }
 
-                    // 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
-            }
+    func session(_: WCSession, didFinish _: WCSessionUserInfoTransfer, error: (any Error)?) {
+        if let error = error {
+            WatchLogger.shared.log("⌚️ transferUserInfo failed with error: \(error.localizedDescription)")
+            WatchLogger.shared.log("⌚️ Saving logs to disk as fallback!")
+            WatchLogger.shared.persistLogsLocally()
         }
     }
 
@@ -342,7 +382,7 @@ import WatchConnectivity
     /// Updates the local reachability status
     func sessionReachabilityDidChange(_ session: WCSession) {
         DispatchQueue.main.async {
-            print("⌚️ Watch reachability changed: \(session.isReachable)")
+            WatchLogger.shared.log("⌚️ Watch reachability changed: \(session.isReachable)")
 
             if session.isReachable {
                 self.forceConditionalWatchStateUpdate()
@@ -395,7 +435,7 @@ import WatchConnectivity
                     self.showSyncingAnimation = false
                 }
 
-                print("⌚️ Received acknowledgment: \(ackMessage), success: \(acknowledged)")
+                WatchLogger.shared.log("⌚️ Received acknowledgment: \(ackMessage), success: \(acknowledged)")
 
                 switch ackMessage {
                 case "Saving carbs...":
@@ -426,6 +466,14 @@ import WatchConnectivity
 
     /// Accumulate new data, set isSyncing, and debounce final update
     private func scheduleUIUpdate(with newData: [String: Any]) {
+        if let incomingTimestamp = newData[WatchMessageKeys.date] as? TimeInterval,
+           let lastTimestamp = lastWatchStateUpdate,
+           incomingTimestamp <= lastTimestamp
+        {
+            WatchLogger.shared.log("⚠️ Skipping UI update — outdated WatchState (\(incomingTimestamp))")
+            return
+        }
+
         // 1) Mark as syncing
         DispatchQueue.main.async {
             self.showSyncingAnimation = true
@@ -455,7 +503,7 @@ import WatchConnectivity
             return
         }
 
-        print("⌚️ Finalizing pending data: \(pendingData)")
+        WatchLogger.shared.log("⌚️ Finalizing pending data")
 
         // Actually set your main UI properties here
         processRawDataForWatchState(pendingData)
@@ -561,10 +609,8 @@ import WatchConnectivity
         }
 
         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)")
             }
         }
 

+ 39 - 0
Trio Watch App Extension/WatchStateSnapshot.swift

@@ -0,0 +1,39 @@
+//
+//  WatchStateSnapshot.swift
+//  Trio
+//
+//  Created by Cengiz Deniz on 18.04.25.
+//
+import Foundation
+
+struct WatchStateSnapshot {
+    let date: Date
+    let payload: [String: Any]
+
+    init?(from dictionary: [String: Any]) {
+        guard let timestamp = dictionary[WatchMessageKeys.date] as? TimeInterval,
+              let payload = dictionary[WatchMessageKeys.watchState] as? [String: Any]
+        else {
+            return nil
+        }
+
+        date = Date(timeIntervalSince1970: timestamp)
+        self.payload = payload
+    }
+
+    func toDictionary() -> [String: Any] {
+        [
+            WatchMessageKeys.date: date.timeIntervalSince1970,
+            WatchMessageKeys.watchState: payload
+        ]
+    }
+
+    static func saveLatestDateToDisk(_ date: Date) {
+        UserDefaults.standard.set(date.timeIntervalSince1970, forKey: "WatchStateSnapshot.latest")
+    }
+
+    static func loadLatestDateFromDisk() -> Date {
+        let interval = UserDefaults.standard.double(forKey: "WatchStateSnapshot.latest")
+        return Date(timeIntervalSince1970: interval)
+    }
+}

+ 12 - 0
Trio.xcodeproj/project.pbxproj

@@ -655,6 +655,9 @@
 		DDF847E62C5D66490049BB3B /* AddMealPresetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF847E52C5D66490049BB3B /* AddMealPresetView.swift */; };
 		DDF847E82C5DABA30049BB3B /* WatchConfigAppleWatchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF847E72C5DABA30049BB3B /* WatchConfigAppleWatchView.swift */; };
 		DDF847EA2C5DABAC0049BB3B /* WatchConfigGarminView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF847E92C5DABAC0049BB3B /* WatchConfigGarminView.swift */; };
+		DDFF204A2DB29EF500AB8A96 /* WatchLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF20492DB29EF500AB8A96 /* WatchLogger.swift */; };
+		DDFF204E2DB2C00B00AB8A96 /* WatchStateSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF204D2DB2C00B00AB8A96 /* WatchStateSnapshot.swift */; };
+		DDFF20502DB2C11900AB8A96 /* WatchStateSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF204F2DB2C11900AB8A96 /* WatchStateSnapshot.swift */; };
 		E00EEC0327368630002FF094 /* ServiceAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = E00EEBFD27368630002FF094 /* ServiceAssembly.swift */; };
 		E00EEC0427368630002FF094 /* SecurityAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = E00EEBFE27368630002FF094 /* SecurityAssembly.swift */; };
 		E00EEC0527368630002FF094 /* StorageAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = E00EEBFF27368630002FF094 /* StorageAssembly.swift */; };
@@ -1447,6 +1450,9 @@
 		DDF847E52C5D66490049BB3B /* AddMealPresetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddMealPresetView.swift; sourceTree = "<group>"; };
 		DDF847E72C5DABA30049BB3B /* WatchConfigAppleWatchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchConfigAppleWatchView.swift; sourceTree = "<group>"; };
 		DDF847E92C5DABAC0049BB3B /* WatchConfigGarminView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchConfigGarminView.swift; sourceTree = "<group>"; };
+		DDFF20492DB29EF500AB8A96 /* WatchLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchLogger.swift; sourceTree = "<group>"; };
+		DDFF204D2DB2C00B00AB8A96 /* WatchStateSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchStateSnapshot.swift; sourceTree = "<group>"; };
+		DDFF204F2DB2C11900AB8A96 /* WatchStateSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchStateSnapshot.swift; sourceTree = "<group>"; };
 		E00EEBFD27368630002FF094 /* ServiceAssembly.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServiceAssembly.swift; sourceTree = "<group>"; };
 		E00EEBFE27368630002FF094 /* SecurityAssembly.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SecurityAssembly.swift; sourceTree = "<group>"; };
 		E00EEBFF27368630002FF094 /* StorageAssembly.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StorageAssembly.swift; sourceTree = "<group>"; };
@@ -2272,6 +2278,7 @@
 		388E5A5925B6F0250019842D /* Models */ = {
 			isa = PBXGroup;
 			children = (
+				DDFF204F2DB2C11900AB8A96 /* WatchStateSnapshot.swift */,
 				DDEBB05B2D89E9050032305D /* TimeInRangeType.swift */,
 				3B2F77852D7E52ED005ED9FA /* TDD.swift */,
 				DD4FFF322D458EE600B6CFF9 /* GarminWatchState.swift */,
@@ -2907,6 +2914,8 @@
 		BDFF7A9C2D25FA730016C40C /* Trio Watch App Extension */ = {
 			isa = PBXGroup;
 			children = (
+				DDFF204D2DB2C00B00AB8A96 /* WatchStateSnapshot.swift */,
+				DDFF20492DB29EF500AB8A96 /* WatchLogger.swift */,
 				BDA25EE52D260D5800035F34 /* WatchState.swift */,
 				BDAE3FFF2D372BA8009C12B1 /* WatchState+Requests.swift */,
 				DD3A3CEC2D29CFBA00AE478E /* Helper */,
@@ -4023,6 +4032,7 @@
 				388E595C25AD948C0019842D /* TrioApp.swift in Sources */,
 				38FEF3FC2737E53800574A46 /* MainStateModel.swift in Sources */,
 				DD1745352C55AE7E00211FAC /* TargetBehavoirRootView.swift in Sources */,
+				DDFF20502DB2C11900AB8A96 /* WatchStateSnapshot.swift in Sources */,
 				5887527C2BD986E1008B081D /* OpenAPSBattery.swift in Sources */,
 				38569348270B5DFB0002C50D /* GlucoseSource.swift in Sources */,
 				CEE9A6582BBB418300EB5194 /* CalibrationsStateModel.swift in Sources */,
@@ -4545,6 +4555,7 @@
 				BD54A95C2D2808A300F9C1EE /* OverridePresetWatch.swift in Sources */,
 				BD54A9592D27FB7800F9C1EE /* OverridePresetsView.swift in Sources */,
 				BDA25F1E2D26D5DD00035F34 /* GlucoseChartView.swift in Sources */,
+				DDFF204E2DB2C00B00AB8A96 /* WatchStateSnapshot.swift in Sources */,
 				DD6F63CC2D27F615007D94CF /* TreatmentMenuView.swift in Sources */,
 				DD3A3CE72D29C93F00AE478E /* Helper+Extensions.swift in Sources */,
 				DD246F062D2836AA0027DDE0 /* GlucoseTrendView.swift in Sources */,
@@ -4556,6 +4567,7 @@
 				BDA25EE62D260D5E00035F34 /* WatchState.swift in Sources */,
 				BD04ECCE2D29952A008C5FEB /* BolusProgressOverlay.swift in Sources */,
 				DD09D5C92D29F3D0000D82C9 /* AcknowledgementPendingView.swift in Sources */,
+				DDFF204A2DB29EF500AB8A96 /* WatchLogger.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};

+ 40 - 0
Trio/Sources/Logger/IssueReporter/SimpleLogReporter.swift

@@ -70,6 +70,46 @@ final class SimpleLogReporter: IssueReporter {
     }
 }
 
+extension SimpleLogReporter {
+    static var watchLogFile: String {
+        getDocumentsDirectory().appendingPathComponent("logs/watch_log.txt").path
+    }
+
+    static var watchLogFilePrev: String {
+        getDocumentsDirectory().appendingPathComponent("logs/watch_log_prev.txt").path
+    }
+
+    static func appendToWatchLog(_ logContent: String) {
+        let fileManager = FileManager.default
+        let logDir = getDocumentsDirectory().appendingPathComponent("logs")
+        let logFile = URL(fileURLWithPath: watchLogFile)
+        let prevLogFile = URL(fileURLWithPath: watchLogFilePrev)
+
+        let now = Date()
+        let startOfDay = Calendar.current.startOfDay(for: now)
+
+        // Create logs directory if needed
+        if !fileManager.fileExists(atPath: logDir.path) {
+            try? fileManager.createDirectory(at: logDir, withIntermediateDirectories: true)
+        }
+
+        // Rotate if needed
+        if fileManager.fileExists(atPath: logFile.path),
+           let attributes = try? fileManager.attributesOfItem(atPath: logFile.path),
+           let creationDate = attributes[.creationDate] as? Date,
+           creationDate < startOfDay
+        {
+            try? fileManager.removeItem(at: prevLogFile)
+            try? fileManager.moveItem(at: logFile, to: prevLogFile)
+            fileManager.createFile(atPath: logFile.path, contents: nil, attributes: [.creationDate: startOfDay])
+        }
+
+        if let data = (logContent + "\n").data(using: .utf8) {
+            try? data.append(fileURL: logFile)
+        }
+    }
+}
+
 private extension Data {
     func append(fileURL: URL) throws {
         if let fileHandle = FileHandle(forWritingAtPath: fileURL.path) {

+ 1 - 0
Trio/Sources/Models/WatchMessageKeys.swift

@@ -5,6 +5,7 @@ enum WatchMessageKeys {
     static let watchState = "watchState"
     static let acknowledged = "acknowledged"
     static let message = "message"
+    static let units = "units"
 
     // Treatment Keys
     static let bolus = "bolus"

+ 67 - 1
Trio/Sources/Models/WatchState.swift

@@ -1,7 +1,7 @@
 import Foundation
 import SwiftUI
 
-struct WatchState: Hashable, Equatable, Sendable, Encodable {
+struct WatchState: Hashable, Equatable, Sendable, Encodable, Decodable {
     var date: Date
     var currentGlucose: String?
     var currentGlucoseColorString: String?
@@ -78,3 +78,69 @@ struct WatchState: Hashable, Equatable, Sendable, Encodable {
         hasher.combine(confirmBolusFaster)
     }
 }
+
+//
+///// Codable snapshot used for saving/loading WatchState to disk
+// struct WatchStateSnapshot: Codable {
+//    let state: WatchState
+//    let timestamp: TimeInterval
+//
+//    init(state: WatchState) {
+//        self.state = state
+//        self.timestamp = state.date.timeIntervalSince1970
+//    }
+// }
+//
+// extension WatchState {
+//    // MARK: - Disk Persistence
+//
+//    private static var snapshotURL: URL {
+//        let dir = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.trio.watch") ?? FileManager.default.temporaryDirectory
+//        return dir.appendingPathComponent("watch_state_snapshot.json")
+//    }
+//
+//    static func loadFromDisk() -> WatchState? {
+//        let url = snapshotURL
+//        guard FileManager.default.fileExists(atPath: url.path) else {
+//            return nil
+//        }
+//
+//        do {
+//            let data = try Data(contentsOf: url)
+//            let snapshot = try JSONDecoder().decode(WatchStateSnapshot.self, from: data)
+//            return snapshot.state
+//        } catch {
+//            print("⌚️ Failed to load WatchState snapshot: \(error.localizedDescription)")
+//            return nil
+//        }
+//    }
+//
+//    static func loadLatestDateFromDisk() -> Date {
+//        let url = snapshotURL
+//        guard FileManager.default.fileExists(atPath: url.path) else {
+//            return Date(timeIntervalSince1970: 0)
+//        }
+//
+//        do {
+//            let data = try Data(contentsOf: url)
+//            let snapshot = try JSONDecoder().decode(WatchStateSnapshot.self, from: data)
+//            return Date(timeIntervalSince1970: snapshot.timestamp)
+//        } catch {
+//            print("⌚️ Failed to load timestamp from WatchState snapshot: \(error.localizedDescription)")
+//            return Date(timeIntervalSince1970: 0)
+//        }
+//    }
+//
+//
+//    func saveToDisk() {
+//        let snapshot = WatchStateSnapshot(state: self)
+//        let url = Self.snapshotURL
+//
+//        do {
+//            let data = try JSONEncoder().encode(snapshot)
+//            try data.write(to: url, options: .atomic)
+//        } catch {
+//            print("⌚️ Failed to save WatchState snapshot: \(error.localizedDescription)")
+//        }
+//    }
+// }

+ 39 - 0
Trio/Sources/Models/WatchStateSnapshot.swift

@@ -0,0 +1,39 @@
+//
+//  WatchStateSnapshot.swift
+//  Trio
+//
+//  Created by Cengiz Deniz on 18.04.25.
+//
+import Foundation
+
+struct WatchStateSnapshot {
+    let date: Date
+    let payload: [String: Any]
+
+    init?(from dictionary: [String: Any]) {
+        guard let timestamp = dictionary[WatchMessageKeys.date] as? TimeInterval,
+              let payload = dictionary[WatchMessageKeys.watchState] as? [String: Any]
+        else {
+            return nil
+        }
+
+        date = Date(timeIntervalSince1970: timestamp)
+        self.payload = payload
+    }
+
+    func toDictionary() -> [String: Any] {
+        [
+            WatchMessageKeys.date: date.timeIntervalSince1970,
+            WatchMessageKeys.watchState: payload
+        ]
+    }
+
+    static func saveLatestDateToDisk(_ date: Date) {
+        UserDefaults.standard.set(date.timeIntervalSince1970, forKey: "WatchStateSnapshot.latest")
+    }
+
+    static func loadLatestDateFromDisk() -> Date {
+        let interval = UserDefaults.standard.double(forKey: "WatchStateSnapshot.latest")
+        return Date(timeIntervalSince1970: interval)
+    }
+}

+ 79 - 31
Trio/Sources/Services/WatchManager/AppleWatchManager.swift

@@ -396,30 +396,9 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
 
     // MARK: - Send to Watch
 
-    /// Sends the state of type WatchState to the connected Watch
-    /// - Parameter state: Current WatchState containing glucose data to be sent
-    @MainActor func sendDataToWatch(_ state: WatchState) async {
-        guard let session = session else { return }
-
-        guard session.isPaired else {
-            debug(.watchManager, "⌚️❌ No Watch is paired")
-            return
-        }
-
-        guard session.isWatchAppInstalled else {
-            debug(.watchManager, "⌚️❌ Trio Watch app is")
-            return
-        }
-
-        guard session.activationState == .activated else {
-            let activationStateString = "\(session.activationState)"
-            debug(.watchManager, "⌚️ Watch session activationState = \(activationStateString). Reactivating...")
-            session.activate()
-            return
-        }
-
-        let message: [String: Any] = [
-            WatchMessageKeys.date: Date().timeIntervalSince1970,
+    func watchStateToDictionary(from state: WatchState) -> [String: Any] {
+        [
+            WatchMessageKeys.date: state.date.timeIntervalSince1970,
             WatchMessageKeys.currentGlucose: state.currentGlucose ?? "--",
             WatchMessageKeys.currentGlucoseColorString: state.currentGlucoseColorString ?? "#ffffff",
             WatchMessageKeys.trend: state.trend ?? "",
@@ -453,8 +432,41 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
             WatchMessageKeys.maxFat: state.maxFat,
             WatchMessageKeys.maxProtein: state.maxProtein,
             WatchMessageKeys.bolusIncrement: state.bolusIncrement,
-            WatchMessageKeys.confirmBolusFaster: state.confirmBolusFaster
+            WatchMessageKeys.confirmBolusFaster: state.confirmBolusFaster,
+            WatchMessageKeys.units: state.units.rawValue
         ]
+    }
+
+    /// Sends the state of type WatchState to the connected Watch
+    /// - Parameter state: Current WatchState containing glucose data to be sent
+    @MainActor func sendDataToWatch(_ state: WatchState) async {
+        guard let session = session else { return }
+
+        guard session.isPaired else {
+            debug(.watchManager, "⌚️❌ No Watch is paired")
+            return
+        }
+
+        guard session.isWatchAppInstalled else {
+            debug(.watchManager, "⌚️❌ Trio Watch app is")
+            return
+        }
+
+        guard session.activationState == .activated else {
+            let activationStateString = "\(session.activationState)"
+            debug(.watchManager, "⌚️ Watch session activationState = \(activationStateString). Reactivating...")
+            session.activate()
+            return
+        }
+
+        // Skip if we already sent this state or older
+        let lastSent = WatchStateSnapshot.loadLatestDateFromDisk()
+        guard lastSent < state.date else {
+            debug(.watchManager, "🕐 Skipping push — newer or equal state already sent")
+            return
+        }
+
+        let message: [String: Any] = watchStateToDictionary(from: state)
 
         // if session is reachable, it means watch App is in the foreground -> send watchState as message
         // if session is not reachable, it means it's in background -> send watchState as userInfo
@@ -462,8 +474,11 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
             session.sendMessage([WatchMessageKeys.watchState: message], replyHandler: nil) { error in
                 debug(.watchManager, "❌ Error sending watch state: \(error.localizedDescription)")
             }
+            WatchStateSnapshot.saveLatestDateToDisk(state.date)
         } else {
+            WatchStateSnapshot.saveLatestDateToDisk(state.date)
             session.transferUserInfo([WatchMessageKeys.watchState: message])
+            debug(.watchManager, "📤 Transferred new WatchState snapshot via userInfo")
         }
     }
 
@@ -503,7 +518,10 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
 
     func session(_: WCSession, didReceiveMessage message: [String: Any]) {
         DispatchQueue.main.async { [weak self] in
-            // Check Watch State Update Request first
+            if let logs = message["watchLogs"] as? String {
+                SimpleLogReporter.appendToWatchLog(logs)
+            }
+
             if let requestWatchUpdate = message[WatchMessageKeys.requestWatchUpdate] as? String,
                requestWatchUpdate == WatchMessageKeys.watchState
             {
@@ -598,7 +616,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                     ]
 
                     if let session = self.session, session.isReachable {
-                        print("📱 Sending recommendedBolus: \(result.insulinCalculated)")
+                        debug(.watchManager, "📱 Sending recommendedBolus: \(result.insulinCalculated)")
                         session.sendMessage(recommendationMessage, replyHandler: nil)
                     }
                 }
@@ -607,6 +625,12 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
         }
     }
 
+    func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
+        if let logs = userInfo["watchLogs"] as? String {
+            SimpleLogReporter.appendToWatchLog(logs)
+        }
+    }
+
     #if os(iOS)
         func sessionDidBecomeInactive(_: WCSession) {}
         func sessionDidDeactivate(_ session: WCSession) {
@@ -989,7 +1013,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
         if session.activationState != .activated {
             session.activate()
             // Then, queue data for eventual delivery in the background
-            session.transferUserInfo(message)
+//            session.transferUserInfo(message)
             return
         }
 
@@ -999,10 +1023,11 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
             session.sendMessage(message, replyHandler: nil) { error in
                 debug(.watchManager, "❌ Error sending bolus progress: \(error.localizedDescription)")
             }
-        } else {
-            // Fallback to be double safe: queue userInfo for eventual delivery
-            session.transferUserInfo(message)
         }
+//        else {
+//            // Fallback to be double safe: queue userInfo for eventual delivery
+//            session.transferUserInfo(message)
+//        }
     }
 
     private func sendBolusCanceledMessageToWatch() {
@@ -1087,3 +1112,26 @@ extension BaseWatchManager {
         return nil
     }
 }
+
+// extension BaseWatchManager {
+//    func pushWatchStateToWatch(_ state: WatchState) {
+//        guard let session = session else { return }
+//
+//        // Skip if we already sent this state or older
+//        let lastSent = WatchStateSnapshot.loadLatestDateFromDisk()
+//        guard lastSent < state.date else {
+//            debug(.watchManager, "🕐 Skipping push — newer or equal state already sent")
+//            return
+//        }
+//
+//        let message: [String: Any] = [
+//            WatchMessageKeys.date: state.date.timeIntervalSince1970,
+//            WatchMessageKeys.watchState: watchStateToDictionary(from: state)
+//        ]
+//
+//        WatchStateSnapshot.saveLatestDateToDisk(state.date)
+//        session.transferUserInfo(message)
+//
+//        debug(.watchManager, "📤 Transferred new WatchState snapshot via userInfo")
+//    }
+// }