Browse Source

Merge branch 'dev' of github.com:nightscout/Trio into fix-race-condition

Marvin Polscheit 1 tháng trước cách đây
mục cha
commit
f3ff51ca02
28 tập tin đã thay đổi với 1918 bổ sung535 xóa
  1. 1 1
      Config.xcconfig
  2. 1 1
      DanaKit
  3. 1 1
      LoopKit
  4. 46 23
      Trio.xcodeproj/project.pbxproj
  5. 2 2
      Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved
  6. 5 5
      Trio/Resources/Info.plist
  7. 14 110
      Trio/Resources/InfoPlist.xcstrings
  8. 6 1
      Trio/Resources/json/defaults/freeaps/freeaps_settings.json
  9. 8 2
      Trio/Sources/APS/DeviceDataManager.swift
  10. 5 0
      Trio/Sources/Helpers/Decimal+Extensions.swift
  11. 7 0
      Trio/Sources/Helpers/Formatters.swift
  12. 84 1
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  13. 0 17
      Trio/Sources/Models/Autotune.swift
  14. 0 24
      Trio/Sources/Models/FetchedProfile.swift
  15. 124 0
      Trio/Sources/Models/GarminWatchSettings.swift
  16. 118 17
      Trio/Sources/Models/GarminWatchState.swift
  17. 80 2
      Trio/Sources/Models/TrioSettings.swift
  18. 16 2
      Trio/Sources/Modules/Home/View/Chart/ChartElements/BasalChart.swift
  19. 2 1
      Trio/Sources/Modules/ISFEditor/View/ISFEditorRootView.swift
  20. 25 16
      Trio/Sources/Modules/Onboarding/View/Animations/LogoBurstSplash.swift
  21. 9 0
      Trio/Sources/Modules/Onboarding/View/Animations/PulsingLogoAnimation.swift
  22. 17 7
      Trio/Sources/Modules/Onboarding/View/OnboardingRootView.swift
  23. 274 0
      Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift
  24. 121 16
      Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminView.swift
  25. 19 1
      Trio/Sources/Modules/WatchConfig/WatchConfigStateModel.swift
  26. 204 0
      Trio/Sources/Services/WatchManager/FLOW_DIAGRAM.md
  27. 728 284
      Trio/Sources/Services/WatchManager/GarminManager.swift
  28. 1 1
      TrioTests/GlucoseSmoothingTests.swift

+ 1 - 1
Config.xcconfig

@@ -19,7 +19,7 @@ TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group
 
 // The developers set the version numbers, please leave them alone
 APP_VERSION = 0.6.0
-APP_DEV_VERSION = 0.6.0.72
+APP_DEV_VERSION = 0.6.0.79
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 

+ 1 - 1
DanaKit

@@ -1 +1 @@
-Subproject commit 3970b2aadc55044c851130567879fd0ae3ade6cc
+Subproject commit 0158fc85391725bb1855ea34469d48cb65667850

+ 1 - 1
LoopKit

@@ -1 +1 @@
-Subproject commit 9c09a6fea98e2638d76d610ba097c4fae14ca220
+Subproject commit 0229bb18d30b095420aae1e8fa04c37794e0a378

+ 46 - 23
Trio.xcodeproj/project.pbxproj

@@ -265,12 +265,15 @@
 		3E62C7822F54CC1B00433237 /* BolusDisplayThreshold.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E62C7812F54CC1600433237 /* BolusDisplayThreshold.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 */; };
 		49239B432EEA27AD00469145 /* TempTargetCalculations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49239B422EEA27AD00469145 /* TempTargetCalculations.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 */; };
+		49C782A72F73D9870062B0DD /* AlertEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C782A62F73D9870062B0DD /* AlertEntry.swift */; };
 		5075C1608E6249A51495C422 /* TargetsEditorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BDEA2DC60EDE0A3CA54DC73 /* TargetsEditorProvider.swift */; };
 		53F2382465BF74DB1A967C8B /* PumpConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8630D58BDAD6D9C650B9B39 /* PumpConfigProvider.swift */; };
 		581516A42BCED84A00BF67D7 /* DebuggingIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581516A32BCED84A00BF67D7 /* DebuggingIdentifiers.swift */; };
@@ -491,7 +494,6 @@
 		CE7CA3562A064973004BE681 /* StateIntentRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7CA34D2A064973004BE681 /* StateIntentRequest.swift */; };
 		CE7CA3582A064E2F004BE681 /* ListStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7CA3572A064E2F004BE681 /* ListStateView.swift */; };
 		CE82E02528E867BA00473A9C /* AlertStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE82E02428E867BA00473A9C /* AlertStorage.swift */; };
-		CE82E02728E869DF00473A9C /* AlertEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE82E02628E869DF00473A9C /* AlertEntry.swift */; };
 		CE94597E29E9E1EE0047C9C6 /* GarminManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE94597D29E9E1EE0047C9C6 /* GarminManager.swift */; };
 		CE94598029E9E3BD0047C9C6 /* WatchConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE94597F29E9E3BD0047C9C6 /* WatchConfigDataFlow.swift */; };
 		CE94598229E9E3D30047C9C6 /* WatchConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE94598129E9E3D30047C9C6 /* WatchConfigProvider.swift */; };
@@ -879,7 +881,6 @@
 		1967DFBD29D052C200759F30 /* Icons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Icons.swift; sourceTree = "<group>"; };
 		1967DFBF29D053AC00759F30 /* IconSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSelection.swift; sourceTree = "<group>"; };
 		1967DFC129D053D300759F30 /* IconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconImage.swift; sourceTree = "<group>"; };
-		199561C0275E61A50077B976 /* HealthKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = HealthKit.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS8.0.sdk/System/Library/Frameworks/HealthKit.framework; sourceTree = DEVELOPER_DIR; };
 		19A910352A24D6D700C8951B /* DateFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFilter.swift; sourceTree = "<group>"; };
 		19B0EF2028F6D66200069496 /* Statistics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Statistics.swift; sourceTree = "<group>"; };
 		19D466A229AA2B80004D5F33 /* MealSettingsDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealSettingsDataFlow.swift; sourceTree = "<group>"; };
@@ -1105,12 +1106,15 @@
 		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>"; };
 		49239B422EEA27AD00469145 /* TempTargetCalculations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetCalculations.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>"; };
+		49C782A62F73D9870062B0DD /* AlertEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertEntry.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>"; };
 		581516A32BCED84A00BF67D7 /* DebuggingIdentifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebuggingIdentifiers.swift; sourceTree = "<group>"; };
@@ -1337,7 +1341,6 @@
 		CE7CA34D2A064973004BE681 /* StateIntentRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateIntentRequest.swift; sourceTree = "<group>"; };
 		CE7CA3572A064E2F004BE681 /* ListStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListStateView.swift; sourceTree = "<group>"; };
 		CE82E02428E867BA00473A9C /* AlertStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertStorage.swift; sourceTree = "<group>"; };
-		CE82E02628E869DF00473A9C /* AlertEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertEntry.swift; sourceTree = "<group>"; };
 		CE94597929E9DF7B0047C9C6 /* ConnectIQ.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ConnectIQ.framework; path = "Dependencies/ios-armv7_arm64/ConnectIQ.framework"; sourceTree = "<group>"; };
 		CE94597D29E9E1EE0047C9C6 /* GarminManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminManager.swift; sourceTree = "<group>"; };
 		CE94597F29E9E3BD0047C9C6 /* WatchConfigDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchConfigDataFlow.swift; sourceTree = "<group>"; };
@@ -1785,7 +1788,6 @@
 		192F0FF5276AC36D0085BE4D /* Recovered References */ = {
 			isa = PBXGroup;
 			children = (
-				199561C0275E61A50077B976 /* HealthKit.framework */,
 			);
 			name = "Recovered References";
 			sourceTree = "<group>";
@@ -2386,22 +2388,9 @@
 		388E5A5925B6F0250019842D /* Models */ = {
 			isa = PBXGroup;
 			children = (
+				49C782A62F73D9870062B0DD /* AlertEntry.swift */,
 				DDA40BB92F4DB18100257798 /* AlgorithmGlucose.swift */,
 				3E62C7812F54CC1600433237 /* BolusDisplayThreshold.swift */,
-				DD3D60302F0377350021A33B /* ExportSetting.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 */,
-				BD54A95A2D28087700F9C1EE /* OverridePresetWatch.swift */,
-				BDA25EFC2D261BF200035F34 /* WatchState.swift */,
-				715120D12D3C2B84005D9FB6 /* GlucoseNotificationsOption.swift */,
-				DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */,
-				DD6D67E32C9C253500660C9B /* ColorSchemeOption.swift */,
 				388E5A5F25B6F2310019842D /* Autosens.swift */,
 				388358C725EEF6D200E024B2 /* BasalProfileEntry.swift */,
 				38D0B3B525EBE24900CB6E88 /* Battery.swift */,
@@ -2409,24 +2398,55 @@
 				3870FF4225EC13F40088248F /* BloodGlucose.swift */,
 				38A9260425F012D8009E3739 /* CarbRatios.swift */,
 				38D0B3D825EC07C400CB6E88 /* CarbsEntry.swift */,
-				3811DF0125CA9FEA00A708ED /* Credentials.swift */,
+				19D4E4EA29FC6A9F00351451 /* Charts.swift */,
+				DD6D67E32C9C253500660C9B /* ColorSchemeOption.swift */,
+				DD9ECB692CA99F6C00AA7C45 /* CommandPayload.swift */,
 				E592A36F2CEEC01E009A472C /* ContactTrickEntry.swift */,
-				38AEE73C25F0200C0013F05B /* TrioSettings.swift */,
+				3811DF0125CA9FEA00A708ED /* Credentials.swift */,
+				19A910352A24D6D700C8951B /* DateFilter.swift */,
+				DD21FCB42C6952AD00AF2C25 /* DecimalPickerSettings.swift */,
+				583684072BD195A700070A60 /* Determination.swift */,
+				DDD6D4D22CDE90720029439A /* EstimatedA1cDisplayUnit.swift */,
+				DD3D60302F0377350021A33B /* ExportSetting.swift */,
+				DD6B7CB32C7B71F700B75029 /* ForecastDisplayType.swift */,
+				DD3078692D42F94000DE0490 /* GarminDevice.swift */,
+				49090A8C2E9FE8D200D0F5DB /* GarminWatchSettings.swift */,
+				DD4FFF322D458EE600B6CFF9 /* GarminWatchState.swift */,
+				DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */,
+				715120D12D3C2B84005D9FB6 /* GlucoseNotificationsOption.swift */,
+				E0D4F80427513ECF00BDF1FE /* HealthKitSample.swift */,
+				1967DFBD29D052C200759F30 /* Icons.swift */,
 				382C133625F13A1E00715CE1 /* InsulinSensitivities.swift */,
 				38887CCD25F5725200944304 /* IOBEntry.swift */,
+				BDF530D72B40F8AC002CAF43 /* LockScreenView.swift */,
+				193F6CDC2A512C8F001240FD /* Loops.swift */,
+				19012CDB291D2CB900FB8210 /* LoopStats.swift */,
 				DD68889C2C386E17006E3C44 /* NightscoutExercise.swift */,
+				FE41E4D529463EE20047FD55 /* NightscoutPreferences.swift */,
+				191F62672AD6B05A004D7911 /* NightscoutSettings.swift */,
 				385CEA8125F23DFD002D6D5B /* NightscoutStatus.swift */,
 				389442CA25F65F7100FA1F27 /* NightscoutTreatment.swift */,
+				BDC2EA462C3045AD00E5BBD0 /* Override.swift */,
+				BD54A95A2D28087700F9C1EE /* OverridePresetWatch.swift */,
 				3895E4C525B9E00D00214B37 /* Preferences.swift */,
 				38A13D3125E28B4B00EAA382 /* PumpHistoryEvent.swift */,
 				3883583325EEB38000E024B2 /* PumpSettings.swift */,
 				38E989DC25F5021400C0CED0 /* PumpStatus.swift */,
+				CC6C406D2ACDD69E009B8058 /* RawFetchedProfile.swift */,
 				38BF021C25E7E3AF00579895 /* Reservoir.swift */,
+				19B0EF2028F6D66200069496 /* Statistics.swift */,
+				3B2F77852D7E52ED005ED9FA /* TDD.swift */,
 				38A0364125ED069400FCBB52 /* TempBasal.swift */,
 				3871F39B25ED892B0013ECB5 /* TempTarget.swift */,
-				3811DE8E25C9D80400A708ED /* User.swift */,
-				E0D4F80427513ECF00BDF1FE /* HealthKitSample.swift */,
+				BD54A9722D281A9C00F9C1EE /* TempTargetPresetWatch.swift */,
+				DDEBB05B2D89E9050032305D /* TimeInRangeType.swift */,
 				1935363F28496F7D001E0B16 /* TrioCustomOrefVariables.swift */,
+				38AEE73C25F0200C0013F05B /* TrioSettings.swift */,
+				3811DE8E25C9D80400A708ED /* User.swift */,
+				DD3078672D42F5CE00DE0490 /* WatchGlucoseObject.swift */,
+				BD432CA02D2F4E3300D1EB79 /* WatchMessageKeys.swift */,
+				BDA25EFC2D261BF200035F34 /* WatchState.swift */,
+				DDFF204F2DB2C11900AB8A96 /* WatchStateSnapshot.swift */,
 				CE82E02628E869DF00473A9C /* AlertEntry.swift */,
 				19B0EF2028F6D66200069496 /* Statistics.swift */,
 				19012CDB291D2CB900FB8210 /* LoopStats.swift */,
@@ -3178,6 +3198,7 @@
 		CE94598529E9E3FE0047C9C6 /* View */ = {
 			isa = PBXGroup;
 			children = (
+				4984D1D32EA2939E00263E83 /* WatchConfigGarminAppConfigView.swift */,
 				CE94598629E9E4110047C9C6 /* WatchConfigRootView.swift */,
 				DDF847E72C5DABA30049BB3B /* WatchConfigAppleWatchView.swift */,
 				DDF847E92C5DABAC0049BB3B /* WatchConfigGarminView.swift */,
@@ -4286,6 +4307,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 */,
@@ -4334,6 +4356,7 @@
 				DDF691012DA2CA11008BF16C /* AppDiagnosticsDataFlow.swift in Sources */,
 				DDA6E3202D258E0500C2988C /* OverrideHelpView.swift in Sources */,
 				DDA6E2502D22187500C2988C /* ChartLegendView.swift in Sources */,
+				49090A8D2E9FE8D200D0F5DB /* GarminWatchSettings.swift in Sources */,
 				49239B432EEA27AD00469145 /* TempTargetCalculations.swift in Sources */,
 				3811DEAF25C9D88300A708ED /* KeyValueStorage.swift in Sources */,
 				DDD6D4D32CDE90720029439A /* EstimatedA1cDisplayUnit.swift in Sources */,
@@ -4639,7 +4662,6 @@
 				BDC531182D1062F200088832 /* ContactImageState.swift in Sources */,
 				BD249D9F2D42FD0600412DEB /* StackedChartSetup.swift in Sources */,
 				E592A37A2CEEC038009A472C /* ContactImageProvider.swift in Sources */,
-				CE82E02728E869DF00473A9C /* AlertEntry.swift in Sources */,
 				DD30786A2D42F94000DE0490 /* GarminDevice.swift in Sources */,
 				38E4451E274DB04600EC9A94 /* AppDelegate.swift in Sources */,
 				BD2FF1A02AE29D43005D1C5D /* ToggleStyles.swift in Sources */,
@@ -4751,6 +4773,7 @@
 				DDE179682C910127003CDDB7 /* TempBasalStored+CoreDataClass.swift in Sources */,
 				DDE179692C910127003CDDB7 /* TempBasalStored+CoreDataProperties.swift in Sources */,
 				BD4D73A22D15A42A0052227B /* TDDStorage.swift in Sources */,
+				49C782A72F73D9870062B0DD /* AlertEntry.swift in Sources */,
 				DDE1796C2C910127003CDDB7 /* OverrideRunStored+CoreDataClass.swift in Sources */,
 				DDE1796D2C910127003CDDB7 /* OverrideRunStored+CoreDataProperties.swift in Sources */,
 				DDE1796E2C910127003CDDB7 /* OrefDetermination+CoreDataClass.swift in Sources */,

+ 2 - 2
Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved

@@ -51,8 +51,8 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/garmin/connectiq-companion-app-sdk-ios",
       "state" : {
-        "revision" : "00594907c84884a9430c6a33825940c2769f261a",
-        "version" : "1.7.0"
+        "revision" : "f0d29ff691d700a132d86205ed9bb091e336c2f7",
+        "version" : "1.8.0"
       }
     },
     {

+ 5 - 5
Trio/Resources/Info.plist

@@ -74,13 +74,13 @@
 	<key>NSBluetoothPeripheralUsageDescription</key>
 	<string>Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices</string>
 	<key>NSCalendarsFullAccessUsageDescription</key>
-	<string>To create events with BG reading values, so that they can be viewed on Apple Watch and CarPlay</string>
+	<string>To create events with glucose values, so they can be viewed on Apple Watch and CarPlay</string>
 	<key>NSCalendarsUsageDescription</key>
-	<string>Calendar is used to create a new glucose events.</string>
+	<string>Calendar is used to create new glucose events.</string>
 	<key>NSContactsUsageDescription</key>
 	<string>Contact is used to create a Apple Watch complication</string>
 	<key>NSFaceIDUsageDescription</key>
-	<string>For authorized acces to bolus</string>
+	<string>For authorized access to bolus</string>
 	<key>NSHealthShareUsageDescription</key>
 	<string>Health App is used to store blood glucose, carbs and insulin</string>
 	<key>NSHealthUpdateUsageDescription</key>
@@ -107,8 +107,8 @@
 		<string>remote-notification</string>
 		<string>audio</string>
 	</array>
-    <key>UIDesignRequiresCompatibility</key>
-    <true/>
+	<key>UIDesignRequiresCompatibility</key>
+	<true/>
 	<key>UIFileSharingEnabled</key>
 	<true/>
 	<key>UILaunchScreen</key>

+ 14 - 110
Trio/Resources/InfoPlist.xcstrings

@@ -457,22 +457,22 @@
         }
       }
     },
-    "NSCalendarsUsageDescription" : {
-      "comment" : "Privacy - Calendars Usage Description",
+    "NSCalendarsFullAccessUsageDescription" : {
+      "comment" : "Privacy - Calendars Full Access Usage Description",
       "extractionState" : "extracted_with_value",
       "localizations" : {
-        "ar" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "Calendar is used to create a new glucose events."
-          }
-        },
-        "ca" : {
+        "en" : {
           "stringUnit" : {
-            "state" : "translated",
-            "value" : "Calendar is used to create a new glucose events."
+            "state" : "new",
+            "value" : "To create events with glucose values, so they can be viewed on Apple Watch and CarPlay"
           }
-        },
+        }
+      }
+    },
+    "NSCalendarsUsageDescription" : {
+      "comment" : "Privacy - Calendars Usage Description",
+      "extractionState" : "extracted_with_value",
+      "localizations" : {
         "da" : {
           "stringUnit" : {
             "state" : "translated",
@@ -488,19 +488,7 @@
         "en" : {
           "stringUnit" : {
             "state" : "new",
-            "value" : "Calendar is used to create a new glucose events."
-          }
-        },
-        "es" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "Calendar is used to create a new glucose events."
-          }
-        },
-        "fi" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "Calendar is used to create a new glucose events."
+            "value" : "Calendar is used to create new glucose events."
           }
         },
         "fr" : {
@@ -509,18 +497,6 @@
             "value" : "Le calendrier est utilisé pour créer un nouvel événement de glycémie."
           }
         },
-        "he" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "Calendar is used to create a new glucose events."
-          }
-        },
-        "hu" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "Calendar is used to create a new glucose events."
-          }
-        },
         "it" : {
           "stringUnit" : {
             "state" : "translated",
@@ -539,24 +515,6 @@
             "value" : "Agenda wordt gebruikt om nieuwe glucose gebeurtenissen aan te maken."
           }
         },
-        "pl" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "Calendar is used to create a new glucose events."
-          }
-        },
-        "pt-BR" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "Calendar is used to create a new glucose events."
-          }
-        },
-        "pt-PT" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "Calendar is used to create a new glucose events."
-          }
-        },
         "ru" : {
           "stringUnit" : {
             "state" : "translated",
@@ -616,18 +574,6 @@
       "comment" : "Privacy - Face ID Usage Description",
       "extractionState" : "extracted_with_value",
       "localizations" : {
-        "ar" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "For authorized acces to bolus"
-          }
-        },
-        "ca" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "For authorized acces to bolus"
-          }
-        },
         "da" : {
           "stringUnit" : {
             "state" : "translated",
@@ -643,19 +589,7 @@
         "en" : {
           "stringUnit" : {
             "state" : "new",
-            "value" : "For authorized acces to bolus"
-          }
-        },
-        "es" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "For authorized acces to bolus"
-          }
-        },
-        "fi" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "For authorized acces to bolus"
+            "value" : "For authorized access to bolus"
           }
         },
         "fr" : {
@@ -664,18 +598,6 @@
             "value" : "Pour les accès autorisés au bolus"
           }
         },
-        "he" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "For authorized acces to bolus"
-          }
-        },
-        "hu" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "For authorized acces to bolus"
-          }
-        },
         "it" : {
           "stringUnit" : {
             "state" : "translated",
@@ -694,24 +616,6 @@
             "value" : "Voor geautoriseerde toegang tot bolus"
           }
         },
-        "pl" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "For authorized acces to bolus"
-          }
-        },
-        "pt-BR" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "For authorized acces to bolus"
-          }
-        },
-        "pt-PT" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "For authorized acces to bolus"
-          }
-        },
         "ru" : {
           "stringUnit" : {
             "state" : "translated",

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

@@ -48,5 +48,10 @@
   "useCalendar": false,
   "displayCalendarIOBandCOB": false,
   "displayCalendarEmojis": false,
-  "timeInRangeType": "timeInTightRange"
+  "timeInRangeType": "timeInTightRange",
+  "garminWatchface": "trio",
+  "garminDatafield": "none",
+  "primaryAttributeChoice": "cob",
+  "secondaryAttributeChoice": "tbr",
+  "isWatchfaceDataEnabled": true  
 }

+ 8 - 2
Trio/Sources/APS/DeviceDataManager.swift

@@ -126,7 +126,10 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
                     pumpExpiresAtDate.send(endTime)
                 }
                 if let medtrumPump = pumpManager as? MedtrumPumpManager {
-                    guard let endTime = medtrumPump.state.patchExpiresAt else {
+                    // Medtrum's state.patchExpiresAt is actually lifespan + grace
+                    // keeping this in line with omnipod, we will use just the lifetime
+                    // i.e., state.patchGracePeriodFrom
+                    guard let endTime = medtrumPump.state.patchGracePeriodFrom else {
                         pumpExpiresAtDate.send(nil)
                         return
                     }
@@ -534,7 +537,10 @@ extension BaseDeviceDataManager: PumpManagerDelegate {
                 $0.pumpReservoirDidChange(Decimal(medtrumPump.state.reservoir))
             }
 
-            guard let endTime = medtrumPump.state.patchExpiresAt else {
+            // Medtrum's state.patchExpiresAt is actually lifespan + grace
+            // keeping this in line with omnipod, we will use just the lifetime
+            // i.e., state.patchGracePeriodFrom
+            guard let endTime = medtrumPump.state.patchGracePeriodFrom else {
                 pumpExpiresAtDate.send(nil)
                 return
             }

+ 5 - 0
Trio/Sources/Helpers/Decimal+Extensions.swift

@@ -5,6 +5,11 @@ extension Double {
     init(_ decimal: Decimal) {
         self.init(truncating: decimal as NSNumber)
     }
+
+    func roundedDouble(toPlaces places: Int) -> Double {
+        let divisor = pow(10.0, Double(places))
+        return (self * divisor).rounded() / divisor
+    }
 }
 
 extension Int {

+ 7 - 0
Trio/Sources/Helpers/Formatters.swift

@@ -57,6 +57,13 @@ extension Formatter {
         return formatter
     }()
 
+    static let timeForLogFormatter: DateFormatter = {
+        let formatter = DateFormatter()
+        formatter.dateFormat = "HH:mm:ss"
+        formatter.timeZone = TimeZone.current
+        return formatter
+    }()
+
     static let decimalFormatterWithOneFractionDigit: NumberFormatter = {
         let formatter = NumberFormatter()
         formatter.numberStyle = .decimal

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

@@ -10410,7 +10410,6 @@
       }
     },
     "%lld h" : {
-      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -28722,6 +28721,7 @@
       }
     },
     "Add Garmin Device to Trio. Please look at the docs to see which devices are supported." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -28839,6 +28839,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" : {
@@ -66977,6 +66980,9 @@
         }
       }
     },
+    "Choose between displayed data types on Garmin device." : {
+
+    },
     "Choose Calendar" : {
       "localizations" : {
         "bg" : {
@@ -67567,6 +67573,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" : {
@@ -68042,6 +68051,9 @@
         }
       }
     },
+    "Choose which datafield to support. Can be used independently of watchface selection." : {
+
+    },
     "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" : {
@@ -68278,6 +68290,9 @@
         }
       }
     },
+    "Choose which watchface to support." : {
+
+    },
     "Clear" : {
       "comment" : "Button",
       "extractionState" : "manual",
@@ -70809,6 +70824,10 @@
         }
       }
     },
+    "Configure Device Apps" : {
+      "comment" : "A button label that navigates to the configuration of a watch's apps.",
+      "isCommentAutoGenerated" : true
+    },
     "Configure diagnostics sharing, optionally sync with Nightscout, and enter essentials." : {
       "localizations" : {
         "bg" : {
@@ -72950,6 +72969,10 @@
         }
       }
     },
+    "Connected Devices" : {
+      "comment" : "A label displayed above the list of connected Garmin watches.",
+      "isCommentAutoGenerated" : true
+    },
     "Connected Services" : {
       "localizations" : {
         "bg" : {
@@ -77876,6 +77899,21 @@
         }
       }
     },
+    "Data Choice 1" : {
+
+    },
+    "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" : {
+
+    },
+    "Datafield Settings" : {
+
+    },
     "Date" : {
       "localizations" : {
         "bg" : {
@@ -89273,6 +89311,10 @@
         }
       }
     },
+    "Device App Settings" : {
+      "comment" : "A section header for settings related to a connected device.",
+      "isCommentAutoGenerated" : true
+    },
     "Devices" : {
       "comment" : "Devices menu item in the Settings main view.",
       "localizations" : {
@@ -90826,6 +90868,9 @@
         }
       }
     },
+    "Disable Watchface Data" : {
+
+    },
     "Disabled" : {
       "comment" : "Title string for BeepPreference.silent",
       "extractionState" : "manual",
@@ -107877,6 +107922,9 @@
         }
       }
     },
+    "evBG" : {
+
+    },
     "Even if you’re an updating user, you’ll be guided through the algorithm settings configuration step-by-step." : {
       "localizations" : {
         "bg" : {
@@ -119333,6 +119381,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" : {
@@ -119451,6 +119503,10 @@
         }
       }
     },
+    "Garmin Devices" : {
+      "comment" : "A label describing the list of Garmin devices.",
+      "isCommentAutoGenerated" : true
+    },
     "Garmin is not available" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -119577,6 +119633,7 @@
       }
     },
     "Garmin Watch" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -200261,6 +200318,9 @@
         }
       }
     },
+    "Resume Data Transmission" : {
+
+    },
     "Resume Insulin Delivery" : {
       "comment" : "Text for suspend resume button when insulin delivery is suspended",
       "extractionState" : "manual",
@@ -207107,6 +207167,10 @@
         }
       }
     },
+    "Sens Ratio" : {
+      "comment" : "Name of the secondary attribute choice for the SwissAlpine watchface.",
+      "isCommentAutoGenerated" : true
+    },
     "Sensitivity" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -227380,6 +227444,10 @@
         }
       }
     },
+    "Swissalpine" : {
+      "comment" : "Name of the Swissalpine watchface.",
+      "isCommentAutoGenerated" : true
+    },
     "System Default" : {
       "localizations" : {
         "bg" : {
@@ -230126,6 +230194,9 @@
         }
       }
     },
+    "TBR" : {
+
+    },
     "TDD" : {
       "localizations" : {
         "bg" : {
@@ -268054,6 +268125,9 @@
         }
       }
     },
+    "Watch App Display Settings" : {
+
+    },
     "Watch Configuration" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -268297,6 +268371,15 @@
         }
       }
     },
+    "Watchface Changed" : {
+
+    },
+    "Watchface Selection" : {
+
+    },
+    "Watchface Settings" : {
+
+    },
     "We recommend reviewing them carefully — Trio will guide you step-by-step." : {
       "localizations" : {
         "bg" : {

+ 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
-}

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

@@ -0,0 +1,124 @@
+import Foundation
+
+// MARK: - Garmin Data Type Settings
+
+/// Primary attribute selection for Garmin watchface and datafield.
+/// Determines whether to display COB, ISF, or Sensitivity Ratio alongside glucose data.
+/// Used by both Trio and SwissAlpine watchfaces.
+enum GarminPrimaryAttributeChoice: 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: "ISF", comment: "")
+        case .sensRatio:
+            return String(localized: "Sens Ratio", comment: "")
+        }
+    }
+}
+
+/// Secondary attribute selection for both Trio and SwissAlpine watchfaces.
+/// Determines whether to display Temp Basal Rate or Eventual BG.
+enum GarminSecondaryAttributeChoice: 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", comment: "")
+        case .eventualBG:
+            return String(localized: "evBG", comment: "")
+        }
+    }
+}
+
+// MARK: - Garmin Watchface Setting
+
+/// Defines the available Garmin watchfaces with their associated UUIDs.
+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", comment: "")
+        case .swissalpine:
+            return String(localized: "Swissalpine", 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 test build
+            return UUID(uuidString: "7a121867-140e-41ba-9982-2e82e2aa6579") // ConnectIQ live build
+        case .swissalpine:
+            // return UUID(uuidString: "5A643C13-D5A7-40D4-B809-84789FDF4A1F") // ConnectIQ test build
+            return UUID(uuidString: "4cea4efd-4aaf-4db4-8891-ef36dde14303") // ConnectIQ live build
+        }
+    }
+}
+
+// MARK: - Garmin Datafield Setting
+
+/// Defines the available Garmin datafields with their associated UUIDs.
+enum GarminDatafield: String, JSON, CaseIterable, Identifiable, Codable, Hashable {
+    var id: String { rawValue }
+
+    case trio
+    case swissalpine
+    case none
+
+    var displayName: String {
+        switch self {
+        case .trio:
+            return String(localized: "Trio", comment: "")
+        case .swissalpine:
+            return String(localized: "Swissalpine", comment: "")
+        case .none:
+            return String(localized: "None", comment: "")
+        }
+    }
+
+    /// 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")  // local build
+            // return UUID(uuidString: "f07f4ef9-108b-4397-95c9-217b5173412e")  // ConnectIQ test build
+            return UUID(uuidString: "3d9b6528-8c84-459a-bbab-989b5f001ebd") // ConnectIQ live build
+        case .swissalpine:
+            // return UUID(uuidString: "7A2268F6-3381-4474-81BD-0A3E7F458CB7") // ConnectIQ test build
+            return UUID(uuidString: "dec5292a-74b0-41bc-8e45-cd93f1d5e137") // ConnectIQ live build
+        case .none:
+            return nil
+        }
+    }
+}
+
+// MARK: - Garmin Watch Settings Group
+
+/// Groups related Garmin watch settings together for easier management.
+/// Both watchfaces use the same settings: primaryAttributeChoice and secondaryAttributeChoice.
+struct GarminWatchSettings: Codable, Hashable {
+    var watchface: GarminWatchface = .trio
+    var datafield: GarminDatafield = .trio
+    var primaryAttributeChoice: GarminPrimaryAttributeChoice = .cob
+    var secondaryAttributeChoice: GarminSecondaryAttributeChoice = .tbr
+    var isWatchfaceDataEnabled: Bool = false
+}

+ 118 - 17
Trio/Sources/Models/GarminWatchState.swift

@@ -7,35 +7,136 @@
 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 enacted loop determination in milliseconds since Unix epoch
+    /// Shows when the loop actually executed, used to indicate loop staleness
+    var date: UInt64?
+
+    /// Timestamp of the glucose reading in milliseconds since Unix epoch
+    /// Used by watchface to determine glucose freshness for coloring logic
+    var glucoseDate: 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 primary attribute to display
+    /// Options: "cob", "isf", or "sensRatio"
+    var displayPrimaryAttributeChoice: String?
+
+    /// Specifies which secondary attribute to display
+    /// Options: "tbr" or "eventualBG"
+    var displaySecondaryAttributeChoice: String?
 
     static func == (lhs: GarminWatchState, rhs: GarminWatchState) -> Bool {
-        lhs.glucose == rhs.glucose &&
-            lhs.trendRaw == rhs.trendRaw &&
+        lhs.date == rhs.date &&
+            lhs.glucoseDate == rhs.glucoseDate &&
+            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.displayPrimaryAttributeChoice == rhs.displayPrimaryAttributeChoice &&
+            lhs.displaySecondaryAttributeChoice == rhs.displaySecondaryAttributeChoice
     }
 
     func hash(into hasher: inout Hasher) {
-        hasher.combine(glucose)
-        hasher.combine(trendRaw)
+        hasher.combine(date)
+        hasher.combine(glucoseDate)
+        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(displayPrimaryAttributeChoice)
+        hasher.combine(displaySecondaryAttributeChoice)
+    }
+
+    enum CodingKeys: String, CodingKey {
+        case date
+        case glucoseDate
+        case sgv
+        case delta
+        case direction
+        case noise
+        case units_hint
+        case iob
+        case tbr
+        case cob
+        case eventualBG
+        case isf
+        case sensRatio
+        case displayPrimaryAttributeChoice
+        case displaySecondaryAttributeChoice
+    }
+
+    /// Custom encoding that excludes nil values from the JSON output
+    /// Double values are rounded to 2 decimal places to prevent floating point artifacts
+    func encode(to encoder: Encoder) throws {
+        var container = encoder.container(keyedBy: CodingKeys.self)
+        try container.encodeIfPresent(date, forKey: .date)
+        try container.encodeIfPresent(glucoseDate, forKey: .glucoseDate)
+        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)
+        // Round Double values to 2 decimal places to prevent floating point artifacts like "0.5600000000000001"
+        try container.encodeIfPresent(iob?.roundedDouble(toPlaces: 2), forKey: .iob)
+        try container.encodeIfPresent(tbr?.roundedDouble(toPlaces: 2), forKey: .tbr)
+        try container.encodeIfPresent(cob, forKey: .cob)
+        try container.encodeIfPresent(eventualBG, forKey: .eventualBG)
+        try container.encodeIfPresent(isf, forKey: .isf)
+        try container.encodeIfPresent(sensRatio?.roundedDouble(toPlaces: 2), forKey: .sensRatio)
+        try container.encodeIfPresent(displayPrimaryAttributeChoice, forKey: .displayPrimaryAttributeChoice)
+        try container.encodeIfPresent(displaySecondaryAttributeChoice, forKey: .displaySecondaryAttributeChoice)
     }
 }

+ 80 - 2
Trio/Sources/Models/TrioSettings.swift

@@ -15,7 +15,7 @@ enum BolusShortcutLimit: String, JSON, CaseIterable, Identifiable {
     }
 }
 
-struct TrioSettings: JSON, Equatable {
+struct TrioSettings: JSON, Equatable, Encodable {
     var units: GlucoseUnits = .mgdL
     var closedLoop: Bool = false
     var isUploadEnabled: Bool = false
@@ -52,6 +52,10 @@ struct TrioSettings: JSON, Equatable {
     var glucoseColorScheme: GlucoseColorScheme = .staticColor
     var xGridLines: Bool = true
     var yGridLines: Bool = true
+    var hideInsulinBadge: Bool = false
+    var allowDilution: Bool = false
+    var insulinConcentration: Decimal = 1
+    var showCobIobChart: Bool = true
     var rulerMarks: Bool = true
     var bolusDisplayThreshold: BolusDisplayThreshold = .allUnits
     var forecastDisplayType: ForecastDisplayType = .cone
@@ -71,10 +75,43 @@ struct TrioSettings: JSON, Equatable {
     var smartStackView: LockScreenView = .simple
     var bolusShortcut: BolusShortcutLimit = .notAllowed
     var timeInRangeType: TimeInRangeType = .timeInTightRange
+
+    /// Selected Garmin watchface (Trio or SwissAlpine)
+    var garminWatchface: GarminWatchface = .trio
+    var garminDatafield: GarminDatafield = .none
+
+    /// Primary attribute choice for Garmin display (COB, ISF, or Sensitivity Ratio)
+    var primaryAttributeChoice: GarminPrimaryAttributeChoice = .cob
+
+    /// Secondary attribute choice for Garmin display (TBR or Eventual BG)
+    var secondaryAttributeChoice: GarminSecondaryAttributeChoice = .tbr
+
+    /// Controls whether watchface data transmission is enabled
+    var isWatchfaceDataEnabled: Bool = false
+
+    /// Computed property that groups all Garmin settings into a single struct
+    var garminSettings: GarminWatchSettings {
+        get {
+            GarminWatchSettings(
+                watchface: garminWatchface,
+                datafield: garminDatafield,
+                primaryAttributeChoice: primaryAttributeChoice,
+                secondaryAttributeChoice: secondaryAttributeChoice,
+                isWatchfaceDataEnabled: isWatchfaceDataEnabled
+            )
+        }
+        set {
+            garminWatchface = newValue.watchface
+            garminDatafield = newValue.datafield
+            primaryAttributeChoice = newValue.primaryAttributeChoice
+            secondaryAttributeChoice = newValue.secondaryAttributeChoice
+            isWatchfaceDataEnabled = newValue.isWatchfaceDataEnabled
+        }
+    }
 }
 
 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()
@@ -245,6 +282,22 @@ extension TrioSettings: Decodable {
             settings.yGridLines = yGridLines
         }
 
+        if let showCobIobChart = try? container.decode(Bool.self, forKey: .showCobIobChart) {
+            settings.showCobIobChart = showCobIobChart
+        }
+
+        if let hideInsulinBadge = try? container.decode(Bool.self, forKey: .hideInsulinBadge) {
+            settings.hideInsulinBadge = hideInsulinBadge
+        }
+
+        if let allowDilution = try? container.decode(Bool.self, forKey: .allowDilution) {
+            settings.allowDilution = allowDilution
+        }
+
+        if let insulinConcentration = try? container.decode(Decimal.self, forKey: .insulinConcentration) {
+            settings.insulinConcentration = insulinConcentration
+        }
+
         if let rulerMarks = try? container.decode(Bool.self, forKey: .rulerMarks) {
             settings.rulerMarks = rulerMarks
         }
@@ -305,6 +358,31 @@ extension TrioSettings: Decodable {
             settings.timeInRangeType = timeInRangeType
         }
 
+        if let garminWatchface = try? container.decode(GarminWatchface.self, forKey: .garminWatchface) {
+            settings.garminWatchface = garminWatchface
+        }
+
+        if let garminDatafield = try? container.decode(GarminDatafield.self, forKey: .garminDatafield) {
+            settings.garminDatafield = garminDatafield
+        }
+
+        if let primaryAttributeChoice = try? container
+            .decode(GarminPrimaryAttributeChoice.self, forKey: .primaryAttributeChoice)
+        {
+            settings.primaryAttributeChoice = primaryAttributeChoice
+        }
+
+        if let secondaryAttributeChoice = try? container.decode(
+            GarminSecondaryAttributeChoice.self,
+            forKey: .secondaryAttributeChoice
+        ) {
+            settings.secondaryAttributeChoice = secondaryAttributeChoice
+        }
+
+        if let isWatchfaceDataEnabled = try? container.decode(Bool.self, forKey: .isWatchfaceDataEnabled) {
+            settings.isWatchfaceDataEnabled = isWatchfaceDataEnabled
+        }
+
         self = settings
     }
 }

+ 16 - 2
Trio/Sources/Modules/Home/View/Chart/ChartElements/BasalChart.swift

@@ -177,6 +177,7 @@ extension MainChartView {
         let startOfDay = Calendar.current.startOfDay(for: beginDate)
         let profile = state.basalProfile
         var basalPoints: [BasalProfile] = []
+        var lastEntryBeforeRange: (amount: Double, date: Date)?
 
         // Iterate over the next three days, multiplying the time intervals
         for dayOffset in 0 ..< 3 {
@@ -185,8 +186,12 @@ extension MainChartView {
                 let basalTime = startOfDay.addingTimeInterval(entry.minutes.minutes.timeInterval + dayTimeOffset)
                 let basalTimeInterval = basalTime.timeIntervalSince1970
 
-                // Only append points within the timeBegin and timeEnd range
-                if basalTimeInterval >= timeBegin, basalTimeInterval < timeEnd {
+                if basalTimeInterval < timeBegin {
+                    // Track the last profile entry before the visible range
+                    if lastEntryBeforeRange == nil || basalTime > lastEntryBeforeRange!.date {
+                        lastEntryBeforeRange = (amount: Double(entry.rate), date: basalTime)
+                    }
+                } else if basalTimeInterval < timeEnd {
                     basalPoints.append(BasalProfile(
                         amount: Double(entry.rate),
                         isOverwritten: false,
@@ -196,6 +201,15 @@ extension MainChartView {
             }
         }
 
+        // Include the active profile entry at timeBegin so the line starts at the chart's left edge
+        if let lastBefore = lastEntryBeforeRange {
+            basalPoints.append(BasalProfile(
+                amount: lastBefore.amount,
+                isOverwritten: false,
+                startDate: beginDate
+            ))
+        }
+
         return basalPoints
     }
 

+ 2 - 1
Trio/Sources/Modules/ISFEditor/View/ISFEditorRootView.swift

@@ -143,7 +143,8 @@ extension ISFEditor {
         private var isfChart: some View {
             Chart {
                 ForEach(Array(state.items.enumerated()), id: \.element.id) { index, item in
-                    let displayValue = state.rateValues[item.rateIndex]
+                    let displayValue = state.units == .mgdL ? state.rateValues[item.rateIndex] : state.rateValues[item.rateIndex]
+                        .asMmolL
 
                     let startDate = Calendar.current
                         .startOfDay(for: now)

+ 25 - 16
Trio/Sources/Modules/Onboarding/View/Animations/LogoBurstSplash.swift

@@ -16,6 +16,8 @@ struct LogoBurstSplash<Content: View>: View {
     @State private var viewOpacity: Double = 1.0
     @State private var splashScale: CGFloat = 1.0
 
+    @Environment(\.accessibilityReduceMotion) var reduceMotion
+
     init(isActive: Binding<Bool>, @ViewBuilder content: () -> Content) {
         _isActive = isActive
         self.content = content()
@@ -58,32 +60,39 @@ struct LogoBurstSplash<Content: View>: View {
                             .scaleEffect(isPulsing ? 1.1 : logoScale)
                             .opacity(logoOpacity)
                             .rotationEffect(.degrees(logoRotation))
-                            .animation(.easeInOut(duration: 1.0), value: logoScale)
+                            .animation(reduceMotion ? nil : .easeInOut(duration: 1.0), value: logoScale)
                             .animation(.easeInOut(duration: 1.0), value: logoOpacity)
-                            .animation(.linear(duration: 2.0), value: logoRotation)
+                            .animation(reduceMotion ? nil : .linear(duration: 2.0), value: logoRotation)
                             .animation(
-                                .easeInOut(duration: 0.8).repeatForever(autoreverses: true),
+                                reduceMotion ? nil : .easeInOut(duration: 0.8).repeatForever(autoreverses: true),
                                 value: isPulsing
                             )
                     }
                     .scaleEffect(splashScale)
                     .opacity(viewOpacity)
                     .onAppear {
-                        shapes = BurstShape.createBurst(count: 250, in: geo.frame(in: .local))
-
-                        withAnimation {
-                            isPulsing = true
-                            logoOpacity = 1
+                        if reduceMotion {
+                            withAnimation(.easeInOut(duration: 1.0)) {
+                                logoOpacity = 1
+                            }
                             logoScale = 1
-                            logoRotation = 360
-                        }
+                        } else {
+                            shapes = BurstShape.createBurst(count: 250, in: geo.frame(in: .local))
+
+                            withAnimation(.easeInOut(duration: 1.0)) {
+                                isPulsing = true
+                                logoOpacity = 1
+                                logoScale = 1
+                                logoRotation = 360
+                            }
 
-                        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
-                            isPulsing = false
-                        }
+                            DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
+                                isPulsing = false
+                            }
 
-                        DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
-                            exploded = true
+                            DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
+                                exploded = true
+                            }
                         }
 
                         DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
@@ -96,7 +105,7 @@ struct LogoBurstSplash<Content: View>: View {
                         DispatchQueue.main.asyncAfter(deadline: .now() + 2.8) {
                             withAnimation(.easeIn(duration: 0.6)) {
                                 viewOpacity = 0
-                                splashScale = 0.1
+                                if !reduceMotion { splashScale = 0.1 }
                             }
                         }
 

+ 9 - 0
Trio/Sources/Modules/Onboarding/View/Animations/PulsingLogoAnimation.swift

@@ -11,6 +11,7 @@ struct PulsingLogoAnimation: View {
     @State private var opacity = 0.0
     @State private var rotation = 0.0
     @State private var isPulsing = false
+    @Environment(\.accessibilityReduceMotion) var reduceMotion
 
     var body: some View {
         Image("trioCircledNoBackground")
@@ -22,6 +23,14 @@ struct PulsingLogoAnimation: View {
             .rotationEffect(.degrees(rotation))
             .scaleEffect(isPulsing ? 1.1 : 1.0)
             .onAppear {
+                if reduceMotion {
+                    scale = 1.0
+                    withAnimation(.easeInOut(duration: 1.0)) {
+                        opacity = 1.0
+                    }
+                    return
+                }
+
                 withAnimation(.easeInOut(duration: 1.0)) {
                     scale = 1.0
                     opacity = 1.0

+ 17 - 7
Trio/Sources/Modules/Onboarding/View/OnboardingRootView.swift

@@ -296,6 +296,19 @@ struct OnboardingStepContent: View {
     @Binding var currentTargetBehaviorSubstep: TargetBehaviorSubstep
     @Bindable var state: Onboarding.StateModel
     var navigationDirection: OnboardingNavigationDirection
+    @Environment(\.accessibilityReduceMotion) var reduceMotion
+
+    private var transition: AnyTransition {
+        if reduceMotion {
+            return .opacity
+        }
+        switch navigationDirection {
+        case .forward:
+            return .asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))
+        case .backward:
+            return .asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .trailing))
+        }
+    }
 
     var body: some View {
         ScrollViewReader { scrollProxy in
@@ -377,11 +390,7 @@ struct OnboardingStepContent: View {
                                 CompletedStepView(isOnboardingCompleted: true, currentChapter: nil)
                             }
                         }
-                        .transition(
-                            navigationDirection == .forward
-                                ? .asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))
-                                : .asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .trailing))
-                        )
+                        .transition(transition)
                         .padding(.horizontal)
                         .id(currentStep.id)
                     }
@@ -464,13 +473,14 @@ struct OnboardingNavigationButtons: View {
     @Bindable var state: Onboarding.StateModel
     var shouldDisableNextButton: Bool
     var navigationDirectionChanged: (OnboardingNavigationDirection) -> Void
+    @Environment(\.accessibilityReduceMotion) var reduceMotion
 
     var body: some View {
         HStack {
             if currentStep != .welcome {
                 Button(action: {
                     navigationDirectionChanged(.backward)
-                    withAnimation {
+                    withAnimation(reduceMotion ? .easeInOut(duration: 0.25) : .default) {
                         handleBackNavigation()
                     }
                 }) {
@@ -487,7 +497,7 @@ struct OnboardingNavigationButtons: View {
 
             Button(action: {
                 navigationDirectionChanged(.forward)
-                withAnimation {
+                withAnimation(reduceMotion ? .easeInOut(duration: 0.25) : .default) {
                     handleNextNavigation()
                 }
             }) {

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

@@ -0,0 +1,274 @@
+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
+    @State private var shouldShowWatchfaceSwitchConfirmDialog: Bool = false
+
+    @Environment(\.colorScheme) var colorScheme
+    @Environment(AppState.self) var appState
+
+    var body: some View {
+        Form {
+            // MARK: - Watchface Selection Section
+
+            Section(
+                header: Text("Watchface Settings"),
+                content: {
+                    VStack {
+                        Picker(
+                            selection: $state.garminSettings.watchface,
+                            label: Text("Watchface Selection").multilineTextAlignment(.leading)
+                        ) {
+                            ForEach(GarminWatchface.allCases) { selection in
+                                Text(selection.displayName).tag(selection)
+                            }
+                        }
+                        .padding(.top)
+                        .onChange(of: state.garminSettings.watchface) { oldValue, newValue in
+                            if oldValue != newValue {
+                                state.handleWatchfaceChange()
+                                shouldShowWatchfaceSwitchConfirmDialog = true
+                            }
+                        }
+
+                        HStack(alignment: .center) {
+                            Text(
+                                "Choose which watchface to support."
+                            )
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                            .lineLimit(nil)
+                            Spacer()
+                            Button(
+                                action: {
+                                    shouldDisplayHint1.toggle()
+                                },
+                                label: {
+                                    HStack {
+                                        Image(systemName: "questionmark.circle")
+                                    }
+                                }
+                            ).buttonStyle(BorderlessButtonStyle())
+                        }.padding(.top)
+                    }.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)
+                        Toggle("Disable Watchface Data", isOn: Binding(
+                            get: { !state.garminSettings.isWatchfaceDataEnabled },
+                            set: { state.garminSettings.isWatchfaceDataEnabled = !$0 }
+                        ))
+
+                        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(.bottom)
+                }
+            ).listRowBackground(Color.chart)
+
+            // MARK: - Datafield Selection Section
+
+            Section(
+                header: Text("Datafield Settings"),
+                content: {
+                    VStack {
+                        Picker(
+                            selection: $state.garminSettings.datafield,
+                            label: Text("Datafield Selection").multilineTextAlignment(.leading)
+                        ) {
+                            ForEach(GarminDatafield.allCases) { selection in
+                                Text(selection.displayName).tag(selection)
+                            }
+                        }
+                        .padding(.top)
+
+                        HStack(alignment: .center) {
+                            Text(
+                                "Choose which datafield to support. Can be used independently of watchface selection."
+                            )
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                            .lineLimit(nil)
+                            Spacer()
+                            Button(
+                                action: {
+                                    shouldDisplayHint4.toggle()
+                                },
+                                label: {
+                                    HStack {
+                                        Image(systemName: "questionmark.circle")
+                                    }
+                                }
+                            ).buttonStyle(BorderlessButtonStyle())
+                        }.padding(.top)
+                    }.padding(.bottom)
+                }
+            ).listRowBackground(Color.chart)
+
+            // MARK: - Data Field Selection Section
+
+            Section(
+                header: Text("Watch App Display Settings"),
+                content: {
+                    VStack {
+                        Picker(
+                            selection: $state.garminSettings.primaryAttributeChoice,
+                            label: Text("Data Choice 1").multilineTextAlignment(.leading)
+                        ) {
+                            ForEach(GarminPrimaryAttributeChoice.allCases) { selection in
+                                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)
+                        ) {
+                            ForEach(GarminSecondaryAttributeChoice.allCases) { selection in
+                                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)
+                }
+            ).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 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. 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")
+            )
+        }
+        .sheet(isPresented: $shouldDisplayHint4) {
+            SettingInputHintView(
+                hintDetent: $hintDetent,
+                shouldDisplayHint: $shouldDisplayHint4,
+                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")
+            )
+        }
+        .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, 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. 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")
+            )
+        }
+        .sheet(isPresented: $shouldDisplayHint3) {
+            SettingInputHintView(
+                hintDetent: $hintDetent,
+                shouldDisplayHint: $shouldDisplayHint3,
+                hintLabel: "Choose data support",
+                hintText: Text(
+                    "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."
+            )
+        }
+    }
+}

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

@@ -1,37 +1,120 @@
+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() {
+            let mockDevice = BaseGarminManager.MockIQDevice.createSimulated()
+            state.devices.append(mockDevice)
+            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 {
+                                    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 +139,11 @@ struct WatchConfigGarminView: View {
                 }
             ).listRowBackground(Color.chart)
 
+            // MARK: - Device List Section
+
             if !state.devices.isEmpty {
                 Section(
-                    header: Text("Garmin Watch"),
+                    header: Text("Connected Devices"),
                     content: {
                         List {
                             ForEach(state.devices, id: \.uuid) { device in
@@ -68,23 +153,43 @@ struct WatchConfigGarminView: View {
                         }
                     }
                 ).listRowBackground(Color.chart)
+
+                // MARK: - App Settings Navigation Section
+
+                Section(
+                    header: Text("Device App Settings"),
+                    content: {
+                        Button(action: {
+                            showDeviceList = false
+                        }) {
+                            HStack {
+                                Text("Configure Device 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))
     }
 }

+ 19 - 1
Trio/Sources/Modules/WatchConfig/WatchConfigStateModel.swift

@@ -1,3 +1,4 @@
+import Combine
 import ConnectIQ
 import SwiftUI
 
@@ -9,18 +10,23 @@ extension WatchConfig {
         @Published var devices: [IQDevice] = []
         @Published var confirmBolusFaster = false
 
+        /// Garmin watch settings containing all watch-related configuration
+        @Published var garminSettings = GarminWatchSettings()
+
         private(set) var preferences = Preferences()
 
         override func subscribe() {
             preferences = provider.preferences
-
             units = settingsManager.settings.units
 
+            // Subscribe to the entire garminSettings struct from TrioSettings
+            subscribeSetting(\.garminSettings, on: $garminSettings) { garminSettings = $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 +34,21 @@ 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
+        /// to allow the user to switch watchfaces on their Garmin device without data conflicts
+        func handleWatchfaceChange() {
+            garminSettings.isWatchfaceDataEnabled = false
+        }
+
+        /// Resumes data transmission after user confirms they have switched watchface on their device
+        func resumeDataTransmission() {
+            garminSettings.isWatchfaceDataEnabled = true
+        }
     }
 }
 

+ 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)

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 728 - 284
Trio/Sources/Services/WatchManager/GarminManager.swift


+ 1 - 1
TrioTests/GlucoseSmoothingTests.swift

@@ -121,7 +121,7 @@ import Testing
 
     @Test(
         "Exponential smoothing stops at gaps >= 12 minutes and only updates the most recent window"
-    )  func testExponentialSmoothingGapStopsWindow() async throws {
+    ) func testExponentialSmoothingGapStopsWindow() async throws {
         let now = Date()
 
         var dates: [Date] = []