Przeglądaj źródła

merged from upstream

kskandis 1 rok temu
rodzic
commit
05c96d4757
100 zmienionych plików z 3624 dodań i 1513 usunięć
  1. 1 4
      .github/workflows/add_identifiers.yml
  2. 2 2
      .github/workflows/build_trio.yml
  3. 1 4
      .github/workflows/create_certs.yml
  4. 3 3
      .github/workflows/validate_secrets.yml
  5. 1 1
      CODE_OF_CONDUCT.md
  6. 1 1
      Config.xcconfig
  7. 153 17
      FreeAPS.xcodeproj/project.pbxproj
  8. 16 0
      FreeAPS.xcodeproj/xcshareddata/xcschemes/Trio.xcscheme
  9. 2 0
      FreeAPS/Resources/FreeAPS.entitlements
  10. 2 0
      FreeAPS/Resources/Info.plist
  11. 1 0
      FreeAPS/Resources/json/defaults/freeaps/freeaps_settings.json
  12. 6 4
      FreeAPS/Sources/APS/APSManager.swift
  13. 24 35
      FreeAPS/Sources/APS/FetchGlucoseManager.swift
  14. 1 0
      FreeAPS/Sources/APS/OpenAPS/Constants.swift
  15. 71 6
      FreeAPS/Sources/APS/OpenAPS/JavaScriptWorker.swift
  16. 14 43
      FreeAPS/Sources/APS/OpenAPS/OpenAPS.swift
  17. 88 12
      FreeAPS/Sources/APS/Storage/CarbsStorage.swift
  18. 36 4
      FreeAPS/Sources/APS/Storage/DeterminationStorage.swift
  19. 165 5
      FreeAPS/Sources/APS/Storage/GlucoseStorage.swift
  20. 38 10
      FreeAPS/Sources/APS/Storage/OverrideStorage.swift
  21. 112 3
      FreeAPS/Sources/APS/Storage/PumpHistoryStorage.swift
  22. 51 1
      FreeAPS/Sources/Application/AppDelegate.swift
  23. 6 1
      FreeAPS/Sources/Helpers/Color+Extensions.swift
  24. 66 0
      FreeAPS/Sources/Helpers/DynamicGlucoseColor.swift
  25. 4 0
      FreeAPS/Sources/Logger/Logger.swift
  26. 3 2
      FreeAPS/Sources/Models/BloodGlucose.swift
  27. 1 1
      FreeAPS/Sources/Models/CarbsEntry.swift
  28. 11 5
      FreeAPS/Sources/Models/DecimalPickerSettings.swift
  29. 5 0
      FreeAPS/Sources/Models/FreeAPSSettings.swift
  30. 22 0
      FreeAPS/Sources/Models/GlucoseColorScheme.swift
  31. 12 0
      FreeAPS/Sources/Models/NightscoutStatus.swift
  32. 1 0
      FreeAPS/Sources/Models/PumpHistoryEvent.swift
  33. 141 0
      FreeAPS/Sources/Models/PushMessage.swift
  34. 22 21
      FreeAPS/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsStateModel.swift
  35. 1 1
      FreeAPS/Sources/Modules/AlgorithmAdvancedSettings/View/AlgorithmAdvancedSettingsRootView.swift
  36. 8 7
      FreeAPS/Sources/Modules/AutosensSettings/AutosensSettingsStateModel.swift
  37. 1 1
      FreeAPS/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift
  38. 9 8
      FreeAPS/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift
  39. 1 1
      FreeAPS/Sources/Modules/BasalProfileEditor/View/BasalProfileEditorRootView.swift
  40. 126 121
      FreeAPS/Sources/Modules/Bolus/BolusStateModel.swift
  41. 1 0
      FreeAPS/Sources/Modules/Bolus/View/AddMealPresetView.swift
  42. 110 99
      FreeAPS/Sources/Modules/Bolus/View/BolusRootView.swift
  43. 72 58
      FreeAPS/Sources/Modules/Bolus/View/ForeCastChart.swift
  44. 1 1
      FreeAPS/Sources/Modules/Bolus/View/MealPresetView.swift
  45. 1 1
      FreeAPS/Sources/Modules/Bolus/View/PopupView.swift
  46. 10 10
      FreeAPS/Sources/Modules/Calibrations/CalibrationsStateModel.swift
  47. 1 1
      FreeAPS/Sources/Modules/Calibrations/View/CalibrationsChart.swift
  48. 2 2
      FreeAPS/Sources/Modules/Calibrations/View/CalibrationsRootView.swift
  49. 7 2
      FreeAPS/Sources/Modules/DataTable/DataTableDataFlow.swift
  50. 36 34
      FreeAPS/Sources/Modules/DataTable/DataTableProvider.swift
  51. 139 63
      FreeAPS/Sources/Modules/DataTable/DataTableStateModel.swift
  52. 3 1
      FreeAPS/Sources/Modules/DataTable/View/DataTableRootView.swift
  53. 13 12
      FreeAPS/Sources/Modules/DynamicSettings/DynamicSettingsStateModel.swift
  54. 1 1
      FreeAPS/Sources/Modules/DynamicSettings/View/DynamicSettingsRootView.swift
  55. 18 18
      FreeAPS/Sources/Modules/GeneralSettings/View/UnitsLimitsSettingsRootView.swift
  56. 2 2
      FreeAPS/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsStateModel.swift
  57. 16 14
      FreeAPS/Sources/Modules/HealthKit/HealthKitStateModel.swift
  58. 6 0
      FreeAPS/Sources/Modules/Home/HomeProvider.swift
  59. 32 0
      FreeAPS/Sources/Modules/Home/HomeStateModel+Setup/BatterySetup.swift
  60. 60 0
      FreeAPS/Sources/Modules/Home/HomeStateModel+Setup/CarbSetup.swift
  61. 101 0
      FreeAPS/Sources/Modules/Home/HomeStateModel+Setup/ChartAxisSetup.swift
  62. 58 0
      FreeAPS/Sources/Modules/Home/HomeStateModel+Setup/DeterminationSetup.swift
  63. 111 0
      FreeAPS/Sources/Modules/Home/HomeStateModel+Setup/ForecastSetup.swift
  64. 37 0
      FreeAPS/Sources/Modules/Home/HomeStateModel+Setup/GlucoseSetup.swift
  65. 98 0
      FreeAPS/Sources/Modules/Home/HomeStateModel+Setup/OverrideSetup.swift
  66. 81 0
      FreeAPS/Sources/Modules/Home/HomeStateModel+Setup/PumpHistorySetup.swift
  67. 158 562
      FreeAPS/Sources/Modules/Home/HomeStateModel.swift
  68. 4 4
      FreeAPS/Sources/Modules/Home/View/Chart/BasalChart.swift
  69. 1 1
      FreeAPS/Sources/Modules/Home/View/Chart/CobChart.swift
  70. 30 4
      FreeAPS/Sources/Modules/Home/View/Chart/DummyCharts.swift
  71. 15 3
      FreeAPS/Sources/Modules/Home/View/Chart/GlucoseChartView.swift
  72. 1 1
      FreeAPS/Sources/Modules/Home/View/Chart/IobChart.swift
  73. 47 120
      FreeAPS/Sources/Modules/Home/View/Chart/MainChartView.swift
  74. 35 45
      FreeAPS/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift
  75. 6 6
      FreeAPS/Sources/Modules/Home/View/Header/LoopView.swift
  76. 7 7
      FreeAPS/Sources/Modules/Home/View/Header/PumpView.swift
  77. 38 33
      FreeAPS/Sources/Modules/Home/View/HomeRootView.swift
  78. 9 8
      FreeAPS/Sources/Modules/ISFEditor/ISFEditorStateModel.swift
  79. 1 1
      FreeAPS/Sources/Modules/ISFEditor/View/ISFEditorRootView.swift
  80. 0 3
      FreeAPS/Sources/Modules/LiveActivitySettings/LiveActivitySettingsStateModel.swift
  81. 12 0
      FreeAPS/Sources/Modules/LiveActivitySettings/View/LiveActivitySettingsRootView.swift
  82. 388 0
      FreeAPS/Sources/Modules/LiveActivitySettings/View/LiveActivityWidgetConfiguration.swift
  83. 5 4
      FreeAPS/Sources/Modules/ManualTempBasal/ManualTempBasalStateModel.swift
  84. 1 1
      FreeAPS/Sources/Modules/ManualTempBasal/View/ManualTempBasalRootView.swift
  85. 3 6
      FreeAPS/Sources/Modules/MealSettings/MealSettingsStateModel.swift
  86. 23 2
      FreeAPS/Sources/Modules/NightscoutConfig/NightscoutConfigStateModel.swift
  87. 14 1
      FreeAPS/Sources/Modules/NightscoutConfig/View/NightscoutConfigRootView.swift
  88. 63 54
      FreeAPS/Sources/Modules/OverrideConfig/OverrideStateModel.swift
  89. 6 3
      FreeAPS/Sources/Modules/OverrideConfig/View/EditOverrideForm.swift
  90. 1 1
      FreeAPS/Sources/Modules/OverrideConfig/View/OverrideRootView.swift
  91. 36 0
      FreeAPS/Sources/Modules/RemoteControl/TrioRemoteControl+APNS.swift
  92. 108 0
      FreeAPS/Sources/Modules/RemoteControl/TrioRemoteControl+Bolus.swift
  93. 12 0
      FreeAPS/Sources/Modules/RemoteControl/TrioRemoteControl+Helpers.swift
  94. 82 0
      FreeAPS/Sources/Modules/RemoteControl/TrioRemoteControl+Meal.swift
  95. 100 0
      FreeAPS/Sources/Modules/RemoteControl/TrioRemoteControl+Override.swift
  96. 49 0
      FreeAPS/Sources/Modules/RemoteControl/TrioRemoteControl+TempTarget.swift
  97. 113 0
      FreeAPS/Sources/Modules/RemoteControl/TrioRemoteControl.swift
  98. 6 0
      FreeAPS/Sources/Modules/RemoteControlConfig/RemoteControlConfigDataFlow.swift
  99. 4 0
      FreeAPS/Sources/Modules/RemoteControlConfig/RemoteControlConfigProvider.swift
  100. 0 0
      FreeAPS/Sources/Modules/RemoteControlConfig/RemoteControlConfigStateModel.swift

+ 1 - 4
.github/workflows/add_identifiers.yml

@@ -12,11 +12,8 @@ jobs:
   identifiers:
     name: Add Identifiers
     needs: validate
-    runs-on: macos-14
+    runs-on: macos-15
     steps:
-      # Uncomment to manually select latest Xcode if needed
-      #- name: Select Latest Xcode
-      #  run: "sudo xcode-select --switch /Applications/Xcode_13.0.app/Contents/Developer"
 
       # Checks-out the repo
       - name: Checkout Repo

+ 2 - 2
.github/workflows/build_trio.yml

@@ -186,7 +186,7 @@ jobs:
   build:
     name: Build
     needs: [validate, check_alive_and_permissions, check_latest_from_upstream]
-    runs-on: macos-14
+    runs-on: macos-15
     permissions:
       contents: write
     if:
@@ -198,7 +198,7 @@ jobs:
       )
     steps:
       - name: Select Xcode version
-        run: "sudo xcode-select --switch /Applications/Xcode_15.4.app/Contents/Developer"
+        run: "sudo xcode-select --switch /Applications/Xcode_16.0.app/Contents/Developer"
       
       - name: Checkout Repo for syncing
         if: |

+ 1 - 4
.github/workflows/create_certs.yml

@@ -12,11 +12,8 @@ jobs:
   certificates:
     name: Create Certificates
     needs: validate
-    runs-on: macos-14
+    runs-on: macos-15
     steps:
-      # Uncomment to manually select latest Xcode if needed
-      #- name: Select Latest Xcode
-      #  run: "sudo xcode-select --switch /Applications/Xcode_13.0.app/Contents/Developer"
 
       # Checks-out the repo
       - name: Checkout Repo

+ 3 - 3
.github/workflows/validate_secrets.yml

@@ -5,7 +5,7 @@ on: [workflow_call, workflow_dispatch]
 jobs:
   validate-access-token:
     name: Access
-    runs-on: macos-14
+    runs-on: macos-15
     env:
       GH_PAT: ${{ secrets.GH_PAT }}
       GH_TOKEN: ${{ secrets.GH_PAT }}
@@ -74,7 +74,7 @@ jobs:
   validate-match-secrets:
     name: Match-Secrets
     needs: validate-access-token
-    runs-on: macos-14
+    runs-on: macos-15
     env:
       GH_TOKEN: ${{ secrets.GH_PAT }}
     steps:
@@ -112,7 +112,7 @@ jobs:
   validate-fastlane-secrets:
     name: Fastlane
     needs: [validate-access-token, validate-match-secrets]
-    runs-on: macos-14
+    runs-on: macos-15
     env:
       GH_PAT: ${{ secrets.GH_PAT }}
       GH_TOKEN: ${{ secrets.GH_PAT }}

+ 1 - 1
CODE_OF_CONDUCT.md

@@ -59,7 +59,7 @@ representative at an online or offline event.
 ## Enforcement
 
 Instances of abusive, harassing, or otherwise unacceptable behavior may be
-reported to the Discord server admins. Please join our [Discord server](https://discord.gg/dbe5Twav8D) to contact
+reported to the Discord server admins. Please join our [Discord server](http://discord.diy-trio.org) to contact
 them directly for any enforcement issues. All complaints will be reviewed and
 investigated promptly and fairly.
 

+ 1 - 1
Config.xcconfig

@@ -1,5 +1,5 @@
 APP_DISPLAY_NAME = Trio
-APP_VERSION = 0.2.0
+APP_VERSION = 0.3.0
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 DEVELOPER_TEAM = ##TEAM_ID##

+ 153 - 17
FreeAPS.xcodeproj/project.pbxproj

@@ -3,7 +3,7 @@
 	archiveVersion = 1;
 	classes = {
 	};
-	objectVersion = 54;
+	objectVersion = 70;
 	objects = {
 
 /* Begin PBXBuildFile section */
@@ -256,6 +256,14 @@
 		583684082BD195A700070A60 /* Determination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 583684072BD195A700070A60 /* Determination.swift */; };
 		5837A5302BD2E3C700A5DC04 /* CarbEntryStored+helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5837A52F2BD2E3C700A5DC04 /* CarbEntryStored+helper.swift */; };
 		585E2CAE2BE7BF46006ECF1A /* PumpEvent+helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585E2CAD2BE7BF46006ECF1A /* PumpEvent+helper.swift */; };
+		58645B992CA2D1A4008AFCE7 /* GlucoseSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58645B982CA2D1A4008AFCE7 /* GlucoseSetup.swift */; };
+		58645B9B2CA2D24F008AFCE7 /* CarbSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58645B9A2CA2D24F008AFCE7 /* CarbSetup.swift */; };
+		58645B9D2CA2D275008AFCE7 /* DeterminationSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58645B9C2CA2D275008AFCE7 /* DeterminationSetup.swift */; };
+		58645B9F2CA2D2BE008AFCE7 /* PumpHistorySetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58645B9E2CA2D2BE008AFCE7 /* PumpHistorySetup.swift */; };
+		58645BA12CA2D2F8008AFCE7 /* OverrideSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58645BA02CA2D2F8008AFCE7 /* OverrideSetup.swift */; };
+		58645BA32CA2D325008AFCE7 /* BatterySetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58645BA22CA2D325008AFCE7 /* BatterySetup.swift */; };
+		58645BA52CA2D347008AFCE7 /* ForecastSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58645BA42CA2D347008AFCE7 /* ForecastSetup.swift */; };
+		58645BA72CA2D390008AFCE7 /* ChartAxisSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58645BA62CA2D390008AFCE7 /* ChartAxisSetup.swift */; };
 		5864E8592C42CFAE00294306 /* DeterminationStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5864E8582C42CFAE00294306 /* DeterminationStorage.swift */; };
 		587DA1F62B77F3DD00B28F8A /* SettingsRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587DA1F52B77F3DD00B28F8A /* SettingsRowView.swift */; };
 		5887527C2BD986E1008B081D /* OpenAPSBattery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5887527B2BD986E1008B081D /* OpenAPSBattery.swift */; };
@@ -314,12 +322,13 @@
 		BD2FF1A02AE29D43005D1C5D /* CheckboxToggleStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD2FF19F2AE29D43005D1C5D /* CheckboxToggleStyle.swift */; };
 		BD3CC0722B0B89D50013189E /* MainChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD3CC0712B0B89D50013189E /* MainChartView.swift */; };
 		BD4064D12C4ED26900582F43 /* CoreDataObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4064D02C4ED26900582F43 /* CoreDataObserver.swift */; };
+		BD6EB2D62C7D049B0086BBB6 /* LiveActivityWidgetConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD6EB2D52C7D049B0086BBB6 /* LiveActivityWidgetConfiguration.swift */; };
 		BD7DA9A52AE06DFC00601B20 /* BolusCalculatorConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD7DA9A42AE06DFC00601B20 /* BolusCalculatorConfigDataFlow.swift */; };
 		BD7DA9A72AE06E2B00601B20 /* BolusCalculatorConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD7DA9A62AE06E2B00601B20 /* BolusCalculatorConfigProvider.swift */; };
 		BD7DA9A92AE06E9200601B20 /* BolusCalculatorStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD7DA9A82AE06E9200601B20 /* BolusCalculatorStateModel.swift */; };
 		BD7DA9AC2AE06EB900601B20 /* BolusCalculatorConfigRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD7DA9AB2AE06EB900601B20 /* BolusCalculatorConfigRootView.swift */; };
 		BDB3C1192C03DD1000CEEAA1 /* UserDefaultsExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDB3C1182C03DD1000CEEAA1 /* UserDefaultsExtension.swift */; };
-		BDB899882C564509006F3298 /* ForeCastChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDB899872C564509006F3298 /* ForeCastChart.swift */; };
+		BDB899882C564509006F3298 /* ForecastChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDB899872C564509006F3298 /* ForecastChart.swift */; };
 		BDB8998A2C565D0C006F3298 /* CarbsGlucose+helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDB899892C565D0B006F3298 /* CarbsGlucose+helper.swift */; };
 		BDBAACFA2C2D439700370AAE /* OverrideData.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBAACF92C2D439700370AAE /* OverrideData.swift */; };
 		BDC2EA452C3043B000E5BBD0 /* OverrideStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC2EA442C3043B000E5BBD0 /* OverrideStorage.swift */; };
@@ -434,6 +443,12 @@
 		DD1745552C55CA6C00211FAC /* UnitsLimitsSettingsRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1745542C55CA6C00211FAC /* UnitsLimitsSettingsRootView.swift */; };
 		DD1DB7CC2BECCA1F0048B367 /* BuildDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1DB7CB2BECCA1F0048B367 /* BuildDetails.swift */; };
 		DD21FCB52C6952AD00AF2C25 /* DecimalPickerSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD21FCB42C6952AD00AF2C25 /* DecimalPickerSettings.swift */; };
+		DD32CF982CC82463003686D6 /* TrioRemoteControl+Bolus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF972CC82460003686D6 /* TrioRemoteControl+Bolus.swift */; };
+		DD32CF9A2CC8247B003686D6 /* TrioRemoteControl+Meal.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF992CC8246F003686D6 /* TrioRemoteControl+Meal.swift */; };
+		DD32CF9C2CC82499003686D6 /* TrioRemoteControl+TempTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF9B2CC82495003686D6 /* TrioRemoteControl+TempTarget.swift */; };
+		DD32CF9E2CC824C5003686D6 /* TrioRemoteControl+Override.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF9D2CC824C2003686D6 /* TrioRemoteControl+Override.swift */; };
+		DD32CFA02CC824D6003686D6 /* TrioRemoteControl+APNS.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF9F2CC824D3003686D6 /* TrioRemoteControl+APNS.swift */; };
+		DD32CFA22CC824E2003686D6 /* TrioRemoteControl+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CFA12CC824E1003686D6 /* TrioRemoteControl+Helpers.swift */; };
 		DD68889D2C386E17006E3C44 /* NightscoutExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD68889C2C386E17006E3C44 /* NightscoutExercise.swift */; };
 		DD6B7CB22C7B6F0800B75029 /* Rounding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6B7CB12C7B6F0800B75029 /* Rounding.swift */; };
 		DD6B7CB42C7B71F700B75029 /* ForecastDisplayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6B7CB32C7B71F700B75029 /* ForecastDisplayType.swift */; };
@@ -442,6 +457,15 @@
 		DD6B7CBB2C7FBBFA00B75029 /* ReviewInsulinActionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6B7CBA2C7FBBFA00B75029 /* ReviewInsulinActionView.swift */; };
 		DD6D67E42C9C253500660C9B /* ColorSchemeOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6D67E32C9C253500660C9B /* ColorSchemeOption.swift */; };
 		DD88C8E22C50420800F2D558 /* DefinitionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD88C8E12C50420800F2D558 /* DefinitionRow.swift */; };
+		DD940BAA2CA7585D000830A5 /* GlucoseColorScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */; };
+		DD940BAC2CA75889000830A5 /* DynamicGlucoseColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD940BAB2CA75889000830A5 /* DynamicGlucoseColor.swift */; };
+		DD9ECB682CA99F4500AA7C45 /* TrioRemoteControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB672CA99F4500AA7C45 /* TrioRemoteControl.swift */; };
+		DD9ECB6A2CA99F6C00AA7C45 /* PushMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB692CA99F6C00AA7C45 /* PushMessage.swift */; };
+		DD9ECB702CA9A0BA00AA7C45 /* RemoteControlConfigStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB6D2CA9A0BA00AA7C45 /* RemoteControlConfigStateModel.swift */; };
+		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 */; };
+		DDCEBF5B2CC1B76400DF4C36 /* LiveActivity+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCEBF5A2CC1B76400DF4C36 /* LiveActivity+Helper.swift */; };
 		DDD163122C4C689900CD525A /* OverrideStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD163112C4C689900CD525A /* OverrideStateModel.swift */; };
 		DDD163142C4C68D300CD525A /* OverrideProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD163132C4C68D300CD525A /* OverrideProvider.swift */; };
 		DDD163162C4C690300CD525A /* OverrideDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD163152C4C690300CD525A /* OverrideDataFlow.swift */; };
@@ -909,6 +933,14 @@
 		583684072BD195A700070A60 /* Determination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Determination.swift; sourceTree = "<group>"; };
 		5837A52F2BD2E3C700A5DC04 /* CarbEntryStored+helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CarbEntryStored+helper.swift"; sourceTree = "<group>"; };
 		585E2CAD2BE7BF46006ECF1A /* PumpEvent+helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PumpEvent+helper.swift"; sourceTree = "<group>"; };
+		58645B982CA2D1A4008AFCE7 /* GlucoseSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSetup.swift; sourceTree = "<group>"; };
+		58645B9A2CA2D24F008AFCE7 /* CarbSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbSetup.swift; sourceTree = "<group>"; };
+		58645B9C2CA2D275008AFCE7 /* DeterminationSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeterminationSetup.swift; sourceTree = "<group>"; };
+		58645B9E2CA2D2BE008AFCE7 /* PumpHistorySetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpHistorySetup.swift; sourceTree = "<group>"; };
+		58645BA02CA2D2F8008AFCE7 /* OverrideSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideSetup.swift; sourceTree = "<group>"; };
+		58645BA22CA2D325008AFCE7 /* BatterySetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatterySetup.swift; sourceTree = "<group>"; };
+		58645BA42CA2D347008AFCE7 /* ForecastSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastSetup.swift; sourceTree = "<group>"; };
+		58645BA62CA2D390008AFCE7 /* ChartAxisSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartAxisSetup.swift; sourceTree = "<group>"; };
 		5864E8582C42CFAE00294306 /* DeterminationStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeterminationStorage.swift; sourceTree = "<group>"; };
 		587DA1F52B77F3DD00B28F8A /* SettingsRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRowView.swift; sourceTree = "<group>"; };
 		5887527B2BD986E1008B081D /* OpenAPSBattery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAPSBattery.swift; sourceTree = "<group>"; };
@@ -968,12 +1000,13 @@
 		BD2FF19F2AE29D43005D1C5D /* CheckboxToggleStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxToggleStyle.swift; sourceTree = "<group>"; };
 		BD3CC0712B0B89D50013189E /* MainChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChartView.swift; sourceTree = "<group>"; };
 		BD4064D02C4ED26900582F43 /* CoreDataObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataObserver.swift; sourceTree = "<group>"; };
+		BD6EB2D52C7D049B0086BBB6 /* LiveActivityWidgetConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityWidgetConfiguration.swift; sourceTree = "<group>"; };
 		BD7DA9A42AE06DFC00601B20 /* BolusCalculatorConfigDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusCalculatorConfigDataFlow.swift; sourceTree = "<group>"; };
 		BD7DA9A62AE06E2B00601B20 /* BolusCalculatorConfigProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusCalculatorConfigProvider.swift; sourceTree = "<group>"; };
 		BD7DA9A82AE06E9200601B20 /* BolusCalculatorStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusCalculatorStateModel.swift; sourceTree = "<group>"; };
 		BD7DA9AB2AE06EB900601B20 /* BolusCalculatorConfigRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusCalculatorConfigRootView.swift; sourceTree = "<group>"; };
 		BDB3C1182C03DD1000CEEAA1 /* UserDefaultsExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsExtension.swift; sourceTree = "<group>"; };
-		BDB899872C564509006F3298 /* ForeCastChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForeCastChart.swift; sourceTree = "<group>"; };
+		BDB899872C564509006F3298 /* ForecastChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastChart.swift; sourceTree = "<group>"; };
 		BDB899892C565D0B006F3298 /* CarbsGlucose+helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CarbsGlucose+helper.swift"; sourceTree = "<group>"; };
 		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>"; };
@@ -1090,6 +1123,12 @@
 		DD1745542C55CA6C00211FAC /* UnitsLimitsSettingsRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitsLimitsSettingsRootView.swift; sourceTree = "<group>"; };
 		DD1DB7CB2BECCA1F0048B367 /* BuildDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildDetails.swift; sourceTree = "<group>"; };
 		DD21FCB42C6952AD00AF2C25 /* DecimalPickerSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecimalPickerSettings.swift; sourceTree = "<group>"; };
+		DD32CF972CC82460003686D6 /* TrioRemoteControl+Bolus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrioRemoteControl+Bolus.swift"; sourceTree = "<group>"; };
+		DD32CF992CC8246F003686D6 /* TrioRemoteControl+Meal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrioRemoteControl+Meal.swift"; sourceTree = "<group>"; };
+		DD32CF9B2CC82495003686D6 /* TrioRemoteControl+TempTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrioRemoteControl+TempTarget.swift"; sourceTree = "<group>"; };
+		DD32CF9D2CC824C2003686D6 /* TrioRemoteControl+Override.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrioRemoteControl+Override.swift"; sourceTree = "<group>"; };
+		DD32CF9F2CC824D3003686D6 /* TrioRemoteControl+APNS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrioRemoteControl+APNS.swift"; sourceTree = "<group>"; };
+		DD32CFA12CC824E1003686D6 /* TrioRemoteControl+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrioRemoteControl+Helpers.swift"; sourceTree = "<group>"; };
 		DD68889C2C386E17006E3C44 /* NightscoutExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutExercise.swift; sourceTree = "<group>"; };
 		DD6B7CB12C7B6F0800B75029 /* Rounding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Rounding.swift; sourceTree = "<group>"; };
 		DD6B7CB32C7B71F700B75029 /* ForecastDisplayType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastDisplayType.swift; sourceTree = "<group>"; };
@@ -1098,6 +1137,15 @@
 		DD6B7CBA2C7FBBFA00B75029 /* ReviewInsulinActionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewInsulinActionView.swift; sourceTree = "<group>"; };
 		DD6D67E32C9C253500660C9B /* ColorSchemeOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorSchemeOption.swift; sourceTree = "<group>"; };
 		DD88C8E12C50420800F2D558 /* DefinitionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefinitionRow.swift; sourceTree = "<group>"; };
+		DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseColorScheme.swift; sourceTree = "<group>"; };
+		DD940BAB2CA75889000830A5 /* DynamicGlucoseColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicGlucoseColor.swift; sourceTree = "<group>"; };
+		DD9ECB672CA99F4500AA7C45 /* TrioRemoteControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioRemoteControl.swift; sourceTree = "<group>"; };
+		DD9ECB692CA99F6C00AA7C45 /* PushMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushMessage.swift; sourceTree = "<group>"; };
+		DD9ECB6D2CA9A0BA00AA7C45 /* RemoteControlConfigStateModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteControlConfigStateModel.swift; sourceTree = "<group>"; };
+		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>"; };
+		DDCEBF5A2CC1B76400DF4C36 /* LiveActivity+Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LiveActivity+Helper.swift"; sourceTree = "<group>"; };
 		DDD163112C4C689900CD525A /* OverrideStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideStateModel.swift; sourceTree = "<group>"; };
 		DDD163132C4C68D300CD525A /* OverrideProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideProvider.swift; sourceTree = "<group>"; };
 		DDD163152C4C690300CD525A /* OverrideDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideDataFlow.swift; sourceTree = "<group>"; };
@@ -1169,6 +1217,10 @@
 		FEFFA7A12929FE49007B8193 /* UIDevice+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+Extensions.swift"; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+		DDCEBF412CC1B42500DF4C36 /* Views */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Views; sourceTree = "<group>"; };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
 /* Begin PBXFrameworksBuildPhase section */
 		388E595525AD948C0019842D /* Frameworks */ = {
 			isa = PBXFrameworksBuildPhase;
@@ -1451,6 +1503,8 @@
 		3811DE0325C9D31700A708ED /* Modules */ = {
 			isa = PBXGroup;
 			children = (
+				DD9ECB6B2CA99FA400AA7C45 /* RemoteControlConfig */,
+				DD9ECB662CA99EFE00AA7C45 /* RemoteControl */,
 				DD1745382C55BF8B00211FAC /* AlgorithmAdvancedSettings */,
 				DD1745422C55C5C400211FAC /* AutosensSettings */,
 				672F63EEAE27400625E14BAD /* AutotuneConfig */,
@@ -1556,6 +1610,7 @@
 				3811DE2A25C9D49500A708ED /* HomeDataFlow.swift */,
 				3811DE2925C9D49500A708ED /* HomeProvider.swift */,
 				3811DE2825C9D49500A708ED /* HomeStateModel.swift */,
+				58645B972CA2D16A008AFCE7 /* HomeStateModel+Setup */,
 				3811DE2C25C9D49500A708ED /* View */,
 			);
 			path = Home;
@@ -1897,6 +1952,7 @@
 		388E5A5925B6F0250019842D /* Models */ = {
 			isa = PBXGroup;
 			children = (
+				DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */,
 				DD6D67E32C9C253500660C9B /* ColorSchemeOption.swift */,
 				385CEAC025F2EA52002D6D5B /* Announcement.swift */,
 				388E5A5F25B6F2310019842D /* Autosens.swift */,
@@ -1941,6 +1997,7 @@
 				DD21FCB42C6952AD00AF2C25 /* DecimalPickerSettings.swift */,
 				DD6B7CB32C7B71F700B75029 /* ForecastDisplayType.swift */,
 				DD6B7CB52C7B748B00B75029 /* TotalInsulinDisplayType.swift */,
+				DD9ECB692CA99F6C00AA7C45 /* PushMessage.swift */,
 			);
 			path = Models;
 			sourceTree = "<group>";
@@ -1948,6 +2005,7 @@
 		388E5A5A25B6F05F0019842D /* Helpers */ = {
 			isa = PBXGroup;
 			children = (
+				DD940BAB2CA75889000830A5 /* DynamicGlucoseColor.swift */,
 				38F37827261260DC009DB701 /* Color+Extensions.swift */,
 				389ECE042601144100D86C4F /* ConcurrentMap.swift */,
 				38192E0C261BAF980094D973 /* ConvenienceExtensions.swift */,
@@ -2250,6 +2308,21 @@
 			path = Helper;
 			sourceTree = "<group>";
 		};
+		58645B972CA2D16A008AFCE7 /* HomeStateModel+Setup */ = {
+			isa = PBXGroup;
+			children = (
+				58645B982CA2D1A4008AFCE7 /* GlucoseSetup.swift */,
+				58645B9A2CA2D24F008AFCE7 /* CarbSetup.swift */,
+				58645B9C2CA2D275008AFCE7 /* DeterminationSetup.swift */,
+				58645B9E2CA2D2BE008AFCE7 /* PumpHistorySetup.swift */,
+				58645BA02CA2D2F8008AFCE7 /* OverrideSetup.swift */,
+				58645BA22CA2D325008AFCE7 /* BatterySetup.swift */,
+				58645BA42CA2D347008AFCE7 /* ForecastSetup.swift */,
+				58645BA62CA2D390008AFCE7 /* ChartAxisSetup.swift */,
+			);
+			path = "HomeStateModel+Setup";
+			sourceTree = "<group>";
+		};
 		587A54C82BCDCE0F009D38E2 /* Model */ = {
 			isa = PBXGroup;
 			children = (
@@ -2287,10 +2360,12 @@
 		6B1A8D1C2B14D91600E76752 /* LiveActivity */ = {
 			isa = PBXGroup;
 			children = (
+				DDCEBF412CC1B42500DF4C36 /* Views */,
 				6B1A8D1D2B14D91600E76752 /* LiveActivityBundle.swift */,
 				6B1A8D1F2B14D91600E76752 /* LiveActivity.swift */,
 				6B1A8D232B14D91700E76752 /* Assets.xcassets */,
 				6B1A8D252B14D91700E76752 /* Info.plist */,
+				DDCEBF5A2CC1B76400DF4C36 /* LiveActivity+Helper.swift */,
 			);
 			path = LiveActivity;
 			sourceTree = "<group>";
@@ -2352,7 +2427,7 @@
 			children = (
 				BDFD16592AE40438007F0DDA /* BolusRootView.swift */,
 				58237D9D2BCF0A6B00A47A79 /* PopupView.swift */,
-				BDB899872C564509006F3298 /* ForeCastChart.swift */,
+				BDB899872C564509006F3298 /* ForecastChart.swift */,
 				BD0B2EF22C5998E600B3298F /* MealPresetView.swift */,
 				DDF847E52C5D66490049BB3B /* AddMealPresetView.swift */,
 			);
@@ -2660,6 +2735,39 @@
 			path = ProfileImport;
 			sourceTree = "<group>";
 		};
+		DD9ECB662CA99EFE00AA7C45 /* RemoteControl */ = {
+			isa = PBXGroup;
+			children = (
+				DD32CFA12CC824E1003686D6 /* TrioRemoteControl+Helpers.swift */,
+				DD32CF9F2CC824D3003686D6 /* TrioRemoteControl+APNS.swift */,
+				DD32CF9D2CC824C2003686D6 /* TrioRemoteControl+Override.swift */,
+				DD32CF9B2CC82495003686D6 /* TrioRemoteControl+TempTarget.swift */,
+				DD32CF992CC8246F003686D6 /* TrioRemoteControl+Meal.swift */,
+				DD32CF972CC82460003686D6 /* TrioRemoteControl+Bolus.swift */,
+				DD9ECB672CA99F4500AA7C45 /* TrioRemoteControl.swift */,
+			);
+			path = RemoteControl;
+			sourceTree = "<group>";
+		};
+		DD9ECB6B2CA99FA400AA7C45 /* RemoteControlConfig */ = {
+			isa = PBXGroup;
+			children = (
+				DD9ECB6F2CA9A0BA00AA7C45 /* RemoteControlConfigDataFlow.swift */,
+				DD9ECB6E2CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift */,
+				DD9ECB6D2CA9A0BA00AA7C45 /* RemoteControlConfigStateModel.swift */,
+				DD9ECB6C2CA99FAE00AA7C45 /* View */,
+			);
+			path = RemoteControlConfig;
+			sourceTree = "<group>";
+		};
+		DD9ECB6C2CA99FAE00AA7C45 /* View */ = {
+			isa = PBXGroup;
+			children = (
+				DD9ECB732CA9A0C300AA7C45 /* RemoteControlConfig.swift */,
+			);
+			path = View;
+			sourceTree = "<group>";
+		};
 		DDD163032C4C67B400CD525A /* OverrideConfig */ = {
 			isa = PBXGroup;
 			children = (
@@ -2735,6 +2843,7 @@
 			isa = PBXGroup;
 			children = (
 				DDF847E32C5C288F0049BB3B /* LiveActivitySettingsRootView.swift */,
+				BD6EB2D52C7D049B0086BBB6 /* LiveActivityWidgetConfiguration.swift */,
 			);
 			path = View;
 			sourceTree = "<group>";
@@ -2932,6 +3041,9 @@
 			);
 			dependencies = (
 			);
+			fileSystemSynchronizedGroups = (
+				DDCEBF412CC1B42500DF4C36 /* Views */,
+			);
 			name = LiveActivityExtension;
 			productName = LiveActivityExtension;
 			productReference = 6B1A8D172B14D91600E76752 /* LiveActivityExtension.appex */;
@@ -3152,12 +3264,14 @@
 				3811DEEB25CA063400A708ED /* PersistedProperty.swift in Sources */,
 				38E44537274E411700EC9A94 /* Disk+Helpers.swift in Sources */,
 				388E5A6025B6F2310019842D /* Autosens.swift in Sources */,
+				DD9ECB6A2CA99F6C00AA7C45 /* PushMessage.swift in Sources */,
 				3811DE8F25C9D80400A708ED /* User.swift in Sources */,
 				19D466A329AA2B80004D5F33 /* MealSettingsDataFlow.swift in Sources */,
 				3811DEB225C9D88300A708ED /* KeychainItemAccessibility.swift in Sources */,
 				385CEAC425F2F154002D6D5B /* AnnouncementsStorage.swift in Sources */,
 				38AEE73D25F0200C0013F05B /* FreeAPSSettings.swift in Sources */,
 				38FCF3FD25E997A80078B0D1 /* PumpHistoryStorage.swift in Sources */,
+				58645BA72CA2D390008AFCE7 /* ChartAxisSetup.swift in Sources */,
 				38D0B3B625EBE24900CB6E88 /* Battery.swift in Sources */,
 				38C4D33725E9A1A300D30B77 /* DispatchQueue+Extensions.swift in Sources */,
 				F90692CF274B999A0037068D /* HealthKitDataFlow.swift in Sources */,
@@ -3183,7 +3297,9 @@
 				38B4F3CD25E5031100E76A18 /* Broadcaster.swift in Sources */,
 				383420D925FFEB3F002D46C1 /* Popup.swift in Sources */,
 				DD1745402C55BFC100211FAC /* AlgorithmAdvancedSettingsRootView.swift in Sources */,
+				58645BA52CA2D347008AFCE7 /* ForecastSetup.swift in Sources */,
 				110AEDEE2C51A0AE00615CC9 /* ShortcutsConfigStateModel.swift in Sources */,
+				DD9ECB722CA9A0BA00AA7C45 /* RemoteControlConfigDataFlow.swift in Sources */,
 				DD1745262C55526F00211FAC /* SMBSettingsRootView.swift in Sources */,
 				3811DE3025C9D49500A708ED /* HomeStateModel.swift in Sources */,
 				38BF021725E7CBBC00579895 /* PumpManagerExtensions.swift in Sources */,
@@ -3236,11 +3352,14 @@
 				3811DE3125C9D49500A708ED /* HomeProvider.swift in Sources */,
 				FE41E4D629463EE20047FD55 /* NightscoutPreferences.swift in Sources */,
 				E013D872273AC6FE0014109C /* GlucoseSimulatorSource.swift in Sources */,
+				58645BA32CA2D325008AFCE7 /* BatterySetup.swift in Sources */,
 				388E5A5C25B6F0770019842D /* JSON.swift in Sources */,
 				3811DF0225CA9FEA00A708ED /* Credentials.swift in Sources */,
 				5837A5302BD2E3C700A5DC04 /* CarbEntryStored+helper.swift in Sources */,
 				389A572026079BAA00BC102F /* Interpolation.swift in Sources */,
+				DD9ECB702CA9A0BA00AA7C45 /* RemoteControlConfigStateModel.swift in Sources */,
 				19A910382A24EF3200C8951B /* ChartsView.swift in Sources */,
+				DD32CF9A2CC8247B003686D6 /* TrioRemoteControl+Meal.swift in Sources */,
 				BDF34F832C10C5B600D51995 /* DataManager.swift in Sources */,
 				38B4F3C625E5017E00E76A18 /* NotificationCenter.swift in Sources */,
 				19D466A729AA2C22004D5F33 /* MealSettingsStateModel.swift in Sources */,
@@ -3270,6 +3389,7 @@
 				388358C825EEF6D200E024B2 /* BasalProfileEntry.swift in Sources */,
 				3811DE0B25C9D32F00A708ED /* BaseView.swift in Sources */,
 				3811DE3225C9D49500A708ED /* HomeDataFlow.swift in Sources */,
+				DD32CFA22CC824E2003686D6 /* TrioRemoteControl+Helpers.swift in Sources */,
 				CE1856F52ADC4858007E39C7 /* AddCarbPresetIntent.swift in Sources */,
 				38569347270B5DFB0002C50D /* CGMType.swift in Sources */,
 				3821ED4C25DD18BA00BC42AD /* Constants.swift in Sources */,
@@ -3285,6 +3405,7 @@
 				DD1745302C55AE5300211FAC /* TargetBehaviorProvider.swift in Sources */,
 				58D08B382C8DFB6000AA37D3 /* BasalChart.swift in Sources */,
 				118DF76D2C5ECBC60067FEB7 /* OverridePresetEntity.swift in Sources */,
+				58645B9F2CA2D2BE008AFCE7 /* PumpHistorySetup.swift in Sources */,
 				DD17454E2C55CA4D00211FAC /* UnitsLimitsSettingsDataFlow.swift in Sources */,
 				DDF847E62C5D66490049BB3B /* AddMealPresetView.swift in Sources */,
 				3811DEAE25C9D88300A708ED /* Cache.swift in Sources */,
@@ -3295,6 +3416,7 @@
 				CEB434E728B9053300B70274 /* LoopUIColorPalette+Default.swift in Sources */,
 				19F95FF329F10FBC00314DDC /* StatDataFlow.swift in Sources */,
 				582DF97B2C8CE209001F516D /* CarbView.swift in Sources */,
+				DD940BAA2CA7585D000830A5 /* GlucoseColorScheme.swift in Sources */,
 				3811DE2225C9D48300A708ED /* MainProvider.swift in Sources */,
 				3811DE0C25C9D32F00A708ED /* BaseProvider.swift in Sources */,
 				CE95BF5A2BA62E4A00DC3DE3 /* PluginSource.swift in Sources */,
@@ -3316,6 +3438,7 @@
 				CE7950242997D81700FA576E /* CGMSettingsView.swift in Sources */,
 				58237D9E2BCF0A6B00A47A79 /* PopupView.swift in Sources */,
 				38D0B3D925EC07C400CB6E88 /* CarbsEntry.swift in Sources */,
+				DD32CF9C2CC82499003686D6 /* TrioRemoteControl+TempTarget.swift in Sources */,
 				38A9260525F012D8009E3739 /* CarbRatios.swift in Sources */,
 				38FCF3D625E8FDF40078B0D1 /* MD5.swift in Sources */,
 				DDD163142C4C68D300CD525A /* OverrideProvider.swift in Sources */,
@@ -3339,6 +3462,7 @@
 				38A504A425DD9C4000C5B9E8 /* UserDefaultsExtensions.swift in Sources */,
 				38FE826A25CC82DB001FF17A /* NetworkService.swift in Sources */,
 				FE66D16B291F74F8005D6F77 /* Bundle+Extensions.swift in Sources */,
+				DD940BAC2CA75889000830A5 /* DynamicGlucoseColor.swift in Sources */,
 				3883581C25EE79BB00E024B2 /* TextFieldWithToolBar.swift in Sources */,
 				58D08B302C8DEA7500AA37D3 /* ForecastView.swift in Sources */,
 				6B1A8D2E2B156EEF00E76752 /* LiveActivityBridge.swift in Sources */,
@@ -3375,17 +3499,20 @@
 				72F1BD388F42FCA6C52E4500 /* ConfigEditorProvider.swift in Sources */,
 				E39E418C56A5A46B61D960EE /* ConfigEditorStateModel.swift in Sources */,
 				45717281F743594AA9D87191 /* ConfigEditorRootView.swift in Sources */,
+				DD32CFA02CC824D6003686D6 /* TrioRemoteControl+APNS.swift in Sources */,
 				CE7CA3532A064973004BE681 /* tempPresetIntent.swift in Sources */,
 				D6DEC113821A7F1056C4AA1E /* NightscoutConfigDataFlow.swift in Sources */,
 				DD6B7CBB2C7FBBFA00B75029 /* ReviewInsulinActionView.swift in Sources */,
 				38E98A3025F52FF700C0CED0 /* Config.swift in Sources */,
-				BDB899882C564509006F3298 /* ForeCastChart.swift in Sources */,
+				BDB899882C564509006F3298 /* ForecastChart.swift in Sources */,
 				110AEDE32C5193D200615CC9 /* BolusIntent.swift in Sources */,
+				58645BA12CA2D2F8008AFCE7 /* OverrideSetup.swift in Sources */,
 				DDD1631A2C4C695E00CD525A /* EditOverrideForm.swift in Sources */,
 				CE1856F72ADC4869007E39C7 /* CarbPresetIntentRequest.swift in Sources */,
 				CE1F6DDB2BAE08B60064EB8D /* TidepoolManager.swift in Sources */,
 				BD2B464E0745FBE7B79913F4 /* NightscoutConfigProvider.swift in Sources */,
 				9825E5E923F0B8FA80C8C7C7 /* NightscoutConfigStateModel.swift in Sources */,
+				58645B9D2CA2D275008AFCE7 /* DeterminationSetup.swift in Sources */,
 				38A43598262E0E4900E80935 /* FetchAnnouncementsManager.swift in Sources */,
 				DD1745442C55C60E00211FAC /* AutosensSettingsDataFlow.swift in Sources */,
 				BDCAF2382C639F35002DC907 /* SettingItems.swift in Sources */,
@@ -3402,6 +3529,7 @@
 				581AC4392BE22ED10038760C /* JSONConverter.swift in Sources */,
 				CE7CA3522A064973004BE681 /* ListTempPresetsIntent.swift in Sources */,
 				BD4064D12C4ED26900582F43 /* CoreDataObserver.swift in Sources */,
+				58645B9B2CA2D24F008AFCE7 /* CarbSetup.swift in Sources */,
 				38E44536274E411700EC9A94 /* Disk.swift in Sources */,
 				19A910362A24D6D700C8951B /* DateFilter.swift in Sources */,
 				582DF9792C8CE1E5001F516D /* MainChartHelper.swift in Sources */,
@@ -3422,6 +3550,7 @@
 				CE7CA3512A064973004BE681 /* ApplyTempPresetIntent.swift in Sources */,
 				FA630397F76B582C8D8681A7 /* BasalProfileEditorProvider.swift in Sources */,
 				DD1745172C54389F00211FAC /* FeatureSettingsView.swift in Sources */,
+				DD9ECB712CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift in Sources */,
 				63E890B4D951EAA91C071D5C /* BasalProfileEditorStateModel.swift in Sources */,
 				38FEF3FA2737E42000574A46 /* BaseStateModel.swift in Sources */,
 				CC6C406E2ACDD69E009B8058 /* RawFetchedProfile.swift in Sources */,
@@ -3449,6 +3578,8 @@
 				BD7DA9A72AE06E2B00601B20 /* BolusCalculatorConfigProvider.swift in Sources */,
 				38192E0D261BAF980094D973 /* ConvenienceExtensions.swift in Sources */,
 				88AB39B23C9552BD6E0C9461 /* ISFEditorRootView.swift in Sources */,
+				BD6EB2D62C7D049B0086BBB6 /* LiveActivityWidgetConfiguration.swift in Sources */,
+				DD32CF982CC82463003686D6 /* TrioRemoteControl+Bolus.swift in Sources */,
 				F816825E28DB441200054060 /* HeartBeatManager.swift in Sources */,
 				58F107742BD1A4D000B1A680 /* Determination+helper.swift in Sources */,
 				38FEF413273B317A00574A46 /* HKUnit.swift in Sources */,
@@ -3492,9 +3623,11 @@
 				38A00B1F25FC00F7006BC0B0 /* Autotune.swift in Sources */,
 				38AAF85525FFF846004AF583 /* CurrentGlucoseView.swift in Sources */,
 				041D1E995A6AE92E9289DC49 /* BolusDataFlow.swift in Sources */,
+				DD32CF9E2CC824C5003686D6 /* TrioRemoteControl+Override.swift in Sources */,
 				23888883D4EA091C88480FF2 /* BolusProvider.swift in Sources */,
 				38E98A2D25F52DC400C0CED0 /* NSLocking+Extensions.swift in Sources */,
 				BDBAACFA2C2D439700370AAE /* OverrideData.swift in Sources */,
+				DD9ECB682CA99F4500AA7C45 /* TrioRemoteControl.swift in Sources */,
 				38569353270B5E350002C50D /* CGMRootView.swift in Sources */,
 				69A31254F2451C20361D172F /* BolusStateModel.swift in Sources */,
 				1967DFC029D053AC00759F30 /* IconSelection.swift in Sources */,
@@ -3512,6 +3645,7 @@
 				C967DACD3B1E638F8B43BE06 /* ManualTempBasalStateModel.swift in Sources */,
 				38E4453B274E411700EC9A94 /* Disk+VolumeInformation.swift in Sources */,
 				118DF76A2C5ECBC60067FEB7 /* ApplyOverridePresetIntent.swift in Sources */,
+				58645B992CA2D1A4008AFCE7 /* GlucoseSetup.swift in Sources */,
 				7BCFACB97C821041BA43A114 /* ManualTempBasalRootView.swift in Sources */,
 				38E44534274E411700EC9A94 /* Disk+InternalHelpers.swift in Sources */,
 				38A00B2325FC2B55006BC0B0 /* LRUCache.swift in Sources */,
@@ -3582,6 +3716,7 @@
 				DDE1796E2C910127003CDDB7 /* OrefDetermination+CoreDataClass.swift in Sources */,
 				DDE1796F2C910127003CDDB7 /* OrefDetermination+CoreDataProperties.swift in Sources */,
 				DDE179702C910127003CDDB7 /* OverrideStored+CoreDataClass.swift in Sources */,
+				DD9ECB742CA9A0C300AA7C45 /* RemoteControlConfig.swift in Sources */,
 				DDE179712C910127003CDDB7 /* OverrideStored+CoreDataProperties.swift in Sources */,
 				CD78BB94E43B249D60CC1A1B /* GlucoseNotificationSettingsRootView.swift in Sources */,
 				CE7CA3502A064973004BE681 /* CancelTempPresetIntent.swift in Sources */,
@@ -3632,6 +3767,7 @@
 			files = (
 				6BCF84DE2B16843A003AD46E /* LiveActitiyAttributes.swift in Sources */,
 				6BCF84DE2B16843A003AD46E /* LiveActitiyAttributes.swift in Sources */,
+				DDCEBF5B2CC1B76400DF4C36 /* LiveActivity+Helper.swift in Sources */,
 				6B1A8D1E2B14D91600E76752 /* LiveActivityBundle.swift in Sources */,
 				6B1A8D202B14D91600E76752 /* LiveActivity.swift in Sources */,
 			);
@@ -3781,7 +3917,7 @@
 				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
 				GCC_WARN_UNUSED_FUNCTION = YES;
 				GCC_WARN_UNUSED_VARIABLE = YES;
-				IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
 				MARKETING_VERSION = "$(APP_VERSION)";
 				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
 				MTL_FAST_MATH = YES;
@@ -3841,7 +3977,7 @@
 				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
 				GCC_WARN_UNUSED_FUNCTION = YES;
 				GCC_WARN_UNUSED_VARIABLE = YES;
-				IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
 				MARKETING_VERSION = "$(APP_VERSION)";
 				MTL_ENABLE_DEBUG_INFO = NO;
 				MTL_FAST_MATH = YES;
@@ -3870,7 +4006,7 @@
 				DEVELOPMENT_TEAM = "$(DEVELOPER_TEAM)";
 				ENABLE_PREVIEWS = YES;
 				INFOPLIST_FILE = FreeAPS/Resources/Info.plist;
-				IPHONEOS_DEPLOYMENT_TARGET = 16.2;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/Frameworks",
@@ -3911,7 +4047,7 @@
 				DEVELOPMENT_TEAM = "$(DEVELOPER_TEAM)";
 				ENABLE_PREVIEWS = YES;
 				INFOPLIST_FILE = FreeAPS/Resources/Info.plist;
-				IPHONEOS_DEPLOYMENT_TARGET = 16.2;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/Frameworks",
@@ -3968,7 +4104,7 @@
 				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
 				SWIFT_VERSION = 5.0;
 				TARGETED_DEVICE_FAMILY = 4;
-				WATCHOS_DEPLOYMENT_TARGET = 8.0;
+				WATCHOS_DEPLOYMENT_TARGET = 10;
 			};
 			name = Debug;
 		};
@@ -4005,7 +4141,7 @@
 				SWIFT_OBJC_BRIDGING_HEADER = "Model/Classes+Properties/FreeAPSWatch-Bridging-Header.h";
 				SWIFT_VERSION = 5.0;
 				TARGETED_DEVICE_FAMILY = 4;
-				WATCHOS_DEPLOYMENT_TARGET = 8.0;
+				WATCHOS_DEPLOYMENT_TARGET = 10;
 			};
 			name = Release;
 		};
@@ -4045,7 +4181,7 @@
 				SWIFT_EMIT_LOC_STRINGS = YES;
 				SWIFT_VERSION = 5.0;
 				TARGETED_DEVICE_FAMILY = 4;
-				WATCHOS_DEPLOYMENT_TARGET = 8.0;
+				WATCHOS_DEPLOYMENT_TARGET = 10;
 			};
 			name = Debug;
 		};
@@ -4085,7 +4221,7 @@
 				SWIFT_EMIT_LOC_STRINGS = YES;
 				SWIFT_VERSION = 5.0;
 				TARGETED_DEVICE_FAMILY = 4;
-				WATCHOS_DEPLOYMENT_TARGET = 8.0;
+				WATCHOS_DEPLOYMENT_TARGET = 10;
 			};
 			name = Release;
 		};
