Browse Source

Merge pull request #124 from mkellerman/add-watch-contact

Add Contact Image for Apple Watch complication

Merged by previous agreement with @dnzxy
Andreas Stokholm 1 year ago
parent
commit
cbda999783
31 changed files with 2806 additions and 38 deletions
  1. 94 18
      FreeAPS.xcodeproj/project.pbxproj
  2. 3 0
      FreeAPS/Resources/Base.lproj/InfoPlist.strings
  3. 2 0
      FreeAPS/Resources/Info.plist
  4. 87 0
      FreeAPS/Sources/APS/Extensions/FontExtensions.swift
  5. 1 0
      FreeAPS/Sources/APS/OpenAPS/Constants.swift
  6. 150 0
      FreeAPS/Sources/APS/Storage/ContactTrickStorage.swift
  7. 1 0
      FreeAPS/Sources/Assemblies/ServiceAssembly.swift
  8. 1 0
      FreeAPS/Sources/Assemblies/StorageAssembly.swift
  9. 3 3
      FreeAPS/Sources/Localizations/Main/en.lproj/Localizable.strings
  10. 192 0
      FreeAPS/Sources/Models/ContactTrickEntry.swift
  11. 8 0
      FreeAPS/Sources/Modules/ContactTrick/ContactTrickDataFlow.swift
  12. 6 0
      FreeAPS/Sources/Modules/ContactTrick/ContactTrickProvider.swift
  13. 179 0
      FreeAPS/Sources/Modules/ContactTrick/ContactTrickStateModel.swift
  14. 248 0
      FreeAPS/Sources/Modules/ContactTrick/View/AddContactTrickSheet.swift
  15. 216 0
      FreeAPS/Sources/Modules/ContactTrick/View/ContactTrickDetailView.swift
  16. 89 0
      FreeAPS/Sources/Modules/ContactTrick/View/ContactTrickRootView.swift
  17. 1 1
      FreeAPS/Sources/Modules/Home/View/HomeRootView.swift
  18. 6 0
      FreeAPS/Sources/Modules/Settings/SettingItems.swift
  19. 4 3
      FreeAPS/Sources/Modules/Settings/View/Subviews/NotificationsView.swift
  20. 16 1
      FreeAPS/Sources/Modules/WatchConfig/View/WatchConfigAppleWatchView.swift
  21. 1 1
      FreeAPS/Sources/Modules/WatchConfig/View/WatchConfigRootView.swift
  22. 3 0
      FreeAPS/Sources/Router/Screen.swift
  23. 500 0
      FreeAPS/Sources/Services/ContactTrick/ContactTrickManager.swift
  24. 882 0
      FreeAPS/Sources/Services/ContactTrick/ContactTrickPicture.swift
  25. 19 0
      FreeAPS/Sources/Services/ContactTrick/ContactTrickState.swift
  26. 0 9
      FreeAPS/Sources/Services/WatchManager/WatchManager.swift
  27. 4 0
      Model/Classes+Properties/ContactTrickEntryStored+CoreDataClass.swift
  28. 24 0
      Model/Classes+Properties/ContactTrickEntryStored+CoreDataProperties.swift
  29. 1 0
      Model/CoreDataActor.swift
  30. 46 0
      Model/NSModelObjectContextExecutor.swift
  31. 19 2
      Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents

+ 94 - 18
FreeAPS.xcodeproj/project.pbxproj

@@ -342,6 +342,11 @@
 		BDBAACFA2C2D439700370AAE /* OverrideData.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBAACF92C2D439700370AAE /* OverrideData.swift */; };
 		BDC2EA452C3043B000E5BBD0 /* OverrideStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC2EA442C3043B000E5BBD0 /* OverrideStorage.swift */; };
 		BDC2EA472C3045AD00E5BBD0 /* Override.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC2EA462C3045AD00E5BBD0 /* Override.swift */; };
+		BDC530FF2D0F6BE300088832 /* ContactTrickManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC530FE2D0F6BE300088832 /* ContactTrickManager.swift */; };
+		BDC531122D1060FA00088832 /* ContactTrickDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC531112D1060FA00088832 /* ContactTrickDetailView.swift */; };
+		BDC531142D10611D00088832 /* AddContactTrickSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC531132D10611D00088832 /* AddContactTrickSheet.swift */; };
+		BDC531162D10629000088832 /* ContactTrickPicture.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC531152D10629000088832 /* ContactTrickPicture.swift */; };
+		BDC531182D1062F200088832 /* ContactTrickState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC531172D1062F200088832 /* ContactTrickState.swift */; };
 		BDCAF2382C639F35002DC907 /* SettingItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDCAF2372C639F35002DC907 /* SettingItems.swift */; };
 		BDCD47AF2C1F3F1700F8BCD5 /* OverrideStored+helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDCD47AE2C1F3F1700F8BCD5 /* OverrideStored+helper.swift */; };
 		BDDAF9EF2D00554500B34E7A /* SelectionPopoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDDAF9EE2D00553E00B34E7A /* SelectionPopoverView.swift */; };
@@ -479,6 +484,8 @@
 		DD9ECB712CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB6E2CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift */; };
 		DD9ECB722CA9A0BA00AA7C45 /* RemoteControlConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB6F2CA9A0BA00AA7C45 /* RemoteControlConfigDataFlow.swift */; };
 		DD9ECB742CA9A0C300AA7C45 /* RemoteControlConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB732CA9A0C300AA7C45 /* RemoteControlConfig.swift */; };
+		DDB37CC52D05048F00D99BF4 /* ContactTrickStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB37CC42D05048F00D99BF4 /* ContactTrickStorage.swift */; };
+		DDB37CC72D05127500D99BF4 /* FontExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB37CC62D05127500D99BF4 /* FontExtensions.swift */; };
 		DDCEBF5B2CC1B76400DF4C36 /* LiveActivity+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCEBF5A2CC1B76400DF4C36 /* LiveActivity+Helper.swift */; };
 		DDD163122C4C689900CD525A /* AdjustmentsStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD163112C4C689900CD525A /* AdjustmentsStateModel.swift */; };
 		DDD163142C4C68D300CD525A /* AdjustmentsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD163132C4C68D300CD525A /* AdjustmentsProvider.swift */; };
@@ -535,6 +542,11 @@
 		E13B7DAB2A435F57066AF02E /* TargetsEditorStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36F58DDD71F0E795464FA3F0 /* TargetsEditorStateModel.swift */; };
 		E39E418C56A5A46B61D960EE /* ConfigEditorStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D5B4F8B4194BB7E260EF251 /* ConfigEditorStateModel.swift */; };
 		E3A08AAE59538BC8A8ABE477 /* GlucoseNotificationSettingsDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3260468377DA9DB4DEE9AF6D /* GlucoseNotificationSettingsDataFlow.swift */; };
+		E592A3702CEEC01E009A472C /* ContactTrickEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = E592A36F2CEEC01E009A472C /* ContactTrickEntry.swift */; };
+		E592A3772CEEC038009A472C /* ContactTrickStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E592A3752CEEC038009A472C /* ContactTrickStateModel.swift */; };
+		E592A3782CEEC038009A472C /* ContactTrickDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E592A3732CEEC038009A472C /* ContactTrickDataFlow.swift */; };
+		E592A3792CEEC038009A472C /* ContactTrickRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E592A3712CEEC038009A472C /* ContactTrickRootView.swift */; };
+		E592A37A2CEEC038009A472C /* ContactTrickProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E592A3742CEEC038009A472C /* ContactTrickProvider.swift */; };
 		E974172296125A5AE99E634C /* PumpConfigRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AD22C985B79A2F0D2EA3D9D /* PumpConfigRootView.swift */; };
 		F5CA3DB1F9DC8B05792BBFAA /* CGMDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B5C0607505A38F256BF99A /* CGMDataFlow.swift */; };
 		F5F7E6C1B7F098F59EB67EC5 /* TargetsEditorDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA49538D56989D8DA6FCF538 /* TargetsEditorDataFlow.swift */; };
@@ -1030,6 +1042,11 @@
 		BDBAACF92C2D439700370AAE /* OverrideData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideData.swift; sourceTree = "<group>"; };
 		BDC2EA442C3043B000E5BBD0 /* OverrideStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideStorage.swift; sourceTree = "<group>"; };
 		BDC2EA462C3045AD00E5BBD0 /* Override.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Override.swift; sourceTree = "<group>"; };
+		BDC530FE2D0F6BE300088832 /* ContactTrickManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickManager.swift; sourceTree = "<group>"; };
+		BDC531112D1060FA00088832 /* ContactTrickDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickDetailView.swift; sourceTree = "<group>"; };
+		BDC531132D10611D00088832 /* AddContactTrickSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactTrickSheet.swift; sourceTree = "<group>"; };
+		BDC531152D10629000088832 /* ContactTrickPicture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickPicture.swift; sourceTree = "<group>"; };
+		BDC531172D1062F200088832 /* ContactTrickState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickState.swift; sourceTree = "<group>"; };
 		BDCAF2372C639F35002DC907 /* SettingItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingItems.swift; sourceTree = "<group>"; };
 		BDCD47AE2C1F3F1700F8BCD5 /* OverrideStored+helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OverrideStored+helper.swift"; sourceTree = "<group>"; };
 		BDDAF9EE2D00553E00B34E7A /* SelectionPopoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionPopoverView.swift; sourceTree = "<group>"; };
@@ -1169,6 +1186,10 @@
 		DD9ECB6E2CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteControlConfigProvider.swift; sourceTree = "<group>"; };
 		DD9ECB6F2CA9A0BA00AA7C45 /* RemoteControlConfigDataFlow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteControlConfigDataFlow.swift; sourceTree = "<group>"; };
 		DD9ECB732CA9A0C300AA7C45 /* RemoteControlConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteControlConfig.swift; sourceTree = "<group>"; };
+		DDB37CC22D05044D00D99BF4 /* ContactTrickEntryStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContactTrickEntryStored+CoreDataClass.swift"; sourceTree = "<group>"; };
+		DDB37CC32D05044D00D99BF4 /* ContactTrickEntryStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContactTrickEntryStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
+		DDB37CC42D05048F00D99BF4 /* ContactTrickStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickStorage.swift; sourceTree = "<group>"; };
+		DDB37CC62D05127500D99BF4 /* FontExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontExtensions.swift; sourceTree = "<group>"; };
 		DDCEBF5A2CC1B76400DF4C36 /* LiveActivity+Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LiveActivity+Helper.swift"; sourceTree = "<group>"; };
 		DDD163112C4C689900CD525A /* AdjustmentsStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjustmentsStateModel.swift; sourceTree = "<group>"; };
 		DDD163132C4C68D300CD525A /* AdjustmentsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjustmentsProvider.swift; sourceTree = "<group>"; };
@@ -1223,6 +1244,11 @@
 		E0CC2C5B275B9DAE00A7BC71 /* HealthKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = HealthKit.framework; path = System/Library/Frameworks/HealthKit.framework; sourceTree = SDKROOT; };
 		E0D4F80427513ECF00BDF1FE /* HealthKitSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitSample.swift; sourceTree = "<group>"; };
 		E26904AACA8D9C15D229D675 /* SnoozeStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnoozeStateModel.swift; sourceTree = "<group>"; };
+		E592A36F2CEEC01E009A472C /* ContactTrickEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickEntry.swift; sourceTree = "<group>"; };
+		E592A3712CEEC038009A472C /* ContactTrickRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickRootView.swift; sourceTree = "<group>"; };
+		E592A3732CEEC038009A472C /* ContactTrickDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickDataFlow.swift; sourceTree = "<group>"; };
+		E592A3742CEEC038009A472C /* ContactTrickProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickProvider.swift; sourceTree = "<group>"; };
+		E592A3752CEEC038009A472C /* ContactTrickStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickStateModel.swift; sourceTree = "<group>"; };
 		E625985B47742D498CB1681A /* GlucoseNotificationSettingsProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GlucoseNotificationSettingsProvider.swift; sourceTree = "<group>"; };
 		F816825D28DB441200054060 /* HeartBeatManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HeartBeatManager.swift; sourceTree = "<group>"; };
 		F816825F28DB441800054060 /* BluetoothTransmitter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BluetoothTransmitter.swift; sourceTree = "<group>"; };
@@ -1534,6 +1560,7 @@
 				E42231DBF0DBE2B4B92D1B15 /* CarbRatioEditor */,
 				F75CB57ED6971B46F8756083 /* CGM */,
 				0610F7D6D2EC00E3BA1569F0 /* ConfigEditor */,
+				E592A3762CEEC038009A472C /* ContactTrick */,
 				9E56E3626FAD933385101B76 /* DataTable */,
 				195D80B22AF696EE00D25097 /* DynamicSettings */,
 				DD17454C2C55CA0200211FAC /* GeneralSettings */,
@@ -1673,6 +1700,7 @@
 				3811DE9225C9D88200A708ED /* Appearance */,
 				CEB434E128B8F9BC00B70274 /* Bluetooth */,
 				3862CC2C2743F9DC00BF832C /* Calendar */,
+				E592A37E2CEEC046009A472C /* ContactTrick */,
 				F90692A8274B7A980037068D /* HealthKit */,
 				6B1A8D2C2B156EC100E76752 /* LiveActivity */,
 				3811DE9425C9D88200A708ED /* Network */,
@@ -1974,6 +2002,7 @@
 				38A9260425F012D8009E3739 /* CarbRatios.swift */,
 				38D0B3D825EC07C400CB6E88 /* CarbsEntry.swift */,
 				3811DF0125CA9FEA00A708ED /* Credentials.swift */,
+				E592A36F2CEEC01E009A472C /* ContactTrickEntry.swift */,
 				38AEE73C25F0200C0013F05B /* FreeAPSSettings.swift */,
 				383948D925CD64D500E91849 /* Glucose.swift */,
 				382C133625F13A1E00715CE1 /* InsulinSensitivities.swift */,
@@ -2054,6 +2083,7 @@
 		38A0362725ECF05300FCBB52 /* Storage */ = {
 			isa = PBXGroup;
 			children = (
+				DDB37CC42D05048F00D99BF4 /* ContactTrickStorage.swift */,
 				385CEAC325F2F154002D6D5B /* AnnouncementsStorage.swift */,
 				38AEE75625F0F18E0013F05B /* CarbsStorage.swift */,
 				38A0363A25ECF07E00FCBB52 /* GlucoseStorage.swift */,
@@ -2069,6 +2099,7 @@
 		38A504F625DDA0E200C5B9E8 /* Extensions */ = {
 			isa = PBXGroup;
 			children = (
+				DDB37CC62D05127500D99BF4 /* FontExtensions.swift */,
 				38A5049125DD9C4000C5B9E8 /* UserDefaultsExtensions.swift */,
 				38BF021625E7CBBC00579895 /* PumpManagerExtensions.swift */,
 				CEB434E628B9053300B70274 /* LoopUIColorPalette+Default.swift */,
@@ -2878,38 +2909,40 @@
 		DDE179112C9100FA003CDDB7 /* Classes+Properties */ = {
 			isa = PBXGroup;
 			children = (
-				58A3D54D2C96EFA8003F90FC /* TempTargetStored+CoreDataClass.swift */,
-				58A3D54E2C96EFA8003F90FC /* TempTargetStored+CoreDataProperties.swift */,
-				58A3D54F2C96EFA8003F90FC /* TempTargetRunStored+CoreDataClass.swift */,
-				58A3D5502C96EFA8003F90FC /* TempTargetRunStored+CoreDataProperties.swift */,
-				DDE179322C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift */,
-				DDE179332C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift */,
-				DDE179342C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift */,
-				DDE179352C910127003CDDB7 /* LoopStatRecord+CoreDataProperties.swift */,
 				DDE179362C910127003CDDB7 /* BolusStored+CoreDataClass.swift */,
 				DDE179372C910127003CDDB7 /* BolusStored+CoreDataProperties.swift */,
-				DDE179382C910127003CDDB7 /* ForecastValue+CoreDataClass.swift */,
-				DDE179392C910127003CDDB7 /* ForecastValue+CoreDataProperties.swift */,
 				DDE1793A2C910127003CDDB7 /* CarbEntryStored+CoreDataClass.swift */,
 				DDE1793B2C910127003CDDB7 /* CarbEntryStored+CoreDataProperties.swift */,
-				DDE1793E2C910127003CDDB7 /* PumpEventStored+CoreDataClass.swift */,
-				DDE1793F2C910127003CDDB7 /* PumpEventStored+CoreDataProperties.swift */,
-				DDE179402C910127003CDDB7 /* StatsData+CoreDataClass.swift */,
-				DDE179412C910127003CDDB7 /* StatsData+CoreDataProperties.swift */,
+				DDB37CC22D05044D00D99BF4 /* ContactTrickEntryStored+CoreDataClass.swift */,
+				DDB37CC32D05044D00D99BF4 /* ContactTrickEntryStored+CoreDataProperties.swift */,
 				DDE179422C910127003CDDB7 /* Forecast+CoreDataClass.swift */,
 				DDE179432C910127003CDDB7 /* Forecast+CoreDataProperties.swift */,
+				DDE179382C910127003CDDB7 /* ForecastValue+CoreDataClass.swift */,
+				DDE179392C910127003CDDB7 /* ForecastValue+CoreDataProperties.swift */,
 				DDE179442C910127003CDDB7 /* GlucoseStored+CoreDataClass.swift */,
 				DDE179452C910127003CDDB7 /* GlucoseStored+CoreDataProperties.swift */,
+				DDE179342C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift */,
+				DDE179352C910127003CDDB7 /* LoopStatRecord+CoreDataProperties.swift */,
+				DDE179322C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift */,
+				DDE179332C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift */,
 				DDE179462C910127003CDDB7 /* OpenAPS_Battery+CoreDataClass.swift */,
 				DDE179472C910127003CDDB7 /* OpenAPS_Battery+CoreDataProperties.swift */,
-				DDE179482C910127003CDDB7 /* TempBasalStored+CoreDataClass.swift */,
-				DDE179492C910127003CDDB7 /* TempBasalStored+CoreDataProperties.swift */,
-				DDE1794C2C910127003CDDB7 /* OverrideRunStored+CoreDataClass.swift */,
-				DDE1794D2C910127003CDDB7 /* OverrideRunStored+CoreDataProperties.swift */,
 				DDE1794E2C910127003CDDB7 /* OrefDetermination+CoreDataClass.swift */,
 				DDE1794F2C910127003CDDB7 /* OrefDetermination+CoreDataProperties.swift */,
+				DDE1794C2C910127003CDDB7 /* OverrideRunStored+CoreDataClass.swift */,
+				DDE1794D2C910127003CDDB7 /* OverrideRunStored+CoreDataProperties.swift */,
 				DDE179502C910127003CDDB7 /* OverrideStored+CoreDataClass.swift */,
 				DDE179512C910127003CDDB7 /* OverrideStored+CoreDataProperties.swift */,
+				DDE1793E2C910127003CDDB7 /* PumpEventStored+CoreDataClass.swift */,
+				DDE1793F2C910127003CDDB7 /* PumpEventStored+CoreDataProperties.swift */,
+				DDE179402C910127003CDDB7 /* StatsData+CoreDataClass.swift */,
+				DDE179412C910127003CDDB7 /* StatsData+CoreDataProperties.swift */,
+				DDE179482C910127003CDDB7 /* TempBasalStored+CoreDataClass.swift */,
+				DDE179492C910127003CDDB7 /* TempBasalStored+CoreDataProperties.swift */,
+				58A3D54F2C96EFA8003F90FC /* TempTargetRunStored+CoreDataClass.swift */,
+				58A3D5502C96EFA8003F90FC /* TempTargetRunStored+CoreDataProperties.swift */,
+				58A3D54D2C96EFA8003F90FC /* TempTargetStored+CoreDataClass.swift */,
+				58A3D54E2C96EFA8003F90FC /* TempTargetStored+CoreDataProperties.swift */,
 			);
 			path = "Classes+Properties";
 			sourceTree = "<group>";
@@ -2958,6 +2991,37 @@
 			path = CarbRatioEditor;
 			sourceTree = "<group>";
 		};
+		E592A3722CEEC038009A472C /* View */ = {
+			isa = PBXGroup;
+			children = (
+				E592A3712CEEC038009A472C /* ContactTrickRootView.swift */,
+				BDC531112D1060FA00088832 /* ContactTrickDetailView.swift */,
+				BDC531132D10611D00088832 /* AddContactTrickSheet.swift */,
+			);
+			path = View;
+			sourceTree = "<group>";
+		};
+		E592A3762CEEC038009A472C /* ContactTrick */ = {
+			isa = PBXGroup;
+			children = (
+				E592A3722CEEC038009A472C /* View */,
+				E592A3732CEEC038009A472C /* ContactTrickDataFlow.swift */,
+				E592A3742CEEC038009A472C /* ContactTrickProvider.swift */,
+				E592A3752CEEC038009A472C /* ContactTrickStateModel.swift */,
+			);
+			path = ContactTrick;
+			sourceTree = "<group>";
+		};
+		E592A37E2CEEC046009A472C /* ContactTrick */ = {
+			isa = PBXGroup;
+			children = (
+				BDC530FE2D0F6BE300088832 /* ContactTrickManager.swift */,
+				BDC531152D10629000088832 /* ContactTrickPicture.swift */,
+				BDC531172D1062F200088832 /* ContactTrickState.swift */,
+			);
+			path = ContactTrick;
+			sourceTree = "<group>";
+		};
 		EEC747824D6593B5CD87E195 /* View */ = {
 			isa = PBXGroup;
 			children = (
@@ -3492,6 +3556,7 @@
 				38B4F3AF25E2979F00E76A18 /* IndexedCollection.swift in Sources */,
 				58D08B222C8DAA8E00AA37D3 /* OverrideView.swift in Sources */,
 				BD0B2EF32C5998E600B3298F /* MealPresetView.swift in Sources */,
+				E592A3702CEEC01E009A472C /* ContactTrickEntry.swift in Sources */,
 				DD6D67E42C9C253500660C9B /* ColorSchemeOption.swift in Sources */,
 				582DF9752C8CDB92001F516D /* GlucoseChartView.swift in Sources */,
 				58A3D53A2C96D4DE003F90FC /* AddTempTargetForm.swift in Sources */,
@@ -3660,10 +3725,12 @@
 				6632A0DC746872439A858B44 /* ISFEditorDataFlow.swift in Sources */,
 				DD17454B2C55C62800211FAC /* AutosensSettingsRootView.swift in Sources */,
 				DDF847DF2C5C28780049BB3B /* LiveActivitySettingsProvider.swift in Sources */,
+				DDB37CC52D05048F00D99BF4 /* ContactTrickStorage.swift in Sources */,
 				DBA5254DBB2586C98F61220C /* ISFEditorProvider.swift in Sources */,
 				BDF34EBE2C0A31D100D51995 /* CustomNotification.swift in Sources */,
 				BDC2EA472C3045AD00E5BBD0 /* Override.swift in Sources */,
 				1BBB001DAD60F3B8CEA4B1C7 /* ISFEditorStateModel.swift in Sources */,
+				DDB37CC72D05127500D99BF4 /* FontExtensions.swift in Sources */,
 				582DF9772C8CDBE7001F516D /* InsulinView.swift in Sources */,
 				F816826028DB441800054060 /* BluetoothTransmitter.swift in Sources */,
 				DD68889D2C386E17006E3C44 /* NightscoutExercise.swift in Sources */,
@@ -3711,6 +3778,11 @@
 				DD5DC9F92CF3DAA900AB8703 /* RadioButton.swift in Sources */,
 				38E44522274E3DDC00EC9A94 /* NetworkReachabilityManager.swift in Sources */,
 				CE7CA34F2A064973004BE681 /* BaseIntentsRequest.swift in Sources */,
+				E592A3772CEEC038009A472C /* ContactTrickStateModel.swift in Sources */,
+				E592A3782CEEC038009A472C /* ContactTrickDataFlow.swift in Sources */,
+				E592A3792CEEC038009A472C /* ContactTrickRootView.swift in Sources */,
+				BDC531182D1062F200088832 /* ContactTrickState.swift in Sources */,
+				E592A37A2CEEC038009A472C /* ContactTrickProvider.swift in Sources */,
 				CE82E02728E869DF00473A9C /* AlertEntry.swift in Sources */,
 				38E4451E274DB04600EC9A94 /* AppDelegate.swift in Sources */,
 				BD2FF1A02AE29D43005D1C5D /* CheckboxToggleStyle.swift in Sources */,
@@ -3729,6 +3801,7 @@
 				69A31254F2451C20361D172F /* TreatmentsStateModel.swift in Sources */,
 				1967DFC029D053AC00759F30 /* IconSelection.swift in Sources */,
 				19D4E4EB29FC6A9F00351451 /* Charts.swift in Sources */,
+				BDC531162D10629000088832 /* ContactTrickPicture.swift in Sources */,
 				FEFFA7A22929FE49007B8193 /* UIDevice+Extensions.swift in Sources */,
 				F90692D3274B9A130037068D /* AppleHealthKitRootView.swift in Sources */,
 				BDF34F852C10C62E00D51995 /* GlucoseData.swift in Sources */,
@@ -3786,6 +3859,8 @@
 				DDE179522C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift in Sources */,
 				DDE179532C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift in Sources */,
 				DDE179542C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift in Sources */,
+				BDC530FF2D0F6BE300088832 /* ContactTrickManager.swift in Sources */,
+				BDC531122D1060FA00088832 /* ContactTrickDetailView.swift in Sources */,
 				DDE179552C910127003CDDB7 /* LoopStatRecord+CoreDataProperties.swift in Sources */,
 				DDE179562C910127003CDDB7 /* BolusStored+CoreDataClass.swift in Sources */,
 				DDE179572C910127003CDDB7 /* BolusStored+CoreDataProperties.swift in Sources */,
@@ -3802,6 +3877,7 @@
 				DDE179632C910127003CDDB7 /* Forecast+CoreDataProperties.swift in Sources */,
 				DDE179642C910127003CDDB7 /* GlucoseStored+CoreDataClass.swift in Sources */,
 				DDE179652C910127003CDDB7 /* GlucoseStored+CoreDataProperties.swift in Sources */,
+				BDC531142D10611D00088832 /* AddContactTrickSheet.swift in Sources */,
 				DDE179662C910127003CDDB7 /* OpenAPS_Battery+CoreDataClass.swift in Sources */,
 				DDE179672C910127003CDDB7 /* OpenAPS_Battery+CoreDataProperties.swift in Sources */,
 				DDE179682C910127003CDDB7 /* TempBasalStored+CoreDataClass.swift in Sources */,

+ 3 - 0
FreeAPS/Resources/Base.lproj/InfoPlist.strings

@@ -18,3 +18,6 @@
 
 /* Privacy - Health Share Usage Description */
 "NSHealthShareUsageDescription" = "Health App is used to store blood glucose, insulin and carbohydrates";
+
+/* Privacy - Contacts Usage Description */
+"NSContactsUsageDescription" = "Allows Trio to access your contacts for live updates to your Apple Watch contact complication using the 'Contact Trick' feature.";

+ 2 - 0
FreeAPS/Resources/Info.plist

@@ -78,6 +78,8 @@
 	<string>Calendar is used to create a new glucose events.</string>
 	<key>NSFaceIDUsageDescription</key>
 	<string>For authorized acces to bolus</string>
+	<key>NSContactsUsageDescription</key>
+	<string>Contact is used to create a Apple Watch complication</string>
 	<key>NSHealthShareUsageDescription</key>
 	<string>Health App is used to store blood glucose, carbs and insulin</string>
 	<key>NSHealthUpdateUsageDescription</key>

+ 87 - 0
FreeAPS/Sources/APS/Extensions/FontExtensions.swift

@@ -0,0 +1,87 @@
+import SwiftUI
+
+extension Font.Weight {
+    var displayName: String {
+        switch self {
+        case .ultraLight: return "Ultra Light"
+        case .light: return "Light"
+        case .regular: return "Regular"
+        case .medium: return "Medium"
+        case .semibold: return "Semibold"
+        case .bold: return "Bold"
+        case .heavy: return "Heavy"
+        case .black: return "Black"
+        default: return "Unknown"
+        }
+    }
+
+    private static let stringToFontWeight: [String: Font.Weight] = [
+        "ultraLight": .ultraLight,
+        "thin": .thin,
+        "light": .light,
+        "regular": .regular,
+        "medium": .medium,
+        "semibold": .semibold,
+        "bold": .bold,
+        "heavy": .heavy,
+        "black": .black
+    ]
+
+    private static let fontWeightToString: [Font.Weight: String] = [
+        .ultraLight: "ultraLight",
+        .thin: "thin",
+        .light: "light",
+        .regular: "regular",
+        .medium: "medium",
+        .semibold: "semibold",
+        .bold: "bold",
+        .heavy: "heavy",
+        .black: "black"
+    ]
+
+    /// Initialize `Font.Weight` from a string
+    static func fromString(_ string: String) -> Font.Weight {
+        stringToFontWeight[string] ?? .regular // Default fallback
+    }
+
+    /// Convert `Font.Weight` to a string
+    var asString: String {
+        Font.Weight.fontWeightToString[self] ?? "regular" // Default fallback
+    }
+}
+
+extension Font.Width {
+    var displayName: String {
+        switch self {
+        case .condensed: return "Condensed"
+        case .expanded: return "Expanded"
+        case .compressed: return "Compressed"
+        case .standard: return "Standard"
+        default: return "Unknown"
+        }
+    }
+
+    private static let stringToFontWidth: [String: Font.Width] = [
+        "compressed": .compressed,
+        "condensed": .condensed,
+        "standard": .standard,
+        "expanded": .expanded
+    ]
+
+    private static let fontWidthToString: [Font.Width: String] = [
+        .compressed: "compressed",
+        .condensed: "condensed",
+        .standard: "standard",
+        .expanded: "expanded"
+    ]
+
+    /// Initialize `Font.Width` from a string
+    static func fromString(_ string: String) -> Font.Width {
+        stringToFontWidth[string] ?? .standard // Default fallback
+    }
+
+    /// Convert `Font.Width` to a string
+    var asString: String {
+        Font.Width.fontWidthToString[self] ?? "standard" // Default fallback
+    }
+}

+ 1 - 0
FreeAPS/Sources/APS/OpenAPS/Constants.swift

@@ -39,6 +39,7 @@ extension OpenAPS {
         static let carbRatios = "settings/carb_ratios.json"
         static let tempTargets = "settings/temptargets.json"
         static let model = "settings/model.json"
+        static let contactTrick = "settings/contact_trick.json"
     }
 
     enum Monitor {

+ 150 - 0
FreeAPS/Sources/APS/Storage/ContactTrickStorage.swift

@@ -0,0 +1,150 @@
+import CoreData
+import Foundation
+import SwiftUI
+import Swinject
+
+protocol ContactTrickStorage {
+    func fetchContactTrickEntries() async -> [ContactTrickEntry]
+    func storeContactTrickEntry(_ entry: ContactTrickEntry) async
+    func updateContactTrickEntry(_ contactTrickEntry: ContactTrickEntry) async
+    func deleteContactTrickEntry(_ objectID: NSManagedObjectID) async
+}
+
+final class BaseContactTrickStorage: ContactTrickStorage, Injectable {
+    @Injected() private var settingsManager: SettingsManager!
+
+    private let backgroundContext = CoreDataStack.shared.newTaskContext()
+
+    init(resolver: Resolver) {
+        injectServices(resolver)
+    }
+
+    /// Fetches all stored Contact Trick entries.
+    ///
+    /// The method retrieves `ContactTrickEntryStored` objects from Core Data, maps them to
+    /// `ContactTrickEntry` objects, and returns the results.
+    ///
+    /// - Returns: An array of `ContactTrickEntry` objects.
+    func fetchContactTrickEntries() async -> [ContactTrickEntry] {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: ContactTrickEntryStored.self,
+            onContext: backgroundContext,
+            predicate: NSPredicate.all,
+            key: "hasHighContrast",
+            ascending: false
+        )
+
+        return await backgroundContext.perform {
+            guard let fetchedContactTrickEntries = results as? [ContactTrickEntryStored] else { return [] }
+
+            return fetchedContactTrickEntries.compactMap { entry in
+                ContactTrickEntry(
+                    name: entry.name ?? "No name provided",
+                    layout: ContactTrickLayout(rawValue: entry.layout ?? "Single") ?? .single,
+                    ring: ContactTrickLargeRing(rawValue: entry.ring ?? "Hidden") ?? .none,
+                    primary: ContactTrickValue(rawValue: entry.primary ?? "Glucose Reading") ?? .glucose,
+                    top: ContactTrickValue(rawValue: entry.top ?? "None") ?? .none,
+                    bottom: ContactTrickValue(rawValue: entry.bottom ?? "None") ?? .none,
+                    contactId: entry.contactId?.string,
+                    hasHighContrast: entry.hasHighContrast,
+                    ringWidth: ContactTrickEntry.RingWidth(rawValue: Int(entry.ringWidth)) ?? .regular,
+                    ringGap: ContactTrickEntry.RingGap(rawValue: Int(entry.ringGap)) ?? .small,
+                    fontSize: ContactTrickEntry.FontSize(rawValue: Int(entry.fontSize)) ?? .regular,
+                    secondaryFontSize: ContactTrickEntry.FontSize(rawValue: Int(entry.fontSizeSecondary)) ?? .small,
+                    fontWeight: Font.Weight.fromString(entry.fontWeight ?? "regular"),
+                    fontWidth: Font.Width.fromString(entry.fontWidth ?? "standard"),
+                    managedObjectID: entry.objectID
+                )
+            }
+        }
+    }
+
+    /// Stores a new Contact Trick entry.
+    ///
+    /// This method creates a new `ContactTrickEntryStored` object in the background context,
+    /// populates its properties with the values from the provided `ContactTrickEntry`, and
+    /// saves the context if changes exist.
+    ///
+    /// - Parameter contactTrickEntry: The `ContactTrickEntry` object to be stored.
+    func storeContactTrickEntry(_ contactTrickEntry: ContactTrickEntry) async {
+        await backgroundContext.perform {
+            let newContactTrickEntry = ContactTrickEntryStored(context: self.backgroundContext)
+
+            newContactTrickEntry.id = UUID()
+            newContactTrickEntry.name = contactTrickEntry.name
+            newContactTrickEntry.contactId = contactTrickEntry.contactId
+            newContactTrickEntry.layout = contactTrickEntry.layout.rawValue
+            newContactTrickEntry.ring = contactTrickEntry.ring.rawValue
+            newContactTrickEntry.primary = contactTrickEntry.primary.rawValue
+            newContactTrickEntry.top = contactTrickEntry.top.rawValue
+            newContactTrickEntry.bottom = contactTrickEntry.bottom.rawValue
+            newContactTrickEntry.hasHighContrast = contactTrickEntry.hasHighContrast
+            newContactTrickEntry.ringWidth = Int16(contactTrickEntry.ringWidth.rawValue)
+            newContactTrickEntry.ringGap = Int16(contactTrickEntry.ringGap.rawValue)
+            newContactTrickEntry.fontSize = Int16(contactTrickEntry.fontSize.rawValue)
+            newContactTrickEntry.fontSizeSecondary = Int16(contactTrickEntry.secondaryFontSize.rawValue)
+            newContactTrickEntry.fontWidth = contactTrickEntry.fontWeight.asString
+            newContactTrickEntry.fontWeight = contactTrickEntry.fontWidth.asString
+
+            do {
+                guard self.backgroundContext.hasChanges else { return }
+                try self.backgroundContext.save()
+            } catch let error as NSError {
+                debugPrint(
+                    "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to save Contact Trick Entry to Core Data with error: \(error.userInfo)"
+                )
+            }
+        }
+    }
+
+    /// Updates an existing Contact Trick entry in Core Data.
+    ///
+    /// This method finds the existing `ContactTrickEntryStored` object by its `contactId` and updates
+    /// its properties with the values from the provided `ContactTrickEntry`. If no matching entry exists,
+    /// it does nothing.
+    ///
+    /// - Parameter contactTrickEntry: The `ContactTrickEntry` object with updated values.
+    func updateContactTrickEntry(_ contactTrickEntry: ContactTrickEntry) async {
+        await backgroundContext.perform {
+            let fetchRequest: NSFetchRequest<ContactTrickEntryStored> = ContactTrickEntryStored.fetchRequest()
+            fetchRequest.predicate = NSPredicate(format: "contactId == %@", contactTrickEntry.contactId ?? "")
+
+            do {
+                if let existingEntry = try self.backgroundContext.fetch(fetchRequest).first {
+                    // Update the properties of the existing entry
+                    existingEntry.name = contactTrickEntry.name
+                    existingEntry.layout = contactTrickEntry.layout.rawValue
+                    existingEntry.ring = contactTrickEntry.ring.rawValue
+                    existingEntry.primary = contactTrickEntry.primary.rawValue
+                    existingEntry.top = contactTrickEntry.top.rawValue
+                    existingEntry.bottom = contactTrickEntry.bottom.rawValue
+                    existingEntry.hasHighContrast = contactTrickEntry.hasHighContrast
+                    existingEntry.ringWidth = Int16(contactTrickEntry.ringWidth.rawValue)
+                    existingEntry.ringGap = Int16(contactTrickEntry.ringGap.rawValue)
+                    existingEntry.fontSize = Int16(contactTrickEntry.fontSize.rawValue)
+                    existingEntry.fontSizeSecondary = Int16(contactTrickEntry.secondaryFontSize.rawValue)
+                    existingEntry.fontWeight = contactTrickEntry.fontWeight.asString
+                    existingEntry.fontWidth = contactTrickEntry.fontWidth.asString
+
+                    guard self.backgroundContext.hasChanges else { return }
+                    try self.backgroundContext.save()
+                } else {
+                    debugPrint(
+                        "\(DebuggingIdentifiers.failed) \(#file) \(#function) No matching Contact Trick Entry found to update."
+                    )
+                }
+            } catch let error as NSError {
+                debugPrint(
+                    "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update Contact Trick Entry with error: \(error.userInfo)"
+                )
+            }
+        }
+    }
+
+    /// Deletes a Contact Trick entry from Core Data.
+    ///
+    /// - Parameter objectID: The `NSManagedObjectID` of the object to delete.
+    func deleteContactTrickEntry(_ objectID: NSManagedObjectID) async {
+        await CoreDataStack.shared.deleteObject(identifiedBy: objectID)
+    }
+}

+ 1 - 0
FreeAPS/Sources/Assemblies/ServiceAssembly.swift

@@ -20,6 +20,7 @@ final class ServiceAssembly: Assembly {
         container.register(UserNotificationsManager.self) { r in BaseUserNotificationsManager(resolver: r) }
         container.register(WatchManager.self) { r in BaseWatchManager(resolver: r) }
         container.register(GarminManager.self) { r in BaseGarminManager(resolver: r) }
+        container.register(ContactTrickManager.self) { r in BaseContactTrickManager(resolver: r) }
         container.register(AlertPermissionsChecker.self) { r in AlertPermissionsChecker(resolver: r) }
         if #available(iOS 16.2, *) {
             container.register(LiveActivityBridge.self) { r in

+ 1 - 0
FreeAPS/Sources/Assemblies/StorageAssembly.swift

@@ -13,6 +13,7 @@ final class StorageAssembly: Assembly {
         container.register(GlucoseStorage.self) { r in BaseGlucoseStorage(resolver: r) }
         container.register(TempTargetsStorage.self) { r in BaseTempTargetsStorage(resolver: r) }
         container.register(CarbsStorage.self) { r in BaseCarbsStorage(resolver: r) }
+        container.register(ContactTrickStorage.self) { r in BaseContactTrickStorage(resolver: r) }
         container.register(AnnouncementsStorage.self) { r in BaseAnnouncementsStorage(resolver: r) }
         container.register(SettingsManager.self) { r in BaseSettingsManager(resolver: r) }
         container.register(Keychain.self) { _ in BaseKeychain() }

+ 3 - 3
FreeAPS/Sources/Localizations/Main/en.lproj/Localizable.strings

@@ -1859,10 +1859,10 @@ Enact a temp Basal or a temp target */
 /* Smoothing of CGM readings */
 "Smooth Glucose Value" = "Smooth Glucose Value";
 
- /* -----------------------------------------------------------------------------------------------------------
+/* -----------------------------------------------------------------------------------------------------------
 
-  Infotexts from openaps.docs and androidaps.docs
-  Trio
+ Infotexts from openaps.docs and androidaps.docs
+ Trio
 */
 
 /* Headline Rewind Resets Autosens */

+ 192 - 0
FreeAPS/Sources/Models/ContactTrickEntry.swift

@@ -0,0 +1,192 @@
+import CoreData
+import SwiftUI
+
+struct ContactTrickEntry: Hashable, Equatable, Sendable {
+    var id = UUID()
+    var name: String = ""
+    var layout: ContactTrickLayout = .single
+    var ring: ContactTrickLargeRing = .none
+    var primary: ContactTrickValue = .glucose
+    var top: ContactTrickValue = .none
+    var bottom: ContactTrickValue = .none
+    var contactId: String? = nil
+    var hasHighContrast: Bool = true
+    var ringWidth: RingWidth = .regular
+    var ringGap: RingGap = .small
+    var fontSize: FontSize = .regular
+    var secondaryFontSize: FontSize = .small
+    var fontWeight: Font.Weight = .medium
+    var fontWidth: Font.Width = .standard
+    var managedObjectID: NSManagedObjectID?
+
+    static func == (lhs: ContactTrickEntry, rhs: ContactTrickEntry) -> Bool {
+        lhs.id == rhs.id &&
+            lhs.name == rhs.name &&
+            lhs.layout == rhs.layout &&
+            lhs.ring == rhs.ring &&
+            lhs.primary == rhs.primary &&
+            lhs.top == rhs.top &&
+            lhs.bottom == rhs.bottom &&
+            lhs.contactId == rhs.contactId &&
+            lhs.hasHighContrast == rhs.hasHighContrast &&
+            lhs.ringWidth == rhs.ringWidth &&
+            lhs.ringGap == rhs.ringGap &&
+            lhs.fontSize == rhs.fontSize &&
+            lhs.secondaryFontSize == rhs.secondaryFontSize &&
+            lhs.fontWeight == rhs.fontWeight &&
+            lhs.fontWidth == rhs.fontWidth
+    }
+
+    // Convert `fontWeight` to a String for Core Data storage
+    var fontWeightString: String {
+        fontWeight.asString
+    }
+
+    // Initialize `fontWeight` from a String
+    static func fontWeight(from string: String) -> Font.Weight {
+        Font.Weight.fromString(string)
+    }
+
+    // Convert `fontWidth` to a String for Core Data storage
+    var fontWidthString: String {
+        fontWidth.asString
+    }
+
+    // Initialize `fontWidth` from a String
+    static func fontWidth(from string: String) -> Font.Width {
+        Font.Width.fromString(string)
+    }
+
+    enum FontSize: Int, Codable, Sendable, CaseIterable {
+        case tiny = 200
+        case small = 250
+        case regular = 300
+        case large = 400
+
+        var displayName: String {
+            switch self {
+            case .tiny: return "Tiny"
+            case .small: return "Small"
+            case .regular: return "Regular"
+            case .large: return "Large"
+            }
+        }
+    }
+
+    enum RingWidth: Int, Codable, Sendable, CaseIterable {
+        case tiny = 3
+        case small = 5
+        case regular = 7
+        case medium = 10
+        case large = 15
+
+        var displayName: String {
+            switch self {
+            case .tiny: return "Tiny"
+            case .small: return "Small"
+            case .regular: return "Regular"
+            case .medium: return "Medium"
+            case .large: return "Large"
+            }
+        }
+    }
+
+    enum RingGap: Int, Codable, Sendable, CaseIterable {
+        case tiny = 1
+        case small = 2
+        case regular = 3
+        case medium = 4
+        case large = 5
+
+        var displayName: String {
+            switch self {
+            case .tiny: return "Tiny"
+            case .small: return "Small"
+            case .regular: return "Regular"
+            case .medium: return "Medium"
+            case .large: return "Large"
+            }
+        }
+    }
+}
+
+protocol ContactTrickObserver: Sendable {
+    // TODO: is this required?
+//    func basalProfileDidChange(_ entry: [ContactTrickEntry])
+}
+
+enum ContactTrickValue: String, JSON, CaseIterable, Identifiable, Codable {
+    var id: String { rawValue }
+    case none
+    case glucose
+    case eventualBG
+    case delta
+    case trend
+    case lastLoopDate
+    case cob
+    case iob
+    case ring
+
+    var displayName: String {
+        switch self {
+        case .none:
+            return NSLocalizedString("None", comment: "")
+        case .glucose:
+            return NSLocalizedString("Glucose Reading", comment: "")
+        case .eventualBG:
+            return NSLocalizedString("Eventual Glucose", comment: "")
+        case .delta:
+            return NSLocalizedString("Glucose Delta", comment: "")
+        case .trend:
+            return NSLocalizedString("Glucose Trend", comment: "")
+        case .lastLoopDate:
+            return NSLocalizedString("Last Loop Time", comment: "")
+        case .cob:
+            return NSLocalizedString("COB", comment: "")
+        case .iob:
+            return NSLocalizedString("IOB", comment: "")
+        case .ring:
+            return NSLocalizedString("Loop Status", comment: "")
+        }
+    }
+}
+
+enum ContactTrickLayout: String, JSON, CaseIterable, Identifiable, Codable {
+    var id: String { rawValue }
+    case single
+    case split
+
+    var displayName: String {
+        switch self {
+        case .single:
+            return NSLocalizedString("Single", comment: "")
+        case .split:
+            return NSLocalizedString("Split", comment: "")
+        }
+    }
+}
+
+enum ContactTrickLargeRing: String, JSON, CaseIterable, Identifiable, Codable {
+    // TODO: revisit rings for iob, cob and combined iob+cob with more user feedback
+    var id: String { rawValue }
+    case none
+    case loop
+//    case iob
+//    case cob
+//    case iobcob
+
+    var displayName: String {
+        switch self {
+        case .none:
+            return NSLocalizedString("Hidden", comment: "")
+        case .loop:
+            return NSLocalizedString("Loop Status", comment: "")
+//        case .iob:
+//            return NSLocalizedString("Insulin on Board (IOB)", comment: "")
+//        case .cob:
+//            return NSLocalizedString("Carbs on Board (COB)", comment: "")
+//        case .iobcob:
+//            return NSLocalizedString("IOB + COB", comment: "")
+        }
+    }
+}

+ 8 - 0
FreeAPS/Sources/Modules/ContactTrick/ContactTrickDataFlow.swift

@@ -0,0 +1,8 @@
+import Combine
+import Foundation
+
+enum ContactTrick {
+    enum Config {}
+}
+
+protocol ContactTrickProvider: Provider {}

+ 6 - 0
FreeAPS/Sources/Modules/ContactTrick/ContactTrickProvider.swift

@@ -0,0 +1,6 @@
+import Combine
+import Foundation
+
+extension ContactTrick {
+    final class Provider: BaseProvider, ContactTrickProvider {}
+}

+ 179 - 0
FreeAPS/Sources/Modules/ContactTrick/ContactTrickStateModel.swift

@@ -0,0 +1,179 @@
+import ConnectIQ
+import CoreData
+import SwiftUI
+
+extension ContactTrick {
+    @Observable final class StateModel: BaseStateModel<Provider>, ContactTrickManagerDelegate {
+        @ObservationIgnored @Injected() var contactTrickStorage: ContactTrickStorage!
+        @ObservationIgnored @Injected() var contactTrickManager: ContactTrickManager!
+
+        var contactTrickEntries = [ContactTrickEntry]()
+        var units: GlucoseUnits = .mmolL
+        // Help Sheet
+        var isHelpSheetPresented: Bool = false
+        var helpSheetDetent = PresentationDetent.large
+
+        // Current state for live preview
+        var state = ContactTrickState()
+
+        /// Subscribes to updates and initializes data fetching.
+        override func subscribe() {
+            units = settingsManager.settings.units
+            contactTrickManager.delegate = self
+
+            Task {
+                /// Initial fetch to fill the ContactTrickEntry array
+                await fetchContactTrickEntriesAndUpdateUI()
+
+                // Initial state update is needed for preview
+                await contactTrickManager.updateContactTrickState()
+            }
+        }
+
+        func contactTrickManagerDidUpdateState(_ state: ContactTrickState) {
+            Task { @MainActor in
+                self.state = state
+            }
+        }
+
+        /// Fetches all ContactTrickEntries and validates them against iOS Contacts.
+        func fetchContactTrickEntriesAndUpdateUI() async {
+            // 1. Get all entries from Core Data
+            let cdEntries = await contactTrickStorage.fetchContactTrickEntries()
+
+            // 2. Validate entries against iOS Contacts
+            let validatedEntries = await validateEntries(cdEntries)
+
+            // 3. Update UI with validated entries
+            await MainActor.run {
+                self.contactTrickEntries = validatedEntries
+            }
+        }
+
+        /// Validates entries against iOS Contacts and removes invalid ones
+        private func validateEntries(_ entries: [ContactTrickEntry]) async -> [ContactTrickEntry] {
+            var validated: [ContactTrickEntry] = []
+
+            for entry in entries {
+                if let contactId = entry.contactId {
+                    // Check if contact still exists in iOS Contacts
+                    let exists = await contactTrickManager.validateContactExists(withIdentifier: contactId)
+
+                    if exists {
+                        validated.append(entry)
+                    } else {
+                        // Contact was deleted in iOS, remove from Core Data
+                        if let objectID = entry.managedObjectID {
+                            await contactTrickStorage.deleteContactTrickEntry(objectID)
+                            debugPrint("Removed orphaned contact entry: \(entry.name)")
+                        }
+                    }
+                }
+            }
+
+            return validated
+        }
+
+        /// Creates a new contact in Apple Contacts and saves it to Core Data.
+        /// - Parameters:
+        ///   - entry: The ContactTrickEntry to be saved.
+        ///   - name: The name of the contact.
+        func createAndSaveContactTrick(entry: ContactTrickEntry, name: String) async {
+            // 1. Check for contact access permissions.
+            let hasAccess = await contactTrickManager.requestAccess()
+            guard hasAccess else {
+                debugPrint("\(DebuggingIdentifiers.failed) No access to contacts.")
+                return
+            }
+
+            // 2. Create the contact and retrieve its `identifier`.
+            guard let contactId = await contactTrickManager.createContact(name: name) else {
+                debugPrint("\(DebuggingIdentifiers.failed) Failed to create contact.")
+                return
+            }
+
+            // 3. Update the entry with the `contactId`.
+            var updatedEntry = entry
+            updatedEntry.contactId = contactId
+            updatedEntry.name = name
+
+            // 4. Save the contact to Core Data.
+            await addContactTrickEntry(updatedEntry)
+
+            // 5. Update ContactTrickState and set the image for the newly created contact
+            await contactTrickManager.updateContactTrickState()
+            await contactTrickManager.setImageForContact(contactId: contactId)
+        }
+
+        /// Adds a ContactTrickEntry to Core Data.
+        /// - Parameter entry: The ContactTrickEntry to be saved.
+        func addContactTrickEntry(_ entry: ContactTrickEntry) async {
+            await contactTrickStorage.storeContactTrickEntry(entry)
+            await fetchContactTrickEntriesAndUpdateUI()
+        }
+
+        /// Deletes a contact from Apple Contacts and Core Data.
+        /// - Parameter entry: The ContactTrickEntry representing the contact to be deleted.
+        func deleteContact(entry: ContactTrickEntry) async {
+            guard let contactId = entry.contactId else {
+                debugPrint("\(DebuggingIdentifiers.failed) Contact does not have a valid ID.")
+                return
+            }
+
+            // 1. Attempt to delete the contact from Apple Contacts.
+            let contactDeleted = await contactTrickManager.deleteContact(withIdentifier: contactId)
+            if contactDeleted {
+                debugPrint("\(DebuggingIdentifiers.succeeded) Contact successfully deleted from Apple Contacts: \(contactId)")
+            } else {
+                debugPrint("\(DebuggingIdentifiers.failed) Failed to delete contact from Apple Contacts. Check if it exists.")
+            }
+
+            // 2. Delete the entry from Core Data.
+            if let objectID = entry.managedObjectID {
+                await deleteContactTrick(objectID: objectID)
+            }
+        }
+
+        /// Deletes a Core Data entry.
+        /// - Parameter objectID: The Managed Object ID of the entry to be deleted.
+        func deleteContactTrick(objectID: NSManagedObjectID) async {
+            await contactTrickStorage.deleteContactTrickEntry(objectID)
+            await fetchContactTrickEntriesAndUpdateUI()
+        }
+
+        /// Updates a contact in Apple Contacts and Core Data.
+        /// - Parameters:
+        ///   - entry: The ContactTrickEntry to be updated.
+        func updateContact(with entry: ContactTrickEntry) async {
+            guard let contactId = entry.contactId else {
+                debugPrint("\(DebuggingIdentifiers.failed) Contact does not have a valid ID.")
+                return
+            }
+
+            // 1. Update the entry in Core Data.
+            await updateContactTrick(entry)
+
+            // 2. Update the contact in Apple Contacts.
+
+            /// Update name
+            let contactUpdated = await contactTrickManager
+                .updateContact(withIdentifier: contactId, newName: entry.name) // TODO: - Probably not needed anymore
+
+            guard contactUpdated else {
+                debugPrint("\(DebuggingIdentifiers.failed) Failed to update contact.")
+                return
+            }
+
+            /// Update state and image
+            await contactTrickManager.updateContactTrickState()
+            await contactTrickManager.setImageForContact(contactId: contactId)
+        }
+
+        /// Updates a Core Data entry.
+        /// - Parameter entry: The updated ContactTrickEntry.
+        func updateContactTrick(_ entry: ContactTrickEntry) async {
+            await contactTrickStorage.updateContactTrickEntry(entry)
+            await fetchContactTrickEntriesAndUpdateUI()
+        }
+    }
+}

+ 248 - 0
FreeAPS/Sources/Modules/ContactTrick/View/AddContactTrickSheet.swift

@@ -0,0 +1,248 @@
+import SwiftUI
+
+struct AddContactTrickSheet: View {
+    @Environment(\.dismiss) var dismiss
+    @Environment(\.colorScheme) var colorScheme
+    @Environment(AppState.self) var appState
+
+    @ObservedObject var state: ContactTrick.StateModel
+
+    @State private var hasHighContrast: Bool = true
+    @State private var ringWidth: ContactTrickEntry.RingWidth = .regular
+    @State private var ringGap: ContactTrickEntry.RingGap = .small
+    @State private var layout: ContactTrickLayout = .single
+    @State private var primary: ContactTrickValue = .glucose
+    @State private var top: ContactTrickValue = .none
+    @State private var bottom: ContactTrickValue = .trend
+    @State private var ring: ContactTrickLargeRing = .none
+    @State private var fontSize: ContactTrickEntry.FontSize = .regular
+    @State private var secondaryFontSize: ContactTrickEntry.FontSize = .small
+    @State private var fontWeight: Font.Weight = .medium
+    @State private var fontWidth: Font.Width = .standard
+
+    private var previewEntry: ContactTrickEntry {
+        ContactTrickEntry(
+            id: UUID(),
+            name: "", // automatically set and populated
+            layout: layout,
+            ring: ring,
+            primary: primary,
+            top: top,
+            bottom: bottom,
+            contactId: nil, // not needed for preview, gets set later in ContactTrickStateModel via ContactTrickManager
+            hasHighContrast: hasHighContrast,
+            ringWidth: ringWidth,
+            ringGap: ringGap,
+            fontSize: fontSize,
+            secondaryFontSize: secondaryFontSize,
+            fontWeight: fontWeight,
+            fontWidth: fontWidth
+        )
+    }
+
+    var body: some View {
+        NavigationView {
+            VStack {
+                // Preview Section
+                HStack {
+                    Spacer()
+                    ZStack {
+                        Circle()
+                            .fill(previewEntry.hasHighContrast ? .black : .white)
+                            .foregroundColor(.white)
+                            .frame(width: 100, height: 100)
+                        Image(uiImage: ContactPicture.getImage(contact: previewEntry, state: state.state))
+                            .resizable()
+                            .frame(width: 100, height: 100)
+                            .clipShape(Circle())
+                        Circle()
+                            .stroke(lineWidth: 2)
+                            .foregroundColor(.white)
+                            .frame(width: 100, height: 100)
+                    }
+                    Spacer()
+                }
+                .padding(.top, 40)
+                .padding(.bottom)
+
+                Form {
+                    // Layout Section
+                    Section(header: Text("Style")) {
+                        Picker("Layout", selection: $layout) {
+                            ForEach(ContactTrickLayout.allCases, id: \.id) { layout in
+                                Text(layout.displayName).tag(layout)
+                            }
+                        }
+                        Toggle("High Contrast Mode", isOn: $hasHighContrast)
+                    }.listRowBackground(Color.chart)
+
+                    // Primary Value Section
+                    Section(header: Text("Display Values")) {
+                        if layout == .single {
+                            Picker("Primary", selection: $primary) {
+                                ForEach(ContactTrickValue.allCases, id: \.id) { value in
+                                    Text(value.displayName).tag(value)
+                                }
+                            }
+                        }
+
+                        Picker("Top Value", selection: $top) {
+                            ForEach(ContactTrickValue.allCases, id: \.id) { value in
+                                Text(value.displayName).tag(value)
+                            }
+                        }
+                        Picker("Bottom Value", selection: $bottom) {
+                            ForEach(ContactTrickValue.allCases, id: \.id) { value in
+                                Text(value.displayName).tag(value)
+                            }
+                        }
+
+                    }.listRowBackground(Color.chart)
+
+                    // Ring Settings Section
+                    Section(header: Text("Ring Settings")) {
+                        Picker("Ring Type", selection: $ring) {
+                            ForEach(ContactTrickLargeRing.allCases, id: \.self) { ring in
+                                Text(ring.displayName).tag(ring)
+                            }
+                        }
+
+                        if ring != .none {
+                            Picker("Ring Width", selection: $ringWidth) {
+                                ForEach(ContactTrickEntry.RingWidth.allCases, id: \.self) { width in
+                                    Text(width.displayName).tag(width)
+                                }
+                            }
+                            Picker("Ring Gap", selection: $ringGap) {
+                                ForEach(ContactTrickEntry.RingGap.allCases, id: \.self) { gap in
+                                    Text(gap.displayName).tag(gap)
+                                }
+                            }
+                        }
+                    }.listRowBackground(Color.chart)
+
+                    // Font Settings Section
+                    Section(header: Text("Font Settings")) {
+                        fontSizePicker
+                        secondaryFontSizePicker
+                        fontWeightPicker
+                        fontWidthPicker
+                    }.listRowBackground(Color.chart)
+                }
+
+                stickySaveButton
+            }
+            .navigationTitle("Add Contact Items")
+            .navigationBarTitleDisplayMode(.inline)
+            .listSectionSpacing(10)
+            .padding(.top, 30)
+            .ignoresSafeArea(edges: .top)
+            .scrollContentBackground(.hidden)
+            .background(appState.trioBackgroundColor(for: colorScheme))
+            .toolbar {
+                ToolbarItem(placement: .topBarLeading) {
+                    Button("Cancel") {
+                        dismiss()
+                    }
+                }
+                ToolbarItem(placement: .topBarTrailing) {
+                    Button(
+                        action: {
+                            state.isHelpSheetPresented.toggle()
+                        },
+                        label: {
+                            Image(systemName: "questionmark.circle")
+                        }
+                    )
+                }
+            }
+            .sheet(isPresented: $state.isHelpSheetPresented) {
+                NavigationStack {
+                    List {
+                        Text("Lorem Ipsum Dolor Sit Amet")
+                    }
+                    .padding(.trailing, 10)
+                    .navigationBarTitle("Help", displayMode: .inline)
+
+                    Button { state.isHelpSheetPresented.toggle() }
+                    label: { Text("Got it!").frame(maxWidth: .infinity, alignment: .center) }
+                        .buttonStyle(.bordered)
+                        .padding(.top)
+                }
+                .padding()
+                .presentationDetents(
+                    [.fraction(0.9), .large],
+                    selection: $state.helpSheetDetent
+                )
+            }
+        }
+    }
+
+    var stickySaveButton: some View {
+        ZStack {
+            Rectangle()
+                .frame(width: UIScreen.main.bounds.width, height: 65)
+                .foregroundStyle(colorScheme == .dark ? Color.bgDarkerDarkBlue : Color.white)
+                .background(.thinMaterial)
+                .opacity(0.8)
+                .clipShape(Rectangle())
+
+            Button(action: {
+                saveNewEntry()
+            }, label: {
+                Text("Save").padding(10)
+            })
+                .frame(width: UIScreen.main.bounds.width * 0.9, alignment: .center)
+                .background(Color(.systemBlue))
+                .tint(.white)
+                .clipShape(RoundedRectangle(cornerRadius: 8))
+                .padding(5)
+        }
+    }
+
+    private var fontSizePicker: some View {
+        Picker("Font Size", selection: $fontSize) {
+            ForEach(ContactTrickEntry.FontSize.allCases, id: \.self) { size in
+                Text(size.displayName).tag(size)
+            }
+        }
+    }
+
+    private var secondaryFontSizePicker: some View {
+        Picker("Secondary Font Size", selection: $secondaryFontSize) {
+            ForEach(ContactTrickEntry.FontSize.allCases, id: \.self) { size in
+                Text(size.displayName).tag(size)
+            }
+        }
+    }
+
+    private var fontWeightPicker: some View {
+        Picker("Font Weight", selection: $fontWeight) {
+            ForEach(
+                [Font.Weight.light, Font.Weight.regular, Font.Weight.medium, Font.Weight.bold, Font.Weight.black],
+                id: \.self
+            ) { weight in
+                Text("\(weight.displayName)".capitalized).tag(weight)
+            }
+        }
+    }
+
+    private var fontWidthPicker: some View {
+        Picker("Font Width", selection: $fontWidth) {
+            ForEach(
+                [Font.Width.standard, Font.Width.condensed, Font.Width.expanded],
+                id: \.self
+            ) { width in
+                Text("\(width.displayName)".capitalized).tag(width)
+            }
+        }
+    }
+
+    private func saveNewEntry() {
+        // Save the currently previewed entry
+        Task {
+            await state.createAndSaveContactTrick(entry: previewEntry, name: "Trio \(state.contactTrickEntries.count + 1)")
+            dismiss()
+        }
+    }
+}

+ 216 - 0
FreeAPS/Sources/Modules/ContactTrick/View/ContactTrickDetailView.swift

@@ -0,0 +1,216 @@
+import SwiftUI
+
+struct ContactTrickDetailView: View {
+    @Environment(\.dismiss) var dismiss
+    @Environment(\.colorScheme) var colorScheme
+    @Environment(AppState.self) var appState
+
+    @ObservedObject var state: ContactTrick.StateModel
+
+    @State private var contactTrickEntry: ContactTrickEntry
+    @State private var initialContactTrickEntry: ContactTrickEntry
+
+    init(entry: ContactTrickEntry, state: ContactTrick.StateModel) {
+        self.state = state
+        _contactTrickEntry = State(initialValue: entry)
+        _initialContactTrickEntry = State(initialValue: entry)
+    }
+
+    var body: some View {
+        VStack {
+            HStack {
+                // TODO: - make this beautiful @Dan
+                Spacer()
+                ZStack {
+                    Circle()
+                        .fill(contactTrickEntry.hasHighContrast ? .black : .white)
+                        .foregroundColor(.white)
+                        .frame(width: 100, height: 100)
+                    Image(uiImage: ContactPicture.getImage(contact: contactTrickEntry, state: state.state))
+                        .resizable()
+                        .frame(width: 100, height: 100)
+                        .clipShape(Circle())
+                    Circle()
+                        .stroke(lineWidth: 2)
+                        .foregroundColor(.white)
+                        .frame(width: 100, height: 100)
+                }
+                Spacer()
+            }
+            .padding(.top, 80)
+            .padding(.bottom)
+
+            Form {
+                Section(header: Text("Style")) {
+                    Picker("Layout", selection: $contactTrickEntry.layout) {
+                        ForEach(ContactTrickLayout.allCases, id: \.id) { layout in
+                            Text(layout.displayName).tag(layout)
+                        }
+                    }
+                    Toggle("High Contrast Mode", isOn: $contactTrickEntry.hasHighContrast)
+                }.listRowBackground(Color.chart)
+
+                Section(header: Text("Display Values")) {
+                    if contactTrickEntry.layout == .single {
+                        Picker("Primary", selection: $contactTrickEntry.primary) {
+                            ForEach(ContactTrickValue.allCases, id: \.id) { value in
+                                Text(value.displayName).tag(value)
+                            }
+                        }
+                    }
+
+                    Picker("Top Value", selection: $contactTrickEntry.top) {
+                        ForEach(ContactTrickValue.allCases, id: \.id) { value in
+                            Text(value.displayName).tag(value)
+                        }
+                    }
+
+                    Picker("Bottom Value", selection: $contactTrickEntry.bottom) {
+                        ForEach(ContactTrickValue.allCases, id: \.id) { value in
+                            Text(value.displayName).tag(value)
+                        }
+                    }
+                }.listRowBackground(Color.chart)
+
+                // Ring Settings Section
+                Section(header: Text("Ring Settings")) {
+                    Picker("Ring Type", selection: $contactTrickEntry.ring) {
+                        ForEach(ContactTrickLargeRing.allCases, id: \.self) { ring in
+                            Text(ring.displayName).tag(ring)
+                        }
+                    }
+
+                    if contactTrickEntry.ring != .none {
+                        Picker("Ring Width", selection: $contactTrickEntry.ringWidth) {
+                            ForEach(ContactTrickEntry.RingWidth.allCases, id: \.self) { width in
+                                Text(width.displayName).tag(width)
+                            }
+                        }
+                        Picker("Ring Gap", selection: $contactTrickEntry.ringGap) {
+                            ForEach(ContactTrickEntry.RingGap.allCases, id: \.self) { gap in
+                                Text(gap.displayName).tag(gap)
+                            }
+                        }
+                    }
+                }.listRowBackground(Color.chart)
+
+                // Font Settings Section
+                Section(header: Text("Font Settings")) {
+                    fontSizePicker
+                    secondaryFontSizePicker
+                    fontWeightPicker
+                    fontWidthPicker
+                }.listRowBackground(Color.chart)
+            }
+        }
+        .navigationTitle("Edit Contact Items")
+        .navigationBarTitleDisplayMode(.inline)
+        .safeAreaInset(edge: .bottom, spacing: 0) { stickySaveButton }
+        .listSectionSpacing(10)
+        .padding(.top, 30)
+        .ignoresSafeArea(edges: .top)
+        .scrollContentBackground(.hidden)
+        .background(appState.trioBackgroundColor(for: colorScheme))
+        .toolbar {
+            ToolbarItem(placement: .topBarTrailing) {
+                Button(
+                    action: {
+                        state.isHelpSheetPresented.toggle()
+                    },
+                    label: {
+                        Image(systemName: "questionmark.circle")
+                    }
+                )
+            }
+        }
+        .sheet(isPresented: $state.isHelpSheetPresented) {
+            NavigationStack {
+                List {
+                    Text("Lorem Ipsum Dolor Sit Amet")
+                }
+                .padding(.trailing, 10)
+                .navigationBarTitle("Help", displayMode: .inline)
+
+                Button { state.isHelpSheetPresented.toggle() }
+                label: { Text("Got it!").frame(maxWidth: .infinity, alignment: .center) }
+                    .buttonStyle(.bordered)
+                    .padding(.top)
+            }
+            .padding()
+            .presentationDetents(
+                [.fraction(0.9), .large],
+                selection: $state.helpSheetDetent
+            )
+        }
+    }
+
+    private func saveChanges() {
+        Task {
+            await state.updateContact(with: contactTrickEntry)
+            dismiss()
+        }
+    }
+
+    var stickySaveButton: some View {
+        var isUnchanged: Bool { initialContactTrickEntry == contactTrickEntry }
+
+        return ZStack {
+            Rectangle()
+                .frame(width: UIScreen.main.bounds.width, height: 65)
+                .foregroundStyle(colorScheme == .dark ? Color.bgDarkerDarkBlue : Color.white)
+                .background(.thinMaterial)
+                .opacity(0.8)
+                .clipShape(Rectangle())
+
+            Button(action: {
+                saveChanges()
+            }, label: {
+                Text("Save").padding(10)
+            })
+                .frame(width: UIScreen.main.bounds.width * 0.9, alignment: .center)
+                .background(isUnchanged ? Color(.systemGray4) : Color(.systemBlue))
+                .disabled(isUnchanged)
+                .tint(.white)
+                .clipShape(RoundedRectangle(cornerRadius: 8))
+                .padding(5)
+        }
+    }
+
+    private var fontSizePicker: some View {
+        Picker("Font Size", selection: $contactTrickEntry.fontSize) {
+            ForEach(ContactTrickEntry.FontSize.allCases, id: \.self) { size in
+                Text(size.displayName).tag(size)
+            }
+        }
+    }
+
+    private var secondaryFontSizePicker: some View {
+        Picker("Secondary Font Size", selection: $contactTrickEntry.secondaryFontSize) {
+            ForEach(ContactTrickEntry.FontSize.allCases, id: \.self) { size in
+                Text(size.displayName).tag(size)
+            }
+        }
+    }
+
+    private var fontWeightPicker: some View {
+        Picker("Font Weight", selection: $contactTrickEntry.fontWeight) {
+            ForEach(
+                [Font.Weight.light, Font.Weight.regular, Font.Weight.medium, Font.Weight.bold, Font.Weight.black],
+                id: \.self
+            ) { weight in
+                Text("\(weight.displayName)".capitalized).tag(weight)
+            }
+        }
+    }
+
+    private var fontWidthPicker: some View {
+        Picker("Font Width", selection: $contactTrickEntry.fontWidth) {
+            ForEach(
+                [Font.Width.standard, Font.Width.condensed, Font.Width.expanded],
+                id: \.self
+            ) { width in
+                Text("\(width.displayName)".capitalized).tag(width)
+            }
+        }
+    }
+}

+ 89 - 0
FreeAPS/Sources/Modules/ContactTrick/View/ContactTrickRootView.swift

@@ -0,0 +1,89 @@
+import Contacts
+import ContactsUI
+import SwiftUI
+import Swinject
+
+extension ContactTrick {
+    struct RootView: BaseView {
+        let resolver: Resolver
+        @State var state = StateModel()
+        @State private var isAddSheetPresented: Bool = false
+
+        @Environment(\.colorScheme) var colorScheme
+        @Environment(AppState.self) var appState
+
+        var body: some View {
+            Form {
+                contactTrickList
+            }
+            .onAppear(perform: configureView)
+            .navigationTitle("Contacts Configuration")
+            .navigationBarTitleDisplayMode(.large)
+            .scrollContentBackground(.hidden)
+            .background(appState.trioBackgroundColor(for: colorScheme))
+            .toolbar {
+                ToolbarItem(placement: .topBarTrailing) {
+                    Button(action: {
+                        isAddSheetPresented.toggle()
+                    }) {
+                        HStack {
+                            Text("Add Contact")
+                            Image(systemName: "plus")
+                        }
+                    }
+                }
+            }
+            .sheet(isPresented: $isAddSheetPresented) {
+                AddContactTrickSheet(state: state)
+            }
+        }
+
+        private var contactTrickList: some View {
+            List {
+                if state.contactTrickEntries.isEmpty {
+                    Section(
+                        header: Text(""),
+                        content: {
+                            Text("No Contact Trick Entries.")
+                        }
+                    ).listRowBackground(Color.chart)
+                } else {
+                    ForEach(state.contactTrickEntries, id: \.id) { entry in
+                        NavigationLink(destination: ContactTrickDetailView(entry: entry, state: state)) {
+                            HStack {
+                                ZStack {
+                                    Circle()
+                                        .fill(entry.hasHighContrast ? .black : .white)
+                                        .foregroundColor(.white)
+                                        .frame(width: 40, height: 40)
+
+                                    Image(uiImage: ContactPicture.getImage(contact: entry, state: state.state))
+                                        .resizable()
+                                        .frame(width: 40, height: 40)
+                                        .clipShape(Circle())
+
+                                    Circle()
+                                        .stroke(lineWidth: 2)
+                                        .foregroundColor(.white)
+                                        .frame(width: 40, height: 40)
+                                }
+
+                                Text("\(entry.name)")
+                            }
+                        }
+                    }
+                    .onDelete(perform: onDelete)
+                }
+            }.listRowBackground(Color.chart)
+        }
+
+        private func onDelete(offsets: IndexSet) {
+            Task {
+                for offset in offsets {
+                    let entry = state.contactTrickEntries[offset]
+                    await state.deleteContact(entry: entry)
+                }
+            }
+        }
+    }
+}

+ 1 - 1
FreeAPS/Sources/Modules/Home/View/HomeRootView.swift

@@ -574,7 +574,7 @@ extension Home {
                                     Color.insulin.opacity(0.1)
                             ) : Color.clear // Use clear and add the Material in the background
                     )
-                    .background(.ultraThinMaterial)
+                    .background(.ultraThinMaterial.opacity(colorScheme == .dark ? 0.35 : 0))
                     .clipShape(RoundedRectangle(cornerRadius: 15))
                     .frame(height: geo.size.height * 0.08)
                     .shadow(

+ 6 - 0
FreeAPS/Sources/Modules/Settings/SettingItems.swift

@@ -52,6 +52,12 @@ enum SettingItems {
             view: .watch,
             searchContents: ["Display on Watch", "Show Protein and Fat", "Confirm Bolus Faster"],
             path: ["Devices", "Smart Watch", "Apple Watch"]
+        ),
+        SettingItem(
+            title: "Contact Image",
+            view: .watch,
+            searchContents: ["Display on Watch", "Watch Complication"],
+            path: ["Devices", "Smart Watch", "Apple Watch", "Contact Image"]
         )
     ]
 

+ 4 - 3
FreeAPS/Sources/Modules/Settings/View/Subviews/NotificationsView.swift

@@ -31,7 +31,8 @@ struct NotificationsView: BaseView {
                 content: {
                     manageNotifications
                 }
-            )
+            ).listRowBackground(Color.chart)
+
             Section {
                 VStack {
                     notificationsEnabledStatus
@@ -59,6 +60,7 @@ struct NotificationsView: BaseView {
                     }.padding(.top)
                 }.padding(.bottom)
             }.listRowBackground(Color.chart)
+
             Section(
                 header: Text("Notification Center"),
                 content: {
@@ -71,8 +73,7 @@ struct NotificationsView: BaseView {
 
                     Text("Calendar Events").navigationLink(to: .calendarEventSettings, from: self)
                 }
-            )
-            .listRowBackground(Color.chart)
+            ).listRowBackground(Color.chart)
         }
         .onReceive(
             resolver.resolve(AlertPermissionsChecker.self)!.$notificationsDisabled,

+ 16 - 1
FreeAPS/Sources/Modules/WatchConfig/View/WatchConfigAppleWatchView.swift

@@ -1,6 +1,8 @@
 import SwiftUI
+import Swinject
 
-struct WatchConfigAppleWatchView: View {
+struct WatchConfigAppleWatchView: BaseView {
+    let resolver: Resolver
     @ObservedObject var state: WatchConfig.StateModel
 
     @State private var shouldDisplayHint: Bool = false
@@ -93,6 +95,19 @@ struct WatchConfigAppleWatchView: View {
                 miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
                 verboseHint: "Confirm Bolus Faster… bla bla bla"
             )
+
+            Section(
+                header: Text("Contact Trick"),
+                content: {
+                    VStack {
+                        HStack {
+                            NavigationLink("Contacts Configuration") {
+                                ContactTrick.RootView(resolver: resolver)
+                            }.foregroundStyle(Color.accentColor)
+                        }
+                    }
+                }
+            ).listRowBackground(Color.chart)
         }
         .sheet(isPresented: $shouldDisplayHint) {
             SettingInputHintView(

+ 1 - 1
FreeAPS/Sources/Modules/WatchConfig/View/WatchConfigRootView.swift

@@ -14,7 +14,7 @@ extension WatchConfig {
                 Section(
                     header: Text("Smartwatch Configuration"),
                     content: {
-                        NavigationLink("Apple Watch", destination: WatchConfigAppleWatchView(state: state))
+                        NavigationLink("Apple Watch", destination: WatchConfigAppleWatchView(resolver: resolver, state: state))
                         NavigationLink("Garmin", destination: WatchConfigGarminView(state: state))
                     }
                 ).listRowBackground(Color.chart)

+ 3 - 0
FreeAPS/Sources/Router/Screen.swift

@@ -42,6 +42,7 @@ enum Screen: Identifiable, Hashable {
     case liveActivitySettings
     case liveActivityBottomRowSettings
     case calendarEventSettings
+    case contactTrick
     case serviceSettings
     case remoteControlConfig
     case autosensSettings
@@ -138,6 +139,8 @@ extension Screen {
             LiveActivityWidgetConfiguration(resolver: resolver, state: LiveActivitySettings.StateModel())
         case .calendarEventSettings:
             CalendarEventSettings.RootView(resolver: resolver)
+        case .contactTrick:
+            ContactTrick.RootView(resolver: resolver)
         case .serviceSettings:
             ServicesView(resolver: resolver, state: Settings.StateModel())
         case .autosensSettings:

+ 500 - 0
FreeAPS/Sources/Services/ContactTrick/ContactTrickManager.swift

@@ -0,0 +1,500 @@
+import Combine
+import Contacts
+import CoreData
+import Swinject
+
+protocol ContactTrickManagerDelegate: AnyObject {
+    func contactTrickManagerDidUpdateState(_ state: ContactTrickState)
+}
+
+protocol ContactTrickManager {
+    var delegate: ContactTrickManagerDelegate? { get set }
+    func requestAccess() async -> Bool
+    func createContact(name: String) async -> String?
+    func deleteContact(withIdentifier identifier: String) async -> Bool
+    func updateContact(withIdentifier identifier: String, newName: String) async -> Bool
+    @MainActor func updateContactTrickState() async
+    func setImageForContact(contactId: String) async
+    func validateContactExists(withIdentifier identifier: String) async -> Bool
+}
+
+final class BaseContactTrickManager: NSObject, ContactTrickManager, Injectable {
+    @Injected() private var glucoseStorage: GlucoseStorage!
+    @Injected() private var contactTrickStorage: ContactTrickStorage!
+    @Injected() private var settingsManager: SettingsManager!
+    @Injected() private var fileStorage: FileStorage!
+
+    private let contactStore = CNContactStore()
+
+    // Make it read-only from outside the class
+    private(set) var state = ContactTrickState()
+
+    private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
+    private let backgroundContext = CoreDataStack.shared.newTaskContext()
+
+    private var coreDataPublisher: AnyPublisher<Set<NSManagedObject>, Never>?
+    private var subscriptions = Set<AnyCancellable>()
+
+    private var units: GlucoseUnits = .mgdL
+
+    private var deltaFormatter: NumberFormatter {
+        let formatter = NumberFormatter()
+        formatter.numberStyle = .decimal
+        formatter.maximumFractionDigits = settingsManager.settings.units == .mmolL ? 1 : 0
+        formatter.positivePrefix = "+"
+        formatter.negativePrefix = "-"
+        return formatter
+    }
+
+    weak var delegate: ContactTrickManagerDelegate?
+
+    init(resolver: Resolver) {
+        super.init()
+        injectServices(resolver)
+        units = settingsManager.settings.units
+        coreDataPublisher =
+            changedObjectsOnManagedObjectContextDidSavePublisher()
+                .receive(on: DispatchQueue.global(qos: .background))
+                .share()
+                .eraseToAnyPublisher()
+
+        glucoseStorage.updatePublisher
+            .receive(on: DispatchQueue.global(qos: .background))
+            .sink { [weak self] _ in
+                guard let self = self else { return }
+                Task {
+                    await self.updateContactTrickState()
+                    await self.updateContactImages()
+                }
+            }
+            .store(in: &subscriptions)
+
+        registerHandlers()
+    }
+
+    // MARK: - Core Data observation
+
+    private func registerHandlers() {
+        /*
+         TODO: - Do we really need to update in both cases, i.e. when OrefDetermination entity AND GlucoseStored entity have received updates ?
+         The main use case is showing glucose values and both updates happen ~ at the same time and if a new glucose value arrives the latest Determination gets fetched with that as well. Moreover, we don't need to update on Determination updates at all if the user hasn't chosen to display anything Determination related
+         */
+//
+//        coreDataPublisher?.filterByEntityName("OrefDetermination").sink { [weak self] _ in
+//            guard let self = self else { return }
+//            Task {
+//                await self.updateContactTrickState()
+//                await self.updateContactImages()
+//            }
+//        }.store(in: &subscriptions)
+
+        // Only needed for manual glucose entries
+        coreDataPublisher?.filterByEntityName("GlucoseStored").sink { [weak self] _ in
+            guard let self = self else { return }
+            Task {
+                await self.updateContactTrickState()
+                await self.updateContactImages()
+            }
+        }.store(in: &subscriptions)
+    }
+
+    // MARK: - Core Data Fetches
+
+    private func fetchlastDetermination() async -> [NSManagedObjectID] {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: OrefDetermination.self,
+            onContext: backgroundContext,
+            predicate: NSPredicate(format: "deliverAt >= %@", Date.halfHourAgo as NSDate), // fetches enacted and suggested
+            key: "deliverAt",
+            ascending: false,
+            fetchLimit: 1
+        )
+
+        return await backgroundContext.perform {
+            guard let fetchedResults = results as? [OrefDetermination] else { return [] }
+
+            return fetchedResults.map(\.objectID)
+        }
+    }
+
+    private func fetchGlucose() async -> [NSManagedObjectID] {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: backgroundContext,
+            predicate: NSPredicate.predicateFor20MinAgo,
+            key: "date",
+            ascending: false,
+            fetchLimit: 3 /// We only need 1-3 values, depending on whether the user wants to show delta or not
+        )
+
+        return await backgroundContext.perform {
+            guard let glucoseResults = results as? [GlucoseStored] else {
+                return []
+            }
+
+            return glucoseResults.map(\.objectID)
+        }
+    }
+
+    private func getCurrentGlucoseTarget() async -> Decimal? {
+        let now = Date()
+        let calendar = Calendar.current
+        let dateFormatter = DateFormatter()
+        dateFormatter.dateFormat = "HH:mm"
+        dateFormatter.timeZone = TimeZone.current
+
+        let bgTargets = await fileStorage.retrieveAsync(OpenAPS.Settings.bgTargets, as: BGTargets.self)
+            ?? BGTargets(from: OpenAPS.defaults(for: OpenAPS.Settings.bgTargets))
+            ?? BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: [])
+        let entries: [(start: String, value: Decimal)] = bgTargets.targets.map { ($0.start, $0.low) }
+
+        for (index, entry) in entries.enumerated() {
+            guard let entryTime = dateFormatter.date(from: entry.start) else {
+                print("Invalid entry start time: \(entry.start)")
+                continue
+            }
+
+            let entryComponents = calendar.dateComponents([.hour, .minute, .second], from: entryTime)
+            let entryStartTime = calendar.date(
+                bySettingHour: entryComponents.hour!,
+                minute: entryComponents.minute!,
+                second: entryComponents.second!,
+                of: now
+            )!
+
+            let entryEndTime: Date
+            if index < entries.count - 1,
+               let nextEntryTime = dateFormatter.date(from: entries[index + 1].start)
+            {
+                let nextEntryComponents = calendar.dateComponents([.hour, .minute, .second], from: nextEntryTime)
+                entryEndTime = calendar.date(
+                    bySettingHour: nextEntryComponents.hour!,
+                    minute: nextEntryComponents.minute!,
+                    second: nextEntryComponents.second!,
+                    of: now
+                )!
+            } else {
+                entryEndTime = calendar.date(byAdding: .day, value: 1, to: entryStartTime)!
+            }
+
+            if now >= entryStartTime, now < entryEndTime {
+                return entry.value
+            }
+        }
+
+        return nil
+    }
+
+    // MARK: - Configure ContactTrickState in order to update ContactTrickImage
+
+    /// Updates the `ContactTrickState` with the latest data from Core Data.
+    /// This function fetches glucose values and determination entries, processes the data,
+    /// and updates the `state` object, which represents the current contact trick state.
+    /// - Important: This function must be called on the main actor to ensure thread safety. Otherwise, we would need to ensure thread safety by either using an actor or a perform closure
+    @MainActor func updateContactTrickState() async {
+        // Get NSManagedObjectIDs on backgroundContext
+        let glucoseValuesIds = await fetchGlucose()
+        let determinationIds = await fetchlastDetermination()
+
+        // Get NSManagedObjects on MainActor
+        let glucoseObjects: [GlucoseStored] = await CoreDataStack.shared
+            .getNSManagedObject(with: glucoseValuesIds, context: viewContext)
+        let determinationObjects: [OrefDetermination] = await CoreDataStack.shared
+            .getNSManagedObject(with: determinationIds, context: viewContext)
+        let lastDetermination = determinationObjects.last
+
+        if let firstGlucoseValue = glucoseObjects.first {
+            let value = settingsManager.settings.units == .mgdL
+                ? Decimal(firstGlucoseValue.glucose)
+                : Decimal(firstGlucoseValue.glucose).asMmolL
+
+            state.glucose = Formatter.glucoseFormatter(for: units).string(from: value as NSNumber)
+            state.trend = firstGlucoseValue.directionEnum?.symbol
+
+            let delta = glucoseObjects.count >= 2
+                ? Decimal(firstGlucoseValue.glucose) - Decimal(glucoseObjects.dropFirst().first?.glucose ?? 0)
+                : 0
+            let deltaConverted = settingsManager.settings.units == .mgdL ? delta : delta.asMmolL
+            state.delta = deltaFormatter.string(from: deltaConverted as NSNumber)
+        }
+
+        state.lastLoopDate = lastDetermination?.timestamp
+
+        let iobValue = lastDetermination?.iob as? Decimal ?? 0.0
+        state.iob = iobValue
+        state.iobText = Formatter.decimalFormatterWithOneFractionDigit.string(from: iobValue as NSNumber)
+
+        // we need to do it complex and unelegant, otherwise unwrapping and parsing of cob results in 0
+        if let cobValue = lastDetermination?.cob {
+            state.cob = Decimal(cobValue)
+            state.cobText = Formatter.integerFormatter.string(from: Int(cobValue) as NSNumber)
+
+        } else {
+            state.cob = 0
+            state.cobText = "0"
+        }
+
+        if let eventualBG = settingsManager.settings.units == .mgdL ? lastDetermination?
+            .eventualBG : lastDetermination?
+            .eventualBG?.decimalValue.asMmolL as NSDecimalNumber?
+        {
+            let eventualBGAsString = Formatter.decimalFormatterWithOneFractionDigit.string(from: eventualBG)
+            state.eventualBG = eventualBGAsString.map { "⇢ " + $0 }
+        }
+
+        // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose
+        let hardCodedLow = Decimal(55)
+        let hardCodedHigh = Decimal(220)
+        let isDynamicColorScheme = settingsManager.settings.glucoseColorScheme == .dynamicColor
+
+        state.highGlucoseColorValue = isDynamicColorScheme ? hardCodedHigh : settingsManager.settings.highGlucose
+        state.lowGlucoseColorValue = isDynamicColorScheme ? hardCodedLow : settingsManager.settings.lowGlucose
+        state
+            .targetGlucose = await getCurrentGlucoseTarget() ??
+            (settingsManager.settings.units == .mgdL ? Decimal(100) : 100.asMmolL)
+        state.glucoseColorScheme = settingsManager.settings.glucoseColorScheme
+
+        // Notify delegate about state update on main thread
+        await MainActor.run {
+            delegate?.contactTrickManagerDidUpdateState(state)
+        }
+    }
+
+    // MARK: - Interactions with CNContactStore API
+
+    /// Checks if the app has access to the user's contacts.
+    func requestAccess() async -> Bool {
+        await withCheckedContinuation { continuation in
+            contactStore.requestAccess(for: .contacts) { granted, _ in
+                continuation.resume(returning: granted)
+            }
+        }
+    }
+
+    /// Sets the image for a specific contact in Apple Contacts.
+    /// This function fetches the associated `ContactTrickEntry` for the provided contact ID, generates an image
+    /// based on the current `ContactTrickState`, and updates the contact in the user's Apple Contacts.
+    /// - Parameter contactId: The unique identifier of the contact in Apple Contacts.
+    /// - Important: This function should be called when a new contact is created and needs its initial image set.
+    func setImageForContact(contactId: String) async {
+        guard let contactEntry = await contactTrickStorage.fetchContactTrickEntries().first(where: { $0.contactId == contactId })
+        else {
+            debugPrint("\(DebuggingIdentifiers.failed) No matching ContactTrickEntry found for contact ID: \(contactId)")
+            return
+        }
+
+        // Create image based on current state
+        let newImage = await ContactPicture.getImage(contact: contactEntry, state: state)
+
+        do {
+            let predicate = CNContact.predicateForContacts(withIdentifiers: [contactId])
+            let contacts = try contactStore.unifiedContacts(
+                matching: predicate,
+                keysToFetch: [
+                    CNContactIdentifierKey as CNKeyDescriptor,
+                    CNContactImageDataKey as CNKeyDescriptor
+                ]
+            )
+
+            guard let contact = contacts.first else {
+                debugPrint("\(DebuggingIdentifiers.failed) Contact with ID \(contactId) not found.")
+                return
+            }
+
+            let mutableContact = contact.mutableCopy() as! CNMutableContact
+            mutableContact.imageData = newImage.pngData()
+
+            let saveRequest = CNSaveRequest()
+            saveRequest.update(mutableContact)
+
+            try contactStore.execute(saveRequest)
+
+            debugPrint("\(DebuggingIdentifiers.succeeded) Image successfully set for contact ID: \(contactId)")
+        } catch {
+            debugPrint("\(DebuggingIdentifiers.failed) Failed to set image for contact ID \(contactId): \(error)")
+        }
+    }
+
+    /// Updates the images of all contacts stored in Core Data.
+    /// This function iterates through all stored `ContactTrickEntry` objects, generates a new contact image
+    /// based on the current `ContactTrickState`, and updates the image in the user's Apple Contacts.
+    /// - Important: This function should be called whenever the `ContactTrickState` changes.
+    func updateContactImages() async {
+        // Iterate through all stored ContactTrickEntry objects
+        for contactEntry in await contactTrickStorage.fetchContactTrickEntries() {
+            // Ensure the contact has a valid contact ID
+            guard let contactId = contactEntry.contactId else { continue }
+
+            // Generate a new image for the contact based on the updated state
+            let newImage = await ContactPicture.getImage(contact: contactEntry, state: state)
+
+            do {
+                // Fetch the existing contact from CNContactStore using its identifier
+                let predicate = CNContact.predicateForContacts(withIdentifiers: [contactId])
+                let contacts = try contactStore.unifiedContacts(
+                    matching: predicate,
+                    keysToFetch: [
+                        CNContactIdentifierKey as CNKeyDescriptor, // To identify the contact
+                        CNContactImageDataKey as CNKeyDescriptor // To fetch current image data
+                    ]
+                )
+
+                // Ensure the contact exists in the CNContactStore
+                guard let contact = contacts.first else {
+                    debugPrint(
+                        "\(DebuggingIdentifiers.failed) Contact with ID \(contactId) and name \(contactEntry.name) not found."
+                    )
+                    continue
+                }
+
+                // Create a mutable copy of the contact to update its image
+                let mutableContact = contact.mutableCopy() as! CNMutableContact
+                mutableContact.imageData = newImage.pngData() // Set the new image data
+
+                // Prepare a save request to update the contact
+                let saveRequest = CNSaveRequest()
+                saveRequest.update(mutableContact)
+
+                // Execute the save request to persist the changes
+                try contactStore.execute(saveRequest)
+
+                debugPrint("\(DebuggingIdentifiers.succeeded) Updated contact image for \(contactId)")
+            } catch {
+                debugPrint("\(DebuggingIdentifiers.failed) Failed to update contact image for \(contactId): \(error)")
+            }
+        }
+    }
+
+    /// Creates a new contact in the Apple contact list or updates an existing one with the same name.
+    /// - Parameter name: The name of the contact.
+    /// - Returns: The `identifier` of the created/updated contact, or `nil` if an error occurs.
+    func createContact(name: String) async -> String? {
+        do {
+            // First check if a contact with this name already exists
+            let predicate = CNContact.predicateForContacts(matchingName: name)
+            let existingContacts = try contactStore.unifiedContacts(
+                matching: predicate,
+                keysToFetch: [
+                    CNContactIdentifierKey as CNKeyDescriptor,
+                    CNContactGivenNameKey as CNKeyDescriptor
+                ]
+            )
+
+            // If contact exists, return its identifier
+            if let existingContact = existingContacts.first {
+                debugPrint("Found existing contact with name: \(name)")
+                return existingContact.identifier
+            }
+
+            // If no existing contact, create a new one
+            let contact = CNMutableContact()
+            contact.givenName = name
+
+            let saveRequest = CNSaveRequest()
+            saveRequest.add(contact, toContainerWithIdentifier: nil)
+
+            try contactStore.execute(saveRequest)
+
+            // Re-fetch to get the identifier
+            let newContacts = try contactStore.unifiedContacts(
+                matching: predicate,
+                keysToFetch: [CNContactIdentifierKey as CNKeyDescriptor]
+            )
+
+            guard let createdContact = newContacts.first else {
+                debugPrint("\(DebuggingIdentifiers.failed) Contact creation failed: No contact found after save.")
+                return nil
+            }
+
+            return createdContact.identifier
+        } catch {
+            debugPrint("\(DebuggingIdentifiers.failed) Error creating/finding contact: \(error)")
+            return nil
+        }
+    }
+
+    /// Validates if a contact still exists in iOS Contacts.
+    func validateContactExists(withIdentifier identifier: String) async -> Bool {
+        let store = CNContactStore()
+        let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier])
+        let keys = [CNContactIdentifierKey] as [CNKeyDescriptor]
+
+        do {
+            let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: keys)
+            return !contacts.isEmpty
+        } catch {
+            debugPrint("\(DebuggingIdentifiers.failed) Error validating contact: \(error)")
+            return false
+        }
+    }
+
+    /// Deletes a contact from the Apple contact list using its `identifier`.
+    /// - Parameter identifier: The unique identifier of the contact.
+    /// - Returns: `true` if the contact was successfully deleted, `false` otherwise.
+    func deleteContact(withIdentifier identifier: String) async -> Bool {
+        do {
+            // Attempt to find the contact using its identifier.
+            let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier])
+            let contacts = try contactStore.unifiedContacts(
+                matching: predicate,
+                keysToFetch: [CNContactIdentifierKey as CNKeyDescriptor]
+            )
+
+            guard let contact = contacts.first else {
+                debugPrint("\(DebuggingIdentifiers.failed) Contact with ID \(identifier) not found.")
+                return false
+            }
+
+            // Contact found -> Delete it.
+            let mutableContact = contact.mutableCopy() as! CNMutableContact
+            let deleteRequest = CNSaveRequest()
+            deleteRequest.delete(mutableContact)
+
+            try contactStore.execute(deleteRequest)
+            debugPrint("\(DebuggingIdentifiers.succeeded) Contact successfully deleted: \(identifier)")
+            return true
+        } catch {
+            debugPrint("\(DebuggingIdentifiers.failed) Error deleting contact: \(error)")
+            return false
+        }
+    }
+
+    /// Updates an existing contact in the Apple contact list.
+    /// - Parameters:
+    ///   - identifier: The unique identifier of the contact.
+    ///   - newName: The new name to assign to the contact.
+    /// - Returns: `true` if the contact was successfully updated, `false` otherwise.
+    func updateContact(withIdentifier identifier: String, newName: String) async -> Bool {
+        do {
+            // Search for the contact using its `identifier`.
+            let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier])
+            let contacts = try contactStore.unifiedContacts(
+                matching: predicate,
+                keysToFetch: [
+                    CNContactIdentifierKey as CNKeyDescriptor,
+                    CNContactGivenNameKey as CNKeyDescriptor,
+                    CNContactFamilyNameKey as CNKeyDescriptor
+                ]
+            )
+
+            guard let contact = contacts.first else {
+                debugPrint("\(DebuggingIdentifiers.failed) Contact with ID \(identifier) not found.")
+                return false
+            }
+
+            // Update the contact.
+            let mutableContact = contact.mutableCopy() as! CNMutableContact
+            mutableContact.givenName = newName
+
+            let updateRequest = CNSaveRequest()
+            updateRequest.update(mutableContact)
+
+            try contactStore.execute(updateRequest)
+            debugPrint("\(DebuggingIdentifiers.succeeded) Contact successfully updated: \(identifier)")
+            return true
+        } catch {
+            debugPrint("\(DebuggingIdentifiers.failed) Error updating contact: \(error)")
+            return false
+        }
+    }
+}

+ 882 - 0
FreeAPS/Sources/Services/ContactTrick/ContactTrickPicture.swift

@@ -0,0 +1,882 @@
+import Foundation
+import SwiftUI
+
+struct ContactPicture: View {
+    private enum Config {
+        static let lag: TimeInterval = 30
+    }
+
+    @Binding var contact: ContactTrickEntry
+    @Binding var state: ContactTrickState
+
+    private static let formatter: DateFormatter = {
+        let formatter = DateFormatter()
+        formatter.dateFormat = "HH:mm"
+        return formatter
+    }()
+
+    static func getImage(
+        contact: ContactTrickEntry,
+        state: ContactTrickState
+    ) -> UIImage {
+        let width = 1024.0
+        let height = 1024.0
+        var rect = CGRect(x: 0, y: 0, width: width, height: height)
+        let textColor: Color = contact.hasHighContrast ?
+            Color(red: 250 / 256, green: 250 / 256, blue: 250 / 256) :
+            Color(red: 20 / 256, green: 20 / 256, blue: 20 / 256)
+        let secondaryTextColor: Color = contact.hasHighContrast ?
+            Color(red: 220 / 256, green: 220 / 256, blue: 220 / 256) :
+            Color(red: 40 / 256, green: 40 / 256, blue: 40 / 256)
+        let fontWeight = contact.fontWeight
+
+        UIGraphicsBeginImageContext(rect.size)
+        if let context = UIGraphicsGetCurrentContext() {
+            context.setShouldAntialias(true)
+            context.setAllowsAntialiasing(true)
+        }
+
+        let ringWidth = Double(contact.ringWidth.rawValue) / 100.0
+        let ringGap = Double(contact.ringGap.rawValue) / 100.0
+        let outerGap = 0.03
+
+        if contact.ring != .none {
+            rect = CGRect(
+                x: rect.minX + width * outerGap,
+                y: rect.minY + height * outerGap,
+                width: rect.width - width * outerGap * 2,
+                height: rect.height - height * outerGap * 2
+            )
+
+            let ringRect = CGRect(
+                x: rect.minX + width * ringWidth * 0.5,
+                y: rect.minY + height * ringWidth * 0.5,
+                width: rect.width - width * ringWidth,
+                height: rect.height - height * ringWidth
+            )
+
+            drawRing(ring: contact.ring, contact: contact, state: state, rect: ringRect, strokeWidth: width * ringWidth)
+
+            rect = CGRect(
+                x: rect.minX + width * (ringWidth + ringGap),
+                y: rect.minY + height * (ringWidth + ringGap),
+                width: rect.width - width * (ringWidth + ringGap) * 2,
+                height: rect.height - height * (ringWidth + ringGap) * 2
+            )
+        }
+
+        switch contact.layout {
+        case .single:
+            let showTop = contact.top != .none
+            let showBottom = contact.bottom != .none
+
+            let centerX = rect.minX + rect.width / 2
+            let centerY = rect.minY + rect.height / 2
+            let radius = min(rect.width, rect.height) / 2
+
+            var primaryHeight = radius * 0.8
+            let topHeight = radius * 0.5
+            var bottomHeight = radius * 0.5
+
+            var primaryY = centerY - primaryHeight / 2
+
+            if contact.bottom == .none, contact.top != .none {
+                primaryY += radius * 0.2
+            }
+            if contact.bottom != .none, contact.top == .none {
+                primaryY -= radius * 0.2
+            }
+
+            let topY = primaryY - topHeight
+            var bottomY = primaryY + primaryHeight
+
+            let primaryWidth = 2 * sqrt(radius * radius - (primaryHeight * 0.5) * (primaryHeight * 0.5))
+            let topWidth = 2 *
+                sqrt(radius * radius - (topHeight + primaryHeight * 0.5) * (topHeight + primaryHeight * 0.5))
+            var bottomWidth = 2 *
+                sqrt(radius * radius - (bottomHeight + primaryHeight * 0.5) * (bottomHeight + primaryHeight * 0.5))
+
+            if contact.bottom != .none, contact.top == .none {
+                // move things around a little bit to give more space to the bottom area
+
+                // TODO: revisit rings for iob, cob and combined iob+cob with more user feedback
+                if contact.bottom == .trend, contact.ring == .loop {
+//                if contact.ring == .iob || contact.ring == .cob || contact.ring == .iobcob ||
+//                    (contact.bottom == .trend && contact.ring == .loop)
+//                {
+                    bottomHeight = bottomHeight + height * ringWidth * 2
+                    bottomWidth = bottomWidth + width * ringWidth * 2
+                } else if contact.ring == .loop {
+                    primaryHeight = primaryHeight - height * ringWidth
+                    bottomY = primaryY + primaryHeight
+                    bottomHeight = bottomHeight + height * ringWidth * 2
+                    bottomWidth = bottomWidth + width * ringWidth * 2
+                }
+            }
+
+            let primaryRect = (showTop || showBottom) ? CGRect(
+                x: centerX - primaryWidth * 0.5,
+                y: primaryY,
+                width: primaryWidth,
+                height: primaryHeight
+            ) : rect
+            let topRect = CGRect(
+                x: centerX - topWidth * 0.5,
+                y: topY,
+                width: topWidth,
+                height: topHeight
+            )
+            let bottomRect = CGRect(
+                x: centerX - bottomWidth * 0.5,
+                y: bottomY,
+                width: bottomWidth,
+                height: bottomHeight
+            )
+            let secondaryFontSize = contact.secondaryFontSize
+
+            displayPiece(
+                value: contact.primary,
+                contact: contact,
+                state: state,
+                rect: primaryRect,
+                fitHeigh: false,
+                fontSize: contact.fontSize.rawValue,
+                fontWeight: fontWeight,
+                fontWidth: contact.fontWidth,
+                color: textColor
+            )
+            if showTop {
+                displayPiece(
+                    value: contact.top,
+                    contact: contact,
+                    state: state,
+                    rect: topRect,
+                    fitHeigh: true,
+                    fontSize: secondaryFontSize.rawValue,
+                    fontWeight: fontWeight,
+                    fontWidth: contact.fontWidth,
+                    color: secondaryTextColor
+                )
+            }
+            if showBottom {
+                displayPiece(
+                    value: contact.bottom,
+                    contact: contact,
+                    state: state,
+                    rect: bottomRect,
+                    fitHeigh: true,
+                    fontSize: secondaryFontSize.rawValue,
+                    fontWeight: fontWeight,
+                    fontWidth: contact.fontWidth,
+                    color: secondaryTextColor
+                )
+            }
+
+        case .split:
+            let centerX = rect.origin.x + rect.size.width / 2
+            let centerY = rect.origin.y + rect.size.height / 2
+            let radius = min(rect.size.width, rect.size.height) / 2
+
+            let rectangleHeight = radius * sqrt(2) / 2
+            let rectangleWidth = sqrt(2) * radius
+
+            let topY = centerY - rectangleHeight
+            let bottomY = centerY
+
+            let topRect = CGRect(
+                x: centerX - rectangleWidth / 2,
+                y: topY,
+                width: rectangleWidth,
+                height: rectangleHeight
+            )
+            let bottomRect = CGRect(
+                x: centerX - rectangleWidth / 2,
+                y: bottomY,
+                width: rectangleWidth,
+                height: rectangleHeight
+            )
+            let topFontSize = contact.fontSize
+            let bottomFontSize = contact.secondaryFontSize
+
+            displayPiece(
+                value: contact.top,
+                contact: contact,
+                state: state,
+                rect: topRect,
+                fitHeigh: true,
+                fontSize: topFontSize.rawValue,
+                fontWeight: fontWeight,
+                fontWidth: contact.fontWidth,
+                color: textColor
+            )
+            displayPiece(
+                value: contact.bottom,
+                contact: contact,
+                state: state,
+                rect: bottomRect,
+                fitHeigh: true,
+                fontSize: bottomFontSize.rawValue,
+                fontWeight: fontWeight,
+                fontWidth: contact.fontWidth,
+                color: textColor
+            )
+        }
+        let image = UIGraphicsGetImageFromCurrentImageContext()
+        UIGraphicsEndImageContext()
+        return image ?? UIImage()
+    }
+
+    private static func displayPiece(
+        value: ContactTrickValue,
+        contact: ContactTrickEntry,
+        state: ContactTrickState,
+        rect: CGRect,
+        fitHeigh: Bool,
+        fontSize: Int,
+        fontWeight: Font.Weight,
+        fontWidth: Font.Width,
+        color: Color
+    ) {
+        guard value != .none else { return }
+        if value == .ring {
+            drawRing(
+                ring: .loop,
+                contact: contact,
+                state: state,
+                rect: CGRect(
+                    x: rect.minX + rect.width * 0.10,
+                    y: rect.minY + rect.height * 0.10,
+                    width: rect.width * 0.80,
+                    height: rect.height * 0.80
+                ),
+                strokeWidth: 10.0
+            )
+            return
+        }
+        let text: String? = switch value {
+        case .glucose: state.glucose
+        case .eventualBG: state.eventualBG
+        case .delta: state.delta
+        case .trend: state.trend
+        case .lastLoopDate: state.lastLoopDate.map({ formatter.string(from: $0) })
+        case .cob: state.cobText
+        case .iob: state.iobText
+        default: nil
+        }
+
+        let glucoseValue = Decimal(string: state.glucose ?? "100") ?? 100
+
+        let dynamicColor: Color = FreeAPS.getDynamicGlucoseColor(
+            glucoseValue: glucoseValue,
+            highGlucoseColorValue: state.highGlucoseColorValue,
+            lowGlucoseColorValue: state.lowGlucoseColorValue,
+            targetGlucose: state.targetGlucose,
+            glucoseColorScheme: state.glucoseColorScheme
+        )
+
+        let textColor: Color = switch value {
+        case .cob:
+            .loopYellow
+        case .glucose:
+            dynamicColor
+        default:
+            color
+        }
+
+        if let text = text {
+            drawText(
+                text: text,
+                rect: rect,
+                fitHeigh: fitHeigh,
+                fontSize: fontSize,
+                fontWeight: fontWeight,
+                fontWidth: fontWidth,
+                color: textColor
+            )
+        }
+    }
+
+    private static func drawText(
+        text: String,
+        rect: CGRect,
+        fitHeigh: Bool,
+        fontSize: Int,
+        fontWeight: Font.Weight,
+        fontWidth: Font.Width,
+        color: Color
+    ) {
+        var theFontSize = fontSize
+
+        func makeAttributes(_ size: Int) -> [NSAttributedString.Key: Any] {
+            let font = UIFont.systemFont(ofSize: CGFloat(size), weight: fontWeight.uiFontWeight)
+            return [
+                .font: font,
+                .foregroundColor: UIColor(color),
+                .kern: fontWidth.value * Double(fontSize) // `kern` is the correct key for tracking
+            ]
+        }
+
+        var attributes: [NSAttributedString.Key: Any] = makeAttributes(theFontSize)
+
+        var stringSize = text.size(withAttributes: attributes)
+        while stringSize.width > rect.width * 0.90 || fitHeigh && (stringSize.height > rect.height * 0.95), theFontSize > 50 {
+            theFontSize -= 10
+            attributes = makeAttributes(theFontSize)
+            stringSize = text.size(withAttributes: attributes)
+        }
+
+        text.draw(
+            in: CGRect(
+                x: rect.minX + (rect.width - stringSize.width) / 2,
+                y: rect.minY + (rect.height - stringSize.height) / 2,
+                width: stringSize.width,
+                height: stringSize.height
+            ),
+            withAttributes: attributes
+        )
+    }
+
+    private static func drawRing(
+        ring: ContactTrickLargeRing,
+        contact: ContactTrickEntry,
+        state: ContactTrickState,
+        rect: CGRect,
+        strokeWidth: Double
+    ) {
+        guard let context = UIGraphicsGetCurrentContext() else {
+            return
+        }
+        switch ring {
+        case .loop:
+            let color = ringColor(contact: contact, state: state)
+
+            let strokeWidth = strokeWidth
+            let center = CGPoint(x: rect.midX, y: rect.midY)
+            let radius = min(rect.width, rect.height) / 2 - strokeWidth / 2
+
+            context.setLineWidth(strokeWidth)
+            context.setStrokeColor(UIColor(color).cgColor)
+
+            context.addArc(center: center, radius: radius, startAngle: 0, endAngle: 2 * .pi, clockwise: false)
+
+            context.strokePath()
+//        case .iob:
+//            if let iob = state.iob, state.maxIOB > 0.1 {
+//                drawProgressBar(
+//                    rect: rect,
+//                    progress: Double(iob) / Double(state.maxIOB),
+//                    colors: [contact.hasHighContrast ? .blue : .blue, contact.hasHighContrast ? .pink : .red],
+//                    strokeWidth: strokeWidth
+//                )
+//            }
+//        case .cob:
+//            if let cob = state.cob, state.maxCOB > 0.01 {
+//                drawProgressBar(
+//                    rect: rect,
+//                    progress: Double(cob) / Double(state.maxCOB),
+//                    colors: [.loopYellow, .red],
+//                    strokeWidth: strokeWidth
+//                )
+//            }
+//        case .iobcob:
+//            if state.maxIOB > 0.01, state.maxCOB > 0.01 {
+//                drawDoubleProgressBar(
+//                    rect: rect,
+//                    progress1: state.iob.map { Double($0) / Double(state.maxIOB) },
+//                    progress2: state.cob.map { Double($0) / Double(state.maxCOB) },
+//                    colors1: [contact.hasHighContrast ? .blue : .blue, contact.hasHighContrast ? .pink : .red],
+//                    colors2: [.loopYellow, .red],
+//                    strokeWidth: strokeWidth
+//                )
+//            }
+        default:
+            break
+        }
+    }
+
+    private static func drawProgressBar(
+        rect: CGRect,
+        progress: Double,
+        colors: [Color],
+        strokeWidth: Double
+    ) {
+        let startAngle: CGFloat = -(.pi + .pi / 4.0)
+        let endAngle: CGFloat = .pi / 4.0
+
+        drawGradientArc(
+            rect: rect,
+            progress: progress,
+            colors: colors,
+            strokeWidth: strokeWidth,
+            startAngle: startAngle,
+            endAngle: endAngle,
+            gradientDirection: .leftToRight
+        )
+    }
+
+    private static func drawDoubleProgressBar(
+        rect: CGRect,
+        progress1: Double?,
+        progress2: Double?,
+        colors1: [Color],
+        colors2: [Color],
+        strokeWidth: Double
+    ) {
+        if let progress1 = progress1 {
+            let startAngle1: CGFloat = .pi / 2 + .pi / 5
+            let endAngle1: CGFloat = 3 * .pi / 2 - .pi / 5
+            drawGradientArc(
+                rect: rect,
+                progress: progress1,
+                colors: colors1,
+                strokeWidth: strokeWidth,
+                startAngle: startAngle1,
+                endAngle: endAngle1,
+                gradientDirection: .bottomToTop
+            )
+        }
+        if let progress2 = progress2 {
+            let startAngle2: CGFloat = .pi / 2 - .pi / 5
+            let endAngle2: CGFloat = -.pi / 2 + .pi / 5
+            drawGradientArc(
+                rect: rect,
+                progress: progress2,
+                colors: colors2,
+                strokeWidth: strokeWidth,
+                startAngle: startAngle2,
+                endAngle: endAngle2,
+                gradientDirection: .bottomToTop
+            )
+        }
+    }
+
+    private static func drawGradientArc(
+        rect: CGRect,
+        progress: Double,
+        colors: [Color],
+        strokeWidth: Double,
+        startAngle: Double,
+        endAngle: Double,
+        gradientDirection: GradientDirection
+    ) {
+        guard let context = UIGraphicsGetCurrentContext() else {
+            return
+        }
+
+        let colors = colors.map { c in UIColor(c).cgColor }
+        let locations: [CGFloat] = [0.0, 1.0]
+        guard let gradient = CGGradient(
+            colorsSpace: CGColorSpaceCreateDeviceRGB(),
+            colors: colors as CFArray,
+            locations: locations
+        ) else {
+            return
+        }
+
+        context.saveGState()
+
+        let center = CGPoint(x: rect.midX, y: rect.midY)
+        let radius = min(rect.width, rect.height) / 2 - strokeWidth / 2
+
+        // angle - The angle to the starting point of the arc, measured in radians from the positive x-axis.
+
+        context.setLineWidth(strokeWidth)
+        context.setLineCap(.round)
+
+        let circumference = 2 * .pi * radius
+        let offsetAngle = (strokeWidth / circumference * 1.1) * 2 * .pi
+
+        let (start, middle, end) = if startAngle > endAngle {
+            (
+                endAngle,
+                startAngle - (startAngle - endAngle) * max(min(progress, 1.0), 0.0),
+                startAngle
+            )
+        } else {
+            (
+                startAngle,
+                startAngle + (endAngle - startAngle) * max(min(progress, 1.0), 0.0),
+                endAngle
+            )
+        }
+
+        if start < middle - offsetAngle {
+            let arcPath1 = UIBezierPath()
+            arcPath1.addArc(
+                withCenter: center,
+                radius: radius,
+                startAngle: start,
+                endAngle: middle - offsetAngle,
+                clockwise: true
+            )
+            context.addPath(arcPath1.cgPath)
+        }
+
+        if middle + offsetAngle < end {
+            let arcPath2 = UIBezierPath()
+            arcPath2.addArc(
+                withCenter: center,
+                radius: radius,
+                startAngle: middle + offsetAngle,
+                endAngle: end,
+                clockwise: true
+            )
+            context.addPath(arcPath2.cgPath)
+        }
+
+        context.replacePathWithStrokedPath()
+        context.clip()
+
+        switch gradientDirection {
+        case .bottomToTop:
+            context.drawLinearGradient(
+                gradient,
+                start: CGPoint(x: rect.midX, y: rect.maxY),
+                end: CGPoint(x: rect.midX, y: rect.minY),
+                options: []
+            )
+
+        case .leftToRight:
+            context.drawLinearGradient(
+                gradient,
+                start: CGPoint(x: rect.minX, y: rect.midY),
+                end: CGPoint(x: rect.maxX, y: rect.midY),
+                options: []
+            )
+        }
+        context.resetClip()
+
+        let circleCenter = CGPoint(
+            x: center.x + radius * cos(middle),
+            y: center.y + radius * sin(middle)
+        )
+
+        context.setLineWidth(strokeWidth * 0.7)
+        context.setStrokeColor(UIColor.white.cgColor)
+        context.addArc(
+            center: circleCenter,
+            radius: 0,
+            startAngle: 0,
+            endAngle: .pi * 2,
+            clockwise: true
+        )
+        context.strokePath()
+
+        context.restoreGState()
+    }
+
+    private static func ringColor(
+        contact _: ContactTrickEntry,
+        state: ContactTrickState
+    ) -> Color {
+        guard let lastLoopDate = state.lastLoopDate else {
+            return .loopGray
+        }
+        let delta = Date().timeIntervalSince(lastLoopDate) - Config.lag
+
+        if delta <= 5.minutes.timeInterval {
+            return .loopGreen
+        } else if delta <= 10.minutes.timeInterval {
+            return .loopYellow
+        } else {
+            return .loopRed
+        }
+    }
+
+    var uiImage: UIImage {
+        ContactPicture.getImage(contact: contact, state: state)
+    }
+
+    var body: some View {
+        Image(uiImage: uiImage)
+            .frame(width: 256, height: 256)
+    }
+}
+
+extension Font.Weight {
+    var uiFontWeight: UIFont.Weight {
+        switch self {
+        case .ultraLight: return .ultraLight
+        case .thin: return .thin
+        case .light: return .light
+        case .regular: return .regular
+        case .medium: return .medium
+        case .semibold: return .semibold
+        case .bold: return .bold
+        case .heavy: return .heavy
+        case .black: return .black
+        default: return .regular
+        }
+    }
+}
+
+enum GradientDirection: Int {
+    case leftToRight
+    case bottomToTop
+}
+
+struct ContactPicturePreview: View {
+    @Binding var contact: ContactTrickEntry
+    @Binding var state: ContactTrickState
+
+    var body: some View {
+        ZStack {
+            ContactPicture(contact: $contact, state: $state)
+            Circle()
+                .stroke(lineWidth: 20)
+                .foregroundColor(.white)
+        }
+        .frame(width: 256, height: 256)
+        .clipShape(Circle())
+        .preferredColorScheme($contact.wrappedValue.hasHighContrast ? .dark : .light)
+    }
+}
+
+struct ContactPicture_Previews: PreviewProvider {
+    struct Preview: View {
+        @State var rangeIndicator: Bool = true
+        @State var hasHighContrast: Bool = true
+        @State var fontSize: ContactTrickEntry.FontSize = .small
+        @State var fontWeight: UIFont.Weight = .bold
+        @State var fontName: String? = "AmericanTypewriter"
+
+        var body: some View {
+            ContactPicturePreview(
+                contact: .constant(
+                    ContactTrickEntry(
+                        primary: .glucose,
+                        top: .delta,
+                        bottom: .trend,
+                        fontSize: fontSize,
+                        fontWeight: .medium
+                    )
+                ),
+                state: .constant(ContactTrickState(
+                    glucose: "6.8",
+                    trend: "↗︎",
+                    delta: "+0.2",
+                    cob: 25,
+                    cobText: "25"
+                ))
+            ).previewDisplayName("bg + trend + delta")
+
+//            ContactPicturePreview(
+//                contact: .constant(
+//                    ContactTrickEntry(
+//                        ring: .iob,
+//                        primary: .glucose,
+//                        bottom: .trend,
+//                        fontSize: fontSize,
+//                        fontWeight: .medium
+//                    )
+//                ),
+//                state: .constant(ContactTrickState(
+//                    glucose: "6.8",
+//                    trend: "↗︎",
+//                    iob: 6.1,
+//                    iobText: "6.1",
+//                    maxIOB: 8.0
+//                ))
+//            ).previewDisplayName("bg + trend + iob ring")
+
+            ContactPicturePreview(
+                contact: .constant(
+                    ContactTrickEntry(
+                        primary: .glucose,
+                        top: .ring,
+                        bottom: .trend,
+                        fontSize: fontSize,
+                        fontWeight: .medium
+                    )
+                ),
+                state: .constant(ContactTrickState(
+                    glucose: "6.8",
+                    trend: "↗︎",
+                    lastLoopDate: .now
+                ))
+
+            ).previewDisplayName("bg + trend + ring")
+
+            ContactPicturePreview(
+                contact: .constant(
+                    ContactTrickEntry(
+                        ring: .loop,
+                        primary: .glucose,
+                        top: .none,
+                        bottom: .trend,
+                        fontSize: fontSize,
+                        fontWeight: .medium
+                    )
+                ),
+                state: .constant(ContactTrickState(
+                    glucose: "8.8",
+                    trend: "→",
+                    lastLoopDate: .now
+                ))
+            ).previewDisplayName("bg + trend + ring")
+
+            ContactPicturePreview(
+                contact: .constant(
+                    ContactTrickEntry(
+                        ring: .loop,
+                        primary: .glucose,
+                        top: .none,
+                        bottom: .eventualBG,
+                        fontSize: fontSize,
+                        fontWeight: .medium
+                    )
+                ),
+                state: .constant(ContactTrickState(
+                    glucose: "6.8",
+                    lastLoopDate: .now - 7.minutes,
+                    eventualBG: "6.2"
+                ))
+            ).previewDisplayName("bg + eventual + ring")
+
+            ContactPicturePreview(
+                contact: .constant(
+                    ContactTrickEntry(
+                        ring: .loop,
+                        primary: .lastLoopDate,
+                        top: .none,
+                        bottom: .none,
+                        fontSize: fontSize,
+                        fontWeight: .medium
+                    )
+                ),
+                state: .constant(ContactTrickState(
+                    glucose: "6.8",
+                    trend: "↗︎",
+                    lastLoopDate: .now - 2.minutes
+                ))
+            ).previewDisplayName("lastLoopDate + ring")
+
+            ContactPicturePreview(
+                contact: .constant(
+                    ContactTrickEntry(
+                        ring: .loop,
+                        primary: .glucose,
+                        top: .none,
+                        bottom: .none,
+                        fontSize: fontSize,
+                        fontWeight: .medium
+                    )
+                ),
+                state: .constant(ContactTrickState(
+                    glucose: "6.8",
+                    lastLoopDate: .now,
+                    iob: 6.1,
+                    iobText: "6.1",
+                    maxIOB: 8.0
+                ))
+            ).previewDisplayName("bg + ring + ring2")
+
+            ContactPicturePreview(
+                contact: .constant(
+                    ContactTrickEntry(
+                        layout: .split,
+                        top: .iob,
+                        bottom: .cob,
+                        fontSize: fontSize,
+                        fontWeight: .medium
+                    )
+                ),
+                state: .constant(ContactTrickState(
+                    iob: 1.5,
+                    iobText: "1.5",
+                    cob: 25,
+                    cobText: "25"
+                ))
+            ).previewDisplayName("iob + cob")
+
+//            ContactPicturePreview(
+//                contact: .constant(
+//                    ContactTrickEntry(
+//                        layout: .single,
+//                        ring: .iobcob,
+//                        primary: .none,
+//                        ringWidth: .regular,
+//                        ringGap: .regular,
+//                        fontSize: fontSize,
+//                        fontWeight: .medium
+//                    )
+//                ),
+//                state: .constant(ContactTrickState(
+//                    iob: 1,
+//                    iobText: "5.5",
+//                    cob: 25,
+//                    cobText: "25",
+//                    maxIOB: 10,
+//                    maxCOB: 120
+//                ))
+//            ).previewDisplayName("iobcob ring")
+//
+//            ContactPicturePreview(
+//                contact: .constant(
+//                    ContactTrickEntry(
+//                        layout: .single,
+//                        ring: .iobcob,
+//                        primary: .none,
+//                        fontSize: fontSize,
+//                        fontWeight: .medium
+//                    )
+//                ),
+//                state: .constant(ContactTrickState(
+//                    iob: -0.2,
+//                    iobText: "0.0",
+//                    cob: 0,
+//                    cobText: "0",
+//                    maxIOB: 10,
+//                    maxCOB: 120
+//                ))
+//            ).previewDisplayName("iobcob ring (0/0)")
+//
+//            ContactPicturePreview(
+//                contact: .constant(
+//                    ContactTrickEntry(
+//                        layout: .single,
+//                        ring: .iobcob,
+//                        primary: .none,
+//                        fontSize: fontSize,
+//                        fontWeight: .medium
+//                    )
+//                ),
+//                state: .constant(ContactTrickState(
+//                    iob: 10,
+//                    iobText: "0.0",
+//                    cob: 120,
+//                    cobText: "0",
+//                    maxIOB: 10,
+//                    maxCOB: 120
+//                ))
+//            ).previewDisplayName("iobcob ring (max/max)")
+//
+//            ContactPicturePreview(
+//                contact: .constant(
+//                    ContactTrickEntry(
+//                        layout: .single,
+//                        ring: .iobcob,
+//                        primary: .glucose,
+//                        bottom: .trend,
+//                        fontSize: fontSize,
+//                        fontWeight: .medium
+//                    )
+//                ),
+//                state: .constant(ContactTrickState(
+//                    glucose: "6.8",
+//                    trend: "↗︎",
+//                    iob: 5.5,
+//                    iobText: "5.5",
+//                    cob: 25,
+//                    cobText: "25",
+//                    maxIOB: 10,
+//                    maxCOB: 120
+//                ))
+//            ).previewDisplayName("bg + trend + iobcob ring")
+        }
+    }
+
+    static var previews: some View {
+        Preview()
+    }
+}

+ 19 - 0
FreeAPS/Sources/Services/ContactTrick/ContactTrickState.swift

@@ -0,0 +1,19 @@
+import Foundation
+
+struct ContactTrickState: Codable {
+    var glucose: String?
+    var trend: String?
+    var delta: String?
+    var lastLoopDate: Date?
+    var iob: Decimal?
+    var iobText: String?
+    var cob: Decimal?
+    var cobText: String?
+    var eventualBG: String?
+    var maxIOB: Decimal = 10.0
+    var maxCOB: Decimal = 120.0
+    var highGlucoseColorValue: Decimal = 180.0
+    var lowGlucoseColorValue: Decimal = 70.0
+    var glucoseColorScheme: GlucoseColorScheme = .staticColor
+    var targetGlucose: Decimal = 100.0
+}

+ 0 - 9
FreeAPS/Sources/Services/WatchManager/WatchManager.swift

@@ -248,15 +248,6 @@ final class BaseWatchManager: NSObject, WatchManager, Injectable {
                 self.state.maxCOB = self.settingsManager.preferences.maxCOB
                 self.state.maxBolus = self.settingsManager.pumpSettings.maxBolus
                 self.state.carbsRequired = lastDetermination?.carbsRequired as? Decimal
-
-//                var insulinRequired = lastDetermination?.insulinReq as? Decimal ?? 0
-//
-//                var double: Decimal = 2
-//                if lastDetermination?.manualBolusErrorString == 0 {
-//                    insulinRequired = lastDetermination?.insulinForManualBolus as? Decimal ?? 0
-//                    double = 1
-//                }
-
                 self.state.bolusRecommended = self.apsManager
                     .roundBolus(amount: max(recommendedInsulin, 0))
                 self.state.displayOnWatch = self.settingsManager.settings.displayOnWatch

+ 4 - 0
Model/Classes+Properties/ContactTrickEntryStored+CoreDataClass.swift

@@ -0,0 +1,4 @@
+import CoreData
+import Foundation
+
+@objc(ContactTrickEntryStored) public class ContactTrickEntryStored: NSManagedObject {}

+ 24 - 0
Model/Classes+Properties/ContactTrickEntryStored+CoreDataProperties.swift

@@ -0,0 +1,24 @@
+import CoreData
+import Foundation
+
+public extension ContactTrickEntryStored {
+    @nonobjc class func fetchRequest() -> NSFetchRequest<ContactTrickEntryStored> {
+        NSFetchRequest<ContactTrickEntryStored>(entityName: "ContactTrickEntryStored")
+    }
+
+    @NSManaged var name: String
+    @NSManaged var layout: String?
+    @NSManaged var ring: String?
+    @NSManaged var primary: String?
+    @NSManaged var top: String?
+    @NSManaged var bottom: String?
+    @NSManaged var contactId: String?
+    @NSManaged var hasHighContrast: Bool
+    @NSManaged var ringWidth: Int16
+    @NSManaged var ringGap: Int16
+    @NSManaged var id: UUID?
+    @NSManaged var fontSize: Int16
+    @NSManaged var fontSizeSecondary: Int16
+    @NSManaged var fontWidth: String?
+    @NSManaged var fontWeight: String?
+}

+ 1 - 0
Model/CoreDataActor.swift

@@ -0,0 +1 @@
+import Foundation

+ 46 - 0
Model/NSModelObjectContextExecutor.swift

@@ -0,0 +1,46 @@
+import CoreData
+import Foundation
+
+public final class NSModelObjectContextExecutor: @unchecked Sendable, SerialExecutor {
+    public let context: NSManagedObjectContext
+
+    public init(context: NSManagedObjectContext) {
+        self.context = context
+    }
+
+    // Enqueue the job to the context's queue.
+    public func enqueue(_ job: consuming ExecutorJob) {
+        let unownedJob = UnownedJob(job)
+        let unownedExecutor = asUnownedSerialExecutor()
+        context.perform {
+            unownedJob.runSynchronously(on: unownedExecutor)
+        }
+    }
+
+    // Return an unowned serial executor reference.
+    public func asUnownedSerialExecutor() -> UnownedSerialExecutor {
+        UnownedSerialExecutor(ordinary: self)
+    }
+}
+
+// A protocol to define common functionalities for Core Data-based actors
+protocol CoreDataActor {
+    var modelExecutor: NSModelObjectContextExecutor { get }
+    var modelContainer: NSPersistentContainer { get }
+}
+
+// Extend the protocol with default implementations and helpers
+extension CoreDataActor {
+    public var modelContext: NSManagedObjectContext {
+        modelExecutor.context
+    }
+
+    public var unownedExecutor: UnownedSerialExecutor {
+        modelExecutor.asUnownedSerialExecutor()
+    }
+
+    // Provide a generic subscript to fetch objects by NSManagedObjectID
+    public subscript<T>(id: NSManagedObjectID, as _: T.Type) -> T? where T: NSManagedObject {
+        try? modelContext.existingObject(with: id) as? T
+    }
+}

+ 19 - 2
Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents

@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
-<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23231" systemVersion="24A348" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23231" systemVersion="24C101" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
     <entity name="BolusStored" representedClassName="BolusStored" syncable="YES">
         <attribute name="amount" optional="YES" attributeType="Decimal" defaultValueString="0"/>
         <attribute name="isExternal" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
@@ -25,6 +25,23 @@
             <fetchIndexElement property="isFPU" type="Binary" order="ascending"/>
         </fetchIndex>
     </entity>
+    <entity name="ContactTrickEntryStored" representedClassName="ContactTrickEntryStored" syncable="YES" codeGenerationType="class">
+        <attribute name="bottom" optional="YES" attributeType="String"/>
+        <attribute name="contactId" optional="YES" attributeType="String"/>
+        <attribute name="fontSize" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
+        <attribute name="fontSizeSecondary" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
+        <attribute name="fontWeight" optional="YES" attributeType="String"/>
+        <attribute name="fontWidth" optional="YES" attributeType="String"/>
+        <attribute name="hasHighContrast" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
+        <attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
+        <attribute name="layout" optional="YES" attributeType="String"/>
+        <attribute name="name" optional="YES" attributeType="String"/>
+        <attribute name="primary" optional="YES" attributeType="String"/>
+        <attribute name="ring" optional="YES" attributeType="String"/>
+        <attribute name="ringGap" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
+        <attribute name="ringWidth" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
+        <attribute name="top" optional="YES" attributeType="String"/>
+    </entity>
     <entity name="Forecast" representedClassName="Forecast" syncable="YES">
         <attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
         <attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
@@ -225,4 +242,4 @@
             <fetchIndexElement property="isPreset" type="Binary" order="descending"/>
         </fetchIndex>
     </entity>
-</model>
+</model>