Browse Source

Initial Garmin functionality changes

*make the Trio-Garmin watchface configurable from Trio on the phone.
* implement proper sensitivityRate for Trio users
* provide support for the popular AAPS watchface by Swissalpine
  * also provide structures to setup other watchfaces and datafields
* provide data queueing that supports the unique and seldomly used Garmin message queue
* remedy the issues of watchfaces becoming un-responsive after another watchface has been used, requiering the user to re-install the Trio watchface
  * This is due to Trio still providing messages after the watchface has been completely de-activated and some severe system disadvantages of the Garmin message queue

Above the hood only the Garmin configuration view has been expanded to support different watchfaces, data type selection and switching off the messaging queue for the watchface if it is not being used. Comprehensive Help sheets have been created to explain the mechanisms.
seperate watch settings from device list

customize navigation to skip device list

add a mock garmin device in simulator

doc

common data structure


fix unnecessary number conversions

harmonize guards and default values across watch states

swissalpine datafield uuid

common data structure from AAPS
Robert 7 months ago
parent
commit
0640688fea

+ 10 - 2
Trio.xcodeproj/project.pbxproj

@@ -259,10 +259,12 @@
 		3BF85FE32E427312000D7351 /* IOBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF85FE12E427312000D7351 /* IOBService.swift */; };
 		45252C95D220E796FDB3B022 /* ConfigEditorDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */; };
 		45717281F743594AA9D87191 /* ConfigEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 920DDB21E5D0EB813197500D /* ConfigEditorRootView.swift */; };
+		49090A8D2E9FE8D200D0F5DB /* GarminWatchSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49090A8C2E9FE8D200D0F5DB /* GarminWatchSettings.swift */; };
 		491D6FBD2D56741C00C49F67 /* TempTargetStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491D6FBC2D56741C00C49F67 /* TempTargetStored+CoreDataProperties.swift */; };
 		491D6FBE2D56741C00C49F67 /* TempTargetRunStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491D6FB92D56741C00C49F67 /* TempTargetRunStored+CoreDataClass.swift */; };
 		491D6FBF2D56741C00C49F67 /* TempTargetRunStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491D6FBA2D56741C00C49F67 /* TempTargetRunStored+CoreDataProperties.swift */; };
 		491D6FC02D56741C00C49F67 /* TempTargetStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491D6FBB2D56741C00C49F67 /* TempTargetStored+CoreDataClass.swift */; };
+		4984D1D42EA2939E00263E83 /* WatchConfigGarminAppConfigView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4984D1D32EA2939E00263E83 /* WatchConfigGarminAppConfigView.swift */; };
 		49B9B57F2D5768D2009C6B59 /* AdjustmentStored+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49B9B57E2D5768D2009C6B59 /* AdjustmentStored+Helper.swift */; };
 		5075C1608E6249A51495C422 /* TargetsEditorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BDEA2DC60EDE0A3CA54DC73 /* TargetsEditorProvider.swift */; };
 		53F2382465BF74DB1A967C8B /* PumpConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8630D58BDAD6D9C650B9B39 /* PumpConfigProvider.swift */; };
@@ -1086,10 +1088,12 @@
 		3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigEditorDataFlow.swift; sourceTree = "<group>"; };
 		42369F66CF91F30624C0B3A6 /* BasalProfileEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BasalProfileEditorProvider.swift; sourceTree = "<group>"; };
 		44080E4709E3AE4B73054563 /* ConfigEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigEditorProvider.swift; sourceTree = "<group>"; };
+		49090A8C2E9FE8D200D0F5DB /* GarminWatchSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminWatchSettings.swift; sourceTree = "<group>"; };
 		491D6FB92D56741C00C49F67 /* TempTargetRunStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TempTargetRunStored+CoreDataClass.swift"; sourceTree = "<group>"; };
 		491D6FBA2D56741C00C49F67 /* TempTargetRunStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TempTargetRunStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
 		491D6FBB2D56741C00C49F67 /* TempTargetStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TempTargetStored+CoreDataClass.swift"; sourceTree = "<group>"; };
 		491D6FBC2D56741C00C49F67 /* TempTargetStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TempTargetStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
+		4984D1D32EA2939E00263E83 /* WatchConfigGarminAppConfigView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchConfigGarminAppConfigView.swift; sourceTree = "<group>"; };
 		49B9B57E2D5768D2009C6B59 /* AdjustmentStored+Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AdjustmentStored+Helper.swift"; sourceTree = "<group>"; };
 		4DD795BA46B193644D48138C /* TargetsEditorRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorRootView.swift; sourceTree = "<group>"; };
 		505E09DC17A0C3D0AF4B66FE /* ISFEditorStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ISFEditorStateModel.swift; sourceTree = "<group>"; };
@@ -2354,11 +2358,10 @@
 		388E5A5925B6F0250019842D /* Models */ = {
 			isa = PBXGroup;
 			children = (
+				49090A8C2E9FE8D200D0F5DB /* GarminWatchSettings.swift */,
 				DDFF204F2DB2C11900AB8A96 /* WatchStateSnapshot.swift */,
 				DDEBB05B2D89E9050032305D /* TimeInRangeType.swift */,
 				3B2F77852D7E52ED005ED9FA /* TDD.swift */,
-				DD4FFF322D458EE600B6CFF9 /* GarminWatchState.swift */,
-				DD3078692D42F94000DE0490 /* GarminDevice.swift */,
 				DD3078672D42F5CE00DE0490 /* WatchGlucoseObject.swift */,
 				BD432CA02D2F4E3300D1EB79 /* WatchMessageKeys.swift */,
 				BD54A9722D281A9C00F9C1EE /* TempTargetPresetWatch.swift */,
@@ -2377,6 +2380,8 @@
 				3811DF0125CA9FEA00A708ED /* Credentials.swift */,
 				E592A36F2CEEC01E009A472C /* ContactTrickEntry.swift */,
 				38AEE73C25F0200C0013F05B /* TrioSettings.swift */,
+				DD3078692D42F94000DE0490 /* GarminDevice.swift */,
+				DD4FFF322D458EE600B6CFF9 /* GarminWatchState.swift */,
 				382C133625F13A1E00715CE1 /* InsulinSensitivities.swift */,
 				38887CCD25F5725200944304 /* IOBEntry.swift */,
 				DD68889C2C386E17006E3C44 /* NightscoutExercise.swift */,
@@ -3123,6 +3128,7 @@
 		CE94598529E9E3FE0047C9C6 /* View */ = {
 			isa = PBXGroup;
 			children = (
+				4984D1D32EA2939E00263E83 /* WatchConfigGarminAppConfigView.swift */,
 				CE94598629E9E4110047C9C6 /* WatchConfigRootView.swift */,
 				DDF847E72C5DABA30049BB3B /* WatchConfigAppleWatchView.swift */,
 				DDF847E92C5DABAC0049BB3B /* WatchConfigGarminView.swift */,
@@ -4215,6 +4221,7 @@
 				BD8E6B232D9036F700ABF8FA /* OnboardingDataFlow.swift in Sources */,
 				C2A6D1E42DB1581D0036DB66 /* GlucoseStatsSetup.swift in Sources */,
 				3811DE3125C9D49500A708ED /* HomeProvider.swift in Sources */,
+				4984D1D42EA2939E00263E83 /* WatchConfigGarminAppConfigView.swift in Sources */,
 				FE41E4D629463EE20047FD55 /* NightscoutPreferences.swift in Sources */,
 				DD868FD82E381A54005D3308 /* APNSJWTClaims.swift in Sources */,
 				E013D872273AC6FE0014109C /* GlucoseSimulatorSource.swift in Sources */,
@@ -4263,6 +4270,7 @@
 				DDF691012DA2CA11008BF16C /* AppDiagnosticsDataFlow.swift in Sources */,
 				DDA6E3202D258E0500C2988C /* OverrideHelpView.swift in Sources */,
 				DDA6E2502D22187500C2988C /* ChartLegendView.swift in Sources */,
+				49090A8D2E9FE8D200D0F5DB /* GarminWatchSettings.swift in Sources */,
 				3811DEAF25C9D88300A708ED /* KeyValueStorage.swift in Sources */,
 				DDD6D4D32CDE90720029439A /* EstimatedA1cDisplayUnit.swift in Sources */,
 				DDA6E3572D25988500C2988C /* ContactImageHelpView.swift in Sources */,

+ 12 - 0
Trio/Resources/InfoPlist.xcstrings

@@ -457,6 +457,18 @@
         }
       }
     },
+    "NSCalendarsFullAccessUsageDescription" : {
+      "comment" : "Privacy - Calendars Full Access Usage Description",
+      "extractionState" : "extracted_with_value",
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "new",
+            "value" : "To create events with BG reading values, so that they can be viewed on Apple Watch and CarPlay"
+          }
+        }
+      }
+    },
     "NSCalendarsUsageDescription" : {
       "comment" : "Privacy - Calendars Usage Description",
       "extractionState" : "extracted_with_value",

+ 5 - 1
Trio/Resources/json/defaults/freeaps/freeaps_settings.json

@@ -48,5 +48,9 @@
   "useCalendar": false,
   "displayCalendarIOBandCOB": false,
   "displayCalendarEmojis": false,
-  "timeInRangeType": "timeInTightRange"
+  "timeInRangeType": "timeInTightRange",
+  "garminWatchface": "trio",
+  "garminDataType1": "cob",
+  "garminDataType2": "tbr",
+  "garminDisableWatchfaceData": true
 }

+ 89 - 1
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -8836,6 +8836,10 @@
         }
       }
     },
+    "%lld h" : {
+      "comment" : "The label for the y-axis in the glucose chart, followed by the time window duration (e.g. \"3 h\").",
+      "isCommentAutoGenerated" : true
+    },
     "%lld hr" : {
       "localizations" : {
         "bg" : {
@@ -24636,6 +24640,7 @@
       }
     },
     "Add Garmin Device to Trio. Please look at the docs to see which devices are supported." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -24741,6 +24746,9 @@
         }
       }
     },
+    "Add Garmin Device to Trio. This happens via Garmin Connect. If you have multiple phones with Garmin Connect and the same Garmin device, you will run into connectivity issue between watch and phone depending of proximity of the phones, which might also affect your watchface function." : {
+
+    },
     "Add Glucose" : {
       "localizations" : {
         "bg" : {
@@ -25515,6 +25523,10 @@
         }
       }
     },
+    "Add Mock Garmin Watch" : {
+      "comment" : "A button label that adds a mock Garmin watch for UI testing purposes.",
+      "isCommentAutoGenerated" : true
+    },
     "Add Omnipod" : {
       "comment" : "Add Omnipod pump",
       "extractionState" : "manual",
@@ -58256,6 +58268,12 @@
     "Choose a value that covers your highest insulin needs — think about a correction for a very high glucose reading plus your biggest meal bolus. This gives Trio room to work while keeping you safe." : {
 
     },
+    "Choose between display of COB or Sensitivity Ratio on Garmin device." : {
+
+    },
+    "Choose between display of TBR or Eventual BG on Garmin device." : {
+
+    },
     "Choose Calendar" : {
       "localizations" : {
         "bg" : {
@@ -58786,6 +58804,9 @@
         }
       }
     },
+    "Choose if you only want to use a datafield and no supported watchface!" : {
+
+    },
     "Choose to display eA1c and GMI in percent or mmol/mol." : {
       "localizations" : {
         "bg" : {
@@ -59210,6 +59231,9 @@
         }
       }
     },
+    "Choose which data type, along 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 format you'd prefer the eA1c (estimated A1c) and GMI (Glucose Management Index) value in the statistics view as a percentage (Example: eA1c: 6.5%) or mmol/mol (Example: eA1c: 48 mmol/mol)." : {
       "localizations" : {
         "bg" : {
@@ -59422,6 +59446,9 @@
         }
       }
     },
+    "Choose which watchface/datafield to support." : {
+
+    },
     "Clear" : {
       "comment" : "Button",
       "extractionState" : "manual",
@@ -61920,6 +61947,10 @@
         }
       }
     },
+    "Configure Watch Apps" : {
+      "comment" : "A button label that navigates to the configuration of watch apps.",
+      "isCommentAutoGenerated" : true
+    },
     "Configure Yourself" : {
       "localizations" : {
         "bg" : {
@@ -63422,6 +63453,10 @@
         }
       }
     },
+    "Connected Watches" : {
+      "comment" : "A section header for the list of connected Garmin devices.",
+      "isCommentAutoGenerated" : true
+    },
     "Connected!" : {
       "comment" : "Connected to NS",
       "extractionState" : "manual",
@@ -67538,6 +67573,12 @@
         }
       }
     },
+    "Data Field 1" : {
+
+    },
+    "Data Field 2" : {
+
+    },
     "Date" : {
       "localizations" : {
         "bg" : {
@@ -78889,6 +78930,9 @@
         }
       }
     },
+    "Disable Watchface Data" : {
+
+    },
     "Disabled" : {
       "comment" : "Title string for BeepPreference.silent",
       "extractionState" : "manual",
@@ -94236,6 +94280,10 @@
         }
       }
     },
+    "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" : {
@@ -103287,6 +103335,10 @@
         }
       }
     },
+    "Garmin App Settings" : {
+      "comment" : "The title of the view that allows configuration of a user's Garmin app.",
+      "isCommentAutoGenerated" : true
+    },
     "Garmin Configuration" : {
       "localizations" : {
         "bg" : {
@@ -103393,6 +103445,10 @@
         }
       }
     },
+    "Garmin Devices" : {
+      "comment" : "A label describing the list of Garmin devices.",
+      "isCommentAutoGenerated" : true
+    },
     "Garmin is not available" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -103507,6 +103563,7 @@
       }
     },
     "Garmin Watch" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -172285,6 +172342,10 @@
         }
       }
     },
+    "Remove All Devices" : {
+      "comment" : "A button label that removes all connected Garmin devices.",
+      "isCommentAutoGenerated" : true
+    },
     "Remove Last" : {
       "localizations" : {
         "bg" : {
@@ -187008,6 +187069,14 @@
         }
       }
     },
+    "Simulator only - for testing UI workflow" : {
+      "comment" : "A note displayed in the simulator that explains that the button below is for testing purposes only. It does not affect the actual functionality of the app.",
+      "isCommentAutoGenerated" : true
+    },
+    "Simulator Testing" : {
+      "comment" : "A section header in the watch configuration view that allows users to test the UI without a physical watch.",
+      "isCommentAutoGenerated" : true
+    },
     "Skip Bolus screen after carbs" : {
       "comment" : "Do you want to show bolus screen after added carbs?",
       "extractionState" : "manual",
@@ -196906,6 +196975,10 @@
         }
       }
     },
+    "Swissalpine xDrip+" : {
+      "comment" : "Name of the Swissalpine xDrip+ watchface.",
+      "isCommentAutoGenerated" : true
+    },
     "System Default" : {
       "localizations" : {
         "bg" : {
@@ -199062,6 +199135,10 @@
         }
       }
     },
+    "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
+    },
     "TDD" : {
       "localizations" : {
         "bg" : {
@@ -221839,6 +221916,10 @@
         }
       }
     },
+    "Trio original" : {
+      "comment" : "Name of a Garmin watchface option.",
+      "isCommentAutoGenerated" : true
+    },
     "Trio Personalization" : {
       "localizations" : {
         "bg" : {
@@ -232826,6 +232907,13 @@
         }
       }
     },
+    "Watch App selection" : {
+
+    },
+    "Watch App Settings" : {
+      "comment" : "A navigation link in the watch configuration view that takes the user to the watch app settings.",
+      "isCommentAutoGenerated" : true
+    },
     "Watch Configuration" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -240884,5 +240972,5 @@
       }
     }
   },
-  "version" : "1.0"
+  "version" : "1.1"
 }

+ 0 - 17
Trio/Sources/Models/Autotune.swift

@@ -1,17 +0,0 @@
-import Foundation
-
-struct Autotune: JSON, Equatable {
-    var createdAt: Date?
-    let basalProfile: [BasalProfileEntry]
-    let sensitivity: Decimal
-    let carbRatio: Decimal
-}
-
-extension Autotune {
-    private enum CodingKeys: String, CodingKey {
-        case createdAt = "created_at"
-        case basalProfile = "basalprofile"
-        case sensitivity = "sens"
-        case carbRatio = "carb_ratio"
-    }
-}

+ 0 - 24
Trio/Sources/Models/FetchedProfile.swift

@@ -1,24 +0,0 @@
-import Foundation
-
-struct FetchedNightscoutProfileStore: JSON {
-    let _id: String
-    let defaultProfile: String
-    let startDate: String
-    let mills: Decimal
-    let enteredBy: String
-    let store: [String: ScheduledNightscoutProfile]
-    let created_at: String
-}
-
-struct FetchedNightscoutProfile: JSON {
-    let dia: Decimal
-    let carbs_hr: Int
-    let delay: Decimal
-    let timezone: String
-    let target_low: [NightscoutTimevalue]
-    let target_high: [NightscoutTimevalue]
-    let sens: [NightscoutTimevalue]
-    let basal: [NightscoutTimevalue]
-    let carbratio: [NightscoutTimevalue]
-    let units: String
-}

+ 96 - 0
Trio/Sources/Models/GarminWatchSettings.swift

@@ -0,0 +1,96 @@
+import Foundation
+
+// MARK: - Garmin Data Type Settings
+
+/// Primary data type selection for Garmin watchface and datafield.
+/// Determines whether to display COB or Sensitivity Ratio alongside glucose data.
+/// Used by both Trio and SwissAlpine watchfaces.
+enum GarminDataType1: String, JSON, CaseIterable, Identifiable, Codable, Hashable {
+    var id: String { rawValue }
+
+    case cob
+    case isf
+    case sensRatio
+
+    var displayName: String {
+        switch self {
+        case .cob:
+            return String(localized: "COB", comment: "")
+        case .isf:
+            return String(localized: "Insulin Sensitivity Factor", comment: "")
+        case .sensRatio:
+            return String(localized: "Sensitivity Ratio", comment: "")
+        }
+    }
+}
+
+/// Secondary data type selection for both Trio and SwissAlpine watchfaces.
+/// Determines whether to display Temp Basal Rate or Eventual BG.
+enum GarminDataType2: String, JSON, CaseIterable, Identifiable, Codable, Hashable {
+    var id: String { rawValue }
+
+    case tbr
+    case eventualBG
+
+    var displayName: String {
+        switch self {
+        case .tbr:
+            return String(localized: "TBR (Temp Basal Rate)", comment: "")
+        case .eventualBG:
+            return String(localized: "Eventual BG", comment: "")
+        }
+    }
+}
+
+// MARK: - Garmin Watchface Setting
+
+/// Defines the available Garmin watchfaces with their associated UUIDs.
+/// Each watchface has both a watchface app UUID and a datafield app UUID.
+/// Both watchfaces now use the same data structure and settings (dataType1 and dataType2).
+enum GarminWatchface: String, JSON, CaseIterable, Identifiable, Codable, Hashable {
+    var id: String { rawValue }
+
+    case trio
+    case swissalpine
+
+    var displayName: String {
+        switch self {
+        case .trio:
+            return String(localized: "Trio original", comment: "")
+        case .swissalpine:
+            return String(localized: "Swissalpine xDrip+", comment: "")
+        }
+    }
+
+    /// The UUID for the watchface application in Garmin Connect IQ
+    var watchfaceUUID: UUID? {
+        switch self {
+        case .trio:
+            // return UUID(uuidString: "EC3420F6-027D-49B3-B45F-D81D6D3ED90A")  // local build
+            return UUID(uuidString: "81204522-B1BE-4E19-8E6E-C4032AAF8C6D") // ConnectIQ build
+        case .swissalpine:
+            return UUID(uuidString: "5A643C13-D5A7-40D4-B809-84789FDF4A1F")
+        }
+    }
+
+    /// The UUID for the datafield application in Garmin Connect IQ
+    var datafieldUUID: UUID? {
+        switch self {
+        case .trio:
+            return UUID(uuidString: "71CF0982-CA41-42A5-8441-EA81D36056C3")
+        case .swissalpine:
+            return UUID(uuidString: "7A2268F6-3381-4474-81BD-0A3E7F458CB7")
+        }
+    }
+}
+
+// MARK: - Garmin Watch Settings Group
+
+/// Groups related Garmin watch settings together for easier management.
+/// Both watchfaces use the same settings: dataType1 and dataType2.
+struct GarminWatchSettings: Codable, Hashable {
+    var watchface: GarminWatchface = .trio
+    var dataType1: GarminDataType1 = .cob
+    var dataType2: GarminDataType2 = .tbr
+    var garminDisableWatchfaceData: Bool = true
+}

+ 107 - 23
Trio/Sources/Models/GarminWatchState.swift

@@ -1,41 +1,125 @@
-//
-//  GarminWatchState.swift
-//  Trio
-//
-//  Created by Cengiz Deniz on 25.01.25.
-//
 import Foundation
 import SwiftUI
 
+// MARK: - Unified Garmin Watch State
+
+/// Unified watch state structure for both Trio and SwissAlpine watchfaces.
+/// Uses the SwissAlpine xDrip+ compatible data format.
+/// Sent as an array where the first entry contains all extended data fields.
 struct GarminWatchState: Hashable, Equatable, Sendable, Encodable {
-    var glucose: String?
-    var trendRaw: String?
-    var delta: String?
-    var iob: String?
-    var cob: String?
-    var lastLoopDateInterval: UInt64?
-    var eventualBGRaw: String?
-    var isf: String?
+    /// Timestamp of the glucose reading in milliseconds since Unix epoch
+    var date: UInt64?
+
+    /// Sensor glucose value in raw mg/dL (no unit conversion applied)
+    var sgv: Int16?
+
+    /// Change in glucose since previous reading as an integer
+    var delta: Int16?
+
+    /// Glucose trend direction (e.g., "Flat", "FortyFiveUp", "SingleUp")
+    var direction: String?
+
+    /// Signal noise level (optional, typically not used)
+    var noise: Double?
+
+    /// Unit hint for the watchface ("mgdl" or "mmol")
+    var units_hint: String?
+
+    /// Insulin on board as a decimal value (only in first array entry)
+    var iob: Double?
+
+    /// Current temp basal rate in U/hr (only in first array entry)
+    var tbr: Double?
+
+    /// Carbs on board as a decimal value (only in first array entry)
+    var cob: Double?
+
+    /// Predicted eventual blood glucose (excluded if data type 2 is set to TBR)
+    var eventualBG: Int16?
+
+    /// Current insulin sensitivity factor as an integer (only in first array entry)
+    var isf: Int16?
+
+    /// AutoISF sensitivity ratio (included only if data type 1 is set to sensRatio)
+    var sensRatio: Double?
+
+    // MARK: - Display Configuration Fields
+
+    /// Specifies which data field to display as primary (dataType1)
+    /// Options: "cob" or "sensRatio"
+    var displayDataType1: String?
+
+    /// Specifies which data field to display as secondary (dataType2)
+    /// Options: "tbr" or "eventualBG"
+    var displayDataType2: String?
 
     static func == (lhs: GarminWatchState, rhs: GarminWatchState) -> Bool {
-        lhs.glucose == rhs.glucose &&
-            lhs.trendRaw == rhs.trendRaw &&
+        lhs.date == rhs.date &&
+            lhs.sgv == rhs.sgv &&
             lhs.delta == rhs.delta &&
+            lhs.direction == rhs.direction &&
+            lhs.noise == rhs.noise &&
+            lhs.units_hint == rhs.units_hint &&
             lhs.iob == rhs.iob &&
+            lhs.tbr == rhs.tbr &&
             lhs.cob == rhs.cob &&
-            lhs.lastLoopDateInterval == rhs.lastLoopDateInterval &&
-            lhs.eventualBGRaw == rhs.eventualBGRaw &&
-            lhs.isf == rhs.isf
+            lhs.eventualBG == rhs.eventualBG &&
+            lhs.isf == rhs.isf &&
+            lhs.sensRatio == rhs.sensRatio &&
+            lhs.displayDataType1 == rhs.displayDataType1 &&
+            lhs.displayDataType2 == rhs.displayDataType2
     }
 
     func hash(into hasher: inout Hasher) {
-        hasher.combine(glucose)
-        hasher.combine(trendRaw)
+        hasher.combine(date)
+        hasher.combine(sgv)
         hasher.combine(delta)
+        hasher.combine(direction)
+        hasher.combine(noise)
+        hasher.combine(units_hint)
         hasher.combine(iob)
+        hasher.combine(tbr)
         hasher.combine(cob)
-        hasher.combine(lastLoopDateInterval)
-        hasher.combine(eventualBGRaw)
+        hasher.combine(eventualBG)
         hasher.combine(isf)
+        hasher.combine(sensRatio)
+        hasher.combine(displayDataType1)
+        hasher.combine(displayDataType2)
+    }
+
+    enum CodingKeys: String, CodingKey {
+        case date
+        case sgv
+        case delta
+        case direction
+        case noise
+        case units_hint
+        case iob
+        case tbr
+        case cob
+        case eventualBG
+        case isf
+        case sensRatio
+        case displayDataType1
+        case displayDataType2
+    }
+
+    /// Custom encoding that excludes nil values from the JSON output
+    func encode(to encoder: Encoder) throws {
+        var container = encoder.container(keyedBy: CodingKeys.self)
+        try container.encodeIfPresent(date, forKey: .date)
+        try container.encodeIfPresent(sgv, forKey: .sgv)
+        try container.encodeIfPresent(delta, forKey: .delta)
+        try container.encodeIfPresent(direction, forKey: .direction)
+        try container.encodeIfPresent(noise, forKey: .noise)
+        try container.encodeIfPresent(units_hint, forKey: .units_hint)
+        try container.encodeIfPresent(iob, forKey: .iob)
+        try container.encodeIfPresent(tbr, forKey: .tbr)
+        try container.encodeIfPresent(cob, forKey: .cob)
+        try container.encodeIfPresent(eventualBG, forKey: .eventualBG)
+        try container.encodeIfPresent(isf, forKey: .isf)
+        try container.encodeIfPresent(sensRatio, forKey: .sensRatio)
+        try container.encodeIfPresent(displayDataType1, forKey: .displayDataType1)
+        try container.encodeIfPresent(displayDataType2, forKey: .displayDataType2)
     }
 }

+ 29 - 1
Trio/Sources/Models/TrioSettings.swift

@@ -70,10 +70,22 @@ struct TrioSettings: JSON, Equatable {
     var lockScreenView: LockScreenView = .simple
     var bolusShortcut: BolusShortcutLimit = .notAllowed
     var timeInRangeType: TimeInRangeType = .timeInTightRange
+
+    /// Selected Garmin watchface (Trio or SwissAlpine)
+    var garminWatchface: GarminWatchface = .trio
+
+    /// Primary data type for Garmin display (COB or Sensitivity Ratio)
+    var garminDataType1: GarminDataType1 = .cob
+
+    /// Secondary data type for SwissAlpine watchface (TBR or Eventual BG)
+    var garminDataType2: GarminDataType2 = .tbr
+
+    /// Controls whether watchface data transmission is disabled
+    var garminDisableWatchfaceData: Bool = true
 }
 
 extension TrioSettings: Decodable {
-    // Needed to decode incomplete JSON
+    /// Custom decoder to handle incomplete JSON and provide default values for missing fields
     init(from decoder: Decoder) throws {
         let container = try decoder.container(keyedBy: CodingKeys.self)
         var settings = TrioSettings()
@@ -300,6 +312,22 @@ extension TrioSettings: Decodable {
             settings.timeInRangeType = timeInRangeType
         }
 
+        if let garminWatchface = try? container.decode(GarminWatchface.self, forKey: .garminWatchface) {
+            settings.garminWatchface = garminWatchface
+        }
+
+        if let garminDataType1 = try? container.decode(GarminDataType1.self, forKey: .garminDataType1) {
+            settings.garminDataType1 = garminDataType1
+        }
+
+        if let garminDataType2 = try? container.decode(GarminDataType2.self, forKey: .garminDataType2) {
+            settings.garminDataType2 = garminDataType2
+        }
+
+        if let garminDisableWatchfaceData = try? container.decode(Bool.self, forKey: .garminDisableWatchfaceData) {
+            settings.garminDisableWatchfaceData = garminDisableWatchfaceData
+        }
+
         self = settings
     }
 }

+ 230 - 0
Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift

@@ -0,0 +1,230 @@
+import SwiftUI
+
+struct WatchConfigGarminAppConfigView: View {
+    @ObservedObject var state: WatchConfig.StateModel
+
+    @State private var shouldDisplayHint1: Bool = false
+    @State private var shouldDisplayHint2: Bool = false
+    @State private var shouldDisplayHint3: Bool = false
+    @State private var shouldDisplayHint4: Bool = false
+    @State var hintDetent = PresentationDetent.large
+
+    @Environment(\.colorScheme) var colorScheme
+    @Environment(AppState.self) var appState
+
+    var body: some View {
+        Form {
+            // MARK: - Watchface Selection Section
+
+            Section(
+                content: {
+                    VStack {
+                        Picker(
+                            selection: $state.garminWatchface,
+                            label: Text("Watch App selection").multilineTextAlignment(.leading)
+                        ) {
+                            ForEach(GarminWatchface.allCases) { selection in
+                                Text(selection.displayName).tag(selection)
+                            }
+                        }
+                        .padding(.top)
+                        .onChange(of: state.garminWatchface) { _ in
+                            state.handleWatchfaceChange()
+                        }
+
+                        HStack(alignment: .center) {
+                            Text(
+                                "Choose which watchface/datafield to support."
+                            )
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                            .lineLimit(nil)
+                            Spacer()
+                            Button(
+                                action: {
+                                    shouldDisplayHint1.toggle()
+                                },
+                                label: {
+                                    HStack {
+                                        Image(systemName: "questionmark.circle")
+                                    }
+                                }
+                            ).buttonStyle(BorderlessButtonStyle())
+                        }.padding(.top)
+                    }.padding(.vertical)
+                }
+            ).listRowBackground(Color.chart)
+
+            // MARK: - Disable Watchface Data Section
+
+            Section(
+                content: {
+                    VStack {
+                        Toggle("Disable Watchface Data", isOn: $state.garminDisableWatchfaceData)
+                            .disabled(state.isDisableToggleLocked)
+
+                        // Display cooldown warning when toggle is locked
+                        if state.isDisableToggleLocked {
+                            HStack {
+                                Text(
+                                    "Please wait \(state.remainingCooldownSeconds) 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(
+                                "Choose if you only want to use a datafield and no supported watchface!"
+                            )
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                            .lineLimit(nil)
+                            Spacer()
+                            Button(
+                                action: {
+                                    shouldDisplayHint2.toggle()
+                                },
+                                label: {
+                                    HStack {
+                                        Image(systemName: "questionmark.circle")
+                                    }
+                                }
+                            ).buttonStyle(BorderlessButtonStyle())
+                        }.padding(.top)
+                    }.padding(.vertical)
+                }
+            ).listRowBackground(Color.chart)
+
+            // MARK: - Data Type 1 Selection Section
+
+            Section(
+                content: {
+                    VStack {
+                        Picker(
+                            selection: $state.garminDataType1,
+                            label: Text("Data Field 1").multilineTextAlignment(.leading)
+                        ) {
+                            ForEach(GarminDataType1.allCases) { selection in
+                                Text(selection.displayName).tag(selection)
+                            }
+                        }.padding(.top)
+                        HStack(alignment: .center) {
+                            Text(
+                                "Choose between display of COB or Sensitivity Ratio 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(.vertical)
+                }
+            ).listRowBackground(Color.chart)
+
+            // MARK: - Data Type 2 Selection Section (Both Watchfaces)
+
+            Section(
+                content: {
+                    VStack {
+                        Picker(
+                            selection: $state.garminDataType2,
+                            label: Text("Data Field 2").multilineTextAlignment(.leading)
+                        ) {
+                            ForEach(GarminDataType2.allCases) { selection in
+                                Text(selection.displayName).tag(selection)
+                            }
+                        }.padding(.top)
+                        HStack(alignment: .center) {
+                            Text(
+                                "Choose between display of TBR or Eventual BG on Garmin device."
+                            )
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                            .lineLimit(nil)
+                            Spacer()
+                            Button(
+                                action: {
+                                    shouldDisplayHint4.toggle()
+                                },
+                                label: {
+                                    HStack {
+                                        Image(systemName: "questionmark.circle")
+                                    }
+                                }
+                            ).buttonStyle(BorderlessButtonStyle())
+                        }.padding(.top)
+                    }.padding(.vertical)
+                }
+            ).listRowBackground(Color.chart)
+        }
+        .listSectionSpacing(sectionSpacing)
+        .scrollContentBackground(.hidden)
+        .background(appState.trioBackgroundColor(for: colorScheme))
+
+        // MARK: - Help Sheets
+
+        .sheet(isPresented: $shouldDisplayHint1) {
+            SettingInputHintView(
+                hintDetent: $hintDetent,
+                shouldDisplayHint: $shouldDisplayHint1,
+                hintLabel: "Choose Garmin App support.",
+                hintText: Text(
+                    "Choose which watchface and datafield combination on your Garmin device you wish to provide data for. Both watchfaces now use the same data structure and configuration options.\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."
+                ),
+                sheetTitle: String(localized: "Help", comment: "Help sheet title")
+            )
+        }
+        .sheet(isPresented: $shouldDisplayHint2) {
+            SettingInputHintView(
+                hintDetent: $hintDetent,
+                shouldDisplayHint: $shouldDisplayHint2,
+                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, use this toggle to disable all 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."
+                ),
+                sheetTitle: String(localized: "Help", comment: "Help sheet title")
+            )
+        }
+        .sheet(isPresented: $shouldDisplayHint3) {
+            SettingInputHintView(
+                hintDetent: $hintDetent,
+                shouldDisplayHint: $shouldDisplayHint3,
+                hintLabel: "Choose data support",
+                hintText: Text(
+                    "Choose which data type, along BG and IOB etc., you want to show on your Garmin device. That data type will be shown both on watchface and datafield"
+                ),
+                sheetTitle: String(localized: "Help", comment: "Help sheet title")
+            )
+        }
+        .sheet(isPresented: $shouldDisplayHint4) {
+            SettingInputHintView(
+                hintDetent: $hintDetent,
+                shouldDisplayHint: $shouldDisplayHint4,
+                hintLabel: "Choose data support",
+                hintText: Text(
+                    "Choose which data type, along BG and IOB etc., you want to show on your Garmin device. That data type will be shown both on watchface and datafield"
+                ),
+                sheetTitle: String(localized: "Help", comment: "Help sheet title")
+            )
+        }
+    }
+}

+ 154 - 16
Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminView.swift

@@ -1,37 +1,127 @@
+import ConnectIQ
 import SwiftUI
 
 struct WatchConfigGarminView: View {
     @ObservedObject var state: WatchConfig.StateModel
-
+    @State private var showDeviceList = false
     @State private var shouldDisplayHint: Bool = false
     @State var hintDetent = PresentationDetent.large
-    @State var selectedVerboseHint: AnyView?
-    @State var hintLabel: String?
-    @State private var decimalPlaceholder: Decimal = 0.0
-    @State private var booleanPlaceholder: Bool = false
 
     @Environment(\.colorScheme) var colorScheme
     @Environment(AppState.self) var appState
 
+    /// Handles deletion of devices from the device list
     private func onDelete(offsets: IndexSet) {
         state.devices.remove(atOffsets: offsets)
         state.deleteGarminDevice()
     }
 
+    #if targetEnvironment(simulator)
+        /// Adds a mock Garmin device for simulator UI testing
+        private func addMockDevice() {
+            // Create a mock IQDevice using a simple class that conforms to the protocol
+            let mockDevice = MockIQDevice(
+                uuid: UUID(),
+                friendlyName: "Mock Garmin Fenix 7",
+                modelName: "fenix7"
+            )
+            state.devices.append(mockDevice)
+            // Persist to garmin manager so it survives view reloads
+            state.deleteGarminDevice()
+        }
+    #endif
+
     var body: some View {
+        Group {
+            if state.devices.isEmpty || showDeviceList {
+                // No devices connected OR user wants to see device list - show device list/add view
+                deviceListView
+            } else {
+                // Devices connected - go directly to configuration
+                WatchConfigGarminAppConfigView(state: state)
+                    .navigationTitle("Garmin App Settings")
+                    .navigationBarTitleDisplayMode(.automatic)
+                    .navigationBarBackButtonHidden(true)
+                    .toolbar {
+                        ToolbarItem(placement: .navigationBarLeading) {
+                            Button(action: {
+                                showDeviceList = true
+                            }) {
+                                HStack {
+                                    Image(systemName: "chevron.left")
+                                    Text("Garmin Devices")
+                                }
+                            }
+                        }
+                    }
+            }
+        }
+        .id(state.devices.count) // Force view refresh when device count changes
+        .onChange(of: state.devices.count) { _, newValue in
+            // If devices were deleted and now empty, ensure we show device list
+            if newValue == 0 {
+                showDeviceList = false
+            }
+        }
+    }
+
+    var deviceListView: some View {
         Form {
+            #if targetEnvironment(simulator)
+
+                // MARK: - Simulator Testing
+
+                Section(
+                    header: Text("Simulator Testing"),
+                    content: {
+                        VStack {
+                            if state.devices.isEmpty {
+                                Button {
+                                    // Add a mock device for UI testing
+                                    addMockDevice()
+                                } label: {
+                                    Text("Add Mock Garmin Watch")
+                                        .font(.title3)
+                                }
+                                .frame(maxWidth: .infinity, alignment: .center)
+                                .buttonStyle(.bordered)
+                            } else {
+                                Button {
+                                    // Remove all devices
+                                    state.devices.removeAll()
+                                    state.deleteGarminDevice()
+                                } label: {
+                                    Text("Remove All Devices")
+                                        .font(.title3)
+                                }
+                                .frame(maxWidth: .infinity, alignment: .center)
+                                .buttonStyle(.bordered)
+                                .tint(.red)
+                            }
+
+                            Text("Simulator only - for testing UI workflow")
+                                .font(.caption)
+                                .foregroundColor(.orange)
+                                .padding(.top, 5)
+                        }.padding(.vertical)
+                    }
+                ).listRowBackground(Color.orange.opacity(0.2))
+            #endif
+
+            // MARK: - Device Configuration Section
+
             Section(
                 header: Text("Garmin Configuration"),
-                content:
-                {
+                content: {
                     VStack {
                         Button {
                             state.selectGarminDevices()
                         } label: {
                             Text("Add Device")
-                                .font(.title3) }
-                            .frame(maxWidth: .infinity, alignment: .center)
-                            .buttonStyle(.bordered)
+                                .font(.title3)
+                        }
+                        .frame(maxWidth: .infinity, alignment: .center)
+                        .buttonStyle(.bordered)
 
                         HStack(alignment: .center) {
                             Text(
@@ -56,9 +146,11 @@ struct WatchConfigGarminView: View {
                 }
             ).listRowBackground(Color.chart)
 
+            // MARK: - Device List Section
+
             if !state.devices.isEmpty {
                 Section(
-                    header: Text("Garmin Watch"),
+                    header: Text("Connected Watches"),
                     content: {
                         List {
                             ForEach(state.devices, id: \.uuid) { device in
@@ -68,23 +160,69 @@ struct WatchConfigGarminView: View {
                         }
                     }
                 ).listRowBackground(Color.chart)
+
+                // MARK: - App Settings Navigation Section
+
+                Section(
+                    header: Text("Watch App Settings"),
+                    content: {
+                        Button(action: {
+                            showDeviceList = false
+                        }) {
+                            HStack {
+                                Text("Configure Watch Apps")
+                                Spacer()
+                                Image(systemName: "chevron.right")
+                                    .font(.caption)
+                                    .foregroundColor(.secondary)
+                            }
+                        }
+                        .foregroundColor(.primary)
+                    }
+                ).listRowBackground(Color.chart)
             }
         }
         .listSectionSpacing(sectionSpacing)
+        .navigationTitle("Garmin Devices")
+        .navigationBarTitleDisplayMode(.automatic)
+        .scrollContentBackground(.hidden)
+        .background(appState.trioBackgroundColor(for: colorScheme))
         .sheet(isPresented: $shouldDisplayHint) {
             SettingInputHintView(
                 hintDetent: $hintDetent,
                 shouldDisplayHint: $shouldDisplayHint,
                 hintLabel: "Add Device",
                 hintText: Text(
-                    "Add Garmin Device to Trio. Please look at the docs to see which devices are supported."
+                    "Add Garmin Device to Trio. This happens via Garmin Connect. If you have multiple phones with Garmin Connect and the same Garmin device, you will run into connectivity issue between watch and phone depending of proximity of the phones, which might also affect your watchface function."
                 ),
                 sheetTitle: String(localized: "Help", comment: "Help sheet title")
             )
         }
-        .navigationTitle("Garmin")
-        .navigationBarTitleDisplayMode(.automatic)
-        .scrollContentBackground(.hidden)
-        .background(appState.trioBackgroundColor(for: colorScheme))
     }
 }
+
+#if targetEnvironment(simulator)
+    // Mock IQDevice class for simulator testing
+    // Minimal implementation just for UI testing - no actual Garmin functionality
+    class MockIQDevice: IQDevice {
+        private let _uuid: UUID
+        private let _friendlyName: String
+        private let _modelName: String
+
+        override var uuid: UUID { _uuid }
+        override var friendlyName: String { _friendlyName }
+        override var modelName: String { _modelName }
+        var status: IQDeviceStatus { .connected }
+
+        init(uuid: UUID, friendlyName: String, modelName: String) {
+            _uuid = uuid
+            _friendlyName = friendlyName
+            _modelName = modelName
+            super.init()
+        }
+
+        @available(*, unavailable) required init?(coder _: NSCoder) {
+            fatalError("init(coder:) not implemented for mock device")
+        }
+    }
+#endif

+ 70 - 2
Trio/Sources/Modules/WatchConfig/WatchConfigStateModel.swift

@@ -1,3 +1,4 @@
+import Combine
 import ConnectIQ
 import SwiftUI
 
@@ -9,18 +10,45 @@ extension WatchConfig {
         @Published var devices: [IQDevice] = []
         @Published var confirmBolusFaster = false
 
+        /// Current selected Garmin watchface (Trio or SwissAlpine)
+        @Published var garminWatchface: GarminWatchface = .trio
+
+        /// Primary data type selection (COB or Sensitivity Ratio)
+        @Published var garminDataType1: GarminDataType1 = .cob
+
+        /// Secondary data type selection (TBR or Eventual BG) - SwissAlpine only
+        @Published var garminDataType2: GarminDataType2 = .tbr
+
+        /// Controls whether watchface data transmission is disabled
+        @Published var garminDisableWatchfaceData: Bool = true
+
+        /// Indicates if the disable toggle is locked during cooldown period
+        @Published var isDisableToggleLocked: Bool = false
+
+        /// Remaining seconds in the cooldown period
+        @Published var remainingCooldownSeconds: Int = 0
+
         private(set) var preferences = Preferences()
 
+        /// Timer for managing the 20-second cooldown after watchface changes
+        private var cooldownTimer: Timer?
+
+        /// The timestamp when the current cooldown period will end
+        private var cooldownEndTime: Date?
+
         override func subscribe() {
             preferences = provider.preferences
-
             units = settingsManager.settings.units
-
+            subscribeSetting(\.garminDataType1, on: $garminDataType1) { garminDataType1 = $0 }
+            subscribeSetting(\.garminDataType2, on: $garminDataType2) { garminDataType2 = $0 }
+            subscribeSetting(\.garminWatchface, on: $garminWatchface) { garminWatchface = $0 }
+            subscribeSetting(\.garminDisableWatchfaceData, on: $garminDisableWatchfaceData) { garminDisableWatchfaceData = $0 }
             subscribeSetting(\.confirmBolusFaster, on: $confirmBolusFaster) { confirmBolusFaster = $0 }
 
             devices = garmin.devices
         }
 
+        /// Prompts the user to select Garmin devices and updates the device list
         func selectGarminDevices() {
             garmin.selectDevices()
                 .receive(on: DispatchQueue.main)
@@ -28,9 +56,49 @@ extension WatchConfig {
                 .store(in: &lifetime)
         }
 
+        /// Updates the Garmin manager with the current device list
         func deleteGarminDevice() {
             garmin.updateDeviceList(devices)
         }
+
+        /// 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
+        func handleWatchfaceChange() {
+            garminDisableWatchfaceData = true
+            startCooldownTimer()
+        }
+
+        /// Starts a 20-second countdown timer that locks the disable toggle and updates
+        /// the remaining seconds display every second until the cooldown period expires
+        private func startCooldownTimer() {
+            cooldownTimer?.invalidate()
+
+            cooldownEndTime = Date().addingTimeInterval(20)
+            isDisableToggleLocked = true
+            remainingCooldownSeconds = 20
+
+            cooldownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
+                guard let self = self else { return }
+
+                if let endTime = self.cooldownEndTime {
+                    let remaining = Int(endTime.timeIntervalSinceNow)
+                    if remaining <= 0 {
+                        self.isDisableToggleLocked = false
+                        self.remainingCooldownSeconds = 0
+                        self.cooldownTimer?.invalidate()
+                        self.cooldownTimer = nil
+                        self.cooldownEndTime = nil
+                    } else {
+                        self.remainingCooldownSeconds = remaining
+                    }
+                }
+            }
+        }
+
+        deinit {
+            cooldownTimer?.invalidate()
+        }
     }
 }
 

+ 204 - 0
Trio/Sources/Services/WatchManager/FLOW_DIAGRAM.md

@@ -0,0 +1,204 @@
+# Garmin Update Flow - Visual Diagram
+
+## New Simplified Architecture
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│                        Loop Cycle Completes                      │
+└───────────────────┬─────────────────────────────────────────────┘
+                    │
+                    ├─────────────────────────────┐
+                    │                             │
+                    ↓                             ↓
+        ┌──────────────────────┐      ┌──────────────────────┐
+        │  Determination       │      │  IOB Update          │
+        │  CoreData Change     │      │  iobPublisher        │
+        └──────────┬───────────┘      └──────────┬───────────┘
+                   │                             │
+                   │  .send(data)                │  .send(data)
+                   ↓                             ↓
+        ┌──────────────────────────────────────────────────────┐
+        │         determinationSubject                          │
+        │         (PassthroughSubject<Data, Never>)            │
+        └──────────────────┬───────────────────────────────────┘
+                           │
+                           │  .throttle(for: .seconds(20),
+                           │            latest: false)
+                           ↓
+        ┌──────────────────────────────────────────────────────┐
+        │              Combine Throttle Logic                   │
+        │   .throttle(for: .seconds(20), latest: false)        │
+        │                                                       │
+        │  ┌────────────────────────────────────┐             │
+        │  │ Event 1 (t=0s)    → HOLD 📦        │             │
+        │  │   [Start 20s timer]                │             │
+        │  │ Event 2 (t=0.5s)  → DROP ❌        │             │
+        │  │ Event 3 (t=1s)    → DROP ❌        │             │
+        │  │ Event 4 (t=5s)    → DROP ❌        │             │
+        │  │ [t=20s: Timer fires]               │             │
+        │  │   → SEND Event 1 ✅                │             │
+        │  │                                    │             │
+        │  │ Event 5 (t=20.1s) → HOLD 📦        │             │
+        │  │   [Start new 20s timer]            │             │
+        │  │ Event 6 (t=23s)   → DROP ❌        │             │
+        │  │ [t=40.1s: Timer fires]             │             │
+        │  │   → SEND Event 5 ✅                │             │
+        │  └────────────────────────────────────┘             │
+        │                                                       │
+        │  Pattern: HOLD first → DROP rest → SEND after 20s  │
+        └──────────────────┬───────────────────────────────────┘
+                           │
+                           ↓
+        ┌──────────────────────────────────────────────────────┐
+        │         subscribeToDeterminationThrottle()            │
+        │                                                       │
+        │  • Check if recent watchface change (<25s)           │
+        │    - If yes: Don't cache (might be old format) ⚠️    │
+        │    - If no: Cache data ✅                            │
+        │  • Convert Data → JSON                               │
+        │  • Set lastImmediateSendTime                         │
+        │  • Log: "Sending determination/IOB" (if enabled)     │
+        └──────────────────┬───────────────────────────────────┘
+                           │
+                           ↓
+        ┌──────────────────────────────────────────────────────┐
+        │         broadcastStateToWatchApps()                   │
+        │                                                       │
+        │  ├─> Watchface App (5A643C13...)                     │
+        │  └─> Data Field App (71CF0982...)                    │
+        └───────────────────────────────────────────────────────┘
+```
+
+## Other Update Sources (Unchanged)
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│                    Glucose Update (Stale Loop)                   │
+│                    (Loop age > 8 minutes)                        │
+└───────────────────┬─────────────────────────────────────────────┘
+                    │
+                    │  Immediate send - no throttle
+                    ↓
+        ┌──────────────────────────────────────────────────────┐
+        │         sendWatchStateDataImmediately()               │
+        │                                                       │
+        │  • Convert Data → JSON                               │
+        │  • Set lastImmediateSendTime                         │
+        │  • broadcastStateToWatchApps()                       │
+        └───────────────────────────────────────────────────────┘
+
+
+┌─────────────────────────────────────────────────────────────────┐
+│              Status Request / Settings Changes                   │
+└───────────────────┬─────────────────────────────────────────────┘
+                    │
+                    │  30s throttle
+                    ↓
+        ┌──────────────────────────────────────────────────────┐
+        │         sendWatchStateDataWith30sThrottle()           │
+        │                                                       │
+        │  • Store pending data                                │
+        │  • Start/update 30s timer                            │
+        │  • Check lastImmediateSendTime before firing         │
+        │  • broadcastStateToWatchApps() after 30s             │
+        └───────────────────────────────────────────────────────┘
+```
+
+## Comparison: Old vs New
+
+### Old Architecture (Complex)
+```
+Determination ──> sendWatchStateDataImmediately() ──> Watch
+                      │
+                      └─> Set lastImmediateSendTime
+                      
+IOB ──> sendWatchStateDataWith30sThrottle() ──> Watch
+         │
+         └─> Check lastImmediateSendTime? ❌ Race condition!
+         └─> Start 30s timer
+         └─> Cancel if determination fired? ⚠️ Complex!
+```
+
+### New Architecture (Simple)
+```
+Determination ──┐
+                ├──> determinationSubject ──> .throttle(10s) ──> Watch
+IOB ───────────┘
+```
+
+## Timeline Example
+
+```
+Time    Event                          Action
+──────────────────────────────────────────────────────────────────
+0:00    Loop completes                 
+        ├─ Determination fires ─┐
+        └─ IOB fires ───────────┴──> determinationSubject.send()
+                                                │
+0:00                                   Throttle: SEND ✅
+                                       Log: "Sending determination/IOB"
+                                                │
+0:00-10s Multiple loop cycles         Throttle: DROP ALL ❌
+        (rapid determinations/IOB)              │
+                                                │
+10:01   Next loop completes            Throttle: SEND ✅
+        ├─ Determination fires ─┐      Log: "Sending determination/IOB"
+        └─ IOB fires ───────────┘
+                                                │
+15:00   Status request arrives         30s timer starts
+                                       (separate pipeline)
+                                                │
+20:01   Loop completes                 Throttle: SEND ✅
+        ├─ Determination fires ─┐      (30s timer cancelled - recent send)
+        └─ IOB fires ───────────┘
+```
+
+## Key Architectural Decisions
+
+### Why Combine Throttle Instead of Manual Timer?
+
+**Combine throttle:**
+✅ Built-in deduplication
+✅ Thread-safe by design
+✅ Predictable scheduler behavior
+✅ Less code to maintain
+✅ No race conditions
+
+**Manual timer:**
+❌ Complex lifecycle management
+❌ Race conditions between publishers
+❌ More code to test
+❌ Threading concerns
+❌ Easy to introduce bugs
+
+### Why 10 Seconds?
+
+1. **Loop cycle timing:** Typical loop = 5 minutes
+2. **Multiple events = same cycle:** Events within 10s are from same loop
+3. **Responsiveness:** 10s is imperceptible to users
+4. **Battery efficiency:** Reduces watch transmissions by ~80%
+
+### Why `latest: false`?
+
+| Setting | Behavior | Result |
+|---------|----------|--------|
+| `latest: false` | Keep **first** event, drop rest | Send immediately when loop completes ✅ |
+| `latest: true` | Drop events, send **last** one after throttle | 10 second delay every time ❌ |
+
+We want immediate response when data arrives, not delayed response.
+
+## Code Metrics
+
+### Lines of Code
+- **Old approach:** ~150 lines of throttling logic
+- **New approach:** ~60 lines of throttling logic
+- **Reduction:** 60% less code
+
+### Complexity
+- **Old approach:** 3 throttle mechanisms (immediate, 10s manual, 30s manual)
+- **New approach:** 2 throttle mechanisms (10s Combine, 30s manual)
+- **Timer objects:** Reduced from 2 to 1
+
+### Edge Cases Handled
+- **Old approach:** ~8 edge cases (race conditions, timer coordination, etc.)
+- **New approach:** ~3 edge cases (all handled by Combine)

File diff suppressed because it is too large
+ 947 - 213
Trio/Sources/Services/WatchManager/GarminManager.swift