@@ -4096,7 +4232,7 @@
 				CODE_SIGN_STYLE = Automatic;
 				DEVELOPMENT_TEAM = "$(DEVELOPER_TEAM)";
 				INFOPLIST_FILE = FreeAPSTests/Info.plist;
-				IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/Frameworks",
@@ -4117,7 +4253,7 @@
 				CODE_SIGN_STYLE = Automatic;
 				DEVELOPMENT_TEAM = "$(DEVELOPER_TEAM)";
 				INFOPLIST_FILE = FreeAPSTests/Info.plist;
-				IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/Frameworks",
@@ -4147,7 +4283,7 @@
 				INFOPLIST_FILE = LiveActivity/Info.plist;
 				INFOPLIST_KEY_CFBundleDisplayName = LiveActivity;
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
-				IPHONEOS_DEPLOYMENT_TARGET = 16.2;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/Frameworks",
@@ -4181,7 +4317,7 @@
 				INFOPLIST_FILE = LiveActivity/Info.plist;
 				INFOPLIST_KEY_CFBundleDisplayName = LiveActivity;
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
-				IPHONEOS_DEPLOYMENT_TARGET = 16.2;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/Frameworks",

+ 16 - 0
FreeAPS.xcodeproj/xcshareddata/xcschemes/Trio.xcscheme

@@ -348,6 +348,8 @@
       buildConfiguration = "Debug"
       selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
       selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      enableThreadSanitizer = "YES"
+      enableUBSanitizer = "YES"
       launchStyle = "0"
       useCustomWorkingDirectory = "NO"
       ignoresPersistentStateOnLaunch = "NO"
@@ -364,6 +366,20 @@
             ReferencedContainer = "container:FreeAPS.xcodeproj">
          </BuildableReference>
       </BuildableProductRunnable>
+      <CommandLineArguments>
+         <CommandLineArgument
+            argument = "-com.apple.CoreData.ConcurrencyDebug 1"
+            isEnabled = "YES">
+         </CommandLineArgument>
+         <CommandLineArgument
+            argument = "-com.apple.CoreData.SQLDebug 1"
+            isEnabled = "NO">
+         </CommandLineArgument>
+         <CommandLineArgument
+            argument = "-com.apple.CoreData.MigrationDebug 1"
+            isEnabled = "NO">
+         </CommandLineArgument>
+      </CommandLineArguments>
       <EnvironmentVariables>
          <EnvironmentVariable
             key = "CG_NUMERICS_SHOW_BACKTRACE"

+ 2 - 0
FreeAPS/Resources/FreeAPS.entitlements

@@ -2,6 +2,8 @@
 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 <plist version="1.0">
 <dict>
+	<key>aps-environment</key>
+	<string>development</string>
 	<key>com.apple.developer.healthkit</key>
 	<true/>
 	<key>com.apple.developer.healthkit.access</key>

+ 2 - 0
FreeAPS/Resources/Info.plist

@@ -2,6 +2,8 @@
 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 <plist version="1.0">
 <dict>
+	<key>TeamID</key>
+	<string>$(DEVELOPER_TEAM)</string>
 	<key>AppGroupID</key>
 	<string>$(APP_GROUP_ID)</string>
 	<key>BGTaskSchedulerPermittedIdentifiers</key>

+ 1 - 0
FreeAPS/Resources/json/defaults/freeaps/freeaps_settings.json

@@ -37,6 +37,7 @@
   "high" : 180,
   "low" : 70,
   "hours" : 6,
+  "glucoseColorScheme" : "staticColor",
   "xGridLines" : true,
   "yGridLines" : true,
   "oneDimensionalGraph" : false,

+ 6 - 4
FreeAPS/Sources/APS/APSManager.swift

@@ -939,11 +939,13 @@ final class BaseAPSManager: APSManager, Injectable {
             batchSize: batchSize
         )
 
-        guard let glucoseResults = results as? [GlucoseStored] else {
-            return []
-        }
+        return await privateContext.perform {
+            guard let glucoseResults = results as? [GlucoseStored] else {
+                return []
+            }
 
-        return glucoseResults
+            return glucoseResults
+        }
     }
 
     // TODO: - Refactor this whole shit here...

+ 24 - 35
FreeAPS/Sources/APS/FetchGlucoseManager.swift

@@ -1,5 +1,6 @@
 import Combine
 import Foundation
+import HealthKit
 import LoopKit
 import LoopKitUI
 import SwiftDate
@@ -28,6 +29,7 @@ extension FetchGlucoseManager {
 
 final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
     private let processQueue = DispatchQueue(label: "BaseGlucoseManager.processQueue")
+
     @Injected() var glucoseStorage: GlucoseStorage!
     @Injected() var nightscoutManager: NightscoutManager!
     @Injected() var tidepoolService: TidepoolManager!
@@ -100,6 +102,14 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         settingsManager.settings.uploadGlucose = cgmM.shouldSyncToRemoteService
     }
 
+    private func updateManagerUnits(_ manager: CGMManagerUI?) {
+        let units = settingsManager.settings.units
+        let managerName = cgmManager.map { "\(type(of: $0))" } ?? "nil"
+        let loopkitUnits: HKUnit = units == .mgdL ? .milligramsPerDeciliter : .millimolesPerLiter
+        print("manager: \(managerName) is changing units to: \(loopkitUnits.description) ")
+        manager?.unitDidChange(to: loopkitUnits)
+    }
+
     func updateGlucoseSource(cgmGlucoseSourceType: CGMType, cgmGlucosePluginId: String, newManager: CGMManagerUI?) {
         // if changed, remove all calibrations
         if self.cgmGlucoseSourceType != cgmGlucoseSourceType || self.cgmGlucosePluginId != cgmGlucosePluginId {
@@ -122,6 +132,8 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
             removeCalibrations()
         } else if self.cgmGlucoseSourceType == .plugin, cgmManager == nil, let rawCGMManager = rawCGMManager {
             cgmManager = cgmManagerFromRawValue(rawCGMManager)
+            updateManagerUnits(cgmManager)
+
         } else {
             saveConfigManager()
         }
@@ -151,7 +163,6 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         else {
             return nil
         }
-
         return Manager.init(rawState: rawState)
     }
 
@@ -165,18 +176,16 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
     /// function to try to force the refresh of the CGM - generally provide by the pump heartbeat
     public func refreshCGM() {
         debug(.deviceManager, "refreshCGM by pump")
-        // updateGlucoseSource(cgmGlucoseSourceType: settingsManager.settings.cgm, cgmGlucosePluginId: settingsManager.settings.cgmPluginIdentifier)
 
-        Publishers.CombineLatest3(
+        Publishers.CombineLatest(
             Just(glucoseStorage.syncDate()),
-            healthKitManager.fetch(nil),
             glucoseSource.fetchIfNeeded()
         )
         .eraseToAnyPublisher()
         .receive(on: processQueue)
-        .sink { syncDate, glucoseFromHealth, glucose in
+        .sink { syncDate, glucose in
             debug(.nightscout, "refreshCGM FETCHGLUCOSE : SyncDate is \(syncDate)")
-            self.glucoseStoreAndHeartDecision(syncDate: syncDate, glucose: glucose, glucoseFromHealth: glucoseFromHealth)
+            self.glucoseStoreAndHeartDecision(syncDate: syncDate, glucose: glucose)
         }
         .store(in: &lifetime)
     }
@@ -210,11 +219,10 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         }
     }
 
