Преглед на файлове

Merge pull request #1042 from mountrcg/updateGarmin

Code fixes and Improvements Garmin Manager & Config
Deniz Cengiz преди 1 месец
родител
ревизия
3d4cabfc6c

+ 22 - 22
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -68052,9 +68052,6 @@
         }
       }
     },
-    "Choose which data types, along with BG and IOB etc., you want to show on your Garmin device. That data type will be shown both on watchface and datafield." : {
-
-    },
     "Choose which datafield to support. Can be used independently of watchface selection." : {
 
     },
@@ -77909,6 +77906,9 @@
     "Data Choice 2" : {
 
     },
+    "Data transmission has been disabled. Now select the new watchface on your Garmin device and resume data transmission once done." : {
+
+    },
     "Datafield Selection" : {
 
     },
@@ -107923,6 +107923,9 @@
         }
       }
     },
+    "evBG" : {
+
+    },
     "Even if you’re an updating user, you’ll be guided through the algorithm settings configuration step-by-step." : {
       "localizations" : {
         "bg" : {
@@ -108041,10 +108044,6 @@
         }
       }
     },
-    "Eventual BG" : {
-      "comment" : "Description of a secondary data type selection for SwissAlpine watchface only. Determines whether to display Temp Basal Rate or Eventual BG.",
-      "isCommentAutoGenerated" : true
-    },
     "Eventual Glucose" : {
       "localizations" : {
         "bg" : {
@@ -139415,10 +139414,6 @@
         }
       }
     },
-    "Insulin Sensitivity Factor" : {
-      "comment" : "Description of a Garmin data type when it is Insulin Sensitivity Factor.",
-      "isCommentAutoGenerated" : true
-    },
     "Insulin Suspended" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -200325,6 +200320,9 @@
         }
       }
     },
+    "Resume Data Transmission" : {
+
+    },
     "Resume Insulin Delivery" : {
       "comment" : "Text for suspend resume button when insulin delivery is suspended",
       "extractionState" : "manual",
@@ -207171,6 +207169,10 @@
         }
       }
     },
+    "Sens Ratio" : {
+      "comment" : "Name of the secondary attribute choice for the SwissAlpine watchface.",
+      "isCommentAutoGenerated" : true
+    },
     "Sensitivity" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -227452,6 +227454,10 @@
         }
       }
     },
+    "Swissalpine" : {
+      "comment" : "Name of the Swissalpine watchface.",
+      "isCommentAutoGenerated" : true
+    },
     "System Default" : {
       "localizations" : {
         "bg" : {
@@ -230198,9 +230204,8 @@
         }
       }
     },
-    "TBR (Temp Basal Rate)" : {
-      "comment" : "Description of a secondary data type selection for SwissAlpine watchface only. Determines whether to display Temp Basal Rate or Eventual BG.",
-      "isCommentAutoGenerated" : true
+    "TBR" : {
+
     },
     "TDD" : {
       "localizations" : {
@@ -256110,10 +256115,6 @@
         }
       }
     },
-    "Trio original" : {
-      "comment" : "Name of a Garmin watchface option.",
-      "isCommentAutoGenerated" : true
-    },
     "Trio Personalization" : {
       "localizations" : {
         "bg" : {
@@ -256704,10 +256705,6 @@
         }
       }
     },
-    "Trio Swissalpine" : {
-      "comment" : "Name for the watchface that combines the features of the original Trio watchface and the Swissalpine watchface.",
-      "isCommentAutoGenerated" : true
-    },
     "Trio Up-Time" : {
       "localizations" : {
         "bg" : {
@@ -268384,6 +268381,9 @@
         }
       }
     },
+    "Watchface Changed" : {
+
+    },
     "Watchface Selection" : {
 
     },

+ 8 - 8
Trio/Sources/Models/GarminWatchSettings.swift

@@ -17,9 +17,9 @@ enum GarminPrimaryAttributeChoice: String, JSON, CaseIterable, Identifiable, Cod
         case .cob:
             return String(localized: "COB", comment: "")
         case .isf:
-            return String(localized: "Insulin Sensitivity Factor", comment: "")
+            return String(localized: "ISF", comment: "")
         case .sensRatio:
-            return String(localized: "Sensitivity Ratio", comment: "")
+            return String(localized: "Sens Ratio", comment: "")
         }
     }
 }
@@ -35,9 +35,9 @@ enum GarminSecondaryAttributeChoice: String, JSON, CaseIterable, Identifiable, C
     var displayName: String {
         switch self {
         case .tbr:
-            return String(localized: "TBR (Temp Basal Rate)", comment: "")
+            return String(localized: "TBR", comment: "")
         case .eventualBG:
-            return String(localized: "Eventual BG", comment: "")
+            return String(localized: "evBG", comment: "")
         }
     }
 }
@@ -54,9 +54,9 @@ enum GarminWatchface: String, JSON, CaseIterable, Identifiable, Codable, Hashabl
     var displayName: String {
         switch self {
         case .trio:
-            return String(localized: "Trio original", comment: "")
+            return String(localized: "Trio", comment: "")
         case .swissalpine:
-            return String(localized: "Trio Swissalpine", comment: "")
+            return String(localized: "Swissalpine", comment: "")
         }
     }
 
@@ -87,9 +87,9 @@ enum GarminDatafield: String, JSON, CaseIterable, Identifiable, Codable, Hashabl
     var displayName: String {
         switch self {
         case .trio:
-            return String(localized: "Trio original", comment: "")
+            return String(localized: "Trio", comment: "")
         case .swissalpine:
-            return String(localized: "Trio Swissalpine", comment: "")
+            return String(localized: "Swissalpine", comment: "")
         case .none:
             return String(localized: "None", comment: "")
         }

+ 57 - 25
Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift

@@ -8,6 +8,7 @@ struct WatchConfigGarminAppConfigView: View {
     @State private var shouldDisplayHint3: Bool = false
     @State private var shouldDisplayHint4: Bool = false
     @State var hintDetent = PresentationDetent.large
+    @State private var shouldShowWatchfaceSwitchConfirmDialog: Bool = false
 
     @Environment(\.colorScheme) var colorScheme
     @Environment(AppState.self) var appState
@@ -29,8 +30,11 @@ struct WatchConfigGarminAppConfigView: View {
                             }
                         }
                         .padding(.top)
-                        .onChange(of: state.garminSettings.watchface) { _ in
-                            state.handleWatchfaceChange()
+                        .onChange(of: state.garminSettings.watchface) { oldValue, newValue in
+                            if oldValue != newValue {
+                                state.handleWatchfaceChange()
+                                shouldShowWatchfaceSwitchConfirmDialog = true
+                            }
                         }
 
                         HStack(alignment: .center) {
@@ -52,7 +56,9 @@ struct WatchConfigGarminAppConfigView: View {
                                 }
                             ).buttonStyle(BorderlessButtonStyle())
                         }.padding(.top)
-                        Spacer()
+                    }.padding(.bottom)
+
+                    VStack {
                         // Inverted binding: "Disable" toggle controls "isEnabled" boolean
                         // When toggle is ON → data transmission is DISABLED (isEnabled = false)
                         // When toggle is OFF → data transmission is ENABLED (isEnabled = true)
@@ -60,22 +66,6 @@ struct WatchConfigGarminAppConfigView: View {
                             get: { !state.garminSettings.isWatchfaceDataEnabled },
                             set: { state.garminSettings.isWatchfaceDataEnabled = !$0 }
                         ))
-                            .disabled(state.isWatchfaceDataCooldownActive)
-
-                        // Display cooldown warning when toggle is locked
-                        if state.isWatchfaceDataCooldownActive {
-                            HStack {
-                                Text(
-                                    "Please wait \(state.watchfaceSwitchCooldownSeconds) seconds!\n\n" +
-                                        "After the lockout you can re-enable watchface data transmission, but you need to change to the new watchface on your Garmin watch before that - e.g. now!"
-                                )
-                                .font(.footnote)
-                                .foregroundColor(.orange)
-                                .multilineTextAlignment(.leading)
-                                .lineLimit(nil)
-                                Spacer()
-                            }
-                        }
 
                         HStack(alignment: .center) {
                             Text(
@@ -96,7 +86,7 @@ struct WatchConfigGarminAppConfigView: View {
                                 }
                             ).buttonStyle(BorderlessButtonStyle())
                         }.padding(.top)
-                    }.padding(.vertical)
+                    }.padding(.bottom)
                 }
             ).listRowBackground(Color.chart)
 
@@ -135,7 +125,7 @@ struct WatchConfigGarminAppConfigView: View {
                                 }
                             ).buttonStyle(BorderlessButtonStyle())
                         }.padding(.top)
-                    }.padding(.vertical)
+                    }.padding(.bottom)
                 }
             ).listRowBackground(Color.chart)
 
@@ -153,6 +143,28 @@ struct WatchConfigGarminAppConfigView: View {
                                 Text(selection.displayName).tag(selection)
                             }
                         }.padding(.top)
+                        HStack(alignment: .center) {
+                            Text(
+                                "Choose between displayed data types on Garmin device."
+                            )
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                            .lineLimit(nil)
+                            Spacer()
+                            Button(
+                                action: {
+                                    shouldDisplayHint3.toggle()
+                                },
+                                label: {
+                                    HStack {
+                                        Image(systemName: "questionmark.circle")
+                                    }
+                                }
+                            ).buttonStyle(BorderlessButtonStyle())
+                        }.padding(.top)
+                    }.padding(.bottom)
+
+                    VStack {
                         Picker(
                             selection: $state.garminSettings.secondaryAttributeChoice,
                             label: Text("Data Choice 2").multilineTextAlignment(.leading)
@@ -180,7 +192,7 @@ struct WatchConfigGarminAppConfigView: View {
                                 }
                             ).buttonStyle(BorderlessButtonStyle())
                         }.padding(.top)
-                    }.padding(.vertical)
+                    }.padding(.bottom)
                 }
             ).listRowBackground(Color.chart)
         }
@@ -197,8 +209,10 @@ struct WatchConfigGarminAppConfigView: View {
                 hintLabel: "Choose Garmin Watchface",
                 hintText: Text(
                     "Choose which watchface on your Garmin device you wish to provide data for. You can independently select which datafield to use in the next section.\n\n" +
+                        "• Trio – The original Trio watchface, developed by Ivan Valkou.\n" +
+                        "• Swissalpine – Originally developed for AAPS, adapted to work with Trio.\n\n" +
                         "You must use this configuration setting here BEFORE you switch the watchface on your Garmin device to another watchface.\n\n" +
-                        "⚠️ Changing the watchface will automatically disable data transmission and lock that setting for 20 seconds to allow time for you to switch the watchface on your Garmin device."
+                        "⚠️ Changing the watchface will automatically disable data transmission. You will be prompted to resume data transmission after you have changed the watchface on your Garmin device."
                 ),
                 sheetTitle: String(localized: "Help", comment: "Help sheet title")
             )
@@ -210,6 +224,8 @@ struct WatchConfigGarminAppConfigView: View {
                 hintLabel: "Choose Garmin Datafield",
                 hintText: Text(
                     "Choose which datafield on your Garmin device you wish to provide data for. The datafield can be used independently from the watchface selection.\n\n" +
+                        "• Trio – The original Trio datafield, developed by Pierre.\n" +
+                        "• Swissalpine – Originally developed for AAPS, adapted to work with Trio.\n\n" +
                         "Select 'None' if you don't want to use a datafield, or want to preserve battery while not exercising."
                 ),
                 sheetTitle: String(localized: "Help", comment: "Help sheet title")
@@ -222,7 +238,7 @@ struct WatchConfigGarminAppConfigView: View {
                 hintLabel: "Disable watchface data transmission",
                 hintText: Text(
                     "Important: If you want to use a different watchface on your Garmin device that has no data requirement from this app, disable data transmission to the Garmin watchface app! Otherwise you will not be able to get current data once you re-enable the supported watchface that shows Trio data and you will have to re-install it on your Garmin device.\n\n" +
-                        "Note: When switching between supported watchfaces, data transmission is automatically disabled for 20 seconds. You would manually need to re-enable it."
+                        "Note: When switching between supported watchfaces, data transmission is automatically disabled. You will be prompted to resume data transmission after you have changed the watchface on your Garmin device."
                 ),
                 sheetTitle: String(localized: "Help", comment: "Help sheet title")
             )
@@ -233,10 +249,26 @@ struct WatchConfigGarminAppConfigView: View {
                 shouldDisplayHint: $shouldDisplayHint3,
                 hintLabel: "Choose data support",
                 hintText: Text(
-                    "Choose which data types, along with BG and IOB etc., you want to show on your Garmin device. That data type will be shown both on watchface and datafield."
+                    "Choose which data types, along with Blood Glucose and IOB etc., you want to show on your Garmin device. That data type will be shown both on watchface and datafield.\n\n" +
+                        "Data Choice 1 options:\n" +
+                        "• COB – Carbs On Board\n" +
+                        "• ISF – Insulin Sensitivity Factor\n" +
+                        "• Sens Ratio – Sensitivity Ratio\n\n" +
+                        "Data Choice 2 options:\n" +
+                        "• Temp Basal Rate\n" +
+                        "• Eventual Glucose"
                 ),
                 sheetTitle: String(localized: "Help", comment: "Help sheet title")
             )
         }
+        .confirmationDialog("Watchface Changed", isPresented: $shouldShowWatchfaceSwitchConfirmDialog) {
+            Button("Resume Data Transmission") {
+                state.resumeDataTransmission()
+            }
+        } message: {
+            Text(
+                "Data transmission has been disabled. Now select the new watchface on your Garmin device and resume data transmission once done."
+            )
+        }
     }
 }

+ 4 - 44
Trio/Sources/Modules/WatchConfig/WatchConfigStateModel.swift

@@ -13,20 +13,8 @@ extension WatchConfig {
         /// Garmin watch settings containing all watch-related configuration
         @Published var garminSettings = GarminWatchSettings()
 
-        /// Indicates if the enable/disable toggle is locked during cooldown period
-        @Published var isWatchfaceDataCooldownActive: Bool = false
-
-        /// Remaining seconds in the cooldown period
-        @Published var watchfaceSwitchCooldownSeconds: Int = 0
-
         private(set) var preferences = Preferences()
 
-        /// Timer for managing the 20-second cooldown after watchface changes
-        private var watchfaceSwitchTimer: Timer?
-
-        /// The timestamp when the current cooldown period will end
-        private var watchfaceSwitchCooldownEndTime: Date?
-
         override func subscribe() {
             preferences = provider.preferences
             units = settingsManager.settings.units
@@ -52,42 +40,14 @@ extension WatchConfig {
         }
 
         /// Handles watchface selection changes by automatically disabling data transmission
-        /// and starting a 20-second cooldown period to allow the user to switch watchfaces
-        /// on their Garmin device without data conflicts
+        /// to allow the user to switch watchfaces on their Garmin device without data conflicts
         func handleWatchfaceChange() {
             garminSettings.isWatchfaceDataEnabled = false
-            startCooldownTimer()
-        }
-
-        /// Starts a 20-second countdown timer that locks the enable/disable toggle and updates
-        /// the remaining seconds display every second until the cooldown period expires
-        private func startCooldownTimer() {
-            watchfaceSwitchTimer?.invalidate()
-
-            watchfaceSwitchCooldownEndTime = Date().addingTimeInterval(20)
-            isWatchfaceDataCooldownActive = true
-            watchfaceSwitchCooldownSeconds = 20
-
-            watchfaceSwitchTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
-                guard let self = self else { return }
-
-                if let endTime = self.watchfaceSwitchCooldownEndTime {
-                    let remaining = Int(endTime.timeIntervalSinceNow)
-                    if remaining <= 0 {
-                        self.isWatchfaceDataCooldownActive = false
-                        self.watchfaceSwitchCooldownSeconds = 0
-                        self.watchfaceSwitchTimer?.invalidate()
-                        self.watchfaceSwitchTimer = nil
-                        self.watchfaceSwitchCooldownEndTime = nil
-                    } else {
-                        self.watchfaceSwitchCooldownSeconds = remaining
-                    }
-                }
-            }
         }
 
-        deinit {
-            watchfaceSwitchTimer?.invalidate()
+        /// Resumes data transmission after user confirms they have switched watchface on their device
+        func resumeDataTransmission() {
+            garminSettings.isWatchfaceDataEnabled = true
         }
     }
 }

+ 31 - 26
Trio/Sources/Services/WatchManager/GarminManager.swift

@@ -544,20 +544,28 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
 
         let tempBasalIds = try await fetchTempBasals()
 
-        let glucoseObjects: [GlucoseStored] = try await CoreDataStack.shared
-            .getNSManagedObject(with: glucoseIds, context: backgroundContext)
-        let allDeterminationObjects: [OrefDetermination] = try await CoreDataStack.shared
-            .getNSManagedObject(with: allDeterminationIds, context: backgroundContext)
-        let tempBasalObjects: [PumpEventStored] = try await CoreDataStack.shared
-            .getNSManagedObject(with: tempBasalIds, context: backgroundContext)
-
-        return await backgroundContext.perform {
+        // Extract all needed values from self before entering perform block (Sendable compliance)
+        let unitsValue = units
+        let iobValue = formatIOB(iobService.currentIOB ?? Decimal(0))
+        let basalProfile = settingsManager.preferences.basalProfile as? [BasalProfileEntry] ?? []
+        let displayPrimaryChoice = settingsManager.settings.garminSettings.primaryAttributeChoice.rawValue
+        let displaySecondaryChoice = settingsManager.settings.garminSettings.secondaryAttributeChoice.rawValue
+        let needsHistoricalData = needsHistoricalGlucoseData
+        let shouldDebug = debugWatchState
+        let previousHash = lastPreparedDataHash
+        let previousWatchState = lastPreparedWatchState
+
+        // Capture context locally for use in perform block
+        let context = backgroundContext
+
+        let watchStates = await context.perform {
+            // Fetch Core Data objects inside perform block
+            let glucoseObjects = glucoseIds.compactMap { context.object(with: $0) as? GlucoseStored }
+            let allDeterminationObjects = allDeterminationIds.compactMap { context.object(with: $0) as? OrefDetermination }
+            let tempBasalObjects = tempBasalIds.compactMap { context.object(with: $0) as? PumpEventStored }
             var watchStates: [GarminWatchState] = []
 
-            let unitsHint = self.units == .mgdL ? "mgdl" : "mmol"
-
-            // IOB with 1 decimal precision
-            let iobValue = self.formatIOB(self.iobService.currentIOB ?? Decimal(0))
+            let unitsHint = unitsValue == .mgdL ? "mgdl" : "mmol"
 
             // Find enacted determination for timestamp (when loop actually ran)
             // If no enacted determination exists in last 30 min, use a synthetic timestamp
@@ -599,7 +607,6 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
                 tbrValue = Double(truncating: tempRate)
             } else {
                 // Fall back to scheduled basal from profile
-                let basalProfile = self.settingsManager.preferences.basalProfile as? [BasalProfileEntry] ?? []
                 if !basalProfile.isEmpty {
                     let now = Date()
                     let calendar = Calendar.current
@@ -614,12 +621,8 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
                 }
             }
 
-            // Display configuration from settings
-            let displayPrimaryChoice = self.settingsManager.settings.garminSettings.primaryAttributeChoice.rawValue
-            let displaySecondaryChoice = self.settingsManager.settings.garminSettings.secondaryAttributeChoice.rawValue
-
             // Process glucose readings
-            let entriesToSend = self.needsHistoricalGlucoseData ? glucoseObjects.count : 1
+            let entriesToSend = needsHistoricalData ? glucoseObjects.count : 1
 
             for (index, glucose) in glucoseObjects.enumerated() {
                 guard index < entriesToSend else { break }
@@ -669,14 +672,14 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
 
             // Deduplicate: Check if data is unchanged from last preparation
             let currentHash = watchStates.hashValue
-            if currentHash == self.lastPreparedDataHash {
-                if self.debugWatchState {
+            if currentHash == previousHash {
+                if shouldDebug {
                     debug(.watchManager, "Garmin: Skipping - data unchanged")
                 }
-                return self.lastPreparedWatchState ?? watchStates
+                return previousWatchState ?? watchStates
             }
 
-            if self.debugWatchState {
+            if shouldDebug {
                 let iobFormatted = String(format: "%.1f", watchStates.first?.iob ?? 0)
                 let cobFormatted = String(format: "%.0f", watchStates.first?.cob ?? 0)
                 let tbrFormatted = String(format: "%.2f", watchStates.first?.tbr ?? 0)
@@ -687,12 +690,14 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
                 )
             }
 
-            // Cache for deduplication
-            self.lastPreparedDataHash = currentHash
-            self.lastPreparedWatchState = watchStates
-
             return watchStates
         }
+
+        // Cache for deduplication (outside perform block)
+        lastPreparedDataHash = watchStates.hashValue
+        lastPreparedWatchState = watchStates
+
+        return watchStates
     }
 
     /// Formats IOB (Insulin On Board) value with 1 decimal precision for display.