-    private func glucoseStoreAndHeartDecision(syncDate: Date, glucose: [BloodGlucose], glucoseFromHealth: [BloodGlucose] = []) {
+    private func glucoseStoreAndHeartDecision(syncDate: Date, glucose: [BloodGlucose]) {
         // calibration add if required only for sensor
         let newGlucose = overcalibrate(entries: glucose)
 
-        let allGlucose = newGlucose + glucoseFromHealth
         var filteredByDate: [BloodGlucose] = []
         var filtered: [BloodGlucose] = []
 
@@ -226,7 +234,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
             backGroundFetchBGTaskID = .invalid
         }
 
-        guard allGlucose.isNotEmpty else {
+        guard newGlucose.isNotEmpty else {
             if let backgroundTask = backGroundFetchBGTaskID {
                 UIApplication.shared.endBackgroundTask(backgroundTask)
                 backGroundFetchBGTaskID = .invalid
@@ -234,11 +242,11 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
             return
         }
 
-        filteredByDate = allGlucose.filter { $0.dateString > syncDate }
+        filteredByDate = newGlucose.filter { $0.dateString > syncDate }
         filtered = glucoseStorage.filterTooFrequentGlucose(filteredByDate, at: syncDate)
 
         guard filtered.isNotEmpty else {
-            // end of the BG tasks
+            // end of the Background tasks
             if let backgroundTask = backGroundFetchBGTaskID {
                 UIApplication.shared.endBackgroundTask(backgroundTask)
                 backGroundFetchBGTaskID = .invalid
@@ -265,24 +273,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
 
         deviceDataManager.heartbeat(date: Date())
 
-        Task.detached {
-            await self.nightscoutManager.uploadGlucose()
-            await self.tidepoolService.uploadGlucose(device: self.cgmManager?.cgmManagerStatus.device)
-        }
-
-        let glucoseForHealth = filteredByDate.filter { !glucoseFromHealth.contains($0) }
-
-        guard glucoseForHealth.isNotEmpty else {
-            // end of the BG tasks
-            if let backgroundTask = backGroundFetchBGTaskID {
-                UIApplication.shared.endBackgroundTask(backgroundTask)
-                backGroundFetchBGTaskID = .invalid
-            }
-            return
-        }
-        healthKitManager.saveIfNeeded(bloodGlucose: glucoseForHealth)
-
-        // end of the BG tasks
+        // End of the Background tasks
         if let backgroundTask = backGroundFetchBGTaskID {
             UIApplication.shared.endBackgroundTask(backgroundTask)
             backGroundFetchBGTaskID = .invalid
@@ -303,17 +294,15 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
             }
             .sink { glucose in
                 debug(.nightscout, "FetchGlucoseManager callback sensor")
-                Publishers.CombineLatest3(
+                Publishers.CombineLatest(
                     Just(glucose),
-                    Just(self.glucoseStorage.syncDate()),
-                    self.healthKitManager.fetch(nil)
+                    Just(self.glucoseStorage.syncDate())
                 )
                 .eraseToAnyPublisher()
-                .sink { newGlucose, syncDate, glucoseFromHealth in
+                .sink { newGlucose, syncDate in
                     self.glucoseStoreAndHeartDecision(
                         syncDate: syncDate,
-                        glucose: newGlucose,
-                        glucoseFromHealth: glucoseFromHealth
+                        glucose: newGlucose
                     )
                 }
                 .store(in: &self.lifetime)

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

@@ -87,6 +87,7 @@ extension OpenAPS {
         static let uploadedPreferences = "upload/uploaded-preferences.json"
         static let uploadedSettings = "upload/uploaded-settings.json"
         static let uploadedManualGlucose = "upload/uploaded-manual-readings.json"
+        static let uploadedNotes = "upload/uploaded-notes.json"
     }
 
     enum FreeAPS {

+ 71 - 6
FreeAPS/Sources/APS/OpenAPS/JavaScriptWorker.swift

@@ -1,11 +1,36 @@
 import Foundation
 import JavaScriptCore
 
+private let contextLock = NSRecursiveLock()
+
+extension String {
+    var lowercasingFirst: String { prefix(1).lowercased() + dropFirst() }
+    var uppercasingFirst: String { prefix(1).uppercased() + dropFirst() }
+    var camelCased: String {
+        guard !isEmpty else { return "" }
+        let parts = components(separatedBy: .alphanumerics.inverted)
+        let first = parts.first!.lowercasingFirst
+        let rest = parts.dropFirst().map(\.uppercasingFirst)
+        return ([first] + rest).joined()
+    }
+
+    var pascalCased: String {
+        guard !isEmpty else { return "" }
+        let parts = components(separatedBy: .alphanumerics.inverted)
+        let first = parts.first!.uppercasingFirst
+        let rest = parts.dropFirst().map(\.uppercasingFirst)
+        return ([first] + rest).joined()
+    }
+}
+
 final class JavaScriptWorker {
     private let processQueue = DispatchQueue(label: "DispatchQueue.JavaScriptWorker", attributes: .concurrent)
     private let virtualMachine: JSVirtualMachine
     private var contextPool: [JSContext] = []
     private let contextPoolLock = NSLock()
+    @SyncAccess(lock: contextLock) private var commonContext: JSContext? = nil
+    private var consoleLogs: [String] = []
+    private var logContext: String = ""
 
     init(poolSize: Int = 5) {
         virtualMachine = JSVirtualMachine()!
@@ -23,7 +48,10 @@ final class JavaScriptWorker {
             }
         }
         let consoleLog: @convention(block) (String) -> Void = { message in
-            debug(.openAPS, "JavaScript log: \(message)")
+            let trimmedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
+            if !trimmedMessage.isEmpty {
+                self.consoleLogs.append("\(trimmedMessage)")
+            }
         }
         context.setObject(consoleLog, forKeyedSubscript: "_consoleLog" as NSString)
         return context
@@ -42,8 +70,37 @@ final class JavaScriptWorker {
         contextPoolLock.unlock()
     }
 
+    // New method to flush aggregated logs
+    private func outputLogs() {
+        var outputLogs = consoleLogs.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
+        consoleLogs.removeAll()
+
+        if outputLogs.isEmpty { return }
+
+        if logContext == "autosens.js" {
+            outputLogs = outputLogs.split(separator: "\n").map { logLine in
+                logLine.replacingOccurrences(
+                    of: "^[-+=x!]|u\\(|\\)|\\d{1,2}h$",
+                    with: "",
+                    options: .regularExpression
+                )
+            }.joined(separator: "\n")
+        }
+
+        if !outputLogs.isEmpty {
+            outputLogs.split(separator: "\n").forEach { logLine in
+                if !"\(logLine)".trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+                    debug(.openAPS, "\(logContext): \(logLine)")
+                }
+            }
+        }
+    }
+
     @discardableResult func evaluate(script: Script) -> JSValue! {
-        evaluate(string: script.body)
+        logContext = URL(fileURLWithPath: script.name).lastPathComponent
+        let result = evaluate(string: script.body)
+        outputLogs()
+        return result
     }
 
     private func evaluate(string: String) -> JSValue! {
@@ -63,15 +120,23 @@ final class JavaScriptWorker {
 
     func inCommonContext<Value>(execute: (JavaScriptWorker) -> Value) -> Value {
         let context = getContext()
-        defer { returnContext(context) }
+        defer {
+            returnContext(context)
+            outputLogs()
+        }
         return execute(self)
     }
 
     func evaluateBatch(scripts: [Script]) {
-        let ctx = getContext()
-        defer { returnContext(ctx) } // Ensure the context is returned to the pool
+        let context = getContext()
+        defer {
+            // Ensure the context is returned to the pool
+            returnContext(context)
+        }
         scripts.forEach { script in
-            ctx.evaluateScript(script.body)
+            logContext = URL(fileURLWithPath: script.name).lastPathComponent
+            context.evaluateScript(script.body)
+            outputLogs()
         }
     }
 }

+ 14 - 43
FreeAPS/Sources/APS/OpenAPS/OpenAPS.swift

@@ -86,35 +86,6 @@ final class OpenAPS {
 
         // First save the current Determination to Core Data
         await attemptToSaveContext()
-
-        // After that check for changes in iob and cob and if there are any post a custom Notification
-        /// this is currently used to update Live Activity so that it stays up to date and not one loop cycle behind
-        await checkForCobIobUpdate(determination)
-    }
-
-    func checkForCobIobUpdate(_ determination: Determination) async {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: OrefDetermination.self,
-            onContext: context,
-            predicate: NSPredicate.predicateFor30MinAgoForDetermination,
-            key: "deliverAt",
-            ascending: false,
-            fetchLimit: 2
-        )
-
-        guard let previousDeterminations = results as? [OrefDetermination] else {
-            return
-        }
-
-        // We need to get the second last Determination for this comparison because we have saved the current Determination already to Core Data
-        if let previousDetermination = previousDeterminations.dropFirst().first {
-            let iobChanged = previousDetermination.iob != decimalToNSDecimalNumber(determination.iob)
-            let cobChanged = previousDetermination.cob != Int16(Int(determination.cob ?? 0))
-
-            if iobChanged || cobChanged {
-                Foundation.NotificationCenter.default.post(name: .didUpdateCobIob, object: nil)
-            }
-        }
     }
 
     func attemptToSaveContext() async {
@@ -140,11 +111,11 @@ final class OpenAPS {
             batchSize: 24
         )
 
-        guard let glucoseResults = results as? [GlucoseStored] else {
-            return ""
-        }
-
         return await context.perform {
+            guard let glucoseResults = results as? [GlucoseStored] else {
+                return ""
+            }
+
             // convert to JSON
             return self.jsonConverter.convertToJSON(glucoseResults)
         }
@@ -159,11 +130,11 @@ final class OpenAPS {
             ascending: false
         )
 
-        guard let carbResults = results as? [CarbEntryStored] else {
-            return ""
-        }
-
         let json = await context.perform {
+            guard let carbResults = results as? [CarbEntryStored] else {
+                return ""
+            }
+
             var jsonArray = self.jsonConverter.convertToJSON(carbResults)
 
             if let additionalCarbs = additionalCarbs {
@@ -209,11 +180,11 @@ final class OpenAPS {
             batchSize: 50
         )
 
-        guard let pumpEventResults = results as? [PumpEventStored] else {
-            return nil
-        }
-
         return await context.perform {
+            guard let pumpEventResults = results as? [PumpEventStored] else {
+                return nil
+            }
+
             return pumpEventResults.map(\.objectID)
         }
     }
@@ -1000,11 +971,11 @@ final class OpenAPS {
 
     private func middlewareScript(name: String) -> Script? {
         if let body = storage.retrieveRaw(name) {
-            return Script(name: "Middleware", body: body)
+            return Script(name: name, body: body)
         }
 
         if let url = Foundation.Bundle.main.url(forResource: "javascript/\(name)", withExtension: "") {
-            return Script(name: "Middleware", body: try! String(contentsOf: url))
+            return Script(name: name, body: try! String(contentsOf: url))
         }
 
         return nil

+ 88 - 12
FreeAPS/Sources/APS/Storage/CarbsStorage.swift

@@ -17,6 +17,8 @@ protocol CarbsStorage {
     func getCarbsNotYetUploadedToNightscout() async -> [NightscoutTreatment]
     func getFPUsNotYetUploadedToNightscout() async -> [NightscoutTreatment]
     func deleteCarbs(at uniqueID: String, fpuID: String, complex: Bool)
+    func getCarbsNotYetUploadedToHealth() async -> [CarbsEntry]
+    func getCarbsNotYetUploadedToTidepool() async -> [CarbsEntry]
 }
 
 final class BaseCarbsStorage: CarbsStorage, Injectable {
@@ -44,8 +46,9 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
             entriesToStore = await filterRemoteEntries(entries: entriesToStore)
         }
 
-        await saveCarbEquivalents(entries: entriesToStore, areFetchedFromRemote: areFetchedFromRemote)
         await saveCarbsToCoreData(entries: entriesToStore, areFetchedFromRemote: areFetchedFromRemote)
+
+        await saveCarbEquivalents(entries: entriesToStore, areFetchedFromRemote: areFetchedFromRemote)
     }
 
     private func filterRemoteEntries(entries: [CarbsEntry]) async -> [CarbsEntry] {
@@ -63,6 +66,7 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         }
 
         // Extract dates into a set for efficient lookup
+        // Since we are not dealing with NSManagedObjects directly it is safe to pass properties between threads
         let existingTimestamps = Set(existing24hCarbEntries.compactMap { $0["date"] as? Date })
 
         // Remove all entries that have a matching date in existingTimestamps
@@ -115,7 +119,7 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
      - Returns: A tuple containing the array of future carb entries and the total carb equivalents.
      */
     private func processFPU(
-        entries _: [CarbsEntry],
+        entries: [CarbsEntry],
         fat: Decimal,
         protein: Decimal,
         createdAt: Date,
@@ -144,7 +148,7 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         var numberOfEquivalents = carbEquivalents / carbEquivalentSize
 
         var useDate = actualDate ?? createdAt
-        let fpuID = UUID().uuidString
+        let fpuID = entries.first?.fpuID ?? UUID().uuidString
         var futureCarbArray = [CarbsEntry]()
         var firstIndex = true
 
@@ -161,7 +165,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
                 fat: 0,
                 protein: 0,
                 note: nil,
-                enteredBy: CarbsEntry.manual, isFPU: true,
+                enteredBy: CarbsEntry.manual,
+                isFPU: true,
                 fpuID: fpuID
             )
             futureCarbArray.append(eachCarbEntry)
@@ -202,6 +207,12 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
             newItem.id = UUID()
             newItem.isFPU = false
             newItem.isUploadedToNS = areFetchedFromRemote ? true : false
+            newItem.isUploadedToHealth = false
+            newItem.isUploadedToTidepool = false
+
+            if entry.fat != nil, entry.protein != nil, let fpuId = entry.fpuID {
+                newItem.fpuID = UUID(uuidString: fpuId)
+            }
 
             do {
                 guard self.coredataContext.hasChanges else { return }
@@ -213,8 +224,10 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     }
 
     private func saveFPUToCoreDataAsBatchInsert(entries: [CarbsEntry], areFetchedFromRemote: Bool) async {
-        let commonFPUID =
-            UUID() // all fpus should only get ONE id per batch insert to be able to delete them referencing the fpuID
+        let commonFPUID = UUID(
+            uuidString: entries.first?.fpuID ?? UUID()
+                .uuidString
+        ) // all fpus should only get ONE id per batch insert to be able to delete them referencing the fpuID
         var entrySlice = ArraySlice(entries) // convert to ArraySlice
         let batchInsert = NSBatchInsertRequest(entity: CarbEntryStored.entity()) { (managedObject: NSManagedObject) -> Bool in
             guard let carbEntry = managedObject as? CarbEntryStored, let entry = entrySlice.popFirst(),
@@ -228,6 +241,7 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
             carbEntry.fpuID = commonFPUID
             carbEntry.isFPU = true
             carbEntry.isUploadedToNS = areFetchedFromRemote ? true : false
+            // do NOT set Health and Tidepool flags to ensure they will NOT be uploaded
             return false // return false to continue
         }
         await coredataContext.perform {
@@ -337,11 +351,11 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
             ascending: false
         )
 
-        guard let carbEntries = results as? [CarbEntryStored] else {
-            return []
-        }
-
         return await coredataContext.perform {
+            guard let carbEntries = results as? [CarbEntryStored] else {
+                return []
+            }
+
             return carbEntries.map { result in
                 NightscoutTreatment(
                     duration: nil,
@@ -376,9 +390,9 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
             ascending: false
         )
 
-        guard let fpuEntries = results as? [CarbEntryStored] else { return [] }
-
         return await coredataContext.perform {
+            guard let fpuEntries = results as? [CarbEntryStored] else { return [] }
+
             return fpuEntries.map { result in
                 NightscoutTreatment(
                     duration: nil,
@@ -402,4 +416,66 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
             }
         }
     }
+
+    func getCarbsNotYetUploadedToHealth() async -> [CarbsEntry] {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: CarbEntryStored.self,
+            onContext: coredataContext,
+            predicate: NSPredicate.carbsNotYetUploadedToHealth,
+            key: "date",
+            ascending: false
+        )
+
+        guard let carbEntries = results as? [CarbEntryStored] else {
+            return []
+        }
+
+        return await coredataContext.perform {
+            return carbEntries.map { result in
+                CarbsEntry(
+                    id: result.id?.uuidString,
+                    createdAt: result.date ?? Date(),
+                    actualDate: result.date,
+                    carbs: Decimal(result.carbs),
+                    fat: Decimal(result.fat),
+                    protein: Decimal(result.protein),
+                    note: result.note,
+                    enteredBy: CarbsEntry.manual,
+                    isFPU: result.isFPU,
+                    fpuID: result.fpuID?.uuidString
+                )
+            }
+        }
+    }
+
+    func getCarbsNotYetUploadedToTidepool() async -> [CarbsEntry] {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: CarbEntryStored.self,
+            onContext: coredataContext,
+            predicate: NSPredicate.carbsNotYetUploadedToTidepool,
+            key: "date",
+            ascending: false
+        )
+
+        guard let carbEntries = results as? [CarbEntryStored] else {
+            return []
+        }
+
+        return await coredataContext.perform {
+            return carbEntries.map { result in
+                CarbsEntry(
+                    id: result.id?.uuidString,
+                    createdAt: result.date ?? Date(),
+                    actualDate: result.date,
+                    carbs: Decimal(result.carbs),
+                    fat: nil,
+                    protein: nil,
+                    note: result.note,
+                    enteredBy: CarbsEntry.manual,
+                    isFPU: nil,
+                    fpuID: nil
+                )
+            }
+        }
+    }
 }

+ 36 - 4
FreeAPS/Sources/APS/Storage/DeterminationStorage.swift

@@ -7,6 +7,10 @@ protocol DeterminationStorage {
     func fetchLastDeterminationObjectID(predicate: NSPredicate) async -> [NSManagedObjectID]
     func getForecastIDs(for determinationID: NSManagedObjectID, in context: NSManagedObjectContext) async -> [NSManagedObjectID]
     func getForecastValueIDs(for forecastID: NSManagedObjectID, in context: NSManagedObjectContext) async -> [NSManagedObjectID]
+    func fetchForecastObjects(
+        for data: (id: UUID, forecastID: NSManagedObjectID, forecastValueIDs: [NSManagedObjectID]),
+        in context: NSManagedObjectContext
+    ) async -> (UUID, Forecast?, [ForecastValue])
     func getOrefDeterminationNotYetUploadedToNightscout(_ determinationIds: [NSManagedObjectID]) async -> Determination?
 }
 
@@ -28,10 +32,10 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable {
             fetchLimit: 1
         )
 
-        guard let fetchedResults = results as? [OrefDetermination] else { return [] }
-
         return await backgroundContext.perform {
-            fetchedResults.map(\.objectID)
+            guard let fetchedResults = results as? [OrefDetermination] else { return [] }
+
+            return fetchedResults.map(\.objectID)
         }
     }
 
@@ -39,7 +43,7 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable {
         await context.perform {
             do {
                 guard let determination = try context.existingObject(with: determinationID) as? OrefDetermination,
-                      let forecastSet = determination.forecasts as? Set<NSManagedObject>
+                      let forecastSet = determination.forecasts
                 else {
                     return []
                 }
@@ -73,6 +77,34 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable {
         }
     }
 
+    // Fetch forecast value IDs for a given data set
+    func fetchForecastObjects(
+        for data: (id: UUID, forecastID: NSManagedObjectID, forecastValueIDs: [NSManagedObjectID]),
+        in context: NSManagedObjectContext
+    ) async -> (UUID, Forecast?, [ForecastValue]) {
+        return await context.perform {
+            var forecast: Forecast?
+            var forecastValues: [ForecastValue] = []
+
+            do {
+                // Fetch the forecast object
+                forecast = try context.existingObject(with: data.forecastID) as? Forecast
+
+                // Fetch the first 3h of forecast values
+                for forecastValueID in data.forecastValueIDs.prefix(36) {
+                    if let forecastValue = try context.existingObject(with: forecastValueID) as? ForecastValue {
+                        forecastValues.append(forecastValue)
+                    }
+                }
+            } catch {
+                debugPrint(
+                    "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to fetch forecast Values with error: \(error.localizedDescription)"
+                )
+            }
+            return (data.id, forecast, forecastValues)
+        }
+    }
+
     // Convert NSDecimalNumber to Decimal
     func decimal(from nsDecimalNumber: NSDecimalNumber?) -> Decimal {
         nsDecimalNumber?.decimalValue ?? 0.0

+ 165 - 5
FreeAPS/Sources/APS/Storage/GlucoseStorage.swift

@@ -2,6 +2,7 @@ import AVFAudio
 import Combine
 import CoreData
 import Foundation
+import LoopKit
 import SwiftDate
 import SwiftUI
 import Swinject
@@ -17,7 +18,12 @@ protocol GlucoseStorage {
     func getGlucoseNotYetUploadedToNightscout() async -> [BloodGlucose]
     func getCGMStateNotYetUploadedToNightscout() async -> [NightscoutTreatment]
     func getManualGlucoseNotYetUploadedToNightscout() async -> [NightscoutTreatment]
+    func getGlucoseNotYetUploadedToHealth() async -> [BloodGlucose]
+    func getManualGlucoseNotYetUploadedToHealth() async -> [BloodGlucose]
+    func getGlucoseNotYetUploadedToTidepool() async -> [StoredGlucoseSample]
+    func getManualGlucoseNotYetUploadedToTidepool() async -> [StoredGlucoseSample]
     var alarm: GlucoseAlarm? { get }
+    func deleteGlucose(_ treatmentObjectID: NSManagedObjectID) async
 }
 
 final class BaseGlucoseStorage: GlucoseStorage, Injectable {
@@ -88,6 +94,8 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
                         glucoseEntry.date = entry.dateString
                         glucoseEntry.direction = entry.direction?.rawValue
                         glucoseEntry.isUploadedToNS = false /// the value is not uploaded to NS (yet)
+                        glucoseEntry.isUploadedToHealth = false /// the value is not uploaded to Health (yet)
+                        glucoseEntry.isUploadedToTidepool = false /// the value is not uploaded to Tidepool (yet)
                         return false // Continue processing
                     }
                 )
@@ -241,7 +249,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     }
 
     // Fetch glucose that is not uploaded to Nightscout yet
-    /// Returns: Array of BloodGlucose to ensure the correct format for the NS Upload
+    /// - Returns: Array of BloodGlucose to ensure the correct format for the NS Upload
     func getGlucoseNotYetUploadedToNightscout() async -> [BloodGlucose] {
         let results = await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
@@ -252,9 +260,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
             fetchLimit: 288
         )
 
-        guard let fetchedResults = results as? [GlucoseStored] else { return [] }
-
         return await coredataContext.perform {
+            guard let fetchedResults = results as? [GlucoseStored] else { return [] }
+
             return fetchedResults.map { result in
                 BloodGlucose(
                     _id: result.id?.uuidString ?? UUID().uuidString,
@@ -272,7 +280,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     }
 
     // Fetch manual glucose that is not uploaded to Nightscout yet
-    /// Returns: Array of NightscoutTreatment to ensure the correct format for the NS Upload
+    /// - Returns: Array of NightscoutTreatment to ensure the correct format for the NS Upload
     func getManualGlucoseNotYetUploadedToNightscout() async -> [NightscoutTreatment] {
         let results = await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
@@ -295,7 +303,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
                     rate: nil,
                     eventType: .capillaryGlucose,
                     createdAt: result.date,
-                    enteredBy: "Trio",
+                    enteredBy: CarbsEntry.manual,
                     bolus: nil,
                     insulin: nil,
                     notes: "Trio User",
@@ -326,6 +334,158 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         return Array(Set(allValuesSet).subtracting(Set(alreadyUploadedValues)))
     }
 
+    // Fetch glucose that is not uploaded to Nightscout yet
+    /// - Returns: Array of BloodGlucose to ensure the correct format for the NS Upload
+    func getGlucoseNotYetUploadedToHealth() async -> [BloodGlucose] {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: coredataContext,
+            predicate: NSPredicate.glucoseNotYetUploadedToHealth,
+            key: "date",
+            ascending: false,
+            fetchLimit: 288
+        )
+
+        guard let fetchedResults = results as? [GlucoseStored] else { return [] }
+
+        return await coredataContext.perform {
+            return fetchedResults.map { result in
+                BloodGlucose(
+                    _id: result.id?.uuidString ?? UUID().uuidString,
+                    sgv: Int(result.glucose),
+                    direction: BloodGlucose.Direction(from: result.direction ?? ""),
+                    date: Decimal(result.date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000,
+                    dateString: result.date ?? Date(),
+                    unfiltered: Decimal(result.glucose),
+                    filtered: Decimal(result.glucose),
+                    noise: nil,
+                    glucose: Int(result.glucose)
+                )
+            }
+        }
+    }
+
+    // Fetch manual glucose that is not uploaded to Nightscout yet
+    /// - Returns: Array of NightscoutTreatment to ensure the correct format for the NS Upload
+    func getManualGlucoseNotYetUploadedToHealth() async -> [BloodGlucose] {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: coredataContext,
+            predicate: NSPredicate.manualGlucoseNotYetUploadedToHealth,
+            key: "date",
+            ascending: false,
+            fetchLimit: 288
+        )
+
+        guard let fetchedResults = results as? [GlucoseStored] else { return [] }
+
+        return await coredataContext.perform {
+            return fetchedResults.map { result in
+                BloodGlucose(
+                    _id: result.id?.uuidString ?? UUID().uuidString,
+                    sgv: Int(result.glucose),
+                    direction: BloodGlucose.Direction(from: result.direction ?? ""),
+                    date: Decimal(result.date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000,
+                    dateString: result.date ?? Date(),
+                    unfiltered: Decimal(result.glucose),
+                    filtered: Decimal(result.glucose),
+                    noise: nil,
+                    glucose: Int(result.glucose)
+                )
+            }
+        }
+    }
+
+    // Fetch glucose that is not uploaded to Tidepool yet
+    /// - Returns: Array of StoredGlucoseSample to ensure the correct format for Tidepool upload
+    func getGlucoseNotYetUploadedToTidepool() async -> [StoredGlucoseSample] {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: coredataContext,
+            predicate: NSPredicate.glucoseNotYetUploadedToTidepool,
+            key: "date",
+            ascending: false,
+            fetchLimit: 288
+        )
+
+        guard let fetchedResults = results as? [GlucoseStored] else { return [] }
+
+        return await coredataContext.perform {
+            return fetchedResults.map { result in
+                BloodGlucose(
+                    _id: result.id?.uuidString ?? UUID().uuidString,
+                    sgv: Int(result.glucose),
+                    direction: BloodGlucose.Direction(from: result.direction ?? ""),
+                    date: Decimal(result.date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000,
+                    dateString: result.date ?? Date(),
+                    unfiltered: Decimal(result.glucose),
+                    filtered: Decimal(result.glucose),
+                    noise: nil,
+                    glucose: Int(result.glucose)
+                )
+            }
+            .map { $0.convertStoredGlucoseSample(isManualGlucose: false) }
+        }
+    }
+
+    // Fetch manual glucose that is not uploaded to Tidepool yet
+    /// - Returns: Array of StoredGlucoseSample to ensure the correct format for the Tidepool upload
+    func getManualGlucoseNotYetUploadedToTidepool() async -> [StoredGlucoseSample] {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: coredataContext,
+            predicate: NSPredicate.manualGlucoseNotYetUploadedToTidepool,
+            key: "date",
+            ascending: false,
+            fetchLimit: 288
+        )
+
+        guard let fetchedResults = results as? [GlucoseStored] else { return [] }
+
+        return await coredataContext.perform {
+            return fetchedResults.map { result in
+                BloodGlucose(
+                    _id: result.id?.uuidString ?? UUID().uuidString,
+                    sgv: Int(result.glucose),
+                    direction: BloodGlucose.Direction(from: result.direction ?? ""),
+                    date: Decimal(result.date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000,
+                    dateString: result.date ?? Date(),
+                    unfiltered: Decimal(result.glucose),
+                    filtered: Decimal(result.glucose),
+                    noise: nil,
+                    glucose: Int(result.glucose)
+                )
+            }.map { $0.convertStoredGlucoseSample(isManualGlucose: true) }
+        }
+    }
+
+    func deleteGlucose(_ treatmentObjectID: NSManagedObjectID) async {
+        let taskContext = CoreDataStack.shared.newTaskContext()
+        taskContext.name = "deleteContext"
+        taskContext.transactionAuthor = "deleteGlucose"
+
+        await taskContext.perform {
+            do {
+                let result = try taskContext.existingObject(with: treatmentObjectID) as? GlucoseStored
+
+                guard let glucoseToDelete = result else {
+                    debugPrint("Data Table State: \(#function) \(DebuggingIdentifiers.failed) glucose not found in core data")
+                    return
+                }
+
+                taskContext.delete(glucoseToDelete)
+
+                guard taskContext.hasChanges else { return }
+                try taskContext.save()
+                debugPrint("\(#file) \(#function) \(DebuggingIdentifiers.succeeded) deleted glucose from core data")
+            } catch {
+                debugPrint(
+                    "\(#file) \(#function) \(DebuggingIdentifiers.failed) error while deleting glucose from core data: \(error.localizedDescription)"
+                )
+            }
+        }
+    }
+
     var alarm: GlucoseAlarm? {
         /// glucose can not be older than 20 minutes due to the predicate in the fetch request
         coredataContext.performAndWait {

+ 38 - 10
FreeAPS/Sources/APS/Storage/OverrideStorage.swift

@@ -12,6 +12,7 @@ protocol OverrideStorage {
     func deleteOverridePreset(_ objectID: NSManagedObjectID) async
     func getOverridesNotYetUploadedToNightscout() async -> [NightscoutExercise]
     func getOverrideRunsNotYetUploadedToNightscout() async -> [NightscoutExercise]
+    func getPresetOverridesForNightscout() async -> [NightscoutPresetOverride]
 }
 
 final class BaseOverrideStorage: OverrideStorage, Injectable {
@@ -45,9 +46,9 @@ final class BaseOverrideStorage: OverrideStorage, Injectable {
             fetchLimit: 1
         )
 
-        guard let fetchedResults = results as? [OverrideStored] else { return [] }
-
         return await backgroundContext.perform {
+            guard let fetchedResults = results as? [OverrideStored] else { return [] }
+
             return fetchedResults.map(\.objectID)
         }
     }
@@ -62,9 +63,9 @@ final class BaseOverrideStorage: OverrideStorage, Injectable {
             fetchLimit: fetchLimit
         )
 
-        guard let fetchedResults = results as? [OverrideStored] else { return [] }
-
         return await backgroundContext.perform {
+            guard let fetchedResults = results as? [OverrideStored] else { return [] }
+
             return fetchedResults.map(\.objectID)
         }
     }
@@ -79,9 +80,9 @@ final class BaseOverrideStorage: OverrideStorage, Injectable {
             ascending: true
         )
 
-        guard let fetchedResults = results as? [OverrideStored] else { return [] }
-
         return await backgroundContext.perform {
+            guard let fetchedResults = results as? [OverrideStored] else { return [] }
+
             return fetchedResults.map(\.objectID)
         }
     }
@@ -220,9 +221,9 @@ final class BaseOverrideStorage: OverrideStorage, Injectable {
             ascending: false
         )
 
-        guard let fetchedOverrides = results as? [OverrideStored] else { return [] }
-
         return await backgroundContext.perform {
+            guard let fetchedOverrides = results as? [OverrideStored] else { return [] }
+
             return fetchedOverrides.map { override in
                 let duration = override.indefinite ? 1440 : override.duration ?? 0 // 1440 min = 1 day
                 return NightscoutExercise(
@@ -250,9 +251,9 @@ final class BaseOverrideStorage: OverrideStorage, Injectable {
             ascending: false
         )
 
-        guard let fetchedOverrideRuns = results as? [OverrideRunStored] else { return [] }
-
         return await backgroundContext.perform {
+            guard let fetchedOverrideRuns = results as? [OverrideRunStored] else { return [] }
+
             return fetchedOverrideRuns.map { overrideRun in
                 var durationInMinutes = (overrideRun.endDate?.timeIntervalSince(overrideRun.startDate ?? Date()) ?? 1) / 60
                 durationInMinutes = durationInMinutes < 1 ? 1 : durationInMinutes
@@ -267,4 +268,31 @@ final class BaseOverrideStorage: OverrideStorage, Injectable {
             }
         }
     }
+
+    func getPresetOverridesForNightscout() async -> [NightscoutPresetOverride] {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: OverrideStored.self,
+            onContext: backgroundContext,
+            predicate: NSPredicate.allOverridePresets,
+            key: "orderPosition",
+            ascending: true
+        )
+
+        return await backgroundContext.perform {
+            guard let fetchedResults = results as? [OverrideStored] else { return [] }
+
+            return fetchedResults.map { overrideStored in
+                let duration = overrideStored.duration as? Decimal != 0 ? overrideStored.duration as? Decimal : nil
+                let percentage = overrideStored.percentage != 0 ? overrideStored.percentage : nil
+                let target = (overrideStored.target as? Decimal) != 0 ? overrideStored.target as? Decimal : nil
+
+                return NightscoutPresetOverride(
+                    name: overrideStored.name ?? "",
+                    duration: duration,
+                    percentage: percentage,
+                    target: target
+                )
+            }
+        }
+    }
 }

+ 112 - 3
FreeAPS/Sources/APS/Storage/PumpHistoryStorage.swift

@@ -15,6 +15,8 @@ protocol PumpHistoryStorage {
     func storeExternalInsulinEvent(amount: Decimal, timestamp: Date) async
     func recent() -> [PumpHistoryEvent]
     func getPumpHistoryNotYetUploadedToNightscout() async -> [NightscoutTreatment]
+    func getPumpHistoryNotYetUploadedToHealth() async -> [PumpHistoryEvent]
+    func getPumpHistoryNotYetUploadedToTidepool() async -> [PumpHistoryEvent]
     func deleteInsulin(at date: Date)
 }
 
@@ -80,6 +82,8 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                                         existingEvent.bolus?.amount = amount as NSDecimalNumber
                                         existingEvent.bolus?.isSMB = dose.automatic ?? true
                                         existingEvent.isUploadedToNS = false
+                                        existingEvent.isUploadedToHealth = false
+                                        existingEvent.isUploadedToTidepool = false
 
                                         print("Updated existing event with smaller value: \(amount)")
                                     }
@@ -93,6 +97,8 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                         newPumpEvent.timestamp = event.date
                         newPumpEvent.type = PumpEvent.bolus.rawValue
                         newPumpEvent.isUploadedToNS = false
+                        newPumpEvent.isUploadedToHealth = false
+                        newPumpEvent.isUploadedToTidepool = false
 
                         let newBolusEntry = BolusStored(context: self.context)
                         newBolusEntry.pumpEvent = newPumpEvent
@@ -122,6 +128,8 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                         newPumpEvent.timestamp = date
                         newPumpEvent.type = PumpEvent.tempBasal.rawValue
                         newPumpEvent.isUploadedToNS = false
+                        newPumpEvent.isUploadedToHealth = false
+                        newPumpEvent.isUploadedToTidepool = false
 
                         let newTempBasal = TempBasalStored(context: self.context)
                         newTempBasal.pumpEvent = newPumpEvent
@@ -140,6 +148,8 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                         newPumpEvent.timestamp = event.date
                         newPumpEvent.type = PumpEvent.pumpSuspend.rawValue
                         newPumpEvent.isUploadedToNS = false
+                        newPumpEvent.isUploadedToHealth = false
+                        newPumpEvent.isUploadedToTidepool = false
 
                     case .resume:
                         guard existingEvents.isEmpty else {
@@ -152,6 +162,8 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                         newPumpEvent.timestamp = event.date
                         newPumpEvent.type = PumpEvent.pumpResume.rawValue
                         newPumpEvent.isUploadedToNS = false
+                        newPumpEvent.isUploadedToHealth = false
+                        newPumpEvent.isUploadedToTidepool = false
 
                     case .rewind:
                         guard existingEvents.isEmpty else {
@@ -164,6 +176,8 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                         newPumpEvent.timestamp = event.date
                         newPumpEvent.type = PumpEvent.rewind.rawValue
                         newPumpEvent.isUploadedToNS = false
+                        newPumpEvent.isUploadedToHealth = false
+                        newPumpEvent.isUploadedToTidepool = false
 
                     case .prime:
                         guard existingEvents.isEmpty else {
@@ -176,6 +190,8 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                         newPumpEvent.timestamp = event.date
                         newPumpEvent.type = PumpEvent.prime.rawValue
                         newPumpEvent.isUploadedToNS = false
+                        newPumpEvent.isUploadedToHealth = false
+                        newPumpEvent.isUploadedToTidepool = false
 
                     case .alarm:
                         guard existingEvents.isEmpty else {
@@ -188,6 +204,8 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                         newPumpEvent.timestamp = event.date
                         newPumpEvent.type = PumpEvent.pumpAlarm.rawValue
                         newPumpEvent.isUploadedToNS = false
+                        newPumpEvent.isUploadedToHealth = false
+                        newPumpEvent.isUploadedToTidepool = false
                         newPumpEvent.note = event.title
 
                     default:
@@ -217,6 +235,8 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
             newPumpEvent.timestamp = timestamp
             newPumpEvent.type = PumpEvent.bolus.rawValue
             newPumpEvent.isUploadedToNS = false
+            newPumpEvent.isUploadedToHealth = false
+            newPumpEvent.isUploadedToTidepool = false
 
             // create bolus entry and specify relationship to pump event
             let newBolusEntry = BolusStored(context: self.context)
@@ -274,10 +294,10 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
             fetchLimit: 288
         )
 
-        guard let fetchedPumpEvents = results as? [PumpEventStored] else { return [] }
-
         return await context.perform { [self] in
-            fetchedPumpEvents.map { event in
+            guard let fetchedPumpEvents = results as? [PumpEventStored] else { return [] }
+
+            return fetchedPumpEvents.map { event in
                 switch event.type {
                 case PumpEvent.bolus.rawValue:
                     // eventType determines whether bolus is external, smb or manual (=administered via app by user)
@@ -423,4 +443,93 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
             }.compactMap { $0 }
         }
     }
+
+    func getPumpHistoryNotYetUploadedToHealth() async -> [PumpHistoryEvent] {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: PumpEventStored.self,
+            onContext: context,
+            predicate: NSPredicate.pumpEventsNotYetUploadedToHealth,
+            key: "timestamp",
+            ascending: false,
+            fetchLimit: 288
+        )
+
+        guard let fetchedPumpEvents = results as? [PumpEventStored] else { return [] }
+
+        return await context.perform {
+            fetchedPumpEvents.map { event in
+                switch event.type {
+                case PumpEvent.bolus.rawValue:
+                    return PumpHistoryEvent(
+                        id: event.id ?? UUID().uuidString,
+                        type: .bolus,
+                        timestamp: event.timestamp ?? Date(),
+                        amount: event.bolus?.amount as Decimal?
+                    )
+                case PumpEvent.tempBasal.rawValue:
+                    if let id = event.id, let timestamp = event.timestamp, let tempBasal = event.tempBasal,
+                       let tempBasalRate = tempBasal.rate
+                    {
+                        return PumpHistoryEvent(
+                            id: id,
+                            type: .tempBasal,
+                            timestamp: timestamp,
+                            amount: tempBasalRate as Decimal,
+                            duration: Int(tempBasal.duration)
+                        )
+                    } else {
+                        return nil
+                    }
+                default:
+                    return nil
+                }
+            }.compactMap { $0 }
+        }
+    }
+
+    func getPumpHistoryNotYetUploadedToTidepool() async -> [PumpHistoryEvent] {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: PumpEventStored.self,
+            onContext: context,
+            predicate: NSPredicate.pumpEventsNotYetUploadedToTidepool,
+            key: "timestamp",
+            ascending: false,
+            fetchLimit: 288
+        )
+
+        guard let fetchedPumpEvents = results as? [PumpEventStored] else { return [] }
+
+        return await context.perform {
+            fetchedPumpEvents.map { event in
+                switch event.type {
+                case PumpEvent.bolus.rawValue:
+                    return PumpHistoryEvent(
+                        id: event.id ?? UUID().uuidString,
+                        type: .bolus,
+                        timestamp: event.timestamp ?? Date(),
+                        amount: event.bolus?.amount as Decimal?,
+                        isSMB: event.bolus?.isSMB ?? true,
+                        isExternal: event.bolus?.isExternal ?? false
+                    )
+                case PumpEvent.tempBasal.rawValue:
+                    if let id = event.id, let timestamp = event.timestamp, let tempBasal = event.tempBasal,
+                       let tempBasalRate = tempBasal.rate
+                    {
+                        return PumpHistoryEvent(
+                            id: id,
+                            type: .tempBasal,
+                            timestamp: timestamp,
+                            amount: tempBasalRate as Decimal,
+                            duration: Int(tempBasal.duration)
+                        )
+                    } else {
+                        return nil
+                    }
+
+                default:
+                    return nil
+                }
+            }.compactMap { $0 }
+        }
+    }
 }

+ 51 - 1
FreeAPS/Sources/Application/AppDelegate.swift

@@ -1,4 +1,54 @@
 import SwiftUI
 import UIKit
+import UserNotifications
 
-class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject {}
+class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject, UNUserNotificationCenterDelegate {
+    func application(
+        _ application: UIApplication,
+        didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?
+    ) -> Bool {
+        UNUserNotificationCenter.current().delegate = self
+        application.registerForRemoteNotifications()
+        return true
+    }
+
+    func application(
+        _: UIApplication,
+        didReceiveRemoteNotification userInfo: [AnyHashable: Any],
+        fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
+    ) {
+        debug(.remoteControl, "Received notification")
+
+        do {
+            let jsonData = try JSONSerialization.data(withJSONObject: userInfo)
+            let pushMessage = try JSONDecoder().decode(PushMessage.self, from: jsonData)
+
+            Task {
+                await TrioRemoteControl.shared.handleRemoteNotification(pushMessage: pushMessage)
+                completionHandler(.newData)
+            }
+        } catch {
+            debug(.remoteControl, "Error decoding push message: \(error.localizedDescription)")
+            completionHandler(.failed)
+        }
+    }
+
+    func application(
+        _: UIApplication,
+        didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
+    ) {
+        let tokenParts = deviceToken.map { data in String(format: "%02.2hhx", data) }
+        let token = tokenParts.joined()
+
+        Task {
+            await TrioRemoteControl.shared.handleAPNSChanges(deviceToken: token)
+        }
+    }
+
+    func application(
+        _: UIApplication,
+        didFailToRegisterForRemoteNotificationsWithError error: Error
+    ) {
+        debug(.remoteControl, "Failed to register for remote notifications: \(error.localizedDescription)")
+    }
+}

+ 6 - 1
FreeAPS/Sources/Helpers/Color+Extensions.swift

@@ -8,7 +8,12 @@ extension Color {
 
     static let glucose = Color("glucose")
 
-    static let insulin = Color("Insulin")
+    static let insulin =
+        // workaround for 'No color named 'Insulin' found in asset catalog' error which is most likely a bug
+        Color(
+            UIColor(named: "Insulin") ??
+                UIColor(red: 0.118, green: 0.588, blue: 0.988, alpha: 1.0) // these are RGB of our insulin color
+        )
 
     // The loopAccent color is intended to be use as the app accent color.
     public static let loopAccent = Color("accent")

+ 66 - 0
FreeAPS/Sources/Helpers/DynamicGlucoseColor.swift

@@ -0,0 +1,66 @@
+import Foundation
+import SwiftUI
+
+// Helper function to decide how to pick the glucose color
+public func getDynamicGlucoseColor(
+    glucoseValue: Decimal,
+    highGlucoseColorValue: Decimal,
+    lowGlucoseColorValue: Decimal,
+    targetGlucose: Decimal,
+    glucoseColorScheme: GlucoseColorScheme
+) -> Color {
+    // Only use calculateHueBasedGlucoseColor if the setting is enabled in preferences
+    if glucoseColorScheme == .dynamicColor {
+        return calculateHueBasedGlucoseColor(
+            glucoseValue: glucoseValue,
+            highGlucose: highGlucoseColorValue,
+            lowGlucose: lowGlucoseColorValue,
+            targetGlucose: targetGlucose
+        )
+    }
+    // Otheriwse, use static (orange = high, red = low, green = range)
+    else {
+        if glucoseValue >= highGlucoseColorValue {
+            return Color.orange
+        } else if glucoseValue <= lowGlucoseColorValue {
+            return Color.red
+        } else {
+            return Color.green
+        }
+    }
+}
+
+// Dynamic color - Define the hue values for the key points
+// We'll shift color gradually one glucose point at a time
+// We'll shift through the rainbow colors of ROY-G-BIV from low to high
+// Start at red for lowGlucose, green for targetGlucose, and violet for highGlucose
+public func calculateHueBasedGlucoseColor(
+    glucoseValue: Decimal,
+    highGlucose: Decimal,
+    lowGlucose: Decimal,
+    targetGlucose: Decimal
+) -> Color {
+    let redHue: CGFloat = 0.0 / 360.0 // 0 degrees
+    let greenHue: CGFloat = 120.0 / 360.0 // 120 degrees
+    let purpleHue: CGFloat = 270.0 / 360.0 // 270 degrees
+
+    // Calculate the hue based on the bgLevel
+    var hue: CGFloat
+    if glucoseValue <= lowGlucose {
+        hue = redHue
+    } else if glucoseValue >= highGlucose {
+        hue = purpleHue
+    } else if glucoseValue <= targetGlucose {
+        // Interpolate between red and green
+        let ratio = CGFloat(truncating: (glucoseValue - lowGlucose) / (targetGlucose - lowGlucose) as NSNumber)
+
+        hue = redHue + ratio * (greenHue - redHue)
+    } else {
+        // Interpolate between green and purple
+        let ratio = CGFloat(truncating: (glucoseValue - targetGlucose) / (highGlucose - targetGlucose) as NSNumber)
+        hue = greenHue + ratio * (purpleHue - greenHue)
+    }
+    // Return the color with full saturation and brightness
+    let color = Color(hue: hue, saturation: 0.6, brightness: 0.9)
+    return color
+}

+ 4 - 0
FreeAPS/Sources/Logger/Logger.swift

@@ -112,6 +112,7 @@ final class Logger {
     static let deviceManager = Logger(category: .deviceManager, reporter: baseReporter)
     static let apsManager = Logger(category: .apsManager, reporter: baseReporter)
     static let nightscout = Logger(category: .nightscout, reporter: baseReporter)
+    static let remoteControl = Logger(category: .remoteControl, reporter: baseReporter)
 
     enum Category: String {
         case `default`
@@ -121,6 +122,7 @@ final class Logger {
         case deviceManager
         case apsManager
         case nightscout
+        case remoteControl
 
         var name: String {
             rawValue.capitalizingFirstLetter()
@@ -135,6 +137,7 @@ final class Logger {
             case .deviceManager: return .deviceManager
             case .apsManager: return .apsManager
             case .nightscout: return .nightscout
+            case .remoteControl: return .remoteControl
             }
         }
 
@@ -147,6 +150,7 @@ final class Logger {
                  .deviceManager,
                  .nightscout,
                  .openAPS,
+                 .remoteControl,
                  .service:
                 return OSLog(subsystem: subsystem, category: name)
             }

+ 3 - 2
FreeAPS/Sources/Models/BloodGlucose.swift

@@ -157,12 +157,13 @@ extension BloodGlucose: SavitzkyGolaySmoothable {
 }
 
 extension BloodGlucose {
-    func convertStoredGlucoseSample(device: HKDevice?) -> StoredGlucoseSample {
+    func convertStoredGlucoseSample(isManualGlucose: Bool) -> StoredGlucoseSample {
         StoredGlucoseSample(
             syncIdentifier: id,
             startDate: dateString.date,
             quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: Double(glucose!)),
-            device: device
+            wasUserEntered: isManualGlucose,
+            device: HKDevice.local()
         )
     }
 }

+ 1 - 1
FreeAPS/Sources/Models/CarbsEntry.swift

@@ -49,7 +49,7 @@ extension CarbsEntry {
             grams: Double(carbs),
             startDate: createdAt,
             uuid: UUID(uuidString: id!),
-            provenanceIdentifier: enteredBy ?? "",
+            provenanceIdentifier: enteredBy ?? "Trio",
             syncIdentifier: id,
             syncVersion: nil,
             userCreatedDate: nil,

+ 11 - 5
FreeAPS/Sources/Models/DecimalPickerSettings.swift

@@ -18,10 +18,16 @@ class PickerSettingsProvider: ObservableObject {
         }
 
         // Glucose values are stored as mg/dl values, so Integers.
-        // Filter out odd numbers to avoid duplicate mmol/L values due to rounding.
+        // Filter out duplicate values when rounded to 1 decimal place.
         if units == .mmolL, setting.type == PickerSetting.PickerSettingType.glucose {
-            values = values.filter { Int($0) % 2 == 0 }
+            // Use a Set to track unique values rounded to 1 decimal
+            var uniqueRoundedValues = Set<String>()
+            values = values.filter { value in
+                let roundedValue = String(format: "%.1f", NSDecimalNumber(decimal: value.asMmolL).doubleValue)
+                return uniqueRoundedValues.insert(roundedValue).inserted
+            }
         }
+
         return values
     }
 }
@@ -110,7 +116,7 @@ struct DecimalPickerSettings {
         value: 0.2,
         step: 0.05,
         min: 0.1,
-        max: 2,
+        max: 0.4,
         type: PickerSetting.PickerSettingType.factor
     )
     var adjustmentFactor = PickerSetting(value: 0.8, step: 0.1, min: 0.5, max: 1.5, type: PickerSetting.PickerSettingType.factor)
@@ -131,9 +137,9 @@ struct DecimalPickerSettings {
     )
     var threshold_setting = PickerSetting(value: 60, step: 1, min: 60, max: 120, type: PickerSetting.PickerSettingType.glucose)
     var updateInterval = PickerSetting(value: 20, step: 5, min: 1, max: 60, type: PickerSetting.PickerSettingType.minute)
-    var delay = PickerSetting(value: 20, step: 5, min: 5, max: 60, type: PickerSetting.PickerSettingType.minute)
+    var delay = PickerSetting(value: 60, step: 15, min: 30, max: 120, type: PickerSetting.PickerSettingType.minute)
     var minuteInterval = PickerSetting(value: 20, step: 5, min: 5, max: 60, type: PickerSetting.PickerSettingType.minute)
-    var timeCap = PickerSetting(value: 20, step: 5, min: 5, max: 60, type: PickerSetting.PickerSettingType.hour)
+    var timeCap = PickerSetting(value: 8, step: 1, min: 5, max: 12, type: PickerSetting.PickerSettingType.hour)
     var hours = PickerSetting(value: 6, step: 0.5, min: 2, max: 24, type: PickerSetting.PickerSettingType.hour)
     var dia = PickerSetting(value: 6, step: 0.5, min: 4, max: 10, type: PickerSetting.PickerSettingType.hour)
     var maxBolus = PickerSetting(value: 10, step: 0.5, min: 1, max: 30, type: PickerSetting.PickerSettingType.insulinUnit)

+ 5 - 0
FreeAPS/Sources/Models/FreeAPSSettings.swift

@@ -57,6 +57,7 @@ struct FreeAPSSettings: JSON, Equatable {
     var high: Decimal = 180
     var low: Decimal = 70
     var hours: Int = 6
+    var glucoseColorScheme: GlucoseColorScheme = .staticColor
     var xGridLines: Bool = true
     var yGridLines: Bool = true
     var oneDimensionalGraph: Bool = false
@@ -270,6 +271,10 @@ extension FreeAPSSettings: Decodable {
             settings.hours = hours
         }
 
+        if let glucoseColorScheme = try? container.decode(GlucoseColorScheme.self, forKey: .glucoseColorScheme) {
+            settings.glucoseColorScheme = glucoseColorScheme
+        }
+
         if let xGridLines = try? container.decode(Bool.self, forKey: .xGridLines) {
             settings.xGridLines = xGridLines
         }

+ 22 - 0
FreeAPS/Sources/Models/GlucoseColorScheme.swift

@@ -0,0 +1,22 @@
+//
+//  GlucoseColorScheme.swift
+//  FreeAPS
+//
+//  Created by Cengiz Deniz on 27.09.24.
+//
+import Foundation
+
+public enum GlucoseColorScheme: String, JSON, CaseIterable, Identifiable, Codable, Hashable {
+    public var id: String { rawValue }
+    case staticColor
+    case dynamicColor
+
+    var displayName: String {
+        switch self {
+        case .staticColor:
+            return "Static"
+        case .dynamicColor:
+            return "Dynamic"
+        }
+    }
+}

+ 12 - 0
FreeAPS/Sources/Models/NightscoutStatus.swift

@@ -52,4 +52,16 @@ struct NightscoutProfileStore: JSON {
     let units: String
     let enteredBy: String
     let store: [String: ScheduledNightscoutProfile]
+    let bundleIdentifier: String
+    let deviceToken: String
+    let isAPNSProduction: Bool
+    let overridePresets: [NightscoutPresetOverride]?
+    let teamID: String
+}
+
+struct NightscoutPresetOverride: JSON {
+    let name: String
+    let duration: Decimal?
+    let percentage: Double?
+    let target: Decimal?
 }

+ 1 - 0
FreeAPS/Sources/Models/PumpHistoryEvent.swift

@@ -77,6 +77,7 @@ enum EventType: String, JSON {
     case nsAnnouncement = "Announcement"
     case nsSensorChange = "Sensor Start"
     case capillaryGlucose = "BG Check"
+    case note = "Note"
 }
 
 enum TempType: String, JSON {

+ 141 - 0
FreeAPS/Sources/Models/PushMessage.swift

@@ -0,0 +1,141 @@
+import Foundation
+
+struct PushMessage: Codable, Sendable {
+    var user: String
+    var commandType: TrioRemoteControl.CommandType
+    var bolusAmount: Decimal?
+    var target: Int?
+    var duration: Int?
+    var carbs: Int?
+    var protein: Int?
+    var fat: Int?
+    var sharedSecret: String
+    var timestamp: TimeInterval
+    var overrideName: String?
+    var scheduledTime: TimeInterval?
+
+    enum CodingKeys: String, CodingKey {
+        case aps
+        case user
+        case commandType = "command_type"
+        case bolusAmount = "bolus_amount"
+        case target
+        case duration
+        case carbs
+        case protein
+        case fat
+        case sharedSecret = "shared_secret"
+        case timestamp
+        case overrideName
+        case scheduledTime = "scheduled_time"
+    }
+
+    func encode(to encoder: Encoder) throws {
+        var container = encoder.container(keyedBy: CodingKeys.self)
+        try container.encode(user, forKey: .user)
+        try container.encode(commandType, forKey: .commandType)
+        try container.encodeIfPresent(bolusAmount, forKey: .bolusAmount)
+        try container.encodeIfPresent(target, forKey: .target)
+        try container.encodeIfPresent(duration, forKey: .duration)
+        try container.encodeIfPresent(carbs, forKey: .carbs)
+        try container.encodeIfPresent(protein, forKey: .protein)
+        try container.encodeIfPresent(fat, forKey: .fat)
+        try container.encode(sharedSecret, forKey: .sharedSecret)
+        try container.encode(timestamp, forKey: .timestamp)
+        try container.encodeIfPresent(overrideName, forKey: .overrideName)
+        if let scheduledTime = scheduledTime {
+            try container.encode(scheduledTime, forKey: .scheduledTime)
+        }
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        user = try container.decode(String.self, forKey: .user)
+        commandType = try container.decode(TrioRemoteControl.CommandType.self, forKey: .commandType)
+        bolusAmount = try container.decodeIfPresent(Decimal.self, forKey: .bolusAmount)
+        target = try container.decodeIfPresent(Int.self, forKey: .target)
+        duration = try container.decodeIfPresent(Int.self, forKey: .duration)
+        carbs = try container.decodeIfPresent(Int.self, forKey: .carbs)
+        protein = try container.decodeIfPresent(Int.self, forKey: .protein)
+        fat = try container.decodeIfPresent(Int.self, forKey: .fat)
+        sharedSecret = try container.decode(String.self, forKey: .sharedSecret)
+        timestamp = try container.decode(TimeInterval.self, forKey: .timestamp)
+        overrideName = try container.decodeIfPresent(String.self, forKey: .overrideName)
+        scheduledTime = try container.decodeIfPresent(TimeInterval.self, forKey: .scheduledTime)
+    }
+
+    init(
+        user: String,
+        commandType: TrioRemoteControl.CommandType,
+        bolusAmount: Decimal? = nil,
+        target: Int? = nil,
+        duration: Int? = nil,
+        carbs: Int? = nil,
+        protein: Int? = nil,
+        fat: Int? = nil,
+        sharedSecret: String,
+        timestamp: TimeInterval,
+        overrideName: String? = nil,
+        scheduledTime: TimeInterval? = nil
+    ) {
+        self.user = user
+        self.commandType = commandType
+        self.bolusAmount = bolusAmount
+        self.target = target
+        self.duration = duration
+        self.carbs = carbs
+        self.protein = protein
+        self.fat = fat
+        self.sharedSecret = sharedSecret
+        self.timestamp = timestamp
+        self.overrideName = overrideName
+        self.scheduledTime = scheduledTime
+    }
+
+    func humanReadableDescription() -> String {
+        var description = "User: \(user). Command Type: \(commandType.description). "
+
+        if let override = overrideName {
+            description += "Override Name: \(override). "
+        }
+
+        switch commandType {
+        case .bolus:
+            if let amount = bolusAmount {
+                description += "Bolus Amount: \(amount) units."
+            } else {
+                description += "Bolus Amount: unknown."
+            }
+        case .tempTarget:
+            let targetDesc = target != nil ? "\(target!) mg/dL" : "unknown target"
+            let durationDesc = duration != nil ? "\(duration!) minutes" : "unknown duration"
+            description += "Temp Target: \(targetDesc), Duration: \(durationDesc)."
+        case .cancelTempTarget:
+            description += "Cancel Temp Target command."
+        case .meal:
+            let carbsDesc = carbs != nil ? "\(carbs!)g carbs" : "unknown carbs"
+            let fatDesc = fat != nil ? "\(fat!)g fat" : "unknown fat"
+            let proteinDesc = protein != nil ? "\(protein!)g protein" : "unknown protein"
+            description += "Meal with \(carbsDesc), \(fatDesc), \(proteinDesc)."
+        case .startOverride:
+            if let override = overrideName {
+                description += "Start Override: \(override)."
+            } else {
+                description += "Start Override: unknown override name."
+            }
+        case .cancelOverride:
+            description += "Cancel Override command."
+        }
+
+        if let scheduledTime = scheduledTime {
+            let date = Date(timeIntervalSince1970: scheduledTime)
+            let formatter = DateFormatter()
+            formatter.dateStyle = .short
+            formatter.timeStyle = .short
+            let dateString = formatter.string(from: date)
+            description += " Scheduled for: \(dateString)."
+        }
+
+        return description
+    }
+}

+ 22 - 21
FreeAPS/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsStateModel.swift

@@ -1,28 +1,29 @@
 import Combine
+import Observation
 import SwiftUI
 
 extension AlgorithmAdvancedSettings {
-    final class StateModel: BaseStateModel<Provider> {
-        @Injected() var settings: SettingsManager!
-        @Injected() var storage: FileStorage!
-        @Injected() var nightscout: NightscoutManager!
-
-        @Published var units: GlucoseUnits = .mgdL
-
-        @Published var maxDailySafetyMultiplier: Decimal = 3
-        @Published var currentBasalSafetyMultiplier: Decimal = 4
-        @Published var useCustomPeakTime: Bool = false
-        @Published var insulinPeakTime: Decimal = 75
-        @Published var skipNeutralTemps: Bool = false
-        @Published var unsuspendIfNoTemp: Bool = false
-        @Published var suspendZerosIOB: Bool = false
-        @Published var min5mCarbimpact: Decimal = 8
-        @Published var autotuneISFAdjustmentFraction: Decimal = 1.0
-        @Published var remainingCarbsFraction: Decimal = 1.0
-        @Published var remainingCarbsCap: Decimal = 90
-        @Published var noisyCGMTargetMultiplier: Decimal = 1.3
-
-        @Published var insulinActionCurve: Decimal = 6
+    @Observable final class StateModel: BaseStateModel<Provider> {
+        @ObservationIgnored @Injected() var settings: SettingsManager!
+        @ObservationIgnored @Injected() var storage: FileStorage!
+        @ObservationIgnored @Injected() var nightscout: NightscoutManager!
+
+        var units: GlucoseUnits = .mgdL
+
+        var maxDailySafetyMultiplier: Decimal = 3
+        var currentBasalSafetyMultiplier: Decimal = 4
+        var useCustomPeakTime: Bool = false
+        var insulinPeakTime: Decimal = 75
+        var skipNeutralTemps: Bool = false
+        var unsuspendIfNoTemp: Bool = false
+        var suspendZerosIOB: Bool = false
+        var min5mCarbimpact: Decimal = 8
+        var autotuneISFAdjustmentFraction: Decimal = 1.0
+        var remainingCarbsFraction: Decimal = 1.0
+        var remainingCarbsCap: Decimal = 90
+        var noisyCGMTargetMultiplier: Decimal = 1.3
+
+        var insulinActionCurve: Decimal = 6
 
         var preferences: Preferences {
             settingsManager.preferences

+ 1 - 1
FreeAPS/Sources/Modules/AlgorithmAdvancedSettings/View/AlgorithmAdvancedSettingsRootView.swift

@@ -4,7 +4,7 @@ import Swinject
 extension AlgorithmAdvancedSettings {
     struct RootView: BaseView {
         let resolver: Resolver
-        @StateObject var state = StateModel()
+        @State var state = StateModel()
         @State private var shouldDisplayHint: Bool = false
         @State var hintDetent = PresentationDetent.large
         @State var selectedVerboseHint: String?

+ 8 - 7
FreeAPS/Sources/Modules/AutosensSettings/AutosensSettingsStateModel.swift

@@ -1,15 +1,16 @@
+import Observation
 import SwiftUI
 
 extension AutosensSettings {
-    final class StateModel: BaseStateModel<Provider> {
-        @Injected() var settings: SettingsManager!
-        @Injected() var storage: FileStorage!
+    @Observable final class StateModel: BaseStateModel<Provider> {
+        @ObservationIgnored @Injected() var settings: SettingsManager!
+        @ObservationIgnored @Injected() var storage: FileStorage!
 
-        @Published var units: GlucoseUnits = .mgdL
+        var units: GlucoseUnits = .mgdL
 
-        @Published var autosensMax: Decimal = 1.2
-        @Published var autosensMin: Decimal = 0.7
-        @Published var rewindResetsAutosens: Bool = true
+        var autosensMax: Decimal = 1.2
+        var autosensMin: Decimal = 0.7
+        var rewindResetsAutosens: Bool = true
 
         var preferences: Preferences {
             settingsManager.preferences

+ 1 - 1
FreeAPS/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift

@@ -4,7 +4,7 @@ import Swinject
 extension AutosensSettings {
     struct RootView: BaseView {
         let resolver: Resolver
-        @StateObject var state = StateModel()
+        @State var state = StateModel()
         @State private var shouldDisplayHint: Bool = false
         @State var hintDetent = PresentationDetent.large
         @State var selectedVerboseHint: String?

+ 9 - 8
FreeAPS/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift

@@ -1,14 +1,15 @@
+import Observation
 import SwiftUI
 
 extension BasalProfileEditor {
-    final class StateModel: BaseStateModel<Provider> {
-        @Injected() private var nightscout: NightscoutManager!
-
-        @Published var syncInProgress: Bool = false
-        @Published var initialItems: [Item] = []
-        @Published var items: [Item] = []
-        @Published var total: Decimal = 0.0
-        @Published var showAlert: Bool = false
+    @Observable final class StateModel: BaseStateModel<Provider> {
+        @ObservationIgnored @Injected() private var nightscout: NightscoutManager!
+
+        var syncInProgress: Bool = false
+        var initialItems: [Item] = []
+        var items: [Item] = []
+        var total: Decimal = 0.0
+        var showAlert: Bool = false
 
         let timeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }
 

+ 1 - 1
FreeAPS/Sources/Modules/BasalProfileEditor/View/BasalProfileEditorRootView.swift

@@ -4,7 +4,7 @@ import Swinject
 extension BasalProfileEditor {
     struct RootView: BaseView {
         let resolver: Resolver
-        @StateObject var state = StateModel()
+        @State var state = StateModel()
         @State private var editMode = EditMode.inactive
 
         @Environment(\.colorScheme) var colorScheme

+ 126 - 121
FreeAPS/Sources/Modules/Bolus/BolusStateModel.swift

@@ -2,121 +2,122 @@ import Combine
 import CoreData
 import Foundation
 import LoopKit
+import Observation
 import SwiftUI
 import Swinject
 
 extension Bolus {
-    final class StateModel: BaseStateModel<Provider> {
-        @Injected() var unlockmanager: UnlockManager!
-        @Injected() var apsManager: APSManager!
-        @Injected() var broadcaster: Broadcaster!
-        @Injected() var pumpHistoryStorage: PumpHistoryStorage!
-        // added for bolus calculator
-        @Injected() var settings: SettingsManager!
-        @Injected() var nsManager: NightscoutManager!
-        @Injected() var carbsStorage: CarbsStorage!
-        @Injected() var glucoseStorage: GlucoseStorage!
-        @Injected() var determinationStorage: DeterminationStorage!
-
-        @Published var lowGlucose: Decimal = 70
-        @Published var highGlucose: Decimal = 180
-
-        @Published var predictions: Predictions?
-        @Published var amount: Decimal = 0
-        @Published var insulinRecommended: Decimal = 0
-        @Published var insulinRequired: Decimal = 0
-        @Published var units: GlucoseUnits = .mgdL
-        @Published var threshold: Decimal = 0
-        @Published var maxBolus: Decimal = 0
+    @Observable final class StateModel: BaseStateModel<Provider> {
+        @ObservationIgnored @Injected() var unlockmanager: UnlockManager!
+        @ObservationIgnored @Injected() var apsManager: APSManager!
+        @ObservationIgnored @Injected() var broadcaster: Broadcaster!
+        @ObservationIgnored @Injected() var pumpHistoryStorage: PumpHistoryStorage!
+        @ObservationIgnored @Injected() var settings: SettingsManager!
+        @ObservationIgnored @Injected() var nsManager: NightscoutManager!
+        @ObservationIgnored @Injected() var carbsStorage: CarbsStorage!
+        @ObservationIgnored @Injected() var glucoseStorage: GlucoseStorage!
+        @ObservationIgnored @Injected() var determinationStorage: DeterminationStorage!
+
+        var lowGlucose: Decimal = 70
+        var highGlucose: Decimal = 180
+        var glucoseColorScheme: GlucoseColorScheme = .staticColor
+
+        var predictions: Predictions?
+        var amount: Decimal = 0
+        var insulinRecommended: Decimal = 0
+        var insulinRequired: Decimal = 0
+        var units: GlucoseUnits = .mgdL
+        var threshold: Decimal = 0
+        var maxBolus: Decimal = 0
         var maxExternal: Decimal { maxBolus * 3 }
-        @Published var errorString: Decimal = 0
-        @Published var evBG: Decimal = 0
-        @Published var insulin: Decimal = 0
-        @Published var isf: Decimal = 0
-        @Published var error: Bool = false
-        @Published var minGuardBG: Decimal = 0
-        @Published var minDelta: Decimal = 0
-        @Published var expectedDelta: Decimal = 0
-        @Published var minPredBG: Decimal = 0
-        @Published var waitForSuggestion: Bool = false
-        @Published var carbRatio: Decimal = 0
-
-        @Published var addButtonPressed: Bool = false
+        var errorString: Decimal = 0
+        var evBG: Decimal = 0
+        var insulin: Decimal = 0
+        var isf: Decimal = 0
+        var error: Bool = false
+        var minGuardBG: Decimal = 0
+        var minDelta: Decimal = 0
+        var expectedDelta: Decimal = 0
+        var minPredBG: Decimal = 0
+        var waitForSuggestion: Bool = false
+        var carbRatio: Decimal = 0
+
+        var addButtonPressed: Bool = false
 
         var waitForSuggestionInitial: Bool = false
 
-        // added for bolus calculator
-        @Published var target: Decimal = 0
-        @Published var cob: Int16 = 0
-        @Published var iob: Decimal = 0
-
-        @Published var currentBG: Decimal = 0
-        @Published var fifteenMinInsulin: Decimal = 0
-        @Published var deltaBG: Decimal = 0
-        @Published var targetDifferenceInsulin: Decimal = 0
-        @Published var targetDifference: Decimal = 0
-        @Published var wholeCob: Decimal = 0
-        @Published var wholeCobInsulin: Decimal = 0
-        @Published var iobInsulinReduction: Decimal = 0
-        @Published var wholeCalc: Decimal = 0
-        @Published var insulinCalculated: Decimal = 0
-        @Published var fraction: Decimal = 0
-        @Published var basal: Decimal = 0
-        @Published var fattyMeals: Bool = false
-        @Published var fattyMealFactor: Decimal = 0
-        @Published var useFattyMealCorrectionFactor: Bool = false
-        @Published var displayPresets: Bool = true
-
-        @Published var currentBasal: Decimal = 0
-        @Published var currentCarbRatio: Decimal = 0
-        @Published var currentBGTarget: Decimal = 0
-        @Published var currentISF: Decimal = 0
-
-        @Published var sweetMeals: Bool = false
-        @Published var sweetMealFactor: Decimal = 0
-        @Published var useSuperBolus: Bool = false
-        @Published var superBolusInsulin: Decimal = 0
-
-        @Published var meal: [CarbsEntry]?
-        @Published var carbs: Decimal = 0
-        @Published var fat: Decimal = 0
-        @Published var protein: Decimal = 0
-        @Published var note: String = ""
-
-        @Published var date = Date()
-
-        @Published var carbsRequired: Decimal?
-        @Published var useFPUconversion: Bool = false
-        @Published var dish: String = ""
-        @Published var selection: MealPresetStored?
-        @Published var summation: [String] = []
-        @Published var maxCarbs: Decimal = 0
-        @Published var maxFat: Decimal = 0
-        @Published var maxProtein: Decimal = 0
-
-        @Published var id_: String = ""
-        @Published var summary: String = ""
-
-        @Published var externalInsulin: Bool = false
-        @Published var showInfo: Bool = false
-        @Published var glucoseFromPersistence: [GlucoseStored] = []
-        @Published var determination: [OrefDetermination] = []
-        @Published var preprocessedData: [(id: UUID, forecast: Forecast, forecastValue: ForecastValue)] = []
-        @Published var predictionsForChart: Predictions?
-        @Published var simulatedDetermination: Determination?
-        @Published var determinationObjectIDs: [NSManagedObjectID] = []
-
-        @Published var minForecast: [Int] = []
-        @Published var maxForecast: [Int] = []
-        @Published var minCount: Int = 12 // count of Forecasts drawn in 5 min distances, i.e. 12 means a min of 1 hour
-        @Published var forecastDisplayType: ForecastDisplayType = .cone
-        @Published var isSmoothingEnabled: Bool = false
-        @Published var stops: [Gradient.Stop] = []
+        var target: Decimal = 0
+        var cob: Int16 = 0
+        var iob: Decimal = 0
+
+        var currentBG: Decimal = 0
+        var fifteenMinInsulin: Decimal = 0
+        var deltaBG: Decimal = 0
+        var targetDifferenceInsulin: Decimal = 0
+        var targetDifference: Decimal = 0
+        var wholeCob: Decimal = 0
+        var wholeCobInsulin: Decimal = 0
+        var iobInsulinReduction: Decimal = 0
+        var wholeCalc: Decimal = 0
+        var insulinCalculated: Decimal = 0
+        var fraction: Decimal = 0
+        var basal: Decimal = 0
+        var fattyMeals: Bool = false
+        var fattyMealFactor: Decimal = 0
+        var useFattyMealCorrectionFactor: Bool = false
+        var displayPresets: Bool = true
+
+        var currentBasal: Decimal = 0
+        var currentCarbRatio: Decimal = 0
+        var currentBGTarget: Decimal = 0
+        var currentISF: Decimal = 0
+
+        var sweetMeals: Bool = false
+        var sweetMealFactor: Decimal = 0
+        var useSuperBolus: Bool = false
+        var superBolusInsulin: Decimal = 0
+
+        var meal: [CarbsEntry]?
+        var carbs: Decimal = 0
+        var fat: Decimal = 0
+        var protein: Decimal = 0
+        var note: String = ""
+
+        var date = Date()
+
+        var carbsRequired: Decimal?
+        var useFPUconversion: Bool = false
+        var dish: String = ""
+        var selection: MealPresetStored?
+        var summation: [String] = []
+        var maxCarbs: Decimal = 0
+        var maxFat: Decimal = 0
+        var maxProtein: Decimal = 0
+
+        var id_: String = ""
+        var summary: String = ""
+
+        var externalInsulin: Bool = false
+        var showInfo: Bool = false
+        var glucoseFromPersistence: [GlucoseStored] = []
+        var determination: [OrefDetermination] = []
+        var preprocessedData: [(id: UUID, forecast: Forecast, forecastValue: ForecastValue)] = []
+        var predictionsForChart: Predictions?
+        var simulatedDetermination: Determination?
+        var determinationObjectIDs: [NSManagedObjectID] = []
+
+        var minForecast: [Int] = []
+        var maxForecast: [Int] = []
+        var minCount: Int = 12 // count of Forecasts drawn in 5 min distances, i.e. 12 means a min of 1 hour
+        var forecastDisplayType: ForecastDisplayType = .cone
+        var isSmoothingEnabled: Bool = false
+        var stops: [Gradient.Stop] = []
 
         let now = Date.now
 
         let viewContext = CoreDataStack.shared.persistentContainer.viewContext
-        let backgroundContext = CoreDataStack.shared.newTaskContext()
+        let glucoseFetchContext = CoreDataStack.shared.newTaskContext()
+        let determinationFetchContext = CoreDataStack.shared.newTaskContext()
 
         private var coreDataPublisher: AnyPublisher<Set<NSManagedObject>, Never>?
         private var subscriptions = Set<AnyCancellable>()
@@ -129,6 +130,8 @@ extension Bolus {
                     .receive(on: DispatchQueue.global(qos: .background))
                     .share()
                     .eraseToAnyPublisher()
+            registerHandlers()
+            registerSubscribers()
             setupBolusStateConcurrently()
         }
 
@@ -136,12 +139,6 @@ extension Bolus {
             Task {
                 await withTaskGroup(of: Void.self) { group in
                     group.addTask {
-                        self.registerHandlers()
-                    }
-                    group.addTask {
-                        self.registerSubscribers()
-                    }
-                    group.addTask {
                         self.setupGlucoseArray()
                     }
                     group.addTask {
@@ -230,20 +227,21 @@ extension Bolus {
             sweetMealFactor = settings.settings.sweetMealFactor
             displayPresets = settings.settings.displayPresets
             forecastDisplayType = settings.settings.forecastDisplayType
-            lowGlucose = units == .mgdL ? settingsManager.settings.low : settingsManager.settings.low.asMmolL
-            highGlucose = units == .mgdL ? settingsManager.settings.high : settingsManager.settings.high.asMmolL
+            lowGlucose = settingsManager.settings.low
+            highGlucose = settingsManager.settings.high
             maxCarbs = settings.settings.maxCarbs
             maxFat = settings.settings.maxFat
             maxProtein = settings.settings.maxProtein
             useFPUconversion = settingsManager.settings.useFPUconversion
             isSmoothingEnabled = settingsManager.settings.smoothGlucose
+            glucoseColorScheme = settingsManager.settings.glucoseColorScheme
         }
 
         private func getCurrentSettingValue(for type: SettingType) async {
             let now = Date()
             let calendar = Calendar.current
             let dateFormatter = DateFormatter()
-            dateFormatter.dateFormat = "HH:mm:ss"
+            dateFormatter.dateFormat = "HH:mm"
             dateFormatter.timeZone = TimeZone.current
 
             let entries: [(start: String, value: Decimal)]
@@ -393,9 +391,16 @@ extension Bolus {
 
                 await saveMeal()
 
-                // if glucose data is stale end the custom loading animation by hiding the modal
-                guard glucoseStorage.isGlucoseDataFresh(glucoseFromPersistence.first?.date) else {
-                    waitForSuggestion = false
+                // If glucose data is stale end the custom loading animation by hiding the modal
+                // Get date on Main thread
+                let date = await MainActor.run {
+                    glucoseFromPersistence.first?.date
+                }
+
+                guard glucoseStorage.isGlucoseDataFresh(date) else {
+                    await MainActor.run {
+                        waitForSuggestion = false
+                    }
                     return hideModal()
                 }
             }
@@ -613,16 +618,16 @@ extension Bolus.StateModel {
     private func fetchGlucose() async -> [NSManagedObjectID] {
         let results = await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: backgroundContext,
+            onContext: glucoseFetchContext,
             predicate: NSPredicate.glucose,
             key: "date",
             ascending: false,
             fetchLimit: 288
         )
 
-        guard let fetchedResults = results as? [GlucoseStored] else { return [] }
+        return await glucoseFetchContext.perform {
+            guard let fetchedResults = results as? [GlucoseStored] else { return [] }
 
-        return await backgroundContext.perform {
             return fetchedResults.map(\.objectID)
         }
     }
@@ -658,16 +663,16 @@ extension Bolus.StateModel {
 
     private func mapForecastsForChart() async -> Determination? {
         let determinationObjects: [OrefDetermination] = await CoreDataStack.shared
-            .getNSManagedObject(with: determinationObjectIDs, context: backgroundContext)
+            .getNSManagedObject(with: determinationObjectIDs, context: determinationFetchContext)
 
-        return await backgroundContext.perform {
+        return await determinationFetchContext.perform {
             guard let determinationObject = determinationObjects.first else {
                 return nil
             }
 
             let eventualBG = determinationObject.eventualBG?.intValue
 
-            let forecastsSet = determinationObject.forecasts as? Set<Forecast> ?? []
+            let forecastsSet = determinationObject.forecasts ?? []
             let predictions = Predictions(
                 iob: forecastsSet.extractValues(for: "iob"),
                 zt: forecastsSet.extractValues(for: "zt"),

+ 1 - 0
FreeAPS/Sources/Modules/Bolus/View/AddMealPresetView.swift

@@ -1,5 +1,6 @@
 import CoreData
 import Foundation
+import Observation
 import SwiftUI
 
 struct AddMealPresetView: View {

+ 110 - 99
FreeAPS/Sources/Modules/Bolus/View/BolusRootView.swift

@@ -17,7 +17,7 @@ extension Bolus {
 
         let resolver: Resolver
 
-        @StateObject var state = StateModel()
+        @State var state = StateModel()
 
         @State private var showPresetSheet = false
         @State private var autofocus: Bool = true
@@ -94,36 +94,40 @@ extension Bolus {
 
         @ViewBuilder private func proteinAndFat() -> some View {
             HStack {
-                Text("Fat").foregroundColor(.orange)
-                Spacer()
-                TextFieldWithToolBar(
-                    text: $state.fat,
-                    placeholder: "0",
-                    keyboardType: .numberPad,
-                    numberFormatter: mealFormatter,
-                    previousTextField: { focusOnPreviousTextField(index: 2) },
-                    nextTextField: { focusOnNextTextField(index: 2) }
-                ).focused($focusedField, equals: .fat)
-                Text("g").foregroundColor(.secondary)
-            }
-            HStack {
-                Text("Protein").foregroundColor(.red)
-                Spacer()
-                TextFieldWithToolBar(
-                    text: $state.protein,
-                    placeholder: "0",
-                    keyboardType: .numberPad,
-                    numberFormatter: mealFormatter,
-                    previousTextField: { focusOnPreviousTextField(index: 3) },
-                    nextTextField: { focusOnNextTextField(index: 3) }
-                ).focused($focusedField, equals: .protein)
-                Text("g").foregroundColor(.secondary)
+                HStack {
+                    Text("Fat")
+                    TextFieldWithToolBar(
+                        text: $state.fat,
+                        placeholder: "0",
+                        keyboardType: .numberPad,
+                        numberFormatter: mealFormatter,
+                        previousTextField: { focusOnPreviousTextField(index: 2) },
+                        nextTextField: { focusOnNextTextField(index: 2) }
+                    ).focused($focusedField, equals: .fat)
+                    Text("g").foregroundColor(.secondary)
+                }
+
+                Divider().foregroundStyle(.primary).fontWeight(.bold).frame(width: 10)
+
+                HStack {
+                    Text("Protein")
+
+                    TextFieldWithToolBar(
+                        text: $state.protein,
+                        placeholder: "0",
+                        keyboardType: .numberPad,
+                        numberFormatter: mealFormatter,
+                        previousTextField: { focusOnPreviousTextField(index: 3) },
+                        nextTextField: { focusOnNextTextField(index: 3) }
+                    ).focused($focusedField, equals: .protein)
+                    Text("g").foregroundColor(.secondary)
+                }
             }
         }
 
         @ViewBuilder private func carbsTextField() -> some View {
             HStack {
-                Text("Carbs").fontWeight(.semibold)
+                Text("Carbs")
                 Spacer()
                 TextFieldWithToolBar(
                     text: $state.carbs,
@@ -133,7 +137,7 @@ extension Bolus {
                     previousTextField: { focusOnPreviousTextField(index: 1) },
                     nextTextField: { focusOnNextTextField(index: 1) }
                 ).focused($focusedField, equals: .carbs)
-                    .onChange(of: state.carbs) { _ in
+                    .onChange(of: state.carbs) {
                         handleDebouncedInput()
                     }
                 Text("g").foregroundColor(.secondary)
@@ -169,98 +173,100 @@ extension Bolus {
         var body: some View {
             ZStack(alignment: .center) {
                 VStack {
-                    Form {
+                    List {
                         Section {
-                            ForeCastChart(state: state, units: $state.units)
+                            ForecastChart(state: state)
                                 .padding(.vertical)
                         }.listRowBackground(Color.chart)
 
                         Section {
                             carbsTextField()
 
-                            DisclosureGroup("Extras") {
-                                if state.useFPUconversion {
-                                    proteinAndFat()
-                                }
+                            if state.useFPUconversion {
+                                proteinAndFat()
+                            }
 
-                                // Time
+                            // Time
+                            HStack {
+                                // Semi-hacky workaround to make sure the List renders the horizontal divider properly between the `Time` and `Note` rows within the Section
                                 HStack {
-                                    Text("Time").foregroundStyle(Color.secondary)
-                                    Spacer()
-                                    if !pushed {
-                                        Button {
-                                            pushed = true
-                                        } label: { Text("Now") }.buttonStyle(.borderless).foregroundColor(.secondary)
-                                            .padding(.trailing, 5)
-                                    } else {
-                                        Button { state.date = state.date.addingTimeInterval(-15.minutes.timeInterval) }
-                                        label: { Image(systemName: "minus.circle") }.tint(.blue).buttonStyle(.borderless)
-                                        DatePicker(
-                                            "Time",
-                                            selection: $state.date,
-                                            displayedComponents: [.hourAndMinute]
-                                        ).controlSize(.mini)
-                                            .labelsHidden()
-                                        Button {
-                                            state.date = state.date.addingTimeInterval(15.minutes.timeInterval)
-                                        }
-                                        label: { Image(systemName: "plus.circle") }.tint(.blue).buttonStyle(.borderless)
-                                    }
+                                    Text("")
+                                    Image(systemName: "clock").padding(.leading, -7)
                                 }
 
-                                // Notes
-                                HStack {
-                                    Image(systemName: "square.and.pencil").foregroundColor(.secondary)
-                                    TextFieldWithToolBarString(text: $state.note, placeholder: "", maxLength: 25)
+                                Spacer()
+                                if !pushed {
+                                    Button {
+                                        pushed = true
+                                    } label: { Text("Now") }.buttonStyle(.borderless).foregroundColor(.secondary)
+                                        .padding(.trailing, 5)
+                                } else {
+                                    Button { state.date = state.date.addingTimeInterval(-15.minutes.timeInterval) }
+                                    label: { Image(systemName: "minus.circle") }.tint(.blue).buttonStyle(.borderless)
+
+                                    DatePicker(
+                                        "Time",
+                                        selection: $state.date,
+                                        displayedComponents: [.hourAndMinute]
+                                    ).controlSize(.mini)
+                                        .labelsHidden()
+                                    Button {
+                                        state.date = state.date.addingTimeInterval(15.minutes.timeInterval)
+                                    }
+                                    label: { Image(systemName: "plus.circle") }.tint(.blue).buttonStyle(.borderless)
                                 }
                             }
+
+                            // Notes
+                            HStack {
+                                Image(systemName: "square.and.pencil")
+                                TextFieldWithToolBarString(text: $state.note, placeholder: "Note...", maxLength: 25)
+                            }
                         }.listRowBackground(Color.chart)
 
                         Section {
-                            HStack {
-                                Button(action: {
-                                    state.showInfo.toggle()
-                                }, label: {
-                                    Image(systemName: "info.circle")
-                                    Text("Calculations")
-                                })
-                                    .foregroundStyle(.blue)
-                                    .font(.footnote)
-                                    .buttonStyle(PlainButtonStyle())
-                                    .frame(maxWidth: .infinity, alignment: .leading)
-
-                                if state.fattyMeals {
-                                    Spacer()
-                                    Toggle(isOn: $state.useFattyMealCorrectionFactor) {
-                                        Text("Fatty Meal")
-                                    }
-                                    .toggleStyle(CheckboxToggleStyle())
-                                    .font(.footnote)
-                                    .onChange(of: state.useFattyMealCorrectionFactor) { _ in
-                                        state.insulinCalculated = state.calculateInsulin()
-                                        if state.useFattyMealCorrectionFactor {
-                                            state.useSuperBolus = false
+                            if state.fattyMeals || state.sweetMeals {
+                                HStack(spacing: 10) {
+                                    if state.fattyMeals {
+                                        Toggle(isOn: $state.useFattyMealCorrectionFactor) {
+                                            Text("Fatty Meal")
+                                        }
+                                        .toggleStyle(CheckboxToggleStyle())
+                                        .font(.footnote)
+                                        .onChange(of: state.useFattyMealCorrectionFactor) {
+                                            state.insulinCalculated = state.calculateInsulin()
+                                            if state.useFattyMealCorrectionFactor {
+                                                state.useSuperBolus = false
+                                            }
                                         }
                                     }
-                                }
-                                if state.sweetMeals {
-                                    Spacer()
-                                    Toggle(isOn: $state.useSuperBolus) {
-                                        Text("Super Bolus")
-                                    }
-                                    .toggleStyle(CheckboxToggleStyle())
-                                    .font(.footnote)
-                                    .onChange(of: state.useSuperBolus) { _ in
-                                        state.insulinCalculated = state.calculateInsulin()
-                                        if state.useSuperBolus {
-                                            state.useFattyMealCorrectionFactor = false
+                                    if state.sweetMeals {
+                                        Toggle(isOn: $state.useSuperBolus) {
+                                            Text("Super Bolus")
+                                        }
+                                        .toggleStyle(CheckboxToggleStyle())
+                                        .font(.footnote)
+                                        .onChange(of: state.useSuperBolus) {
+                                            state.insulinCalculated = state.calculateInsulin()
+                                            if state.useSuperBolus {
+                                                state.useFattyMealCorrectionFactor = false
+                                            }
                                         }
                                     }
                                 }
                             }
 
                             HStack {
-                                Text("Recommended Bolus")
+                                HStack {
+                                    Text("Recommendation")
+                                    Button(action: {
+                                        state.showInfo.toggle()
+                                    }, label: {
+                                        Image(systemName: "info.circle")
+                                    })
+                                        .foregroundStyle(.blue)
+                                        .buttonStyle(PlainButtonStyle())
+                                }
                                 Spacer()
                                 Text(
                                     formatter
@@ -287,7 +293,7 @@ extension Bolus {
                                     previousTextField: { focusOnPreviousTextField(index: 4) },
                                     nextTextField: { focusOnNextTextField(index: 4) }
                                 ).focused($focusedField, equals: .bolus)
-                                    .onChange(of: state.amount) { _ in
+                                    .onChange(of: state.amount) {
                                         Task {
                                             await state.updateForecasts()
                                         }
@@ -296,14 +302,14 @@ extension Bolus {
                             }
 
                             HStack {
-                                Text("External insulin")
+                                Text("External Insulin")
                                 Spacer()
                                 Toggle("", isOn: $state.externalInsulin).toggleStyle(Checkbox())
                             }
                         }.listRowBackground(Color.chart)
 
                         treatmentButton
-                    }
+                    }.listSectionSpacing(20)
                 }
                 .blur(radius: state.waitForSuggestion ? 5 : 0)
 
@@ -311,6 +317,8 @@ extension Bolus {
                     CustomProgressView(text: progressText.rawValue)
                 }
             }
+            .padding(.top)
+            .ignoresSafeArea(edges: .top)
             .scrollContentBackground(.hidden).background(color)
             .blur(radius: state.showInfo ? 3 : 0)
             .navigationTitle("Treatments")
@@ -380,7 +388,10 @@ extension Bolus {
                     .frame(height: 35)
             }
             .disabled(disableTaskButton)
-            .listRowBackground(limitExceeded ? Color(.systemRed) : Color(.systemBlue))
+            .listRowBackground(
+                limitExceeded ? Color(.systemRed) :
+                    Color(.systemBlue)
+            )
             .shadow(radius: 3)
             .clipShape(RoundedRectangle(cornerRadius: 8))
         }

+ 72 - 58
FreeAPS/Sources/Modules/Bolus/View/ForeCastChart.swift

@@ -3,10 +3,9 @@ import CoreData
 import Foundation
 import SwiftUI
 
-struct ForeCastChart: View {
-    @StateObject var state: Bolus.StateModel
+struct ForecastChart: View {
+    var state: Bolus.StateModel
     @Environment(\.colorScheme) var colorScheme
-    @Binding var units: GlucoseUnits
 
     @State private var startMarker = Date(timeIntervalSinceNow: -4 * 60 * 60)
 
@@ -23,7 +22,7 @@ struct ForeCastChart: View {
         let formatter = NumberFormatter()
         formatter.numberStyle = .decimal
 
-        if units == .mmolL {
+        if state.units == .mmolL {
             formatter.maximumFractionDigits = 1
             formatter.minimumFractionDigits = 1
             formatter.roundingMode = .halfUp
@@ -35,57 +34,54 @@ struct ForeCastChart: View {
 
     var body: some View {
         VStack {
-            HStack {
-                HStack {
-                    Text("Added carbs: ")
-                        .font(.footnote)
-                        .fontWeight(.bold)
-                        .foregroundStyle(.orange)
+            forecastChartLabels
+                .padding(.bottom, 8)
 
-                    Text("\(state.carbs.description) g")
-                        .font(.footnote)
-                        .foregroundStyle(.orange)
-                }
-                .padding(8)
-                .background {
-                    RoundedRectangle(cornerRadius: 10)
-                        .fill(Color.orange.opacity(0.2))
-                }
+            forecastChart
+        }
+    }
 
-                Spacer()
+    private var forecastChartLabels: some View {
+        HStack {
+            HStack {
+                Image(systemName: "fork.knife")
+                Text("\(state.carbs.description) g")
+            }
+            .font(.footnote)
+            .foregroundStyle(.orange)
+            .padding(8)
+            .background {
+                RoundedRectangle(cornerRadius: 10)
+                    .fill(Color.orange.opacity(0.2))
+            }
 
-                HStack {
-                    Text("Added insulin: ")
-                        .font(.footnote)
-                        .fontWeight(.bold)
-                        .foregroundStyle(.blue)
+            Spacer()
 
-                    Text("\(state.amount.description) U")
-                        .font(.footnote)
-                        .foregroundStyle(.blue)
-                }
-                .padding(8)
-                .background {
-                    RoundedRectangle(cornerRadius: 10)
-                        .fill(Color.blue.opacity(0.2))
-                }
+            HStack {
+                Image(systemName: "syringe.fill")
+                Text("\(state.amount.description) U")
+            }
+            .font(.footnote)
+            .foregroundStyle(.blue)
+            .padding(8)
+            .background {
+                RoundedRectangle(cornerRadius: 10)
+                    .fill(Color.blue.opacity(0.2))
             }
 
-            forecastChart
-                .padding(.vertical, 3)
+            Spacer()
+
             HStack {
-                Spacer()
                 Image(systemName: "arrow.right.circle")
-                    .font(.system(size: 16, weight: .bold))
 
-                if let eventualBG = state.simulatedDetermination?.eventualBG {
+                if let simulatedDetermination = state.simulatedDetermination, let eventualBG = simulatedDetermination.eventualBG {
                     HStack {
                         Text(
-                            units == .mgdL ? Decimal(eventualBG).description : eventualBG.formattedAsMmolL
+                            state.units == .mgdL ? Decimal(eventualBG).description : eventualBG.formattedAsMmolL
                         )
                         .font(.footnote)
                         .foregroundStyle(.primary)
-                        Text("\(units.rawValue)")
+                        Text("\(state.units.rawValue)")
                             .font(.footnote)
                             .foregroundStyle(.secondary)
                     }
@@ -93,11 +89,18 @@ struct ForeCastChart: View {
                     Text("---")
                         .font(.footnote)
                         .foregroundStyle(.primary)
-                    Text("\(units.rawValue)")
+                    Text("\(state.units.rawValue)")
                         .font(.footnote)
                         .foregroundStyle(.secondary)
                 }
             }
+            .font(.footnote)
+            .foregroundStyle(.primary)
+            .padding(8)
+            .background {
+                RoundedRectangle(cornerRadius: 10)
+                    .fill(Color.primary.opacity(0.2))
+            }
         }
     }
 
@@ -115,16 +118,27 @@ struct ForeCastChart: View {
         .chartXAxis { forecastChartXAxis }
         .chartXScale(domain: startMarker ... endMarker)
         .chartYAxis { forecastChartYAxis }
-        .chartYScale(domain: units == .mgdL ? 0 ... 300 : 0.asMmolL ... 300.asMmolL)
+        .chartYScale(domain: state.units == .mgdL ? 0 ... 300 : 0.asMmolL ... 300.asMmolL)
         .backport.chartForegroundStyleScale(state: state)
     }
 
     private func drawGlucose() -> some ChartContent {
         ForEach(state.glucoseFromPersistence) { item in
             let glucoseToDisplay = state.units == .mgdL ? Decimal(item.glucose) : Decimal(item.glucose).asMmolL
-            let pointMarkColor: Color = glucoseToDisplay > state.highGlucose ? Color.orange :
-                glucoseToDisplay < state.lowGlucose ? Color.red :
-                Color.green
+            let targetGlucose = (state.determination.first?.currentTarget ?? state.currentBGTarget as NSDecimalNumber) as Decimal
+
+            // 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 = state.glucoseColorScheme == .dynamicColor
+
+            let pointMarkColor: Color = FreeAPS.getDynamicGlucoseColor(
+                glucoseValue: Decimal(item.glucose),
+                highGlucoseColorValue: isDynamicColorScheme ? hardCodedHigh : state.highGlucose,
+                lowGlucoseColorValue: isDynamicColorScheme ? hardCodedLow : state.lowGlucose,
+                targetGlucose: targetGlucose,
+                glucoseColorScheme: state.glucoseColorScheme
+            )
 
             if !state.isSmoothingEnabled {
                 PointMark(
@@ -132,7 +146,7 @@ struct ForeCastChart: View {
                     y: .value("Value", glucoseToDisplay)
                 )
                 .foregroundStyle(pointMarkColor)
-                .symbolSize(20)
+                .symbolSize(18)
             } else {
                 PointMark(
                     x: .value("Time", item.date ?? Date(), unit: .second),
@@ -140,7 +154,7 @@ struct ForeCastChart: View {
                 )
                 .symbol {
                     Image(systemName: "record.circle.fill")
-                        .font(.system(size: 8))
+                        .font(.system(size: 6))
                         .bold()
                         .foregroundStyle(pointMarkColor)
                 }
@@ -163,17 +177,17 @@ struct ForeCastChart: View {
 
                 // if distance between respective min and max is 0, provide a default range
                 if yMinMaxDelta == 0 {
-                    let yMinValue = units == .mgdL ? Decimal(state.minForecast[index] - 1) :
+                    let yMinValue = state.units == .mgdL ? Decimal(state.minForecast[index] - 1) :
                         Decimal(state.minForecast[index] - 1)
                         .asMmolL
-                    let yMaxValue = units == .mgdL ? Decimal(state.minForecast[index] + 1) :
+                    let yMaxValue = state.units == .mgdL ? Decimal(state.minForecast[index] + 1) :
                         Decimal(state.minForecast[index] + 1)
                         .asMmolL
 
                     AreaMark(
                         x: .value("Time", xValue <= endMarker ? xValue : endMarker),
-                        yStart: .value("Min Value", units == .mgdL ? yMinValue : yMinValue.asMmolL),
-                        yEnd: .value("Max Value", units == .mgdL ? yMaxValue : yMaxValue.asMmolL)
+                        yStart: .value("Min Value", state.units == .mgdL ? yMinValue : yMinValue.asMmolL),
+                        yEnd: .value("Max Value", state.units == .mgdL ? yMaxValue : yMaxValue.asMmolL)
                     )
                     .foregroundStyle(Color.blue.opacity(0.5))
                     .interpolationMethod(.catmullRom)
@@ -184,8 +198,8 @@ struct ForeCastChart: View {
 
                     AreaMark(
                         x: .value("Time", timeForIndex(Int32(index)) <= endMarker ? timeForIndex(Int32(index)) : endMarker),
-                        yStart: .value("Min Value", units == .mgdL ? yMinValue : yMinValue.asMmolL),
-                        yEnd: .value("Max Value", units == .mgdL ? yMaxValue : yMaxValue.asMmolL)
+                        yStart: .value("Min Value", state.units == .mgdL ? yMinValue : yMinValue.asMmolL),
+                        yEnd: .value("Max Value", state.units == .mgdL ? yMaxValue : yMaxValue.asMmolL)
                     )
                     .foregroundStyle(Color.blue.opacity(0.5))
                     .interpolationMethod(.catmullRom)
@@ -210,7 +224,7 @@ struct ForeCastChart: View {
                 ForEach(values.indices, id: \.self) { index in
                     LineMark(
                         x: .value("Time", timeForIndex(Int32(index))),
-                        y: .value("Value", units == .mgdL ? Decimal(values[index]) : Decimal(values[index]).asMmolL)
+                        y: .value("Value", state.units == .mgdL ? Decimal(values[index]) : Decimal(values[index]).asMmolL)
                     )
                     .foregroundStyle(by: .value("Prediction Type", name))
                 }
@@ -232,8 +246,8 @@ struct ForeCastChart: View {
         AxisMarks(values: .stride(by: .hour, count: 2)) { _ in
             AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
             AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .narrow)), anchor: .top)
-                .font(.footnote)
-                .foregroundStyle(Color.primary)
+                .font(.caption2)
+                .foregroundStyle(Color.secondary)
         }
     }
 
@@ -241,7 +255,7 @@ struct ForeCastChart: View {
         AxisMarks(position: .trailing) { _ in
             AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
             AxisTick(length: 3, stroke: .init(lineWidth: 3)).foregroundStyle(Color.secondary)
-            AxisValueLabel().font(.footnote).foregroundStyle(Color.primary)
+            AxisValueLabel().font(.caption2).foregroundStyle(Color.secondary)
         }
     }
 }

+ 1 - 1
FreeAPS/Sources/Modules/Bolus/View/MealPresetView.swift

@@ -3,7 +3,7 @@ import Foundation
 import SwiftUI
 
 struct MealPresetView: View {
-    @StateObject var state: Bolus.StateModel
+    @Bindable var state: Bolus.StateModel
     @Environment(\.colorScheme) var colorScheme
     @Environment(\.dismiss) var dismiss
     @Environment(\.managedObjectContext) var moc

+ 1 - 1
FreeAPS/Sources/Modules/Bolus/View/PopupView.swift

@@ -1,7 +1,7 @@
 import SwiftUI
 
 struct PopupView: View {
-    @StateObject var state: Bolus.StateModel
+    var state: Bolus.StateModel
     @Environment(\.colorScheme) var colorScheme
 
     private var fractionDigits: Int {

+ 10 - 10
FreeAPS/Sources/Modules/Calibrations/CalibrationsStateModel.swift

@@ -1,21 +1,21 @@
+import Observation
 import SwiftDate
 import SwiftUI
 
 extension Calibrations {
-    final class StateModel: BaseStateModel<Provider> {
-        @Injected() var glucoseStorage: GlucoseStorage!
-        @Injected() var calibrationService: CalibrationService!
+    @Observable final class StateModel: BaseStateModel<Provider> {
+        @ObservationIgnored @Injected() var glucoseStorage: GlucoseStorage!
+        @ObservationIgnored @Injected() var calibrationService: CalibrationService!
 
-        @Published var slope: Double = 1
-        @Published var intercept: Double = 1
-        @Published var newCalibration: Decimal = 0
-        @Published var calibrations: [Calibration] = []
-        @Published var calibrate: (Int) -> Double = { Double($0) }
-        @Published var items: [Item] = []
+        var slope: Double = 1
+        var intercept: Double = 1
+        var newCalibration: Decimal = 0
+        var calibrations: [Calibration] = []
+        var calibrate: (Int) -> Double = { Double($0) }
+        var items: [Item] = []
 
         var units: GlucoseUnits = .mgdL
 
-        // TODO: - test if we need to use the viewContext here
         private let context = CoreDataStack.shared.newTaskContext()
 
         override func subscribe() {

+ 1 - 1
FreeAPS/Sources/Modules/Calibrations/View/CalibrationsChart.swift

@@ -1,7 +1,7 @@
 import SwiftUI
 
 struct CalibrationsChart: View {
-    @EnvironmentObject var state: Calibrations.StateModel
+    var state: Calibrations.StateModel
 
     private var dateFormatter: DateFormatter {
         let formatter = DateFormatter()

+ 2 - 2
FreeAPS/Sources/Modules/Calibrations/View/CalibrationsRootView.swift

@@ -4,7 +4,7 @@ import Swinject
 extension Calibrations {
     struct RootView: BaseView {
         let resolver: Resolver
-        @StateObject var state = StateModel()
+        @State var state = StateModel()
 
         @Environment(\.colorScheme) var colorScheme
         var color: LinearGradient {
@@ -101,7 +101,7 @@ extension Calibrations {
 
                     if state.calibrations.isNotEmpty {
                         Section(header: Text("Chart")) {
-                            CalibrationsChart().environmentObject(state)
+                            CalibrationsChart(state: state)
                                 .frame(minHeight: geo.size.width)
                         }.listRowBackground(Color.chart)
                     }

+ 7 - 2
FreeAPS/Sources/Modules/DataTable/DataTableDataFlow.swift

@@ -1,5 +1,6 @@
 import CoreData
 import Foundation
+import HealthKit
 import SwiftUI
 
 enum DataTable {
@@ -218,6 +219,10 @@ enum DataTable {
 }
 
 protocol DataTableProvider: Provider {
-    func deleteCarbsFromNightscout(withID id: String) async
-    func deleteInsulin(with treatmentObjectID: NSManagedObjectID) async
+    func deleteCarbsFromNightscout(withID id: String)
+    func deleteInsulinFromNightscout(withID id: String)
+    func deleteManualGlucoseFromNightscout(withID id: String)
+    func deleteGlucoseFromHealth(withSyncID id: String)
+    func deleteMealDataFromHealth(byID id: String, sampleType: HKSampleType)
+    func deleteInsulinFromHealth(withSyncID id: String)
 }

+ 36 - 34
FreeAPS/Sources/Modules/DataTable/DataTableProvider.swift

@@ -1,5 +1,6 @@
 import CoreData
 import Foundation
+import HealthKit
 
 extension DataTable {
     final class Provider: BaseProvider, DataTableProvider {
@@ -14,52 +15,53 @@ extension DataTable {
         }
 
         func deleteCarbsFromNightscout(withID id: String) {
-            Task {
-                await nightscoutManager.deleteCarbs(withID: id)
+            Task.detached { [weak self] in
+                guard let self = self else { return }
+                await self.nightscoutManager.deleteCarbs(withID: id)
             }
         }
 
-        func deleteInsulin(with treatmentObjectID: NSManagedObjectID) async {
-            let taskContext = CoreDataStack.shared.newTaskContext()
-
-            await taskContext.perform {
-                do {
-                    guard let treatmentToDelete = try taskContext.existingObject(with: treatmentObjectID) as? PumpEventStored
-                    else {
-                        debug(.default, "Could not cast the object to PumpEventStored")
-                        return
-                    }
-
-                    // Delete Insulin from Nightscout
-                    if let id = treatmentToDelete.id {
-                        self.deleteInsulinFromNightscout(withID: id)
-                    }
-
-                    // TODO: - Rewrite healthkit implementation
-
-//                    let id = treatmentToDelete.id
-//                    self.healthkitManager.deleteInsulin(syncID: id)
+        func deleteInsulinFromNightscout(withID id: String) {
+            Task.detached { [weak self] in
+                guard let self = self else { return }
+                await self.nightscoutManager.deleteInsulin(withID: id)
+            }
+        }
 
-                    taskContext.delete(treatmentToDelete)
-                    try taskContext.save()
+        func deleteInsulinFromHealth(withSyncID id: String) {
+            Task.detached { [weak self] in
+                guard let self = self else { return }
+                await self.healthkitManager.deleteInsulin(syncID: id)
+            }
+        }
 
-                    debug(.default, "Successfully deleted the treatment object.")
-                } catch {
-                    debug(.default, "Failed to delete the treatment object: \(error.localizedDescription)")
-                }
+        func deleteManualGlucoseFromNightscout(withID id: String) {
+            Task.detached { [weak self] in
+                guard let self = self else { return }
+                await self.nightscoutManager.deleteManualGlucose(withID: id)
             }
         }
 
-        func deleteInsulinFromNightscout(withID id: String) {
-            Task {
-                await nightscoutManager.deleteInsulin(withID: id)
+        func deleteGlucoseFromHealth(withSyncID id: String) {
+            Task.detached { [weak self] in
+                guard let self = self else { return }
+                await self.healthkitManager.deleteGlucose(syncID: id)
             }
         }
 
-        func deleteManualGlucose(withID id: String) {
-            Task {
-                await nightscoutManager.deleteManualGlucose(withID: id)
+        func deleteMealDataFromHealth(byID id: String, sampleType: HKSampleType) {
+            Task.detached { [weak self] in
+                guard let self = self else { return }
+                await self.healthkitManager.deleteMealData(byID: id, sampleType: sampleType)
             }
         }
+
+        func deleteInsulinFromTidepool(withSyncId id: String, amount: Decimal, at: Date) {
+            tidepoolManager.deleteInsulin(withSyncId: id, amount: amount, at: at)
+        }
+
+        func deleteCarbsFromTidepool(withSyncId id: UUID, carbs: Decimal, at: Date, enteredBy: String) {
+            tidepoolManager.deleteCarbs(withSyncId: id, carbs: carbs, at: at, enteredBy: enteredBy)
+        }
     }
 }

+ 139 - 63
FreeAPS/Sources/Modules/DataTable/DataTableStateModel.swift

@@ -1,29 +1,31 @@
 import CoreData
+import HealthKit
+import Observation
 import SwiftUI
 
 extension DataTable {
-    final class StateModel: BaseStateModel<Provider> {
-        @Injected() var broadcaster: Broadcaster!
-        @Injected() var apsManager: APSManager!
-        @Injected() var unlockmanager: UnlockManager!
-        @Injected() private var storage: FileStorage!
-        @Injected() var pumpHistoryStorage: PumpHistoryStorage!
-        @Injected() var glucoseStorage: GlucoseStorage!
-        @Injected() var healthKitManager: HealthKitManager!
-        @Injected() var carbsStorage: CarbsStorage!
+    @Observable final class StateModel: BaseStateModel<Provider> {
+        @ObservationIgnored @Injected() var broadcaster: Broadcaster!
+        @ObservationIgnored @Injected() var apsManager: APSManager!
+        @ObservationIgnored @Injected() var unlockmanager: UnlockManager!
+        @ObservationIgnored @Injected() private var storage: FileStorage!
+        @ObservationIgnored @Injected() var pumpHistoryStorage: PumpHistoryStorage!
+        @ObservationIgnored @Injected() var glucoseStorage: GlucoseStorage!
+        @ObservationIgnored @Injected() var healthKitManager: HealthKitManager!
+        @ObservationIgnored @Injected() var carbsStorage: CarbsStorage!
 
         let coredataContext = CoreDataStack.shared.newTaskContext()
 
-        @Published var mode: Mode = .treatments
-        @Published var treatments: [Treatment] = []
-        @Published var glucose: [Glucose] = []
-        @Published var meals: [Treatment] = []
-        @Published var manualGlucose: Decimal = 0
-        @Published var maxBolus: Decimal = 0
-        @Published var waitForSuggestion: Bool = false
+        var mode: Mode = .treatments
+        var treatments: [Treatment] = []
+        var glucose: [Glucose] = []
+        var meals: [Treatment] = []
+        var manualGlucose: Decimal = 0
+        var maxBolus: Decimal = 0
+        var waitForSuggestion: Bool = false
 
-        @Published var insulinEntryDeleted: Bool = false
-        @Published var carbEntryDeleted: Bool = false
+        var insulinEntryDeleted: Bool = false
+        var carbEntryDeleted: Bool = false
 
         var units: GlucoseUnits = .mgdL
 
@@ -37,19 +39,26 @@ extension DataTable {
             glucoseStorage.isGlucoseDataFresh(glucoseDate)
         }
 
-        // Carb and FPU deletion from history
-        /// marked as MainActor to be able to publish changes from the background
-        /// - Parameter: NSManagedObjectID to be able to transfer the object safely from one thread to another thread
-        @MainActor func invokeGlucoseDeletionTask(_ treatmentObjectID: NSManagedObjectID) {
+        // Glucose deletion from history and from remote services
+        /// -**Parameter**: NSManagedObjectID to be able to transfer the object safely from one thread to another thread
+        func invokeGlucoseDeletionTask(_ treatmentObjectID: NSManagedObjectID) {
             Task {
                 await deleteGlucose(treatmentObjectID)
             }
         }
 
         func deleteGlucose(_ treatmentObjectID: NSManagedObjectID) async {
+            // Delete from Apple Health/Tidepool
+            await deleteGlucoseFromServices(treatmentObjectID)
+
+            // Delete from Core Data
+            await glucoseStorage.deleteGlucose(treatmentObjectID)
+        }
+
+        func deleteGlucoseFromServices(_ treatmentObjectID: NSManagedObjectID) async {
             let taskContext = CoreDataStack.shared.newTaskContext()
             taskContext.name = "deleteContext"
-            taskContext.transactionAuthor = "deleteGlucose"
+            taskContext.transactionAuthor = "deleteGlucoseFromServices"
 
             await taskContext.perform {
                 do {
@@ -60,41 +69,55 @@ extension DataTable {
                         return
                     }
 
-                    // Delete Manual Glucose from Nightscout
-                    if glucoseToDelete.isManual == true {
-                        if let id = glucoseToDelete.id?.uuidString {
-                            self.provider.deleteManualGlucose(withID: id)
-                        }
+                    // Delete from Nightscout
+                    if let id = glucoseToDelete.id?.uuidString {
+                        self.provider.deleteManualGlucoseFromNightscout(withID: id)
                     }
 
-                    taskContext.delete(glucoseToDelete)
+                    // Delete from Apple Health
+                    if let id = glucoseToDelete.id?.uuidString {
+                        self.provider.deleteGlucoseFromHealth(withSyncID: id)
+                    }
 
-                    guard taskContext.hasChanges else { return }
-                    try taskContext.save()
-                    debugPrint("Data Table State: \(#function) \(DebuggingIdentifiers.succeeded) deleted glucose from core data")
+                    debugPrint(
+                        "\(#file) \(#function) \(DebuggingIdentifiers.succeeded) deleted glucose from remote service(s) (Nightscout, Apple Health, Tidepool)"
+                    )
                 } catch {
                     debugPrint(
-                        "Data Table State: \(#function) \(DebuggingIdentifiers.failed) error while deleting glucose from core data: \(error.localizedDescription)"
+                        "\(#file) \(#function) \(DebuggingIdentifiers.failed) error while deleting glucose remote service(s) (Nightscout, Apple Health, Tidepool) with error: \(error.localizedDescription)"
                     )
                 }
             }
         }
 
         // Carb and FPU deletion from history
-        /// marked as MainActor to be able to publish changes from the background
-        /// - Parameter: NSManagedObjectID to be able to transfer the object safely from one thread to another thread
-        @MainActor func invokeCarbDeletionTask(_ treatmentObjectID: NSManagedObjectID) {
+        /// - **Parameter**: NSManagedObjectID to be able to transfer the object safely from one thread to another thread
+        func invokeCarbDeletionTask(_ treatmentObjectID: NSManagedObjectID) {
             Task {
                 await deleteCarbs(treatmentObjectID)
-                carbEntryDeleted = true
-                waitForSuggestion = true
+
+                await MainActor.run {
+                    carbEntryDeleted = true
+                    waitForSuggestion = true
+                }
             }
         }
 
         func deleteCarbs(_ treatmentObjectID: NSManagedObjectID) async {
+            // Delete from Apple Health/Tidepool
+            await deleteCarbsFromServices(treatmentObjectID)
+
+            // Delete from Core Data
+            await carbsStorage.deleteCarbs(treatmentObjectID)
+
+            // Perform a determine basal sync to update cob
+            await apsManager.determineBasalSync()
+        }
+
+        func deleteCarbsFromServices(_ treatmentObjectID: NSManagedObjectID) async {
             let taskContext = CoreDataStack.shared.newTaskContext()
             taskContext.name = "deleteContext"
-            taskContext.transactionAuthor = "deleteCarbs"
+            taskContext.transactionAuthor = "deleteCarbsFromServices"
 
             var carbEntry: CarbEntryStored?
 
@@ -103,46 +126,66 @@ extension DataTable {
                 do {
                     carbEntry = try taskContext.existingObject(with: treatmentObjectID) as? CarbEntryStored
                     guard let carbEntry = carbEntry else {
-                        debugPrint("Carb entry for batch delete not found. \(DebuggingIdentifiers.failed)")
+                        debugPrint("Carb entry for deletion not found. \(DebuggingIdentifiers.failed)")
                         return
                     }
 
                     if carbEntry.isFPU, let fpuID = carbEntry.fpuID {
-                        // Delete FPUs from Nightscout
+                        // Delete Fat and Protein entries from Nightscout
                         self.provider.deleteCarbsFromNightscout(withID: fpuID.uuidString)
+
+                        // Delete Fat and Protein entries from Apple Health
+                        let healthObjectsToDelete: [HKSampleType?] = [
+                            AppleHealthConfig.healthFatObject,
+                            AppleHealthConfig.healthProteinObject
+                        ]
+
+                        for sampleType in healthObjectsToDelete {
+                            if let validSampleType = sampleType {
+                                self.provider.deleteMealDataFromHealth(byID: fpuID.uuidString, sampleType: validSampleType)
+                            }
+                        }
                     } else {
                         // Delete carbs from Nightscout
-                        if let id = carbEntry.id?.uuidString {
-                            self.provider.deleteCarbsFromNightscout(withID: id)
+                        if let id = carbEntry.id, let entryDate = carbEntry.date {
+                            self.provider.deleteCarbsFromNightscout(withID: id.uuidString)
+
+                            // Delete carbs from Apple Health
+                            if let sampleType = AppleHealthConfig.healthCarbObject {
+                                self.provider.deleteMealDataFromHealth(byID: id.uuidString, sampleType: sampleType)
+                            }
+
+                            self.provider.deleteCarbsFromTidepool(
+                                withSyncId: id,
+                                carbs: Decimal(carbEntry.carbs),
+                                at: entryDate,
+                                enteredBy: CarbsEntry.manual
+                            )
                         }
                     }
 
                 } catch {
                     debugPrint(
-                        "\(DebuggingIdentifiers.failed) Error deleting carb entry from Nightscout: \(error.localizedDescription)"
+                        "\(DebuggingIdentifiers.failed) Error deleting carb entry from remote service(s) (Nightscout, Apple Health, Tidepool) with error: \(error.localizedDescription)"
                     )
                 }
             }
-
-            // Delete carbs from Core Data
-            await carbsStorage.deleteCarbs(treatmentObjectID)
-
-            // Perform a determine basal sync to update cob
-            await apsManager.determineBasalSync()
         }
 
         // Insulin deletion from history
-        /// marked as MainActor to be able to publish changes from the background
-        /// - Parameter: NSManagedObjectID to be able to transfer the object safely from one thread to another thread
-        @MainActor func invokeInsulinDeletionTask(_ treatmentObjectID: NSManagedObjectID) {
+        /// - **Parameter**: NSManagedObjectID to be able to transfer the object safely from one thread to another thread
+        func invokeInsulinDeletionTask(_ treatmentObjectID: NSManagedObjectID) {
             Task {
-                await deleteInsulin(treatmentObjectID)
-                insulinEntryDeleted = true
-                waitForSuggestion = true
+                await invokeInsulinDeletion(treatmentObjectID)
+
+                await MainActor.run {
+                    insulinEntryDeleted = true
+                    waitForSuggestion = true
+                }
             }
         }
 
-        func deleteInsulin(_ treatmentObjectID: NSManagedObjectID) async {
+        func invokeInsulinDeletion(_ treatmentObjectID: NSManagedObjectID) async {
             do {
                 let authenticated = try await unlockmanager.unlock()
 
@@ -151,14 +194,14 @@ extension DataTable {
                     return
                 }
 
-                async let deleteNSManagedObjectTask: () = CoreDataStack.shared.deleteObject(identifiedBy: treatmentObjectID)
-                async let deleteInsulinFromNightScoutTask: () = provider.deleteInsulin(with: treatmentObjectID)
-                async let determineBasalTask: () = apsManager.determineBasalSync()
+                // Delete from remote service(s) (i.e. Nightscout, Apple Health, Tidepool)
+                await deleteInsulinFromServices(with: treatmentObjectID)
 
-                await deleteNSManagedObjectTask
-                await deleteInsulinFromNightScoutTask
-                await determineBasalTask
+                // Delete from Core Data
+                await CoreDataStack.shared.deleteObject(identifiedBy: treatmentObjectID)
 
+                // Perform a determine basal sync to update iob
+                await apsManager.determineBasalSync()
             } catch {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Error while Insulin Deletion Task: \(error.localizedDescription)"
@@ -166,6 +209,37 @@ extension DataTable {
             }
         }
 
+        func deleteInsulinFromServices(with treatmentObjectID: NSManagedObjectID) async {
+            let taskContext = CoreDataStack.shared.newTaskContext()
+            taskContext.name = "deleteContext"
+            taskContext.transactionAuthor = "deleteInsulinFromServices"
+
+            await taskContext.perform {
+                do {
+                    guard let treatmentToDelete = try taskContext.existingObject(with: treatmentObjectID) as? PumpEventStored
+                    else {
+                        debug(.default, "Could not cast the object to PumpEventStored")
+                        return
+                    }
+
+                    if let id = treatmentToDelete.id, let timestamp = treatmentToDelete.timestamp,
+                       let bolus = treatmentToDelete.bolus, let bolusAmount = bolus.amount
+                    {
+                        self.provider.deleteInsulinFromNightscout(withID: id)
+                        self.provider.deleteInsulinFromHealth(withSyncID: id)
+                        self.provider.deleteInsulinFromTidepool(withSyncId: id, amount: bolusAmount as Decimal, at: timestamp)
+                    }
+
+                    taskContext.delete(treatmentToDelete)
+                    try taskContext.save()
+
+                    debug(.default, "Successfully deleted the treatment object.")
+                } catch {
+                    debug(.default, "Failed to delete the treatment object: \(error.localizedDescription)")
+                }
+            }
+        }
+
         func addManualGlucose() {
             let glucose = units == .mmolL ? manualGlucose.asMgdL : manualGlucose
             let glucoseAsInt = Int(glucose)
@@ -178,6 +252,8 @@ extension DataTable {
                 newItem.glucose = Int16(glucoseAsInt)
                 newItem.isManual = true
                 newItem.isUploadedToNS = false
+                newItem.isUploadedToHealth = false
+                newItem.isUploadedToTidepool = false
 
                 do {
                     guard self.coredataContext.hasChanges else { return }

+ 3 - 1
FreeAPS/Sources/Modules/DataTable/View/DataTableRootView.swift

@@ -5,7 +5,9 @@ import Swinject
 extension DataTable {
     struct RootView: BaseView {
         let resolver: Resolver
-        @StateObject var state = StateModel()
+
+        @State var state = StateModel()
+
         @State private var isRemoveHistoryItemAlertPresented: Bool = false
         @State private var alertTitle: String = ""
         @State private var alertMessage: String = ""

+ 13 - 12
FreeAPS/Sources/Modules/DynamicSettings/DynamicSettingsStateModel.swift

@@ -1,19 +1,20 @@
+import Observation
 import SwiftUI
 
 extension DynamicSettings {
-    final class StateModel: BaseStateModel<Provider> {
-        @Injected() var settings: SettingsManager!
-        @Injected() var storage: FileStorage!
+    @Observable final class StateModel: BaseStateModel<Provider> {
+        @ObservationIgnored @Injected() var settings: SettingsManager!
+        @ObservationIgnored @Injected() var storage: FileStorage!
 
-        @Published var useNewFormula: Bool = false
-        @Published var enableDynamicCR: Bool = false
-        @Published var sigmoid: Bool = false
-        @Published var adjustmentFactor: Decimal = 0.8
-        @Published var adjustmentFactorSigmoid: Decimal = 0.5
-        @Published var weightPercentage: Decimal = 0.65
-        @Published var tddAdjBasal: Bool = false
-        @Published var threshold_setting: Decimal = 60
-        @Published var units: GlucoseUnits = .mgdL
+        var useNewFormula: Bool = false
+        var enableDynamicCR: Bool = false
+        var sigmoid: Bool = false
+        var adjustmentFactor: Decimal = 0.8
+        var adjustmentFactorSigmoid: Decimal = 0.5
+        var weightPercentage: Decimal = 0.65
+        var tddAdjBasal: Bool = false
+        var threshold_setting: Decimal = 60
+        var units: GlucoseUnits = .mgdL
 
         var preferences: Preferences {
             settingsManager.preferences

+ 1 - 1
FreeAPS/Sources/Modules/DynamicSettings/View/DynamicSettingsRootView.swift

@@ -4,7 +4,7 @@ import Swinject
 extension DynamicSettings {
     struct RootView: BaseView {
         let resolver: Resolver
-        @StateObject var state = StateModel()
+        @State var state = StateModel()
         @State private var shouldDisplayHint: Bool = false
         @State var hintDetent = PresentationDetent.large
         @State var selectedVerboseHint: String?

Plik diff jest za duży
+ 18 - 18
FreeAPS/Sources/Modules/GeneralSettings/View/UnitsLimitsSettingsRootView.swift


+ 2 - 2
FreeAPS/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsStateModel.swift

@@ -32,7 +32,7 @@ extension GlucoseNotificationSettings {
                 addSourceInfoToGlucoseNotifications = $0 }
             subscribeSetting(\.lowGlucose, on: $lowGlucose, initial: {
                 let value = max(min($0, 400), 40)
-                lowGlucose = units == .mmolL ? value.asMmolL : value
+                lowGlucose = value
             }, map: {
                 guard units == .mmolL else { return $0 }
                 return $0.asMgdL
@@ -40,7 +40,7 @@ extension GlucoseNotificationSettings {
 
             subscribeSetting(\.highGlucose, on: $highGlucose, initial: {
                 let value = max(min($0, 400), 40)
-                highGlucose = units == .mmolL ? value.asMmolL : value
+                highGlucose = value
             }, map: {
                 guard units == .mmolL else { return $0 }
                 return $0.asMgdL

+ 16 - 14
FreeAPS/Sources/Modules/HealthKit/HealthKitStateModel.swift

@@ -14,7 +14,7 @@ extension AppleHealthKit {
 
             useAppleHealth = settingsManager.settings.useAppleHealth
 
-            needShowInformationTextForSetPermissions = healthKitManager.areAllowAllPermissions
+            needShowInformationTextForSetPermissions = healthKitManager.hasGrantedFullWritePermissions
 
             subscribeSetting(\.useAppleHealth, on: $useAppleHealth) {
                 useAppleHealth = $0
@@ -26,20 +26,22 @@ extension AppleHealthKit {
                     return
                 }
 
-                self.healthKitManager.requestPermission { ok, error in
-                    DispatchQueue.main.async {
-                        self.needShowInformationTextForSetPermissions = !self.healthKitManager.checkAvailabilitySaveBG()
+                Task {
+                    do {
+                        let permissionGranted = try await self.healthKitManager.requestPermission()
+
+                        await MainActor.run {
+                            self.needShowInformationTextForSetPermissions = !self.healthKitManager.hasGlucoseWritePermission()
+                        }
+
+                        if permissionGranted {
+                            debug(.service, "Permission granted for HealthKitManager")
+                        } else {
+                            warning(.service, "Permission not granted for HealthKitManager")
+                        }
+                    } catch {
+                        warning(.service, "Error requesting permission for HealthKitManager", error: error)
                     }
-
-                    guard ok, error == nil else {
-                        warning(.service, "Permission not granted for HealthKitManager", error: error)
-                        return
-                    }
-
-                    debug(.service, "Permission  granted HealthKitManager")
-
-                    self.healthKitManager.createBGObserver()
-                    self.healthKitManager.enableBackgroundDelivery()
                 }
             }
         }

+ 6 - 0
FreeAPS/Sources/Modules/Home/HomeProvider.swift

@@ -53,5 +53,11 @@ extension Home {
             storage.retrieve(OpenAPS.Settings.pumpProfile, as: Autotune.self)?.basalProfile
                 ?? [BasalProfileEntry(start: "00:00", minutes: 0, rate: 1)]
         }
+
+        func getBGTarget() async -> BGTargets {
+            await storage.retrieveAsync(OpenAPS.Settings.bgTargets, as: BGTargets.self)
+                ?? BGTargets(from: OpenAPS.defaults(for: OpenAPS.Settings.bgTargets))
+                ?? BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: [])
+        }
     }
 }

+ 32 - 0
FreeAPS/Sources/Modules/Home/HomeStateModel+Setup/BatterySetup.swift

@@ -0,0 +1,32 @@
+import CoreData
+import Foundation
+
+extension Home.StateModel {
+    func setupBatteryArray() {
+        Task {
+            let ids = await self.fetchBattery()
+            let batteryObjects: [OpenAPS_Battery] = await CoreDataStack.shared.getNSManagedObject(with: ids, context: viewContext)
+            await updateBatteryArray(with: batteryObjects)
+        }
+    }
+
+    private func fetchBattery() async -> [NSManagedObjectID] {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: OpenAPS_Battery.self,
+            onContext: batteryFetchContext,
+            predicate: NSPredicate.predicateFor30MinAgo,
+            key: "date",
+            ascending: false
+        )
+
+        return await batteryFetchContext.perform {
+            guard let fetchedResults = results as? [OpenAPS_Battery] else { return [] }
+
+            return fetchedResults.map(\.objectID)
+        }
+    }
+
+    @MainActor private func updateBatteryArray(with objects: [OpenAPS_Battery]) {
+        batteryFromPersistence = objects
+    }
+}

+ 60 - 0
FreeAPS/Sources/Modules/Home/HomeStateModel+Setup/CarbSetup.swift

@@ -0,0 +1,60 @@
+import CoreData
+import Foundation
+
+extension Home.StateModel {
+    func setupCarbsArray() {
+        Task {
+            let ids = await self.fetchCarbs()
+            let carbObjects: [CarbEntryStored] = await CoreDataStack.shared.getNSManagedObject(with: ids, context: viewContext)
+            await updateCarbsArray(with: carbObjects)
+        }
+    }
+
+    private func fetchCarbs() async -> [NSManagedObjectID] {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: CarbEntryStored.self,
+            onContext: carbsFetchContext,
+            predicate: NSPredicate.carbsForChart,
+            key: "date",
+            ascending: false
+        )
+
+        return await carbsFetchContext.perform {
+            guard let fetchedResults = results as? [CarbEntryStored] else { return [] }
+
+            return fetchedResults.map(\.objectID)
+        }
+    }
+
+    @MainActor private func updateCarbsArray(with objects: [CarbEntryStored]) {
+        carbsFromPersistence = objects
+    }
+
+    func setupFPUsArray() {
+        Task {
+            let ids = await self.fetchFPUs()
+            let fpuObjects: [CarbEntryStored] = await CoreDataStack.shared.getNSManagedObject(with: ids, context: viewContext)
+            await updateFPUsArray(with: fpuObjects)
+        }
+    }
+
+    private func fetchFPUs() async -> [NSManagedObjectID] {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: CarbEntryStored.self,
+            onContext: fpuFetchContext,
+            predicate: NSPredicate.fpusForChart,
+            key: "date",
+            ascending: false
+        )
+
+        return await fpuFetchContext.perform {
+            guard let fetchedResults = results as? [CarbEntryStored] else { return [] }
+
+            return fetchedResults.map(\.objectID)
+        }
+    }
+
+    @MainActor private func updateFPUsArray(with objects: [CarbEntryStored]) {
+        fpusFromPersistence = objects
+    }
+}

+ 101 - 0
FreeAPS/Sources/Modules/Home/HomeStateModel+Setup/ChartAxisSetup.swift

@@ -0,0 +1,101 @@
+import Foundation
+
+extension Home.StateModel {
+    func yAxisChartData(glucoseValues: [GlucoseStored]) {
+        // Capture the forecast values from `preprocessedData` on the main thread
+        Task { @MainActor in
+            let forecastValues = self.preprocessedData.map { Decimal($0.forecastValue.value) }
+
+            // Perform the glucose processing on the background context
+            glucoseFetchContext.perform {
+                let glucoseMapped = glucoseValues.map { Decimal($0.glucose) }
+
+                // Calculate min and max values for glucose and forecast
+                let minGlucose = glucoseMapped.min()
+                let maxGlucose = glucoseMapped.max()
+                let minForecast = forecastValues.min()
+                let maxForecast = forecastValues.max()
+
+                // Ensure all values exist, otherwise set default values
+                guard let minGlucose = minGlucose, let maxGlucose = maxGlucose else {
+                    Task {
+                        await self.updateChartBounds(minValue: 39, maxValue: 300)
+                    }
+                    return
+                }
+
+                // Adjust max forecast to be no more than 100 over max glucose
+                let adjustedMaxForecast = min(maxForecast ?? maxGlucose + 100, maxGlucose + 100)
+                let minOverall = min(minGlucose, minForecast ?? minGlucose)
+                let maxOverall = max(maxGlucose, adjustedMaxForecast)
+
+                // Update the chart bounds on the main thread
+                Task {
+                    await self.updateChartBounds(minValue: minOverall - 50, maxValue: maxOverall + 80)
+                }
+            }
+        }
+    }
+
+    @MainActor private func updateChartBounds(minValue: Decimal, maxValue: Decimal) async {
+        minYAxisValue = minValue
+        maxYAxisValue = maxValue
+    }
+
+    func yAxisChartDataCobChart(determinations: [[String: Any]]) {
+        determinationFetchContext.perform {
+            // Map the COB values from the dictionary results
+            let cobMapped = determinations.compactMap { entry in
+                // First cast to Int16, then convert to Decimal
+                if let cobValue = entry["cob"] as? Int16 {
+                    return Decimal(cobValue)
+                }
+                return nil
+            }
+            let maxCob = cobMapped.max()
+
+            // Ensure the result exists or set default values
+            if let maxCob = maxCob {
+                let calculatedMax = maxCob == 0 ? 20 : maxCob + 20
+                Task {
+                    await self.updateCobChartBounds(minValue: 0, maxValue: calculatedMax)
+                }
+            } else {
+                Task {
+                    await self.updateCobChartBounds(minValue: 0, maxValue: 20)
+                }
+            }
+        }
+    }
+
+    @MainActor private func updateCobChartBounds(minValue: Decimal, maxValue: Decimal) {
+        minValueCobChart = minValue
+        maxValueCobChart = maxValue
+    }
+
+    func yAxisChartDataIobChart(determinations: [[String: Any]]) {
+        determinationFetchContext.perform {
+            // Map the IOB values from the fetched dictionaries
+            let iobMapped = determinations.compactMap { ($0["iob"] as? NSDecimalNumber)?.decimalValue }
+            let minIob = iobMapped.min()
+            let maxIob = iobMapped.max()
+
+            // Ensure min and max IOB values exist, or set defaults
+            if let minIob = minIob, let maxIob = maxIob {
+                let adjustedMin = minIob < 0 ? minIob - 2 : 0
+                Task {
+                    await self.updateIobChartBounds(minValue: adjustedMin, maxValue: maxIob + 2)
+                }
+            } else {
+                Task {
+                    await self.updateIobChartBounds(minValue: 0, maxValue: 5)
+                }
+            }
+        }
+    }
+
+    @MainActor private func updateIobChartBounds(minValue: Decimal, maxValue: Decimal) async {
+        minValueIobChart = minValue
+        maxValueIobChart = maxValue
+    }
+}

+ 58 - 0
FreeAPS/Sources/Modules/Home/HomeStateModel+Setup/DeterminationSetup.swift

@@ -0,0 +1,58 @@
+import CoreData
+import Foundation
+
+extension Home.StateModel {
+    func setupDeterminationsArray() {
+        Task {
+            // Get the NSManagedObjectIDs
+            async let enactedObjectIds = determinationStorage
+                .fetchLastDeterminationObjectID(predicate: NSPredicate.enactedDetermination)
+            async let enactedAndNonEnactedObjectIds = fetchCobAndIob()
+
+            let enactedIDs = await enactedObjectIds
+            let enactedAndNonEnactedIds = await enactedAndNonEnactedObjectIds
+
+            // Get the NSManagedObjects and return them on the Main Thread
+            await updateDeterminationsArray(with: enactedIDs, keyPath: \.determinationsFromPersistence)
+            await updateDeterminationsArray(with: enactedAndNonEnactedIds, keyPath: \.enactedAndNonEnactedDeterminations)
+
+            await updateForecastData()
+        }
+    }
+
+    @MainActor private func updateDeterminationsArray(
+        with IDs: [NSManagedObjectID],
+        keyPath: ReferenceWritableKeyPath<Home.StateModel, [OrefDetermination]>
+    ) async {
+        // Fetch the objects off the main thread
+        let determinationObjects: [OrefDetermination] = await CoreDataStack.shared
+            .getNSManagedObject(with: IDs, context: viewContext)
+
+        // Update the array on the main thread
+        self[keyPath: keyPath] = determinationObjects
+    }
+
+    // Custom fetch to more efficiently filter only for cob and iob
+    private func fetchCobAndIob() async -> [NSManagedObjectID] {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: OrefDetermination.self,
+            onContext: determinationFetchContext,
+            predicate: NSPredicate.determinationsForCobIobCharts,
+            key: "deliverAt",
+            ascending: false,
+            batchSize: 50,
+            propertiesToFetch: ["cob", "iob", "objectID"]
+        )
+
+        return await determinationFetchContext.perform {
+            guard let fetchedResults = results as? [[String: Any]] else {
+                return []
+            }
+
+            // Update Chart Scales
+            self.yAxisChartDataCobChart(determinations: fetchedResults)
+            self.yAxisChartDataIobChart(determinations: fetchedResults)
+            return fetchedResults.compactMap { $0["objectID"] as? NSManagedObjectID }
+        }
+    }
+}

+ 111 - 0
FreeAPS/Sources/Modules/Home/HomeStateModel+Setup/ForecastSetup.swift

@@ -0,0 +1,111 @@
+import CoreData
+import Foundation
+
+extension Home.StateModel {
+    // Asynchronously preprocess Forecast data in a background thread
+    func preprocessForecastData() async -> [(id: UUID, forecastID: NSManagedObjectID, forecastValueIDs: [NSManagedObjectID])] {
+        // Get the Determination ID on the main context
+        guard let id = await viewContext.perform({ self.enactedAndNonEnactedDeterminations.first?.objectID }) else {
+            return []
+        }
+
+        // Get the Forecast IDs for the Determination ID
+        // Here we can safely use a background context since we are using the NSManagedObjectID
+        let forecastIDs = await determinationStorage.getForecastIDs(for: id, in: taskContext)
+
+        var result: [(id: UUID, forecastID: NSManagedObjectID, forecastValueIDs: [NSManagedObjectID])] = []
+
+        // Use a task group to fetch Forecast VALUE IDs concurrently
+        await withTaskGroup(of: (UUID, NSManagedObjectID, [NSManagedObjectID]).self) { group in
+            for forecastID in forecastIDs {
+                group.addTask {
+                    // Fetch forecast value IDs asynchronously (but outside of perform)
+                    let forecastValueIDs = await self.determinationStorage.getForecastValueIDs(
+                        for: forecastID,
+                        in: self.taskContext
+                    )
+                    return (UUID(), forecastID, forecastValueIDs)
+                }
+            }
+
+            // Collect the results from the task group
+            for await (uuid, forecastID, forecastValueIDs) in group {
+                result.append((id: uuid, forecastID: forecastID, forecastValueIDs: forecastValueIDs))
+            }
+        }
+
+        return result
+    }
+
+    // Update forecast data and UI on the main thread
+    @MainActor func updateForecastData() async {
+        // Preprocess forecast data on a background thread
+        let forecastDataIDs = await preprocessForecastData()
+
+        // Use an Array of Int instead of ForecastValues to be able to pass values thread safe
+        var allForecastValues = [[Int]]()
+        var preprocessedData = [(id: UUID, forecast: Forecast, forecastValue: ForecastValue)]()
+
+        // Use a task group to fetch forecast values concurrently
+        await withTaskGroup(of: (UUID, Forecast?, [ForecastValue]).self) { group in
+            for data in forecastDataIDs {
+                group.addTask {
+                    await self.determinationStorage
+                        .fetchForecastObjects(
+                            for: data,
+                            in: self.viewContext
+                        ) // This directly returns NSManagedobjects on the Main Thread
+                }
+            }
+
+            // Collect the results from the task group
+            for await (id, forecast, forecastValues) in group {
+                guard let forecast = forecast, !forecastValues.isEmpty else { continue }
+
+                // Extract only the 'value' from ForecastValue on the main thread
+                let forecastValueInts = forecastValues
+                    .compactMap { Int($0.value) }
+                allForecastValues.append(forecastValueInts)
+                preprocessedData.append(contentsOf: forecastValues.map { (id: id, forecast: forecast, forecastValue: $0) })
+            }
+        }
+
+        // Update Array on the Main Thread
+        self.preprocessedData = preprocessedData
+
+        // Ensure there are forecast values to process
+        guard !allForecastValues.isEmpty else {
+            minForecast = []
+            maxForecast = []
+            return
+        }
+
+        // Update minCount on the Main Thread
+        minCount = max(12, allForecastValues.map(\.count).min() ?? 0)
+
+        // Safely read minCount for use inside the detached task
+        let localMinCount = minCount
+
+        guard localMinCount > 0 else { return }
+
+        // Copy allForecastValues to a local constant for thread safety
+        let localAllForecastValues = allForecastValues
+
+        // Calculate min and max forecast values in a background task
+        let (minResult, maxResult) = await Task.detached {
+            let minForecast = (0 ..< localMinCount).map { index in
+                localAllForecastValues.compactMap { $0.indices.contains(index) ? $0[index] : nil }.min() ?? 0
+            }
+
+            let maxForecast = (0 ..< localMinCount).map { index in
+                localAllForecastValues.compactMap { $0.indices.contains(index) ? $0[index] : nil }.max() ?? 0
+            }
+
+            return (minForecast, maxForecast)
+        }.value
+
+        // Update the properties on the main thread
+        minForecast = minResult
+        maxForecast = maxResult
+    }
+}

+ 37 - 0
FreeAPS/Sources/Modules/Home/HomeStateModel+Setup/GlucoseSetup.swift

@@ -0,0 +1,37 @@
+import CoreData
+import Foundation
+
+extension Home.StateModel {
+    func setupGlucoseArray() {
+        Task {
+            let ids = await self.fetchGlucose()
+            let glucoseObjects: [GlucoseStored] = await CoreDataStack.shared.getNSManagedObject(with: ids, context: viewContext)
+            await updateGlucoseArray(with: glucoseObjects)
+        }
+    }
+
+    private func fetchGlucose() async -> [NSManagedObjectID] {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: glucoseFetchContext,
+            predicate: NSPredicate.glucose,
+            key: "date",
+            ascending: true
+        )
+
+        return await glucoseFetchContext.perform {
+            guard let fetchedResults = results as? [GlucoseStored] else { return [] }
+
+            // Update Main Chart Y Axis Values
+            // Perform everything on "context" to be thread safe
+            self.yAxisChartData(glucoseValues: fetchedResults)
+
+            return fetchedResults.map(\.objectID)
+        }
+    }
+
+    @MainActor private func updateGlucoseArray(with objects: [GlucoseStored]) {
+        glucoseFromPersistence = objects
+        latestTwoGlucoseValues = Array(objects.suffix(2))
+    }
+}

+ 98 - 0
FreeAPS/Sources/Modules/Home/HomeStateModel+Setup/OverrideSetup.swift

@@ -0,0 +1,98 @@
+import CoreData
+import Foundation
+
+extension Home.StateModel {
+    // Setup Overrides
+    func setupOverrides() {
+        Task {
+            let ids = await self.fetchOverrides()
+            let overrideObjects: [OverrideStored] = await CoreDataStack.shared.getNSManagedObject(with: ids, context: viewContext)
+            await updateOverrideArray(with: overrideObjects)
+        }
+    }
+
+    private func fetchOverrides() async -> [NSManagedObjectID] {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: OverrideStored.self,
+            onContext: overrideFetchContext,
+            predicate: NSPredicate.lastActiveOverride, // this predicate filters for all Overrides within the last 24h
+            key: "date",
+            ascending: false
+        )
+
+        return await overrideFetchContext.perform {
+            guard let fetchedResults = results as? [OverrideStored] else { return [] }
+
+            return fetchedResults.map(\.objectID)
+        }
+    }
+
+    @MainActor private func updateOverrideArray(with objects: [OverrideStored]) {
+        overrides = objects
+    }
+
+    @MainActor func calculateDuration(override: OverrideStored) -> TimeInterval {
+        guard let overrideDuration = override.duration as? Double, overrideDuration != 0 else {
+            return TimeInterval(60 * 60 * 24) // one day
+        }
+        return TimeInterval(overrideDuration * 60) // return seconds
+    }
+
+    @MainActor func calculateTarget(override: OverrideStored) -> Decimal {
+        guard let overrideTarget = override.target, overrideTarget != 0 else {
+            return 100 // default
+        }
+        return overrideTarget.decimalValue
+    }
+
+    // Setup expired Overrides
+    func setupOverrideRunStored() {
+        Task {
+            let ids = await self.fetchOverrideRunStored()
+            let overrideRunObjects: [OverrideRunStored] = await CoreDataStack.shared
+                .getNSManagedObject(with: ids, context: viewContext)
+            await updateOverrideRunStoredArray(with: overrideRunObjects)
+        }
+    }
+
+    private func fetchOverrideRunStored() async -> [NSManagedObjectID] {
+        let predicate = NSPredicate(format: "startDate >= %@", Date.oneDayAgo as NSDate)
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: OverrideRunStored.self,
+            onContext: overrideFetchContext,
+            predicate: predicate,
+            key: "startDate",
+            ascending: false
+        )
+
+        return await overrideFetchContext.perform {
+            guard let fetchedResults = results as? [OverrideRunStored] else { return [] }
+
+            return fetchedResults.map(\.objectID)
+        }
+    }
+
+    @MainActor private func updateOverrideRunStoredArray(with objects: [OverrideRunStored]) {
+        overrideRunStored = objects
+    }
+
+    @MainActor func saveToOverrideRunStored(withID id: NSManagedObjectID) async {
+        await viewContext.perform {
+            do {
+                guard let object = try self.viewContext.existingObject(with: id) as? OverrideStored else { return }
+
+                let newOverrideRunStored = OverrideRunStored(context: self.viewContext)
+                newOverrideRunStored.id = UUID()
+                newOverrideRunStored.name = object.name
+                newOverrideRunStored.startDate = object.date ?? .distantPast
+                newOverrideRunStored.endDate = Date()
+                newOverrideRunStored.target = NSDecimalNumber(decimal: self.calculateTarget(override: object))
+                newOverrideRunStored.override = object
+                newOverrideRunStored.isUploadedToNS = false
+
+            } catch {
+                debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to initialize a new Override Run Object")
+            }
+        }
+    }
+}

+ 81 - 0
FreeAPS/Sources/Modules/Home/HomeStateModel+Setup/PumpHistorySetup.swift

@@ -0,0 +1,81 @@
+import CoreData
+import Foundation
+
+extension Home.StateModel {
+    func setupInsulinArray() {
+        Task {
+            let ids = await self.fetchInsulin()
+            let insulinObjects: [PumpEventStored] = await CoreDataStack.shared.getNSManagedObject(with: ids, context: viewContext)
+            await updateInsulinArray(with: insulinObjects)
+        }
+    }
+
+    private func fetchInsulin() async -> [NSManagedObjectID] {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: PumpEventStored.self,
+            onContext: pumpHistoryFetchContext,
+            predicate: NSPredicate.pumpHistoryLast24h,
+            key: "timestamp",
+            ascending: true
+        )
+
+        return await pumpHistoryFetchContext.perform {
+            guard let pumpEvents = results as? [PumpEventStored] else {
+                return []
+            }
+
+            return pumpEvents.map(\.objectID)
+        }
+    }
+
+    @MainActor private func updateInsulinArray(with insulinObjects: [PumpEventStored]) {
+        insulinFromPersistence = insulinObjects
+
+        manualTempBasal = apsManager.isManualTempBasal
+        tempBasals = insulinFromPersistence.filter({ $0.tempBasal != nil })
+
+        suspensions = insulinFromPersistence.filter {
+            $0.type == EventType.pumpSuspend.rawValue || $0.type == EventType.pumpResume.rawValue
+        }
+        let lastSuspension = suspensions.last
+
+        pumpSuspended = tempBasals.last?.timestamp ?? Date() > lastSuspension?.timestamp ?? .distantPast && lastSuspension?
+            .type == EventType.pumpSuspend.rawValue
+    }
+
+    // Setup Last Bolus to display the bolus progress bar
+    // The predicate filters out all external boluses to prevent the progress bar from displaying the amount of an external bolus when an external bolus is added after a pump bolus
+    func setupLastBolus() {
+        Task {
+            guard let id = await self.fetchLastBolus() else { return }
+            await updateLastBolus(with: id)
+        }
+    }
+
+    func fetchLastBolus() async -> NSManagedObjectID? {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: PumpEventStored.self,
+            onContext: pumpHistoryFetchContext,
+            predicate: NSPredicate.lastPumpBolus,
+            key: "timestamp",
+            ascending: false,
+            fetchLimit: 1
+        )
+
+        return await pumpHistoryFetchContext.perform {
+            guard let fetchedResults = results as? [PumpEventStored] else { return [].first }
+
+            return fetchedResults.map(\.objectID).first
+        }
+    }
+
+    @MainActor private func updateLastBolus(with ID: NSManagedObjectID) {
+        do {
+            lastPumpBolus = try viewContext.existingObject(with: ID) as? PumpEventStored
+        } catch {
+            debugPrint(
+                "Home State: \(#function) \(DebuggingIdentifiers.failed) error while updating the insulin array: \(error.localizedDescription)"
+            )
+        }
+    }
+}

+ 158 - 562
FreeAPS/Sources/Modules/Home/HomeStateModel.swift

@@ -2,93 +2,112 @@ import Combine
 import CoreData
 import Foundation
 import LoopKitUI
+import Observation
 import SwiftDate
 import SwiftUI
 
 extension Home {
-    final class StateModel: BaseStateModel<Provider> {
-        @Injected() var broadcaster: Broadcaster!
-        @Injected() var apsManager: APSManager!
-        @Injected() var fetchGlucoseManager: FetchGlucoseManager!
-        @Injected() var nightscoutManager: NightscoutManager!
-        @Injected() var determinationStorage: DeterminationStorage!
-        @Injected() var glucoseStorage: GlucoseStorage!
-        @Injected() var carbsStorage: CarbsStorage!
+    @Observable final class StateModel: BaseStateModel<Provider> {
+        @ObservationIgnored @Injected() var broadcaster: Broadcaster!
+        @ObservationIgnored @Injected() var apsManager: APSManager!
+        @ObservationIgnored @Injected() var fetchGlucoseManager: FetchGlucoseManager!
+        @ObservationIgnored @Injected() var nightscoutManager: NightscoutManager!
+        @ObservationIgnored @Injected() var determinationStorage: DeterminationStorage!
+        @ObservationIgnored @Injected() var glucoseStorage: GlucoseStorage!
+        @ObservationIgnored @Injected() var carbsStorage: CarbsStorage!
         private let timer = DispatchTimer(timeInterval: 5)
         private(set) var filteredHours = 24
-        @Published var manualGlucose: [BloodGlucose] = []
-        @Published var announcement: [Announcement] = []
-        @Published var uploadStats = false
-        @Published var recentGlucose: BloodGlucose?
-        @Published var maxBasal: Decimal = 2
-        @Published var autotunedBasalProfile: [BasalProfileEntry] = []
-        @Published var basalProfile: [BasalProfileEntry] = []
-        @Published var tempTargets: [TempTarget] = []
-        @Published var timerDate = Date()
-        @Published var closedLoop = false
-        @Published var pumpSuspended = false
-        @Published var isLooping = false
-        @Published var statusTitle = ""
-        @Published var lastLoopDate: Date = .distantPast
-        @Published var battery: Battery?
-        @Published var reservoir: Decimal?
-        @Published var pumpName = ""
-        @Published var pumpExpiresAtDate: Date?
-        @Published var tempTarget: TempTarget?
-        @Published var setupPump = false
-        @Published var errorMessage: String? = nil
-        @Published var errorDate: Date? = nil
-        @Published var bolusProgress: Decimal?
-        @Published var eventualBG: Int?
-        @Published var allowManualTemp = false
-        @Published var units: GlucoseUnits = .mgdL
-        @Published var pumpDisplayState: PumpDisplayState?
-        @Published var alarm: GlucoseAlarm?
-        @Published var manualTempBasal = false
-        @Published var isSmoothingEnabled = false
-        @Published var maxValue: Decimal = 1.2
-        @Published var lowGlucose: Decimal = 70
-        @Published var highGlucose: Decimal = 180
-        @Published var overrideUnit: Bool = false
-        @Published var displayXgridLines: Bool = false
-        @Published var displayYgridLines: Bool = false
-        @Published var thresholdLines: Bool = false
-        @Published var timeZone: TimeZone?
-        @Published var hours: Int16 = 6
-        @Published var totalBolus: Decimal = 0
-        @Published var isStatusPopupPresented: Bool = false
-        @Published var isLegendPresented: Bool = false
-        @Published var legendSheetDetent = PresentationDetent.large
-        @Published var totalInsulinDisplayType: TotalInsulinDisplayType = .totalDailyDose
-        @Published var roundedTotalBolus: String = ""
-        @Published var selectedTab: Int = 0
-        @Published var waitForSuggestion: Bool = false
-        @Published var glucoseFromPersistence: [GlucoseStored] = []
-        @Published var latestTwoGlucoseValues: [GlucoseStored] = []
-        @Published var carbsFromPersistence: [CarbEntryStored] = []
-        @Published var fpusFromPersistence: [CarbEntryStored] = []
-        @Published var determinationsFromPersistence: [OrefDetermination] = []
-        @Published var enactedAndNonEnactedDeterminations: [OrefDetermination] = []
-        @Published var insulinFromPersistence: [PumpEventStored] = []
-        @Published var tempBasals: [PumpEventStored] = []
-        @Published var suspensions: [PumpEventStored] = []
-        @Published var batteryFromPersistence: [OpenAPS_Battery] = []
-        @Published var lastPumpBolus: PumpEventStored?
-        @Published var overrides: [OverrideStored] = []
-        @Published var overrideRunStored: [OverrideRunStored] = []
-        @Published var isOverrideCancelled: Bool = false
-        @Published var preprocessedData: [(id: UUID, forecast: Forecast, forecastValue: ForecastValue)] = []
-        @Published var pumpStatusHighlightMessage: String? = nil
-        @Published var cgmAvailable: Bool = false
-        @Published var showCarbsRequiredBadge: Bool = true
+        var manualGlucose: [BloodGlucose] = []
+        var announcement: [Announcement] = []
+        var uploadStats = false
+        var recentGlucose: BloodGlucose?
+        var maxBasal: Decimal = 2
+        var autotunedBasalProfile: [BasalProfileEntry] = []
+        var basalProfile: [BasalProfileEntry] = []
+        var tempTargets: [TempTarget] = []
+        var timerDate = Date()
+        var closedLoop = false
+        var pumpSuspended = false
+        var isLooping = false
+        var statusTitle = ""
+        var lastLoopDate: Date = .distantPast
+        var battery: Battery?
+        var reservoir: Decimal?
+        var pumpName = ""
+        var pumpExpiresAtDate: Date?
+        var tempTarget: TempTarget?
+        var setupPump = false
+        var errorMessage: String?
+        var errorDate: Date?
+        var bolusProgress: Decimal?
+        var eventualBG: Int?
+        var allowManualTemp = false
+        var units: GlucoseUnits = .mgdL
+        var pumpDisplayState: PumpDisplayState?
+        var alarm: GlucoseAlarm?
+        var manualTempBasal = false
+        var isSmoothingEnabled = false
+        var maxValue: Decimal = 1.2
+        var lowGlucose: Decimal = 70
+        var highGlucose: Decimal = 180
+        var currentGlucoseTarget: Decimal = 100
+        var glucoseColorScheme: GlucoseColorScheme = .staticColor
+        var overrideUnit: Bool = false
+        var displayXgridLines: Bool = false
+        var displayYgridLines: Bool = false
+        var thresholdLines: Bool = false
+        var timeZone: TimeZone?
+        var hours: Int16 = 6
+        var totalBolus: Decimal = 0
+        var isStatusPopupPresented: Bool = false
+        var isLegendPresented: Bool = false
+        var legendSheetDetent = PresentationDetent.large
+        var totalInsulinDisplayType: TotalInsulinDisplayType = .totalDailyDose
+        var roundedTotalBolus: String = ""
+        var selectedTab: Int = 0
+        var waitForSuggestion: Bool = false
+        var glucoseFromPersistence: [GlucoseStored] = []
+        var latestTwoGlucoseValues: [GlucoseStored] = []
+        var carbsFromPersistence: [CarbEntryStored] = []
+        var fpusFromPersistence: [CarbEntryStored] = []
+        var determinationsFromPersistence: [OrefDetermination] = []
+        var enactedAndNonEnactedDeterminations: [OrefDetermination] = []
+        var insulinFromPersistence: [PumpEventStored] = []
+        var tempBasals: [PumpEventStored] = []
+        var suspensions: [PumpEventStored] = []
+        var batteryFromPersistence: [OpenAPS_Battery] = []
+        var lastPumpBolus: PumpEventStored?
+        var overrides: [OverrideStored] = []
+        var overrideRunStored: [OverrideRunStored] = []
+        var isOverrideCancelled: Bool = false
+        var preprocessedData: [(id: UUID, forecast: Forecast, forecastValue: ForecastValue)] = []
+        var pumpStatusHighlightMessage: String?
+        var cgmAvailable: Bool = false
+        var showCarbsRequiredBadge: Bool = true
         private(set) var setupPumpType: PumpConfig.PumpType = .minimed
 
-        @Published var minForecast: [Int] = []
-        @Published var maxForecast: [Int] = []
-        @Published var minCount: Int = 12 // count of Forecasts drawn in 5 min distances, i.e. 12 means a min of 1 hour
-        @Published var forecastDisplayType: ForecastDisplayType = .cone
+        var minForecast: [Int] = []
+        var maxForecast: [Int] = []
+        var minCount: Int = 12 // count of Forecasts drawn in 5 min distances, i.e. 12 means a min of 1 hour
+        var forecastDisplayType: ForecastDisplayType = .cone
 
-        let context = CoreDataStack.shared.newTaskContext()
+        var minYAxisValue: Decimal = 39
+        var maxYAxisValue: Decimal = 300
+
+        var minValueCobChart: Decimal = 0
+        var maxValueCobChart: Decimal = 20
+
+        var minValueIobChart: Decimal = 0
+        var maxValueIobChart: Decimal = 5
+
+        let taskContext = CoreDataStack.shared.newTaskContext()
+        let glucoseFetchContext = CoreDataStack.shared.newTaskContext()
+        let carbsFetchContext = CoreDataStack.shared.newTaskContext()
+        let fpuFetchContext = CoreDataStack.shared.newTaskContext()
+        let determinationFetchContext = CoreDataStack.shared.newTaskContext()
+        let pumpHistoryFetchContext = CoreDataStack.shared.newTaskContext()
+        let overrideFetchContext = CoreDataStack.shared.newTaskContext()
+        let batteryFetchContext = CoreDataStack.shared.newTaskContext()
         let viewContext = CoreDataStack.shared.persistentContainer.viewContext
 
         private var coreDataPublisher: AnyPublisher<Set<NSManagedObject>, Never>?
@@ -103,6 +122,9 @@ extension Home {
                     .share()
                     .eraseToAnyPublisher()
 
+            registerSubscribers()
+            registerHandlers()
+
             // Parallelize Setup functions
             setupHomeViewConcurrently()
         }
@@ -111,12 +133,6 @@ extension Home {
             Task {
                 await withTaskGroup(of: Void.self) { group in
                     group.addTask {
-                        self.registerSubscribers()
-                    }
-                    group.addTask {
-                        self.registerHandlers()
-                    }
-                    group.addTask {
                         self.setupGlucoseArray()
                     }
                     group.addTask {
@@ -313,9 +329,10 @@ extension Home {
             manualTempBasal = apsManager.isManualTempBasal
             setupCurrentTempTarget()
             isSmoothingEnabled = settingsManager.settings.smoothGlucose
+            glucoseColorScheme = settingsManager.settings.glucoseColorScheme
             maxValue = settingsManager.preferences.autosensMax
-            lowGlucose = units == .mgdL ? settingsManager.settings.low : settingsManager.settings.low.asMmolL
-            highGlucose = units == .mgdL ? settingsManager.settings.high : settingsManager.settings.high.asMmolL
+            lowGlucose = settingsManager.settings.low
+            highGlucose = settingsManager.settings.high
             overrideUnit = settingsManager.settings.overrideHbA1cUnit
             displayXgridLines = settingsManager.settings.xGridLines
             displayYgridLines = settingsManager.settings.yGridLines
@@ -375,7 +392,7 @@ extension Home {
                 guard viewContext.hasChanges else { return }
                 try viewContext.save()
 
-                Foundation.NotificationCenter.default.post(name: .didUpdateOverrideConfiguration, object: nil)
+                Foundation.NotificationCenter.default.post(name: .willUpdateOverrideConfiguration, object: nil)
             } catch {
                 debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to cancel Profile")
             }
@@ -478,6 +495,54 @@ extension Home {
             }
         }
 
+        private func getCurrentGlucoseTarget() async {
+            let now = Date()
+            let calendar = Calendar.current
+            let dateFormatter = DateFormatter()
+            dateFormatter.dateFormat = "HH:mm"
+            dateFormatter.timeZone = TimeZone.current
+
+            let bgTargets = await provider.getBGTarget()
+            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 {
+                    await MainActor.run {
+                        currentGlucoseTarget = entry.value
+                    }
+                    return
+                }
+            }
+        }
+
         func openCGM() {
             router.mainSecondaryModalView.send(router.view(for: .cgmDirect))
         }
@@ -519,9 +584,13 @@ extension Home.StateModel:
         units = settingsManager.settings.units
         manualTempBasal = apsManager.isManualTempBasal
         isSmoothingEnabled = settingsManager.settings.smoothGlucose
-        lowGlucose = units == .mgdL ? settingsManager.settings.low : settingsManager.settings.low.asMmolL
-        highGlucose = units == .mgdL ? settingsManager.settings.high : settingsManager.settings.high.asMmolL
+        lowGlucose = settingsManager.settings.low
+        highGlucose = settingsManager.settings.high
+        Task {
+            await getCurrentGlucoseTarget()
+        }
         overrideUnit = settingsManager.settings.overrideHbA1cUnit
+        glucoseColorScheme = settingsManager.settings.glucoseColorScheme
         displayXgridLines = settingsManager.settings.xGridLines
         displayYgridLines = settingsManager.settings.yGridLines
         thresholdLines = settingsManager.settings.rulerMarks
@@ -588,476 +657,3 @@ extension Home.StateModel: PumpManagerOnboardingDelegate {
         // TODO:
     }
 }
-
-// MARK: - Handle Core Data changes and update Arrays to display them in the UI
-
-extension Home.StateModel {
-    // Setup Glucose
-    private func setupGlucoseArray() {
-        Task {
-            let ids = await self.fetchGlucose()
-            let glucoseObjects: [GlucoseStored] = await CoreDataStack.shared.getNSManagedObject(with: ids, context: viewContext)
-            await updateGlucoseArray(with: glucoseObjects)
-        }
-    }
-
-    private func fetchGlucose() async -> [NSManagedObjectID] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: GlucoseStored.self,
-            onContext: context,
-            predicate: NSPredicate.glucose,
-            key: "date",
-            ascending: true,
-            fetchLimit: 288
-        )
-
-        guard let fetchedResults = results as? [GlucoseStored] else { return [] }
-
-        return await context.perform {
-            return fetchedResults.map(\.objectID)
-        }
-    }
-
-    @MainActor private func updateGlucoseArray(with objects: [GlucoseStored]) {
-        glucoseFromPersistence = objects
-        latestTwoGlucoseValues = Array(objects.suffix(2))
-    }
-
-    // Setup Carbs
-    private func setupCarbsArray() {
-        Task {
-            let ids = await self.fetchCarbs()
-            let carbObjects: [CarbEntryStored] = await CoreDataStack.shared.getNSManagedObject(with: ids, context: viewContext)
-            await updateCarbsArray(with: carbObjects)
-        }
-    }
-
-    private func fetchCarbs() async -> [NSManagedObjectID] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: CarbEntryStored.self,
-            onContext: context,
-            predicate: NSPredicate.carbsForChart,
-            key: "date",
-            ascending: false
-        )
-
-        guard let fetchedResults = results as? [CarbEntryStored] else { return [] }
-
-        return await context.perform {
-            return fetchedResults.map(\.objectID)
-        }
-    }
-
-    @MainActor private func updateCarbsArray(with objects: [CarbEntryStored]) {
-        carbsFromPersistence = objects
-    }
-
-    // Setup FPUs
-    private func setupFPUsArray() {
-        Task {
-            let ids = await self.fetchFPUs()
-            let fpuObjects: [CarbEntryStored] = await CoreDataStack.shared.getNSManagedObject(with: ids, context: viewContext)
-            await updateFPUsArray(with: fpuObjects)
-        }
-    }
-
-    private func fetchFPUs() async -> [NSManagedObjectID] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: CarbEntryStored.self,
-            onContext: context,
-            predicate: NSPredicate.fpusForChart,
-            key: "date",
-            ascending: false
-        )
-
-        guard let fetchedResults = results as? [CarbEntryStored] else { return [] }
-
-        return await context.perform {
-            return fetchedResults.map(\.objectID)
-        }
-    }
-
-    @MainActor private func updateFPUsArray(with objects: [CarbEntryStored]) {
-        fpusFromPersistence = objects
-    }
-
-    // Custom fetch to more efficiently filter only for cob and iob
-    private func fetchCobAndIob() async -> [NSManagedObjectID] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: OrefDetermination.self,
-            onContext: context,
-            predicate: NSPredicate.determinationsForCobIobCharts,
-            key: "deliverAt",
-            ascending: false,
-            batchSize: 50,
-            propertiesToFetch: ["cob", "iob", "objectID"]
-        )
-
-        guard let fetchedResults = results as? [[String: Any]] else {
-            return []
-        }
-
-        return await context.perform {
-            return fetchedResults.compactMap { $0["objectID"] as? NSManagedObjectID }
-        }
-    }
-
-    // Setup Determinations
-    private func setupDeterminationsArray() {
-        Task {
-            // Get the NSManagedObjectIDs
-            async let enactedObjectIDs = determinationStorage
-                .fetchLastDeterminationObjectID(predicate: NSPredicate.enactedDetermination)
-            async let enactedAndNonEnactedObjectIDs = fetchCobAndIob()
-
-            let enactedIDs = await enactedObjectIDs
-            let enactedAndNonEnactedIDs = await enactedAndNonEnactedObjectIDs
-
-            // Get the NSManagedObjects and return them on the Main Thread
-            await updateDeterminationsArray(with: enactedIDs, keyPath: \.determinationsFromPersistence)
-            await updateDeterminationsArray(with: enactedAndNonEnactedIDs, keyPath: \.enactedAndNonEnactedDeterminations)
-
-            await updateForecastData()
-        }
-    }
-
-    @MainActor private func updateDeterminationsArray(
-        with IDs: [NSManagedObjectID],
-        keyPath: ReferenceWritableKeyPath<Home.StateModel, [OrefDetermination]>
-    ) async {
-        // Fetch the objects off the main thread
-        let determinationObjects: [OrefDetermination] = await CoreDataStack.shared
-            .getNSManagedObject(with: IDs, context: viewContext)
-
-        // Update the array on the main thread
-        self[keyPath: keyPath] = determinationObjects
-    }
-
-    // Setup Insulin
-    private func setupInsulinArray() {
-        Task {
-            let ids = await self.fetchInsulin()
-            let insulinObjects: [PumpEventStored] = await CoreDataStack.shared.getNSManagedObject(with: ids, context: viewContext)
-            await updateInsulinArray(with: insulinObjects)
-        }
-    }
-
-    private func fetchInsulin() async -> [NSManagedObjectID] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: PumpEventStored.self,
-            onContext: context,
-            predicate: NSPredicate.pumpHistoryLast24h,
-            key: "timestamp",
-            ascending: true
-        )
-
-        guard let pumpEvents = results as? [PumpEventStored] else {
-            return []
-        }
-
-        return await context.perform {
-            return pumpEvents.map(\.objectID)
-        }
-    }
-
-    @MainActor private func updateInsulinArray(with insulinObjects: [PumpEventStored]) {
-        insulinFromPersistence = insulinObjects
-
-        // Filter tempbasals
-        manualTempBasal = apsManager.isManualTempBasal
-        tempBasals = insulinFromPersistence.filter({ $0.tempBasal != nil })
-
-        // Suspension and resume events
-        suspensions = insulinFromPersistence.filter {
-            $0.type == EventType.pumpSuspend.rawValue || $0.type == EventType.pumpResume.rawValue
-        }
-        let lastSuspension = suspensions.last
-
-        pumpSuspended = tempBasals.last?.timestamp ?? Date() > lastSuspension?.timestamp ?? .distantPast && lastSuspension?
-            .type == EventType.pumpSuspend.rawValue
-    }
-
-    // Setup Last Bolus to display the bolus progress bar
-    // The predicate filters out all external boluses to prevent the progress bar from displaying the amount of an external bolus when an external bolus is added after a pump bolus
-    private func setupLastBolus() {
-        Task {
-            guard let id = await self.fetchLastBolus() else { return }
-            await updateLastBolus(with: id)
-        }
-    }
-
-    private func fetchLastBolus() async -> NSManagedObjectID? {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: PumpEventStored.self,
-            onContext: context,
-            predicate: NSPredicate.lastPumpBolus,
-            key: "timestamp",
-            ascending: false,
-            fetchLimit: 1
-        )
-
-        guard let fetchedResults = results as? [PumpEventStored] else { return [].first }
-
-        return await context.perform {
-            return fetchedResults.map(\.objectID).first
-        }
-    }
-
-    @MainActor private func updateLastBolus(with ID: NSManagedObjectID) {
-        do {
-            lastPumpBolus = try viewContext.existingObject(with: ID) as? PumpEventStored
-        } catch {
-            debugPrint(
-                "Home State: \(#function) \(DebuggingIdentifiers.failed) error while updating the insulin array: \(error.localizedDescription)"
-            )
-        }
-    }
-
-    // Setup Battery
-    private func setupBatteryArray() {
-        Task {
-            let ids = await self.fetchBattery()
-            let batteryObjects: [OpenAPS_Battery] = await CoreDataStack.shared.getNSManagedObject(with: ids, context: viewContext)
-            await updateBatteryArray(with: batteryObjects)
-        }
-    }
-
-    private func fetchBattery() async -> [NSManagedObjectID] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: OpenAPS_Battery.self,
-            onContext: context,
-            predicate: NSPredicate.predicateFor30MinAgo,
-            key: "date",
-            ascending: false
-        )
-
-        guard let fetchedResults = results as? [OpenAPS_Battery] else { return [] }
-
-        return await context.perform {
-            return fetchedResults.map(\.objectID)
-        }
-    }
-
-    @MainActor private func updateBatteryArray(with objects: [OpenAPS_Battery]) {
-        batteryFromPersistence = objects
-    }
-}
-
-extension Home.StateModel {
-    // Setup Overrides
-    private func setupOverrides() {
-        Task {
-            let ids = await self.fetchOverrides()
-            let overrideObjects: [OverrideStored] = await CoreDataStack.shared.getNSManagedObject(with: ids, context: viewContext)
-            await updateOverrideArray(with: overrideObjects)
-        }
-    }
-
-    private func fetchOverrides() async -> [NSManagedObjectID] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: OverrideStored.self,
-            onContext: context,
-            predicate: NSPredicate.lastActiveOverride, // this predicate filters for all Overrides within the last 24h
-            key: "date",
-            ascending: false
-        )
-
-        guard let fetchedResults = results as? [OverrideStored] else { return [] }
-
-        return await context.perform {
-            return fetchedResults.map(\.objectID)
-        }
-    }
-
-    @MainActor private func updateOverrideArray(with objects: [OverrideStored]) {
-        overrides = objects
-    }
-
-    @MainActor func calculateDuration(override: OverrideStored) -> TimeInterval {
-        guard let overrideDuration = override.duration as? Double, overrideDuration != 0 else {
-            return TimeInterval(60 * 60 * 24) // one day
-        }
-        return TimeInterval(overrideDuration * 60) // return seconds
-    }
-
-    @MainActor func calculateTarget(override: OverrideStored) -> Decimal {
-        guard let overrideTarget = override.target, overrideTarget != 0 else {
-            return 100 // default
-        }
-        return overrideTarget.decimalValue
-    }
-
-    // Setup expired Overrides
-    private func setupOverrideRunStored() {
-        Task {
-            let ids = await self.fetchOverrideRunStored()
-            let overrideRunObjects: [OverrideRunStored] = await CoreDataStack.shared
-                .getNSManagedObject(with: ids, context: viewContext)
-            await updateOverrideRunStoredArray(with: overrideRunObjects)
-        }
-    }
-
-    private func fetchOverrideRunStored() async -> [NSManagedObjectID] {
-        let predicate = NSPredicate(format: "startDate >= %@", Date.oneDayAgo as NSDate)
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: OverrideRunStored.self,
-            onContext: context,
-            predicate: predicate,
-            key: "startDate",
-            ascending: false
-        )
-
-        guard let fetchedResults = results as? [OverrideRunStored] else { return [] }
-
-        return await context.perform {
-            return fetchedResults.map(\.objectID)
-        }
-    }
-
-    @MainActor private func updateOverrideRunStoredArray(with objects: [OverrideRunStored]) {
-        overrideRunStored = objects
-    }
-
-    @MainActor func saveToOverrideRunStored(withID id: NSManagedObjectID) async {
-        await viewContext.perform {
-            do {
-                guard let object = try self.viewContext.existingObject(with: id) as? OverrideStored else { return }
-
-                let newOverrideRunStored = OverrideRunStored(context: self.viewContext)
-                newOverrideRunStored.id = UUID()
-                newOverrideRunStored.name = object.name
-                newOverrideRunStored.startDate = object.date ?? .distantPast
-                newOverrideRunStored.endDate = Date()
-                newOverrideRunStored.target = NSDecimalNumber(decimal: self.calculateTarget(override: object))
-                newOverrideRunStored.override = object
-                newOverrideRunStored.isUploadedToNS = false
-
-            } catch {
-                debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to initialize a new Override Run Object")
-            }
-        }
-    }
-}
-
-extension Home.StateModel {
-    // Asynchronously preprocess forecast data in a background thread
-    func preprocessForecastData() async -> [(id: UUID, forecastID: NSManagedObjectID, forecastValueIDs: [NSManagedObjectID])] {
-        await Task.detached { [self] () -> [(id: UUID, forecastID: NSManagedObjectID, forecastValueIDs: [NSManagedObjectID])] in
-            // Get the first determination ID from persistence
-            guard let id = enactedAndNonEnactedDeterminations.first?.objectID else {
-                return []
-            }
-
-            // Get the forecast IDs for the determination ID
-            let forecastIDs = await determinationStorage.getForecastIDs(for: id, in: context)
-            var result: [(id: UUID, forecastID: NSManagedObjectID, forecastValueIDs: [NSManagedObjectID])] = []
-
-            // Use a task group to fetch forecast value IDs concurrently
-            await withTaskGroup(of: (UUID, NSManagedObjectID, [NSManagedObjectID]).self) { group in
-                for forecastID in forecastIDs {
-                    group.addTask {
-                        let forecastValueIDs = await self.determinationStorage.getForecastValueIDs(
-                            for: forecastID,
-                            in: self.context
-                        )
-                        return (UUID(), forecastID, forecastValueIDs)
-                    }
-                }
-
-                // Collect the results from the task group
-                for await (uuid, forecastID, forecastValueIDs) in group {
-                    result.append((id: uuid, forecastID: forecastID, forecastValueIDs: forecastValueIDs))
-                }
-            }
-
-            return result
-        }.value
-    }
-
-    // Fetch forecast values for a given data set
-    func fetchForecastValues(
-        for data: (id: UUID, forecastID: NSManagedObjectID, forecastValueIDs: [NSManagedObjectID]),
-        in context: NSManagedObjectContext
-    ) async -> (UUID, Forecast?, [ForecastValue]) {
-        var forecast: Forecast?
-        var forecastValues: [ForecastValue] = []
-
-        do {
-            try await context.perform {
-                // Fetch the forecast object
-                forecast = try context.existingObject(with: data.forecastID) as? Forecast
-
-                // Fetch the first 3h of forecast values
-                for forecastValueID in data.forecastValueIDs.prefix(36) {
-                    if let forecastValue = try context.existingObject(with: forecastValueID) as? ForecastValue {
-                        forecastValues.append(forecastValue)
-                    }
-                }
-            }
-        } catch {
-            debugPrint(
-                "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to fetch forecast Values with error: \(error.localizedDescription)"
-            )
-        }
-
-        return (data.id, forecast, forecastValues)
-    }
-
-    // Update forecast data and UI on the main thread
-    @MainActor func updateForecastData() async {
-        // Preprocess forecast data on a background thread
-        let forecastData = await preprocessForecastData()
-
-        var allForecastValues = [[ForecastValue]]()
-        var preprocessedData = [(id: UUID, forecast: Forecast, forecastValue: ForecastValue)]()
-
-        // Use a task group to fetch forecast values concurrently
-        await withTaskGroup(of: (UUID, Forecast?, [ForecastValue]).self) { group in
-            for data in forecastData {
-                group.addTask {
-                    await self.fetchForecastValues(for: data, in: self.viewContext)
-                }
-            }
-
-            // Collect the results from the task group
-            for await (id, forecast, forecastValues) in group {
-                guard let forecast = forecast, !forecastValues.isEmpty else { continue }
-
-                allForecastValues.append(forecastValues)
-                preprocessedData.append(contentsOf: forecastValues.map { (id: id, forecast: forecast, forecastValue: $0) })
-            }
-        }
-
-        self.preprocessedData = preprocessedData
-
-        // Ensure there are forecast values to process
-        guard !allForecastValues.isEmpty else {
-            minForecast = []
-            maxForecast = []
-            return
-        }
-
-        minCount = max(12, allForecastValues.map(\.count).min() ?? 0)
-        guard minCount > 0 else { return }
-
-        // Copy allForecastValues to a local constant for thread safety
-        let localAllForecastValues = allForecastValues
-
-        // Calculate min and max forecast values in a background task
-        let (minResult, maxResult) = await Task.detached {
-            let minForecast = (0 ..< self.minCount).map { index in
-                localAllForecastValues.compactMap { $0.indices.contains(index) ? Int($0[index].value) : nil }.min() ?? 0
-            }
-
-            let maxForecast = (0 ..< self.minCount).map { index in
-                localAllForecastValues.compactMap { $0.indices.contains(index) ? Int($0[index].value) : nil }.max() ?? 0
-            }
-
-            return (minForecast, maxForecast)
-        }.value
-
-        // Update the properties on the main thread
-        minForecast = minResult
-        maxForecast = maxResult
-    }
-}

+ 4 - 4
FreeAPS/Sources/Modules/Home/View/Chart/BasalChart.swift

@@ -25,17 +25,17 @@ extension MainChartView {
                 drawTempBasals(dummy: false)
                 drawBasalProfile()
                 drawSuspensions()
-            }.onChange(of: state.tempBasals) { _ in
+            }.onChange(of: state.tempBasals) {
                 calculateBasals()
                 calculateTempBasalsInBackground()
             }
-            .onChange(of: state.maxBasal) { _ in
+            .onChange(of: state.maxBasal) {
                 calculateBasals()
             }
-            .onChange(of: state.autotunedBasalProfile) { _ in
+            .onChange(of: state.autotunedBasalProfile) {
                 calculateBasals()
             }
-            .onChange(of: state.basalProfile) { _ in
+            .onChange(of: state.basalProfile) {
                 calculateBasals()
             }
             .frame(minHeight: geo.size.height * 0.05)

+ 1 - 1
FreeAPS/Sources/Modules/Home/View/Chart/CobChart.swift

@@ -32,7 +32,7 @@ extension MainChartView {
         .backport.chartXSelection(value: $selection)
         .chartXAxis { basalChartXAxis }
         .chartYAxis { cobChartYAxis }
-        .chartYScale(domain: minValueCobChart ... maxValueCobChart)
+        .chartYScale(domain: state.minValueCobChart ... state.maxValueCobChart)
     }
 
     func drawCOB(dummy: Bool) -> some ChartContent {

+ 30 - 4
FreeAPS/Sources/Modules/Home/View/Chart/DummyCharts.swift

@@ -7,10 +7,33 @@ extension MainChartView {
     var staticYAxisChart: some View {
         Chart {
             /// high and low threshold lines
+
+            // 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 = glucoseColorScheme == .dynamicColor
+
             if thresholdLines {
-                RuleMark(y: .value("High", highGlucose)).foregroundStyle(Color.loopYellow)
+                let highColor = FreeAPS.getDynamicGlucoseColor(
+                    glucoseValue: highGlucose,
+                    highGlucoseColorValue: isDynamicColorScheme ? hardCodedHigh : highGlucose,
+                    lowGlucoseColorValue: isDynamicColorScheme ? hardCodedLow : lowGlucose,
+                    targetGlucose: currentGlucoseTarget,
+                    glucoseColorScheme: glucoseColorScheme
+                )
+                let lowColor = FreeAPS.getDynamicGlucoseColor(
+                    glucoseValue: lowGlucose,
+                    highGlucoseColorValue: isDynamicColorScheme ? hardCodedHigh : highGlucose,
+                    lowGlucoseColorValue: isDynamicColorScheme ? hardCodedLow : lowGlucose,
+                    targetGlucose: currentGlucoseTarget,
+                    glucoseColorScheme: glucoseColorScheme
+                )
+
+                RuleMark(y: .value("High", units == .mgdL ? highGlucose : highGlucose.asMmolL))
+                    .foregroundStyle(highColor)
                     .lineStyle(.init(lineWidth: 1, dash: [5]))
-                RuleMark(y: .value("Low", lowGlucose)).foregroundStyle(Color.loopRed)
+                RuleMark(y: .value("Low", units == .mgdL ? lowGlucose : lowGlucose.asMmolL))
+                    .foregroundStyle(lowColor)
                     .lineStyle(.init(lineWidth: 1, dash: [5]))
             }
         }
@@ -21,7 +44,10 @@ extension MainChartView {
         .chartXScale(domain: startMarker ... endMarker)
         .chartXAxis(.hidden)
         .chartYAxis { mainChartYAxis }
-        .chartYScale(domain: units == .mgdL ? minValue ... maxValue : minValue.asMmolL ... maxValue.asMmolL)
+        .chartYScale(
+            domain: units == .mgdL ? state.minYAxisValue ... state.maxYAxisValue : state.minYAxisValue.asMmolL ... state
+                .maxYAxisValue.asMmolL
+        )
         .chartLegend(.hidden)
     }
 
@@ -48,7 +74,7 @@ extension MainChartView {
         .chartXAxis(.hidden)
         .chartYAxis { cobChartYAxis }
         .chartYAxis(.hidden)
-        .chartYScale(domain: minValueCobChart ... maxValueCobChart)
+        .chartYScale(domain: state.minValueCobChart ... state.maxValueCobChart)
         .chartLegend(.hidden)
     }
 }

+ 15 - 3
FreeAPS/Sources/Modules/Home/View/Chart/GlucoseChartView.swift

@@ -7,7 +7,9 @@ struct GlucoseChartView: ChartContent {
     let units: GlucoseUnits
     let highGlucose: Decimal
     let lowGlucose: Decimal
+    let currentGlucoseTarget: Decimal
     let isSmoothingEnabled: Bool
+    let glucoseColorScheme: GlucoseColorScheme
 
     var body: some ChartContent {
         drawGlucoseChart()
@@ -16,9 +18,19 @@ struct GlucoseChartView: ChartContent {
     private func drawGlucoseChart() -> some ChartContent {
         ForEach(glucoseData) { item in
             let glucoseToDisplay = units == .mgdL ? Decimal(item.glucose) : Decimal(item.glucose).asMmolL
-            let pointMarkColor: Color = glucoseToDisplay > highGlucose ? Color.orange :
-                glucoseToDisplay < lowGlucose ? Color.red :
-                Color.green
+
+            // 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 = glucoseColorScheme == .dynamicColor
+
+            let pointMarkColor: Color = FreeAPS.getDynamicGlucoseColor(
+                glucoseValue: Decimal(item.glucose),
+                highGlucoseColorValue: isDynamicColorScheme ? hardCodedHigh : highGlucose,
+                lowGlucoseColorValue: isDynamicColorScheme ? hardCodedLow : lowGlucose,
+                targetGlucose: currentGlucoseTarget,
+                glucoseColorScheme: glucoseColorScheme
+            )
 
             if !isSmoothingEnabled {
                 PointMark(

+ 1 - 1
FreeAPS/Sources/Modules/Home/View/Chart/IobChart.swift

@@ -32,7 +32,7 @@ extension MainChartView {
             .backport.chartXSelection(value: $selection)
             .chartXAxis { basalChartXAxis }
             .chartYAxis { cobChartYAxis }
-            .chartYScale(domain: minValueIobChart ... maxValueIobChart)
+            .chartYScale(domain: state.minValueIobChart ... state.maxValueIobChart)
             .chartYAxis(.hidden)
         }
     }

+ 47 - 120
FreeAPS/Sources/Modules/Home/View/Chart/MainChartView.swift

@@ -7,17 +7,18 @@ let calendar = Calendar.current
 
 struct MainChartView: View {
     var geo: GeometryProxy
-    @Binding var units: GlucoseUnits
-    @Binding var hours: Int
-    @Binding var tempTargets: [TempTarget]
-    @Binding var highGlucose: Decimal
-    @Binding var lowGlucose: Decimal
-    @Binding var screenHours: Int16
-    @Binding var displayXgridLines: Bool
-    @Binding var displayYgridLines: Bool
-    @Binding var thresholdLines: Bool
-
-    @StateObject var state: Home.StateModel
+    var units: GlucoseUnits
+    var hours: Int
+    var tempTargets: [TempTarget]
+    var highGlucose: Decimal
+    var lowGlucose: Decimal
+    var currentGlucoseTarget: Decimal
+    var glucoseColorScheme: GlucoseColorScheme
+    var screenHours: Int16
+    var displayXgridLines: Bool
+    var displayYgridLines: Bool
+    var thresholdLines: Bool
+    var state: Home.StateModel
 
     @State var basalProfiles: [BasalProfile] = []
     @State var preparedTempBasals: [(start: Date, end: Date, rate: Double)] = []
@@ -25,13 +26,9 @@ struct MainChartView: View {
     @State var startMarker =
         Date(timeIntervalSinceNow: TimeInterval(hours: -24))
     @State var endMarker = Date(timeIntervalSinceNow: TimeInterval(hours: 3))
-    @State var minValue: Decimal = 39
-    @State var maxValue: Decimal = 300
+
     @State var selection: Date? = nil
-    @State var minValueCobChart: Decimal = 0
-    @State var maxValueCobChart: Decimal = 20
-    @State var minValueIobChart: Decimal = 0
-    @State var maxValueIobChart: Decimal = 5
+
     @State var mainChartHasInitialized = false
 
     let now = Date.now
@@ -100,33 +97,26 @@ struct MainChartView: View {
                                 iobChart
                             }
 
-                        }.onChange(of: screenHours) { _ in
+                        }.onChange(of: screenHours) {
                             scroller.scrollTo("MainChart", anchor: .trailing)
                         }
-                        .onChange(of: state.glucoseFromPersistence.last?.glucose) { _ in
-                            updateStartEndMarkers()
-                            yAxisChartData()
+                        .onChange(of: state.glucoseFromPersistence.last?.glucose) {
                             scroller.scrollTo("MainChart", anchor: .trailing)
+                            updateStartEndMarkers()
                         }
-                        .onChange(of: state.enactedAndNonEnactedDeterminations.first?.deliverAt) { _ in
-                            yAxisChartDataCobChart()
-                            yAxisChartDataIobChart()
+                        .onChange(of: state.enactedAndNonEnactedDeterminations.first?.deliverAt) {
                             scroller.scrollTo("MainChart", anchor: .trailing)
                         }
-                        .onChange(of: units) { _ in
-                            yAxisChartData()
-                            yAxisChartDataCobChart()
-                            yAxisChartDataIobChart()
+                        .onChange(of: units) {
+                            // TODO: - Refactor this to only update the Y Axis Scale
+                            state.setupGlucoseArray()
                         }
                         .onAppear {
                             if !mainChartHasInitialized {
+                                scroller.scrollTo("MainChart", anchor: .trailing)
                                 updateStartEndMarkers()
-                                yAxisChartData()
-                                yAxisChartDataCobChart()
-                                yAxisChartDataIobChart()
                                 calculateTempBasalsInBackground()
                                 mainChartHasInitialized = true
-                                scroller.scrollTo("MainChart", anchor: .trailing)
                             }
                         }
                     }
@@ -152,7 +142,9 @@ extension MainChartView {
                     units: state.units,
                     highGlucose: state.highGlucose,
                     lowGlucose: state.lowGlucose,
-                    isSmoothingEnabled: state.isSmoothingEnabled
+                    currentGlucoseTarget: state.currentGlucoseTarget,
+                    isSmoothingEnabled: state.isSmoothingEnabled,
+                    glucoseColorScheme: state.glucoseColorScheme
                 )
 
                 InsulinView(
@@ -166,7 +158,7 @@ extension MainChartView {
                     units: state.units,
                     carbData: state.carbsFromPersistence,
                     fpuData: state.fpusFromPersistence,
-                    minValue: minValue
+                    minValue: state.minYAxisValue
                 )
 
                 OverrideView(
@@ -181,7 +173,7 @@ extension MainChartView {
                     minForecast: state.minForecast,
                     maxForecast: state.maxForecast,
                     units: state.units,
-                    maxValue: maxValue,
+                    maxValue: state.maxYAxisValue,
                     forecastDisplayType: state.forecastDisplayType
                 )
 
@@ -226,10 +218,10 @@ extension MainChartView {
                 }
             }
             .id("MainChart")
-            .onChange(of: state.insulinFromPersistence) { _ in
+            .onChange(of: state.insulinFromPersistence) {
                 state.roundedTotalBolus = state.calculateTINS()
             }
-            .onChange(of: tempTargets) { _ in
+            .onChange(of: tempTargets) {
                 Task {
                     await calculateTempTargets()
                 }
@@ -241,14 +233,16 @@ extension MainChartView {
             .chartYAxis { mainChartYAxis }
             .chartYAxis(.hidden)
             .backport.chartXSelection(value: $selection)
-            .chartYScale(domain: units == .mgdL ? minValue ... maxValue : minValue.asMmolL ... maxValue.asMmolL)
+            .chartYScale(
+                domain: units == .mgdL ? state.minYAxisValue ... state.maxYAxisValue : state.minYAxisValue
+                    .asMmolL ... state.maxYAxisValue.asMmolL
+            )
             .backport.chartForegroundStyleScale(state: state)
         }
     }
 
     @ViewBuilder var selectionPopover: some View {
         if let sgv = selectedGlucose?.glucose {
-            let glucoseToShow = units == .mgdL ? Decimal(sgv) : Decimal(sgv).asMmolL
             VStack(alignment: .leading) {
                 HStack {
                     Image(systemName: "clock")
@@ -256,13 +250,24 @@ extension MainChartView {
                         .font(.body).bold()
                 }.font(.body).padding(.bottom, 5)
 
+                // 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 = glucoseColorScheme == .dynamicColor
+
+                let glucoseColor = FreeAPS.getDynamicGlucoseColor(
+                    glucoseValue: Decimal(sgv),
+                    highGlucoseColorValue: isDynamicColorScheme ? hardCodedHigh : highGlucose,
+                    lowGlucoseColorValue: isDynamicColorScheme ? hardCodedLow : lowGlucose,
+                    targetGlucose: currentGlucoseTarget,
+                    glucoseColorScheme: glucoseColorScheme
+                )
                 HStack {
-                    Text(units == .mgdL ? glucoseToShow.description : Decimal(sgv).formattedAsMmolL)
+                    Text(units == .mgdL ? Decimal(sgv).description : Decimal(sgv).formattedAsMmolL)
                         .bold()
                         + Text(" \(units.rawValue)")
                 }.foregroundStyle(
-                    glucoseToShow < lowGlucose ? Color
-                        .red : (glucoseToShow > highGlucose ? Color.orange : Color.primary)
+                    Color(glucoseColor)
                 ).font(.body)
 
                 if let selectedIOBValue, let iob = selectedIOBValue.iob {
@@ -411,84 +416,6 @@ extension MainChartView {
             .forecastDisplayType == .lines ? threeHourSinceNow : dynamicFutureDateForCone <= threeHourSinceNow ?
             dynamicFutureDateForCone.addingTimeInterval(TimeInterval(minutes: 30)) : threeHourSinceNow
     }
-
-    private func yAxisChartData() {
-        Task {
-            let (minGlucose, maxGlucose, minForecast, maxForecast) = await Task
-                .detached { () -> (Decimal?, Decimal?, Decimal?, Decimal?) in
-                    let glucoseMapped = await state.glucoseFromPersistence.map { Decimal($0.glucose) }
-                    let forecastValues = await state.preprocessedData.map { Decimal($0.forecastValue.value) }
-
-                    // Calculate min and max values for glucose and forecast
-                    return (glucoseMapped.min(), glucoseMapped.max(), forecastValues.min(), forecastValues.max())
-                }.value
-
-            // Ensure all values exist, otherwise set default values
-            guard let minGlucose = minGlucose, let maxGlucose = maxGlucose,
-                  let minForecast = minForecast, let maxForecast = maxForecast
-            else {
-                await updateChartBounds(minValue: 39, maxValue: 300)
-                return
-            }
-
-            // Adjust max forecast to be no more than 100 over max glucose
-            let adjustedMaxForecast = min(maxForecast, maxGlucose + 100)
-            let minOverall = min(minGlucose, minForecast)
-            let maxOverall = max(maxGlucose, adjustedMaxForecast)
-
-            // Update the chart bounds on the main thread
-            await updateChartBounds(minValue: minOverall - 50, maxValue: maxOverall + 80)
-        }
-    }
-
-    @MainActor private func updateChartBounds(minValue: Decimal, maxValue: Decimal) async {
-        self.minValue = minValue
-        self.maxValue = maxValue
-    }
-
-    private func yAxisChartDataCobChart() {
-        Task {
-            let maxCob = await Task.detached { () -> Decimal? in
-                let cobMapped = await state.enactedAndNonEnactedDeterminations.map { Decimal($0.cob) }
-                return cobMapped.max()
-            }.value
-
-            // Ensure the result exists or set default values
-            if let maxCob = maxCob {
-                let calculatedMax = maxCob == 0 ? 20 : maxCob + 20
-                await updateCobChartBounds(minValue: 0, maxValue: calculatedMax)
-            } else {
-                await updateCobChartBounds(minValue: 0, maxValue: 20)
-            }
-        }
-    }
-
-    @MainActor private func updateCobChartBounds(minValue: Decimal, maxValue: Decimal) async {
-        minValueCobChart = minValue
-        maxValueCobChart = maxValue
-    }
-
-    private func yAxisChartDataIobChart() {
-        Task {
-            let (minIob, maxIob) = await Task.detached { () -> (Decimal?, Decimal?) in
-                let iobMapped = await state.enactedAndNonEnactedDeterminations.compactMap { $0.iob?.decimalValue }
-                return (iobMapped.min(), iobMapped.max())
-            }.value
-
-            // Ensure min and max IOB values exist, or set defaults
-            if let minIob = minIob, let maxIob = maxIob {
-                let adjustedMin = minIob < 0 ? minIob - 2 : 0
-                await updateIobChartBounds(minValue: adjustedMin, maxValue: maxIob + 2)
-            } else {
-                await updateIobChartBounds(minValue: 0, maxValue: 5)
-            }
-        }
-    }
-
-    @MainActor private func updateIobChartBounds(minValue: Decimal, maxValue: Decimal) async {
-        minValueIobChart = minValue
-        maxValueIobChart = maxValue
-    }
 }
 
 extension Int16 {

+ 35 - 45
FreeAPS/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift

@@ -2,14 +2,15 @@ import CoreData
 import SwiftUI
 
 struct CurrentGlucoseView: View {
-    @Binding var timerDate: Date
-    @Binding var units: GlucoseUnits
-    @Binding var alarm: GlucoseAlarm?
-    @Binding var lowGlucose: Decimal
-    @Binding var highGlucose: Decimal
-    @Binding var cgmAvailable: Bool
-
-    var glucose: [GlucoseStored] // This contains the last two glucose values, no matter if its manual or a cgm reading
+    let timerDate: Date
+    let units: GlucoseUnits
+    let alarm: GlucoseAlarm?
+    let lowGlucose: Decimal
+    let highGlucose: Decimal
+    let cgmAvailable: Bool
+    var currentGlucoseTarget: Decimal
+    let glucoseColorScheme: GlucoseColorScheme
+    let glucose: [GlucoseStored] // This contains the last two glucose values, no matter if its manual or a cgm reading
 
     @State private var rotationDegrees: Double = 0.0
     @State private var angularGradient = AngularGradient(colors: [
@@ -79,15 +80,33 @@ struct CurrentGlucoseView: View {
                         if let glucoseValue = glucose.last?.glucose {
                             let displayGlucose = units == .mgdL ? Decimal(glucoseValue).description : Decimal(glucoseValue)
                                 .formattedAsMmolL
-                            Text(
+
+                            var glucoseDisplayColor = Color.primary
+
+                            // 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 = glucoseColorScheme == .dynamicColor
+
+                            if Decimal(glucoseValue) <= lowGlucose || Decimal(glucoseValue) >= highGlucose {
+                                glucoseDisplayColor = FreeAPS.getDynamicGlucoseColor(
+                                    glucoseValue: Decimal(glucoseValue),
+                                    highGlucoseColorValue: isDynamicColorScheme ? hardCodedHigh : highGlucose,
+                                    lowGlucoseColorValue: isDynamicColorScheme ? hardCodedLow : lowGlucose,
+                                    targetGlucose: currentGlucoseTarget,
+                                    glucoseColorScheme: glucoseColorScheme
+                                )
+                            }
+
+                            return Text(
                                 glucoseValue == 400 ? "HIGH" : displayGlucose
                             )
                             .font(.system(size: 40, weight: .bold, design: .rounded))
-                            .foregroundColor(alarm == nil ? glucoseDisplayColor : .loopRed)
+                            .foregroundStyle(glucoseDisplayColor)
                         } else {
-                            Text("--")
+                            return Text("--")
                                 .font(.system(size: 40, weight: .bold, design: .rounded))
-                                .foregroundColor(.secondary)
+                                .foregroundStyle(.secondary)
                         }
                     }
                     HStack {
@@ -99,18 +118,18 @@ struct CurrentGlucoseView: View {
                                     NSLocalizedString("min", comment: "Short form for minutes") + " "
                             )
                         )
-                        .font(.caption2).foregroundColor(colorScheme == .dark ? Color.white.opacity(0.9) : Color.secondary)
+                        .font(.caption2).foregroundStyle(colorScheme == .dark ? Color.white.opacity(0.9) : Color.secondary)
 
                         Text(
                             delta
                         )
-                        .font(.caption2).foregroundColor(colorScheme == .dark ? Color.white.opacity(0.9) : Color.secondary)
+                        .font(.caption2).foregroundStyle(colorScheme == .dark ? Color.white.opacity(0.9) : Color.secondary)
                     }.frame(alignment: .top)
                 }
             }
-            .onChange(of: glucose.last?.directionEnum) { newDirection in
+            .onChange(of: glucose.last?.directionEnum) {
                 withAnimation {
-                    switch newDirection {
+                    switch glucose.last?.directionEnum {
                     case .doubleUp,
                          .singleUp,
                          .tripleUp:
@@ -159,35 +178,6 @@ struct CurrentGlucoseView: View {
         let deltaAsDecimal = units == .mmolL ? Decimal(delta).asMmolL : Decimal(delta)
         return deltaFormatter.string(from: deltaAsDecimal as NSNumber) ?? "--"
     }
-
-    var glucoseDisplayColor: Color {
-        guard let lastGlucose = glucose.last?.glucose else { return .primary }
-
-        // Convert the lastest glucose value to Int for comparison
-        let whichGlucose = Int(lastGlucose)
-
-        // Define default color based on the color scheme
-        let defaultColor: Color = colorScheme == .dark ? .white : .black
-
-        // low and high glucose is parsed in state to mmol/L; parse it back to mg/dl here for comparison
-        let lowGlucose = units == .mgdL ? lowGlucose : lowGlucose.asMgdL
-        let highGlucose = units == .mgdL ? highGlucose : highGlucose.asMgdL
-
-        // Ensure the thresholds are logical
-        guard lowGlucose < highGlucose else { return .primary }
-
-        // Perform range checks using Int converted values
-        switch whichGlucose {
-        case 0 ..< Int(lowGlucose):
-            return .loopRed
-        case Int(lowGlucose) ..< Int(highGlucose):
-            return defaultColor
-        case Int(highGlucose)...:
-            return .loopYellow
-        default:
-            return defaultColor
-        }
-    }
 }
 
 struct Triangle: Shape {

+ 6 - 6
FreeAPS/Sources/Modules/Home/View/Header/LoopView.swift

@@ -8,13 +8,13 @@ struct LoopView: View {
         static let lag: TimeInterval = 30
     }
 
-    @Binding var closedLoop: Bool
-    @Binding var timerDate: Date
-    @Binding var isLooping: Bool
-    @Binding var lastLoopDate: Date
-    @Binding var manualTempBasal: Bool
+    let closedLoop: Bool
+    let timerDate: Date
+    let isLooping: Bool
+    let lastLoopDate: Date
+    let manualTempBasal: Bool
 
-    var determination: [OrefDetermination]
+    let determination: [OrefDetermination]
 
     private var dateFormatter: DateFormatter {
         let formatter = DateFormatter()

+ 7 - 7
FreeAPS/Sources/Modules/Home/View/Header/PumpView.swift

@@ -2,13 +2,13 @@ import CoreData
 import SwiftUI
 
 struct PumpView: View {
-    @Binding var reservoir: Decimal?
-    @Binding var name: String
-    @Binding var expiresAtDate: Date?
-    @Binding var timerDate: Date
-    @Binding var timeZone: TimeZone?
-    @Binding var pumpStatusHighlightMessage: String?
-    @Binding var battery: [OpenAPS_Battery]
+    let reservoir: Decimal?
+    let name: String
+    let expiresAtDate: Date?
+    let timerDate: Date
+    let timeZone: TimeZone?
+    let pumpStatusHighlightMessage: String?
+    let battery: [OpenAPS_Battery]
 
     @Environment(\.colorScheme) var colorScheme
 

+ 38 - 33
FreeAPS/Sources/Modules/Home/View/HomeRootView.swift

@@ -8,7 +8,7 @@ extension Home {
     struct RootView: BaseView {
         let resolver: Resolver
 
-        @StateObject var state = StateModel()
+        @State var state = StateModel()
         @State var isStatusPopupPresented = false
         @State var showCancelAlert = false
         @State var isMenuPresented = false
@@ -299,12 +299,14 @@ extension Home {
 
         var glucoseView: some View {
             CurrentGlucoseView(
-                timerDate: $state.timerDate,
-                units: $state.units,
-                alarm: $state.alarm,
-                lowGlucose: $state.lowGlucose,
-                highGlucose: $state.highGlucose,
-                cgmAvailable: $state.cgmAvailable,
+                timerDate: state.timerDate,
+                units: state.units,
+                alarm: state.alarm,
+                lowGlucose: state.lowGlucose,
+                highGlucose: state.highGlucose,
+                cgmAvailable: state.cgmAvailable,
+                currentGlucoseTarget: state.currentGlucoseTarget,
+                glucoseColorScheme: state.glucoseColorScheme,
                 glucose: state.latestTwoGlucoseValues
             ).scaleEffect(0.9)
                 .onTapGesture {
@@ -319,13 +321,13 @@ extension Home {
 
         var pumpView: some View {
             PumpView(
-                reservoir: $state.reservoir,
-                name: $state.pumpName,
-                expiresAtDate: $state.pumpExpiresAtDate,
-                timerDate: $state.timerDate,
-                timeZone: $state.timeZone,
-                pumpStatusHighlightMessage: $state.pumpStatusHighlightMessage,
-                battery: $state.batteryFromPersistence
+                reservoir: state.reservoir,
+                name: state.pumpName,
+                expiresAtDate: state.pumpExpiresAtDate,
+                timerDate: state.timerDate,
+                timeZone: state.timeZone,
+                pumpStatusHighlightMessage: state.pumpStatusHighlightMessage,
+                battery: state.batteryFromPersistence
             ).onTapGesture {
                 if state.pumpDisplayState == nil {
                     // shows user confirmation dialog with pump model choices, then proceeds to setup
@@ -505,15 +507,17 @@ extension Home {
             ZStack {
                 MainChartView(
                     geo: geo,
-                    units: $state.units,
-                    hours: .constant(state.filteredHours),
-                    tempTargets: $state.tempTargets,
-                    highGlucose: $state.highGlucose,
-                    lowGlucose: $state.lowGlucose,
-                    screenHours: $state.hours,
-                    displayXgridLines: $state.displayXgridLines,
-                    displayYgridLines: $state.displayYgridLines,
-                    thresholdLines: $state.thresholdLines,
+                    units: state.units,
+                    hours: state.filteredHours,
+                    tempTargets: state.tempTargets,
+                    highGlucose: state.highGlucose,
+                    lowGlucose: state.lowGlucose,
+                    currentGlucoseTarget: state.currentGlucoseTarget,
+                    glucoseColorScheme: state.glucoseColorScheme,
+                    screenHours: state.hours,
+                    displayXgridLines: state.displayXgridLines,
+                    displayYgridLines: state.displayYgridLines,
+                    thresholdLines: state.thresholdLines,
                     state: state
                 )
             }
@@ -530,11 +534,11 @@ extension Home {
             VStack(alignment: .leading, spacing: 20) {
                 /// Loop view at bottomLeading
                 LoopView(
-                    closedLoop: $state.closedLoop,
-                    timerDate: $state.timerDate,
-                    isLooping: $state.isLooping,
-                    lastLoopDate: $state.lastLoopDate,
-                    manualTempBasal: $state.manualTempBasal,
+                    closedLoop: state.closedLoop,
+                    timerDate: state.timerDate,
+                    isLooping: state.isLooping,
+                    lastLoopDate: state.lastLoopDate,
+                    manualTempBasal: state.manualTempBasal,
                     determination: state.determinationsFromPersistence
                 ).onTapGesture {
                     state.isStatusPopupPresented = true
@@ -596,8 +600,9 @@ extension Home {
                         .foregroundColor(.loopYellow)
                     Text(
                         (
-                            numberFormatter
-                                .string(from: (state.enactedAndNonEnactedDeterminations.first?.cob ?? 0) as NSNumber) ?? "0"
+                            numberFormatter.string(
+                                from: NSNumber(value: state.enactedAndNonEnactedDeterminations.first?.cob ?? 0)
+                            ) ?? "0"
                         ) +
                             NSLocalizedString(" g", comment: "gram of carbs")
                     )
@@ -644,7 +649,7 @@ extension Home {
                                 NSLocalizedString(" U", comment: "Unit in number of units delivered (keep the space character!)")
                         )
                         .font(.system(size: 16, weight: .bold, design: .rounded))
-                        .onChange(of: state.hours) { _ in
+                        .onChange(of: state.hours) {
                             state.roundedTotalBolus = state.calculateTINS()
                         }
                         .onAppear {
@@ -967,7 +972,7 @@ extension Home {
                     }
                 }
             }
-            .onChange(of: state.hours) { _ in
+            .onChange(of: state.hours) {
                 highlightButtons()
             }
             .onAppear {
@@ -1138,7 +1143,7 @@ extension Home {
                     }
                 )
             }.ignoresSafeArea(.keyboard, edges: .bottom).blur(radius: state.waitForSuggestion ? 8 : 0)
-                .onChange(of: selectedTab) { _ in
+                .onChange(of: selectedTab) {
                     print("current path is empty: \(settingsPath.isEmpty)")
                     settingsPath = NavigationPath()
                 }

+ 9 - 8
FreeAPS/Sources/Modules/ISFEditor/ISFEditorStateModel.swift

@@ -1,18 +1,19 @@
 import CoreData
+import Observation
 import SwiftUI
 
 extension ISFEditor {
-    final class StateModel: BaseStateModel<Provider> {
-        @Injected() var determinationStorage: DeterminationStorage!
-        @Injected() private var nightscout: NightscoutManager!
+    @Observable final class StateModel: BaseStateModel<Provider> {
+        @ObservationIgnored @Injected() var determinationStorage: DeterminationStorage!
+        @ObservationIgnored @Injected() private var nightscout: NightscoutManager!
 
-        @Published var items: [Item] = []
-        @Published var initialItems: [Item] = []
-        @Published var shouldDisplaySaving: Bool = false
+        var items: [Item] = []
+        var initialItems: [Item] = []
+        var shouldDisplaySaving: Bool = false
         private(set) var autosensISF: Decimal?
         private(set) var autosensRatio: Decimal = 0
-        @Published var autotune: Autotune?
-        @Published var determinationsFromPersistence: [OrefDetermination] = []
+        var autotune: Autotune?
+        var determinationsFromPersistence: [OrefDetermination] = []
 
         let context = CoreDataStack.shared.newTaskContext()
         let viewContext = CoreDataStack.shared.persistentContainer.viewContext

+ 1 - 1
FreeAPS/Sources/Modules/ISFEditor/View/ISFEditorRootView.swift

@@ -4,7 +4,7 @@ import Swinject
 extension ISFEditor {
     struct RootView: BaseView {
         let resolver: Resolver
-        @StateObject var state = StateModel()
+        @State var state = StateModel()
         @State private var editMode = EditMode.inactive
 
         @Environment(\.colorScheme) var colorScheme

+ 0 - 3
FreeAPS/Sources/Modules/LiveActivitySettings/LiveActivitySettingsStateModel.swift

@@ -3,16 +3,13 @@ import SwiftUI
 
 extension LiveActivitySettings {
     final class StateModel: BaseStateModel<Provider> {
-        @Injected() var settings: SettingsManager!
         @Injected() var storage: FileStorage!
 
         @Published var units: GlucoseUnits = .mgdL
         @Published var useLiveActivity = false
         @Published var lockScreenView: LockScreenView = .simple
-
         override func subscribe() {
             units = settingsManager.settings.units
-
             subscribeSetting(\.useLiveActivity, on: $useLiveActivity) { useLiveActivity = $0 }
             subscribeSetting(\.lockScreenView, on: $lockScreenView) { lockScreenView = $0 }
         }

+ 12 - 0
FreeAPS/Sources/Modules/LiveActivitySettings/View/LiveActivitySettingsRootView.swift

@@ -115,6 +115,18 @@ extension LiveActivitySettings {
                                     ).buttonStyle(BorderlessButtonStyle())
                                 }.padding(.top)
                             }.padding(.bottom)
+
+                            if state.lockScreenView == .detailed {
+                                HStack {
+                                    NavigationLink(
+                                        "Widget Configuration",
+                                        destination: LiveActivityWidgetConfiguration(
+                                            resolver: resolver,
+                                            state: state
+                                        )
+                                    ).foregroundStyle(Color.accentColor)
+                                }
+                            }
                         }.listRowBackground(Color.chart)
                     }
                 }

+ 388 - 0
FreeAPS/Sources/Modules/LiveActivitySettings/View/LiveActivityWidgetConfiguration.swift

@@ -0,0 +1,388 @@
+import Charts
+import Foundation
+import SwiftUI
+import Swinject
+import UniformTypeIdentifiers
+
+struct LiveActivityWidgetConfiguration: BaseView {
+    let resolver: Resolver
+
+    @ObservedObject var state: LiveActivitySettings.StateModel
+
+    @State private var selectedItems: [LiveActivityItem?] = Array(repeating: nil, count: 4)
+    @State private var showAddItemDialog: Bool = false
+    @State private var buttonIndexToUpdate: Int?
+    @State private var itemToRemove: LiveActivityItem?
+    @State private var isRemovalConfirmationPresented: Bool = false
+    @State private var glucoseData: [DummyGlucoseData] = []
+
+    @Environment(\.colorScheme) var colorScheme
+    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
+
+    private var color: LinearGradient {
+        colorScheme == .dark ? LinearGradient(
+            gradient: Gradient(colors: [
+                Color.bgDarkBlue,
+                Color.bgDarkerDarkBlue
+            ]),
+            startPoint: .top,
+            endPoint: .bottom
+        )
+            :
+            LinearGradient(
+                gradient: Gradient(colors: [Color.gray.opacity(0.1)]),
+                startPoint: .top,
+                endPoint: .bottom
+            )
+    }
+
+    private func generateDummyGlucoseData() -> [DummyGlucoseData] {
+        var data = [DummyGlucoseData]()
+        let totalMinutes = 6 * 60
+        let interval = 5
+
+        var glucoseLevel: Double = 90 // Start at a normal fasting glucose level
+
+        for minute in stride(from: 0, to: totalMinutes, by: interval) {
+            let time = Double(minute) / 60.0 // Convert minutes to hours
+
+            let trendFactor: Double
+            let randomFactor = Double.random(in: -5 ... 5) // Add slight randomness to each point
+
+            // Simulate different phases during the 6-hour window
+            if time < 1 { // Stable glucose (pre-meal or fasting period)
+                trendFactor = 0.5 + randomFactor // Small increase with some variability
+            } else if time >= 1, time < 2 { // Glucose rising (e.g., post-meal spike)
+                trendFactor = 3.0 + randomFactor // Rapid increase with slight variation
+            } else if time >= 2, time < 3.5 { // Peak and plateau
+                trendFactor = -0.1 + randomFactor // Gradual decrease after the peak with variability
+            } else if time >= 3.5, time < 4.5 { // Second peak (optional, simulate another meal)
+                trendFactor = 2.5 + randomFactor // Another spike with some randomness
+            } else { // Post-meal decrease (insulin effect)
+                trendFactor = -1.5 + randomFactor // Glucose decreasing gradually with some variability
+            }
+
+            // Calculate the next glucose level with trend factors
+            glucoseLevel += trendFactor
+
+            // Ensure glucose level doesn't go out of realistic bounds:
+            glucoseLevel = max(70, min(glucoseLevel, 200))
+
+            data.append(DummyGlucoseData(time: Double(minute), glucoseLevel: Int(glucoseLevel.rounded())))
+        }
+        return data
+    }
+
+    var body: some View {
+        VStack {
+            Group {
+                VStack(alignment: .trailing, spacing: 0) {
+                    Text("Live Activity Personalization".uppercased())
+                        .frame(maxWidth: .infinity, alignment: .leading)
+                        .foregroundColor(.secondary)
+                        .font(.footnote)
+                        .padding(.leading)
+                }
+            }.padding(.bottom, -15)
+
+            GroupBox {
+                VStack {
+                    dummyChart(glucoseData)
+
+                    HStack(spacing: 15) {
+                        ForEach(0 ..< 4, id: \.self) { index in
+                            widgetButton(for: index)
+                        }
+                    }
+                    .padding()
+                    .overlay(
+                        RoundedRectangle(cornerRadius: 12)
+                            .stroke(style: StrokeStyle(lineWidth: 2, dash: [5]))
+                            .foregroundColor(.gray)
+                    )
+                    .cornerRadius(12)
+                }
+
+            }.padding(.vertical).groupBoxStyle(.dummyChart)
+
+            Group {
+                HStack {
+                    Image(systemName: "info.circle")
+                    Text(
+                        "To re-order widgets, remove them and re-add them in the desired order."
+                    )
+                }
+            }.frame(maxWidth: .infinity, alignment: .leading)
+                .foregroundColor(.secondary)
+                .font(.footnote)
+                .padding(.horizontal)
+
+            Spacer()
+        }
+        .padding()
+        .scrollContentBackground(.hidden).background(color)
+        .navigationTitle("Widget Configuration")
+        .navigationBarTitleDisplayMode(.automatic)
+        .onAppear {
+            if glucoseData.isEmpty {
+                glucoseData = generateDummyGlucoseData()
+            }
+            loadOrder() // Load the saved order when the view appears
+        }
+        .confirmationDialog("Add Widget", isPresented: $showAddItemDialog, titleVisibility: .visible) {
+            ForEach(LiveActivityItem.allCases.filter { !selectedItems.contains($0) }, id: \.self) { item in
+                Button(item.displayName) {
+                    if let index = buttonIndexToUpdate {
+                        addItem(item, at: index)
+                    }
+                }
+            }
+        }
+    }
+
+    @ViewBuilder private func widgetButton(for index: Int) -> some View {
+        if index < selectedItems.count, let selectedItem = selectedItems[index] {
+            // Display selected item preview
+            ZStack(alignment: .topTrailing) {
+                getItemPreview(for: selectedItem)
+                    .frame(width: 50, height: 50)
+                    .padding(5)
+                    .background(Color.clear)
+                    .cornerRadius(12)
+                    .overlay(
+                        RoundedRectangle(cornerRadius: 12)
+                            .stroke(Color.primary, lineWidth: 1)
+                    )
+                Button(action: {
+                    isRemovalConfirmationPresented = true
+                    itemToRemove = selectedItem
+                }) {
+                    Image(systemName: "trash.circle.fill")
+                        .foregroundColor(Color(UIColor.systemGray2))
+                        .background(Color.white)
+                        .clipShape(Circle())
+                        .font(.system(size: 20))
+                }
+                .offset(x: 10, y: -10)
+                .confirmationDialog("Remove Widget", isPresented: $isRemovalConfirmationPresented, titleVisibility: .hidden) {
+                    Button("Remove Widget", role: .destructive) {
+                        if let itemToRemove = itemToRemove {
+                            removeItem(itemToRemove)
+                        }
+                    }
+                }
+            }
+        } else {
+            // Show "+" symbol for empty slots
+            Button(action: {
+                buttonIndexToUpdate = index
+                showAddItemDialog.toggle()
+            }) {
+                VStack {
+                    Image(systemName: "plus")
+                        .font(.title2)
+                        .foregroundColor(.accentColor)
+                }
+                .frame(width: 50, height: 50)
+                .padding(5)
+                .overlay(
+                    RoundedRectangle(cornerRadius: 12)
+                        .stroke(style: StrokeStyle(lineWidth: 1, dash: [5]))
+                        .foregroundColor(.primary)
+                )
+            }
+            .buttonStyle(.plain)
+        }
+    }
+
+    private func getItemPreview(for item: LiveActivityItem) -> some View {
+        switch item {
+        case .currentGlucose:
+            return AnyView(currentGlucosePreview)
+        case .cob:
+            return AnyView(cobPreview)
+        case .iob:
+            return AnyView(iobPreview)
+        case .updatedLabel:
+            return AnyView(updatedLabelPreview)
+        }
+    }
+
+    @ViewBuilder private func dummyChart(_ glucoseData: [DummyGlucoseData]) -> some View {
+        Chart {
+            ForEach(glucoseData) { data in
+                let pointMarkColor = FreeAPS.getDynamicGlucoseColor(
+                    glucoseValue: Decimal(data.glucoseLevel),
+                    highGlucoseColorValue: !(state.settingsManager.settings.glucoseColorScheme == .dynamicColor) ? state
+                        .settingsManager.settings.highGlucose : Decimal(220),
+                    lowGlucoseColorValue: !(state.settingsManager.settings.glucoseColorScheme == .dynamicColor) ? state
+                        .settingsManager.settings.lowGlucose : Decimal(55),
+                    targetGlucose: Decimal(100),
+                    glucoseColorScheme: state.settingsManager.settings.glucoseColorScheme
+                )
+
+                PointMark(
+                    x: .value("Time", data.time),
+                    y: .value("Glucose Level", data.glucoseLevel)
+                ).foregroundStyle(pointMarkColor).symbolSize(15)
+            }
+        }
+        .chartYAxis {
+            AxisMarks(position: .trailing) { _ in
+                AxisGridLine(stroke: .init(lineWidth: 0.2, dash: [2, 3])).foregroundStyle(Color.white)
+                AxisValueLabel().foregroundStyle(.primary).font(.footnote)
+            }
+        }
+        .chartYScale(domain: 39 ... 200)
+        .chartYAxis(.hidden)
+        .chartPlotStyle { plotContent in
+            plotContent
+                .background(
+                    RoundedRectangle(cornerRadius: 12)
+                        .fill(Color.cyan.opacity(0.15))
+                )
+                .clipShape(RoundedRectangle(cornerRadius: 12))
+        }
+        .chartXAxis {
+            AxisMarks(position: .automatic) { _ in
+                AxisGridLine(stroke: .init(lineWidth: 0.2, dash: [2, 3])).foregroundStyle(Color.primary)
+            }
+        }
+        .frame(height: 100)
+    }
+
+    private var currentGlucosePreview: some View {
+        VStack {
+            HStack(alignment: .center) {
+                Text("123")
+                    .fontWeight(.bold)
+                    .font(.caption)
+            }
+            HStack(spacing: -5) {
+                HStack {
+                    Text("\u{2192}")
+                    Text("+6")
+                }.foregroundStyle(.primary).font(.caption2)
+            }
+        }
+    }
+
+    private var cobPreview: some View {
+        VStack(spacing: 2) {
+            Text("25 g").fontWeight(.bold).font(.caption)
+            Text("COB").font(.caption2).foregroundStyle(.primary)
+        }
+    }
+
+    private var iobPreview: some View {
+        VStack(spacing: 2) {
+            Text("2 U").fontWeight(.bold).font(.caption)
+            Text("IOB").font(.caption2).foregroundStyle(.primary)
+        }
+    }
+
+    private var updatedLabelPreview: some View {
+        VStack {
+            Text("19:05")
+                .fontWeight(.bold)
+                .font(.caption)
+                .foregroundStyle(.primary)
+
+            Text("Updated").font(.caption2).foregroundStyle(.primary)
+        }
+    }
+
+    private func loadOrder() {
+        if let savedItems = UserDefaults.standard.loadLiveActivityOrder() {
+            selectedItems = savedItems.count == 4 ? savedItems : savedItems + Array(repeating: nil, count: 4 - savedItems.count)
+        } else {
+            selectedItems = LiveActivityItem.defaultItems
+            saveOrder()
+        }
+    }
+
+    private func saveOrder() {
+        UserDefaults.standard.saveLiveActivityOrder(selectedItems)
+        Foundation.NotificationCenter.default.post(name: .liveActivityOrderDidChange, object: nil)
+    }
+
+    private func addItem(_ item: LiveActivityItem, at index: Int) {
+        selectedItems[index] = item
+        saveOrder()
+    }
+
+    private func removeItem(_ item: LiveActivityItem) {
+        if let index = selectedItems.firstIndex(of: item) {
+            selectedItems[index] = nil
+            saveOrder()
+        }
+    }
+}
+
+// Extension for UserDefaults to save and load the order
+extension UserDefaults {
+    private enum Keys {
+        static let liveActivityOrder = "liveActivityOrder"
+    }
+
+    func saveLiveActivityOrder(_ items: [LiveActivityItem?]) {
+        let itemStrings = items.map { $0?.rawValue ?? "" }
+        set(itemStrings, forKey: Keys.liveActivityOrder)
+    }
+
+    func loadLiveActivityOrder() -> [LiveActivityItem?]? {
+        if let itemStrings = array(forKey: Keys.liveActivityOrder) as? [String] {
+            return itemStrings.map { $0.isEmpty ? nil : LiveActivityItem(rawValue: $0) }
+        }
+        return nil
+    }
+}
+
+// Enum to represent each live activity item
+enum LiveActivityItem: String, CaseIterable, Identifiable {
+    case currentGlucose
+    case iob
+    case cob
+    case updatedLabel
+
+    var id: String { rawValue }
+
+    static var defaultItems: [LiveActivityItem] {
+        [.currentGlucose, .iob, .cob, .updatedLabel]
+    }
+
+    var displayName: String {
+        switch self {
+        case .currentGlucose:
+            return "Current Glucose"
+        case .iob:
+            return "IOB"
+        case .cob:
+            return "COB"
+        case .updatedLabel:
+            return "Last Updated"
+        }
+    }
+}
+
+struct DummyGlucoseData: Identifiable {
+    let id = UUID()
+    let time: Double // Time in hours
+    let glucoseLevel: Int // Glucose level in mg/dL
+}
+
+struct DummyChartGroupBoxStyle: GroupBoxStyle {
+    func makeBody(configuration: Configuration) -> some View {
+        VStack {
+            configuration.content
+        }
+        .padding()
+        .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
+        .background(Color.chart, in: RoundedRectangle(cornerRadius: 12))
+        .frame(width: UIScreen.main.bounds.width * 0.9)
+    }
+}
+
+extension GroupBoxStyle where Self == DummyChartGroupBoxStyle {
+    static var dummyChart: DummyChartGroupBoxStyle { .init() }
+}

+ 5 - 4
FreeAPS/Sources/Modules/ManualTempBasal/ManualTempBasalStateModel.swift

@@ -1,10 +1,11 @@
+import Observation
 import SwiftUI
 
 extension ManualTempBasal {
-    final class StateModel: BaseStateModel<Provider> {
-        @Injected() var apsManager: APSManager!
-        @Published var rate: Decimal = 0
-        @Published var durationIndex = 0
+    @Observable final class StateModel: BaseStateModel<Provider> {
+        @ObservationIgnored @Injected() var apsManager: APSManager!
+        var rate: Decimal = 0
+        var durationIndex = 0
 
         let durationValues = stride(from: 30.0, to: 720.1, by: 30.0).map { $0 }
 

+ 1 - 1
FreeAPS/Sources/Modules/ManualTempBasal/View/ManualTempBasalRootView.swift

@@ -4,7 +4,7 @@ import Swinject
 extension ManualTempBasal {
     struct RootView: BaseView {
         let resolver: Resolver
-        @StateObject var state = StateModel()
+        @State var state = StateModel()
 
         @Environment(\.colorScheme) var colorScheme
         var color: LinearGradient {

+ 3 - 6
FreeAPS/Sources/Modules/MealSettings/MealSettingsStateModel.swift

@@ -20,22 +20,19 @@ extension MealSettings {
             subscribeSetting(\.maxFat, on: $maxFat) { maxFat = $0 }
             subscribeSetting(\.maxProtein, on: $maxProtein) { maxProtein = $0 }
             subscribeSetting(\.timeCap, on: $timeCap.map(Int.init), initial: {
-                let value = max(min($0, 12), 5)
-                timeCap = Decimal(value)
+                timeCap = Decimal($0)
             }, map: {
                 $0
             })
 
             subscribeSetting(\.minuteInterval, on: $minuteInterval.map(Int.init), initial: {
-                let value = max(min($0, 60), 10)
-                minuteInterval = Decimal(value)
+                minuteInterval = Decimal($0)
             }, map: {
                 $0
             })
 
             subscribeSetting(\.delay, on: $delay.map(Int.init), initial: {
-                let value = max(min($0, 120), 60)
-                delay = Decimal(value)
+                delay = Decimal($0)
             }, map: {
                 $0
             })

+ 23 - 2
FreeAPS/Sources/Modules/NightscoutConfig/NightscoutConfigStateModel.swift

@@ -67,6 +67,22 @@ extension NightscoutConfig {
             importedInsulinActionCurve = pumpSettings.insulinActionCurve
 
             isConnectedToNS = nightscoutAPI != nil
+
+            $isUploadEnabled
+                .dropFirst()
+                .removeDuplicates()
+                .sink { [weak self] enabled in
+                    guard let self = self else { return }
+                    if enabled {
+                        debug(.nightscout, "Upload has been enabled by the user.")
+                        Task {
+                            await self.nightscoutManager.uploadProfiles()
+                        }
+                    } else {
+                        debug(.nightscout, "Upload has been disabled by the user.")
+                    }
+                }
+                .store(in: &lifetime)
         }
 
         func connect() {
@@ -326,12 +342,17 @@ extension NightscoutConfig {
             if glucose.isNotEmpty {
                 await MainActor.run {
                     self.backfilling = false
-                    self.healthKitManager.saveIfNeeded(bloodGlucose: glucose)
-                    self.glucoseStorage.storeGlucose(glucose)
+                }
+
+                glucoseStorage.storeGlucose(glucose)
+
+                Task.detached {
+                    await self.healthKitManager.uploadGlucose()
                 }
             } else {
                 await MainActor.run {
                     self.backfilling = false
+                    debug(.nightscout, "No glucose values found or fetched to backfill.")
                 }
             }
         }

+ 14 - 1
FreeAPS/Sources/Modules/NightscoutConfig/View/NightscoutConfigRootView.swift

@@ -41,7 +41,20 @@ extension NightscoutConfig {
                     Section(
                         header: Text("Nightscout Integration"),
                         content: {
-                            NavigationLink("Connect", destination: NightscoutConnectView(state: state))
+                            NavigationLink(destination: NightscoutConnectView(state: state), label: {
+                                HStack {
+                                    Text("Connect")
+                                    ZStack {
+                                        if state.isConnectedToNS {
+                                            Image(systemName: "network")
+                                            Image(systemName: "checkmark.circle.fill").foregroundColor(.green).font(.caption2)
+                                                .offset(x: 9, y: 6)
+                                        } else {
+                                            Image(systemName: "network.slash")
+                                        }
+                                    }
+                                }
+                            })
                             NavigationLink("Upload", destination: NightscoutUploadView(state: state))
                             NavigationLink("Fetch & Remote Control", destination: NightscoutFetchView(state: state))
                         }

+ 63 - 54
FreeAPS/Sources/Modules/OverrideConfig/OverrideStateModel.swift

@@ -1,61 +1,66 @@
+import Combine
 import CoreData
+import Observation
 import SwiftUI
 
 extension OverrideConfig {
-    final class StateModel: BaseStateModel<Provider> {
-        @Injected() var broadcaster: Broadcaster!
-        @Injected() var storage: TempTargetsStorage!
-        @Injected() var apsManager: APSManager!
-        @Injected() var overrideStorage: OverrideStorage!
-
-        @Published var overrideSliderPercentage: Double = 100
-        @Published var isEnabled = false
-        @Published var indefinite = true
-        @Published var overrideDuration: Decimal = 0
-        @Published var target: Decimal = 0
-        @Published var shouldOverrideTarget: Bool = false
-        @Published var smbIsOff: Bool = false
-        @Published var id = ""
-        @Published var overrideName: String = ""
-        @Published var isPreset: Bool = false
-        @Published var overridePresets: [OverrideStored] = []
-        @Published var advancedSettings: Bool = false
-        @Published var isfAndCr: Bool = true
-        @Published var isf: Bool = true
-        @Published var cr: Bool = true
-        @Published var smbIsAlwaysOff: Bool = false
-        @Published var start: Decimal = 0
-        @Published var end: Decimal = 23
-        @Published var smbMinutes: Decimal = 0
-        @Published var uamMinutes: Decimal = 0
-        @Published var defaultSmbMinutes: Decimal = 0
-        @Published var defaultUamMinutes: Decimal = 0
-        @Published var selectedTab: Tab = .overrides
-        @Published var activeOverrideName: String = ""
-        @Published var currentActiveOverride: OverrideStored?
-        @Published var showOverrideEditSheet = false
-        @Published var showInvalidTargetAlert = false
+    @Observable final class StateModel: BaseStateModel<Provider> {
+        @ObservationIgnored @Injected() var broadcaster: Broadcaster!
+        @ObservationIgnored @Injected() var storage: TempTargetsStorage!
+        @ObservationIgnored @Injected() var apsManager: APSManager!
+        @ObservationIgnored @Injected() var overrideStorage: OverrideStorage!
+        @ObservationIgnored @Injected() var nightscoutManager: NightscoutManager!
+
+        var overrideSliderPercentage: Double = 100
+        var isEnabled = false
+        var indefinite = true
+        var overrideDuration: Decimal = 0
+        var target: Decimal = 0
+        var shouldOverrideTarget: Bool = false
+        var smbIsOff: Bool = false
+        var id = ""
+        var overrideName: String = ""
+        var isPreset: Bool = false
+        var overridePresets: [OverrideStored] = []
+        var advancedSettings: Bool = false
+        var isfAndCr: Bool = true
+        var isf: Bool = true
+        var cr: Bool = true
+        var smbIsAlwaysOff: Bool = false
+        var start: Decimal = 0
+        var end: Decimal = 23
+        var smbMinutes: Decimal = 0
+        var uamMinutes: Decimal = 0
+        var defaultSmbMinutes: Decimal = 0
+        var defaultUamMinutes: Decimal = 0
+        var selectedTab: Tab = .overrides
+        var activeOverrideName: String = ""
+        var currentActiveOverride: OverrideStored?
+        var showOverrideEditSheet = false
+        var showInvalidTargetAlert = false
 
         var units: GlucoseUnits = .mgdL
 
         // temp target stuff
-        @Published var low: Decimal = 0
-        @Published var high: Decimal = 0
-        @Published var durationTT: Decimal = 0
-        @Published var date = Date()
-        @Published var newPresetName = ""
-        @Published var presetsTT: [TempTarget] = []
-        @Published var percentageTT = 100.0
-        @Published var maxValue: Decimal = 1.2
-        @Published var viewPercantage = false
-        @Published var hbt: Double = 160
-        @Published var didSaveSettings: Bool = false
+        var low: Decimal = 0
+        var high: Decimal = 0
+        var durationTT: Decimal = 0
+        var date = Date()
+        var newPresetName = ""
+        var presetsTT: [TempTarget] = []
+        var percentageTT = 100.0
+        var maxValue: Decimal = 1.2
+        var viewPercantage = false
+        var hbt: Double = 160
+        var didSaveSettings: Bool = false
 
         var alertMessage: String {
             let target: String = units == .mgdL ? "70-270 mg/dl" : "4-15 mmol/l"
             return "Please enter a valid target between" + " \(target)."
         }
 
+        private var cancellables = Set<AnyCancellable>()
+
         override func subscribe() {
             setupNotification()
             units = settingsManager.settings.units
@@ -96,16 +101,12 @@ extension OverrideConfig {
 extension OverrideConfig.StateModel {
     // Custom Notification to update View when an Override has been cancelled via Home View
     func setupNotification() {
-        Foundation.NotificationCenter.default.addObserver(
-            self,
-            selector: #selector(handleOverrideConfigurationUpdate),
-            name: .didUpdateOverrideConfiguration,
-            object: nil
-        )
-    }
-
-    @objc private func handleOverrideConfigurationUpdate() {
-        updateLatestOverrideConfiguration()
+        Foundation.NotificationCenter.default.publisher(for: .willUpdateOverrideConfiguration)
+            .sink { [weak self] _ in
+                guard let self = self else { return }
+                self.updateLatestOverrideConfiguration()
+            }
+            .store(in: &cancellables)
     }
 
     // MARK: - Enact Overrides
@@ -123,6 +124,10 @@ extension OverrideConfig.StateModel {
 
             // Update Presets View
             setupOverridePresetsArray()
+
+            Task {
+                await nightscoutManager.uploadProfiles()
+            }
         } catch {
             debugPrint(
                 "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to save after reordering Override Presets with error: \(error.localizedDescription)"
@@ -286,6 +291,8 @@ extension OverrideConfig.StateModel {
 
         // Update Presets View
         setupOverridePresetsArray()
+
+        await nightscoutManager.uploadProfiles()
     }
 
     // MARK: - Setup Override Presets Array
@@ -318,6 +325,8 @@ extension OverrideConfig.StateModel {
 
         // Update Presets View
         setupOverridePresetsArray()
+
+        await nightscoutManager.uploadProfiles()
     }
 
     // MARK: - Setup the State variables with the last Override configuration

+ 6 - 3
FreeAPS/Sources/Modules/OverrideConfig/View/EditOverrideForm.swift

@@ -2,10 +2,11 @@ import Foundation
 import SwiftUI
 
 struct EditOverrideForm: View {
+    @ObservationIgnored @Injected() var nightscoutManager: NightscoutManager!
     @ObservedObject var override: OverrideStored
     @Environment(\.presentationMode) var presentationMode
     @Environment(\.colorScheme) var colorScheme
-    @StateObject var state: OverrideConfig.StateModel
+    @Bindable var state: OverrideConfig.StateModel
 
     @State private var name: String
     @State private var percentage: Double
@@ -30,7 +31,7 @@ struct EditOverrideForm: View {
 
     init(overrideToEdit: OverrideStored, state: OverrideConfig.StateModel) {
         override = overrideToEdit
-        _state = StateObject(wrappedValue: state)
+        _state = Bindable(wrappedValue: state)
         _name = State(initialValue: overrideToEdit.name ?? "")
         _percentage = State(initialValue: overrideToEdit.percentage)
         _indefinite = State(initialValue: overrideToEdit.indefinite)
@@ -294,7 +295,9 @@ struct EditOverrideForm: View {
                         guard let moc = override.managedObjectContext else { return }
                         guard moc.hasChanges else { return }
                         try moc.save()
-
+                        Task {
+                            await nightscoutManager.uploadProfiles()
+                        }
                         if let currentActiveOverride = state.currentActiveOverride {
                             Task {
                                 await state.disableAllActiveOverrides(

+ 1 - 1
FreeAPS/Sources/Modules/OverrideConfig/View/OverrideRootView.swift

@@ -6,7 +6,7 @@ extension OverrideConfig {
     struct RootView: BaseView {
         let resolver: Resolver
 
-        @StateObject var state = StateModel()
+        @State var state = StateModel()
 
         @State private var isEditing = false
         @State private var showOverrideCreationSheet = false

+ 36 - 0
FreeAPS/Sources/Modules/RemoteControl/TrioRemoteControl+APNS.swift

@@ -0,0 +1,36 @@
+import Foundation
+
+extension TrioRemoteControl {
+    internal func handleAPNSChanges(deviceToken: String?) async {
+        let previousDeviceToken = UserDefaults.standard.string(forKey: "deviceToken")
+        let previousIsAPNSProduction = UserDefaults.standard.bool(forKey: "isAPNSProduction")
+
+        let isAPNSProduction = isRunningInAPNSProductionEnvironment()
+        var shouldUploadProfiles = false
+
+        if let token = deviceToken, token != previousDeviceToken {
+            UserDefaults.standard.set(token, forKey: "deviceToken")
+            debug(.remoteControl, "Device token updated: \(token)")
+            shouldUploadProfiles = true
+        }
+
+        if previousIsAPNSProduction != isAPNSProduction {
+            UserDefaults.standard.set(isAPNSProduction, forKey: "isAPNSProduction")
+            debug(.remoteControl, "APNS environment changed to: \(isAPNSProduction ? "Production" : "Sandbox")")
+            shouldUploadProfiles = true
+        }
+
+        if shouldUploadProfiles {
+            await nightscoutManager.uploadProfiles()
+        } else {
+            debug(.remoteControl, "No changes detected in device token or APNS environment.")
+        }
+    }
+
+    private func isRunningInAPNSProductionEnvironment() -> Bool {
+        if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL {
+            return appStoreReceiptURL.lastPathComponent != "sandboxReceipt"
+        }
+        return false
+    }
+}

+ 108 - 0
FreeAPS/Sources/Modules/RemoteControl/TrioRemoteControl+Bolus.swift

@@ -0,0 +1,108 @@
+import Foundation
+
+extension TrioRemoteControl {
+    internal func handleBolusCommand(_ pushMessage: PushMessage) async {
+        guard let bolusAmount = pushMessage.bolusAmount else {
+            await logError("Command rejected: bolus amount is missing or invalid.", pushMessage: pushMessage)
+            return
+        }
+
+        let maxBolus = await FreeAPSApp.resolver.resolve(SettingsManager.self)?.pumpSettings.maxBolus ?? Decimal(0)
+
+        if bolusAmount > maxBolus {
+            await logError(
+                "Command rejected: bolus amount (\(bolusAmount) units) exceeds the maximum allowed (\(maxBolus) units).",
+                pushMessage: pushMessage
+            )
+            return
+        }
+
+        let maxIOB = settings.preferences.maxIOB
+        let currentIOB = await fetchCurrentIOB()
+        if (currentIOB + bolusAmount) > maxIOB {
+            await logError(
+                "Command rejected: bolus amount (\(bolusAmount) units) would exceed max IOB (\(maxIOB) units). Current IOB: \(currentIOB) units.",
+                pushMessage: pushMessage
+            )
+            return
+        }
+
+        let totalRecentBolusAmount = await fetchTotalRecentBolusAmount(since: Date(timeIntervalSince1970: pushMessage.timestamp))
+
+        if totalRecentBolusAmount >= bolusAmount * 0.2 {
+            await logError(
+                "Command rejected: boluses totaling more than 20% of the requested amount have been delivered since the command was sent.",
+                pushMessage: pushMessage
+            )
+            return
+        }
+
+        debug(.remoteControl, "Enacting bolus command with amount: \(bolusAmount) units.")
+
+        guard let apsManager = await FreeAPSApp.resolver.resolve(APSManager.self) else {
+            await logError(
+                "Error: unable to process bolus command because the APS Manager is not available.",
+                pushMessage: pushMessage
+            )
+            return
+        }
+
+        await apsManager.enactBolus(amount: Double(truncating: bolusAmount as NSNumber), isSMB: false)
+
+        debug(
+            .remoteControl,
+            "Remote command processed successfully. \(pushMessage.humanReadableDescription())"
+        )
+    }
+
+    private func fetchCurrentIOB() async -> Decimal {
+        let predicate = NSPredicate.predicateFor30MinAgoForDetermination
+
+        let determinations = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: OrefDetermination.self,
+            onContext: pumpHistoryFetchContext,
+            predicate: predicate,
+            key: "timestamp",
+            ascending: false,
+            fetchLimit: 1,
+            propertiesToFetch: ["iob"]
+        )
+
+        guard let fetchedResults = determinations as? [[String: Any]],
+              let firstResult = fetchedResults.first,
+              let iob = firstResult["iob"] as? Decimal
+        else {
+            await logError("Failed to fetch current IOB.")
+            return Decimal(0)
+        }
+
+        return iob
+    }
+
+    private func fetchTotalRecentBolusAmount(since date: Date) async -> Decimal {
+        let predicate = NSPredicate(
+            format: "type == %@ AND timestamp > %@",
+            PumpEventStored.EventType.bolus.rawValue,
+            date as NSDate
+        )
+
+        let results: Any = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: PumpEventStored.self,
+            onContext: pumpHistoryFetchContext,
+            predicate: predicate,
+            key: "timestamp",
+            ascending: true,
+            fetchLimit: nil,
+            propertiesToFetch: ["bolus.amount"]
+        )
+
+        guard let bolusDictionaries = results as? [[String: Any]] else {
+            await logError("Failed to cast fetched bolus events. Fetched entities type: \(type(of: results))")
+            return 0
+        }
+
+        let totalAmount = bolusDictionaries.compactMap { ($0["bolus.amount"] as? NSNumber)?.decimalValue }.reduce(0, +)
+
+        return totalAmount
+    }
+}

+ 12 - 0
FreeAPS/Sources/Modules/RemoteControl/TrioRemoteControl+Helpers.swift

@@ -0,0 +1,12 @@
+import Foundation
+
+extension TrioRemoteControl {
+    func logError(_ errorMessage: String, pushMessage: PushMessage? = nil) async {
+        var note = errorMessage
+        if let pushMessage = pushMessage {
+            note += " Details: \(pushMessage.humanReadableDescription())"
+        }
+        debug(.remoteControl, note)
+        await nightscoutManager.uploadNoteTreatment(note: note)
+    }
+}

+ 82 - 0
FreeAPS/Sources/Modules/RemoteControl/TrioRemoteControl+Meal.swift

@@ -0,0 +1,82 @@
+import Foundation
+
+extension TrioRemoteControl {
+    func handleMealCommand(_ pushMessage: PushMessage) async {
+        guard pushMessage.carbs != nil || pushMessage.fat != nil || pushMessage.protein != nil else {
+            await logError("Command rejected: meal data is incomplete or invalid.", pushMessage: pushMessage)
+            return
+        }
+
+        let carbsDecimal = pushMessage.carbs != nil ? Decimal(pushMessage.carbs!) : nil
+        let fatDecimal = pushMessage.fat != nil ? Decimal(pushMessage.fat!) : nil
+        let proteinDecimal = pushMessage.protein != nil ? Decimal(pushMessage.protein!) : nil
+
+        let settings = await FreeAPSApp.resolver.resolve(SettingsManager.self)?.settings
+        let maxCarbs = settings?.maxCarbs ?? Decimal(0)
+        let maxFat = settings?.maxFat ?? Decimal(0)
+        let maxProtein = settings?.maxProtein ?? Decimal(0)
+
+        if let carbs = carbsDecimal, carbs > maxCarbs {
+            await logError(
+                "Command rejected: carbs amount (\(carbs)g) exceeds the maximum allowed (\(maxCarbs)g).",
+                pushMessage: pushMessage
+            )
+            return
+        }
+
+        if let fat = fatDecimal, fat > maxFat {
+            await logError(
+                "Command rejected: fat amount (\(fat)g) exceeds the maximum allowed (\(maxFat)g).",
+                pushMessage: pushMessage
+            )
+            return
+        }
+
+        if let protein = proteinDecimal, protein > maxProtein {
+            await logError(
+                "Command rejected: protein amount (\(protein)g) exceeds the maximum allowed (\(maxProtein)g).",
+                pushMessage: pushMessage
+            )
+            return
+        }
+
+        let pushMessageDate = Date(timeIntervalSince1970: pushMessage.timestamp)
+        let recentCarbEntries = carbsStorage.recent()
+        let carbsAfterPushMessage = recentCarbEntries.filter { $0.createdAt > pushMessageDate }
+
+        if !carbsAfterPushMessage.isEmpty {
+            await logError(
+                "Command rejected: newer carb entries have been logged since the command was sent.",
+                pushMessage: pushMessage
+            )
+            return
+        }
+
+        let actualDate: Date?
+        if let scheduledTime = pushMessage.scheduledTime {
+            actualDate = Date(timeIntervalSince1970: scheduledTime)
+        } else {
+            actualDate = nil
+        }
+
+        let mealEntry = CarbsEntry(
+            id: UUID().uuidString,
+            createdAt: Date(),
+            actualDate: actualDate,
+            carbs: carbsDecimal ?? 0,
+            fat: fatDecimal,
+            protein: proteinDecimal,
+            note: "Remote meal command",
+            enteredBy: CarbsEntry.manual,
+            isFPU: false,
+            fpuID: nil
+        )
+
+        await carbsStorage.storeCarbs([mealEntry], areFetchedFromRemote: false)
+
+        debug(
+            .remoteControl,
+            "Remote command processed successfully. \(pushMessage.humanReadableDescription())"
+        )
+    }
+}

+ 100 - 0
FreeAPS/Sources/Modules/RemoteControl/TrioRemoteControl+Override.swift

@@ -0,0 +1,100 @@
+import Foundation
+
+extension TrioRemoteControl {
+    @MainActor internal func handleCancelOverrideCommand(_ pushMessage: PushMessage) async {
+        await disableAllActiveOverrides()
+
+        debug(
+            .remoteControl,
+            "Remote command processed successfully. \(pushMessage.humanReadableDescription())"
+        )
+    }
+
+    @MainActor internal func handleStartOverrideCommand(_ pushMessage: PushMessage) async {
+        guard let overrideName = pushMessage.overrideName, !overrideName.isEmpty else {
+            await logError("Command rejected: override name is missing.", pushMessage: pushMessage)
+            return
+        }
+
+        let presetIDs = await overrideStorage.fetchForOverridePresets()
+
+        let presets = presetIDs.compactMap { id in
+            try? viewContext.existingObject(with: id) as? OverrideStored
+        }
+
+        if let preset = presets.first(where: { $0.name == overrideName }) {
+            await enactOverridePreset(preset: preset, pushMessage: pushMessage)
+        } else {
+            await logError("Command rejected: override preset '\(overrideName)' not found.", pushMessage: pushMessage)
+        }
+    }
+
+    @MainActor private func enactOverridePreset(preset: OverrideStored, pushMessage: PushMessage) async {
+        await disableAllActiveOverrides()
+
+        preset.enabled = true
+        preset.date = Date()
+        preset.isUploadedToNS = false
+
+        do {
+            if viewContext.hasChanges {
+                try viewContext.save()
+
+                Foundation.NotificationCenter.default.post(name: .willUpdateOverrideConfiguration, object: nil)
+                await awaitNotification(.didUpdateOverrideConfiguration)
+
+                debug(
+                    .remoteControl,
+                    "Remote command processed successfully. \(pushMessage.humanReadableDescription())"
+                )
+            }
+        } catch {
+            debug(.remoteControl, "Failed to enact override preset: \(error.localizedDescription)")
+        }
+    }
+
+    @MainActor private func disableAllActiveOverrides() async {
+        let ids = await overrideStorage.loadLatestOverrideConfigurations(fetchLimit: 0) // 0 = no fetch limit
+
+        let didPostNotification = await viewContext.perform { () -> Bool in
+            do {
+                let results = try ids.compactMap { id in
+                    try self.viewContext.existingObject(with: id) as? OverrideStored
+                }
+
+                guard !results.isEmpty else { return false }
+
+                for canceledOverride in results where canceledOverride.enabled {
+                    let newOverrideRunStored = OverrideRunStored(context: self.viewContext)
+                    newOverrideRunStored.id = UUID()
+                    newOverrideRunStored.name = canceledOverride.name
+                    newOverrideRunStored.startDate = canceledOverride.date ?? .distantPast
+                    newOverrideRunStored.endDate = Date()
+                    newOverrideRunStored
+                        .target = NSDecimalNumber(decimal: self.overrideStorage.calculateTarget(override: canceledOverride))
+                    newOverrideRunStored.override = canceledOverride
+                    newOverrideRunStored.isUploadedToNS = false
+
+                    canceledOverride.enabled = false
+                }
+
+                if self.viewContext.hasChanges {
+                    try self.viewContext.save()
+                    Foundation.NotificationCenter.default.post(name: .willUpdateOverrideConfiguration, object: nil)
+                    return true
+                } else {
+                    return false
+                }
+            } catch {
+                debugPrint(
+                    "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to disable active Overrides with error: \(error.localizedDescription)"
+                )
+                return false
+            }
+        }
+
+        if didPostNotification {
+            await awaitNotification(.didUpdateOverrideConfiguration)
+        }
+    }
+}

+ 49 - 0
FreeAPS/Sources/Modules/RemoteControl/TrioRemoteControl+TempTarget.swift

@@ -0,0 +1,49 @@
+import Foundation
+
+extension TrioRemoteControl {
+    func handleTempTargetCommand(_ pushMessage: PushMessage) async {
+        guard let targetValue = pushMessage.target,
+              let durationValue = pushMessage.duration
+        else {
+            await logError("Command rejected: temp target data is incomplete or invalid.", pushMessage: pushMessage)
+            return
+        }
+
+        let durationInMinutes = Int(durationValue)
+        let pushMessageDate = Date(timeIntervalSince1970: pushMessage.timestamp)
+
+        let tempTarget = TempTarget(
+            name: TempTarget.custom,
+            createdAt: pushMessageDate,
+            targetTop: Decimal(targetValue),
+            targetBottom: Decimal(targetValue),
+            duration: Decimal(durationInMinutes),
+            enteredBy: TempTarget.manual,
+            reason: TempTarget.custom
+        )
+
+        tempTargetsStorage.storeTempTargets([tempTarget])
+
+        debug(
+            .remoteControl,
+            "Remote command processed successfully. \(pushMessage.humanReadableDescription())"
+        )
+    }
+
+    func cancelTempTarget(_ pushMessage: PushMessage) async {
+        debug(.remoteControl, "Cancelling temp target.")
+
+        guard tempTargetsStorage.current() != nil else {
+            await logError("Command rejected: no active temp target to cancel.")
+            return
+        }
+
+        let cancelEntry = TempTarget.cancel(at: Date())
+        tempTargetsStorage.storeTempTargets([cancelEntry])
+
+        debug(
+            .remoteControl,
+            "Remote command processed successfully. \(pushMessage.humanReadableDescription())"
+        )
+    }
+}

+ 113 - 0
FreeAPS/Sources/Modules/RemoteControl/TrioRemoteControl.swift

@@ -0,0 +1,113 @@
+import CoreData
+import Foundation
+import Swinject
+
+class TrioRemoteControl: Injectable {
+    static let shared = TrioRemoteControl()
+
+    @Injected() internal var tempTargetsStorage: TempTargetsStorage!
+    @Injected() internal var carbsStorage: CarbsStorage!
+    @Injected() internal var nightscoutManager: NightscoutManager!
+    @Injected() internal var overrideStorage: OverrideStorage!
+    @Injected() internal var settings: SettingsManager!
+
+    private let timeWindow: TimeInterval = 600 // Defines how old messages that are accepted, 10 minutes
+
+    internal let pumpHistoryFetchContext: NSManagedObjectContext
+    internal let viewContext: NSManagedObjectContext
+
+    private init() {
+        pumpHistoryFetchContext = CoreDataStack.shared.newTaskContext()
+        viewContext = CoreDataStack.shared.persistentContainer.viewContext
+        injectServices(FreeAPSApp.resolver)
+    }
+
+    func handleRemoteNotification(pushMessage: PushMessage) async {
+        let isTrioRemoteControlEnabled = UserDefaults.standard.bool(forKey: "isTrioRemoteControlEnabled")
+        guard isTrioRemoteControlEnabled else {
+            await logError("Remote command received, but remote control is disabled in settings. Ignoring the command.")
+            return
+        }
+
+        let currentTime = Date().timeIntervalSince1970
+        let timeDifference = currentTime - pushMessage.timestamp
+
+        if timeDifference > timeWindow {
+            await logError(
+                "Command rejected: the message is too old (sent \(Int(timeDifference)) seconds ago, which exceeds the allowed limit).",
+                pushMessage: pushMessage
+            )
+            return
+        } else if timeDifference < -timeWindow {
+            await logError(
+                "Command rejected: the message has an invalid future timestamp (timestamp is \(Int(-timeDifference)) seconds ahead of the current time).",
+                pushMessage: pushMessage
+            )
+            return
+        }
+
+        debug(.remoteControl, "Command received with acceptable time difference: \(Int(timeDifference)) seconds.")
+
+        let storedSecret = UserDefaults.standard.string(forKey: "trioRemoteControlSharedSecret") ?? ""
+        guard !storedSecret.isEmpty else {
+            await logError(
+                "Command rejected: shared secret is missing in settings. Cannot authenticate the command.",
+                pushMessage: pushMessage
+            )
+            return
+        }
+
+        guard pushMessage.sharedSecret == storedSecret else {
+            await logError(
+                "Command rejected: shared secret does not match. Cannot authenticate the command.",
+                pushMessage: pushMessage
+            )
+            return
+        }
+
+        switch pushMessage.commandType {
+        case .bolus:
+            await handleBolusCommand(pushMessage)
+        case .tempTarget:
+            await handleTempTargetCommand(pushMessage)
+        case .cancelTempTarget:
+            await cancelTempTarget(pushMessage)
+        case .meal:
+            await handleMealCommand(pushMessage)
+        case .startOverride:
+            await handleStartOverrideCommand(pushMessage)
+        case .cancelOverride:
+            await handleCancelOverrideCommand(pushMessage)
+        }
+    }
+}
+
+// MARK: - CommandType Enum
+
+extension TrioRemoteControl {
+    enum CommandType: String, Codable {
+        case bolus
+        case tempTarget = "temp_target"
+        case cancelTempTarget = "cancel_temp_target"
+        case meal
+        case startOverride = "start_override"
+        case cancelOverride = "cancel_override"
+
+        var description: String {
+            switch self {
+            case .bolus:
+                return "Bolus"
+            case .tempTarget:
+                return "Temporary Target"
+            case .cancelTempTarget:
+                return "Cancel Temporary Target"
+            case .meal:
+                return "Meal"
+            case .startOverride:
+                return "Start Override"
+            case .cancelOverride:
+                return "Cancel Override"
+            }
+        }
+    }
+}

+ 6 - 0
FreeAPS/Sources/Modules/RemoteControlConfig/RemoteControlConfigDataFlow.swift

@@ -0,0 +1,6 @@
+import Foundation
+enum RemoteControlConfig {
+    enum Config {}
+}
+
+protocol RemoteControlConfigProvider {}

+ 4 - 0
FreeAPS/Sources/Modules/RemoteControlConfig/RemoteControlConfigProvider.swift

@@ -0,0 +1,4 @@
+import Foundation
+extension RemoteControlConfig {
+    final class Provider: BaseProvider, RemoteControlConfigProvider {}
+}

+ 0 - 0
FreeAPS/Sources/Modules/RemoteControlConfig/RemoteControlConfigStateModel.swift


Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików