Просмотр исходного кода

Merge branch 'core-data-sync-trio' of github.com:dnzxy/Open-iAPS into auggie-dynamic-bg-color

Deniz Cengiz 1 год назад
Родитель
Сommit
7ed2e51792
100 измененных файлов с 3393 добавлено и 1347 удалено
  1. 684 341
      FreeAPS.xcodeproj/project.pbxproj
  2. 16 0
      FreeAPS.xcodeproj/xcshareddata/xcschemes/Trio.xcscheme
  3. 10 11
      FreeAPS/Resources/json/defaults/freeaps/freeaps_settings.json
  4. 4 4
      FreeAPS/Resources/json/defaults/settings/bg_targets.json
  5. 3 3
      FreeAPS/Resources/json/defaults/settings/insulin_sensitivities.json
  6. 16 6
      FreeAPS/Sources/APS/APSManager.swift
  7. 1 36
      FreeAPS/Sources/APS/CGM/CGMType.swift
  8. 37 0
      FreeAPS/Sources/APS/DeviceDataManager.swift
  9. 0 2
      FreeAPS/Sources/APS/FetchGlucoseManager.swift
  10. 1 1
      FreeAPS/Sources/APS/FetchTreatmentsManager.swift
  11. 26 25
      FreeAPS/Sources/APS/OpenAPS/OpenAPS.swift
  12. 2 2
      FreeAPS/Sources/APS/PluginManager.swift
  13. 108 18
      FreeAPS/Sources/APS/Storage/CarbsStorage.swift
  14. 38 6
      FreeAPS/Sources/APS/Storage/DeterminationStorage.swift
  15. 13 7
      FreeAPS/Sources/APS/Storage/GlucoseStorage.swift
  16. 10 10
      FreeAPS/Sources/APS/Storage/OverrideStorage.swift
  17. 15 3
      FreeAPS/Sources/APS/Storage/PumpHistoryStorage.swift
  18. 15 0
      FreeAPS/Sources/Application/FreeAPSApp.swift
  19. 2 3
      FreeAPS/Sources/Helpers/DynamicGlucoseColor.swift
  20. 99 0
      FreeAPS/Sources/Helpers/MainChartHelper.swift
  21. 1 1
      FreeAPS/Sources/Localizations/Main/ar.lproj/Localizable.strings
  22. 1 1
      FreeAPS/Sources/Localizations/Main/ca.lproj/Localizable.strings
  23. 1 1
      FreeAPS/Sources/Localizations/Main/da.lproj/Localizable.strings
  24. 1 1
      FreeAPS/Sources/Localizations/Main/de.lproj/Localizable.strings
  25. 1 1
      FreeAPS/Sources/Localizations/Main/en.lproj/Localizable.strings
  26. 1 1
      FreeAPS/Sources/Localizations/Main/es.lproj/Localizable.strings
  27. 1 1
      FreeAPS/Sources/Localizations/Main/fi.lproj/Localizable.strings
  28. 1 1
      FreeAPS/Sources/Localizations/Main/fr.lproj/Localizable.strings
  29. 1 1
      FreeAPS/Sources/Localizations/Main/he.lproj/Localizable.strings
  30. 1 1
      FreeAPS/Sources/Localizations/Main/hu.lproj/Localizable.strings
  31. 1 1
      FreeAPS/Sources/Localizations/Main/it.lproj/Localizable.strings
  32. 1 1
      FreeAPS/Sources/Localizations/Main/nb.lproj/Localizable.strings
  33. 1 1
      FreeAPS/Sources/Localizations/Main/nl.lproj/Localizable.strings
  34. 1 1
      FreeAPS/Sources/Localizations/Main/pl.lproj/Localizable.strings
  35. 1 1
      FreeAPS/Sources/Localizations/Main/pt-BR.lproj/Localizable.strings
  36. 1 1
      FreeAPS/Sources/Localizations/Main/pt-PT.lproj/Localizable.strings
  37. 1 1
      FreeAPS/Sources/Localizations/Main/ru.lproj/Localizable.strings
  38. 1 1
      FreeAPS/Sources/Localizations/Main/sk.lproj/Localizable.strings
  39. 1 1
      FreeAPS/Sources/Localizations/Main/sv.lproj/Localizable.strings
  40. 1 1
      FreeAPS/Sources/Localizations/Main/tr.lproj/Localizable.strings
  41. 1 1
      FreeAPS/Sources/Localizations/Main/uk.lproj/Localizable.strings
  42. 1 1
      FreeAPS/Sources/Localizations/Main/vi.lproj/Localizable.strings
  43. 1 1
      FreeAPS/Sources/Localizations/Main/zh-Hans.lproj/Localizable.strings
  44. 4 4
      FreeAPS/Sources/Models/BGTargets.swift
  45. 0 10
      FreeAPS/Sources/Models/BloodGlucose.swift
  46. 15 0
      FreeAPS/Sources/Models/ColorSchemeOption.swift
  47. 158 0
      FreeAPS/Sources/Models/DecimalPickerSettings.swift
  48. 10 10
      FreeAPS/Sources/Models/Determination.swift
  49. 16 0
      FreeAPS/Sources/Models/ForecastDisplayType.swift
  50. 12 32
      FreeAPS/Sources/Models/FreeAPSSettings.swift
  51. 22 0
      FreeAPS/Sources/Models/GlucoseColorStyle.swift
  52. 0 16
      FreeAPS/Sources/Models/HistoryLayout.swift
  53. 4 4
      FreeAPS/Sources/Models/InsulinSensitivities.swift
  54. 0 6
      FreeAPS/Sources/Models/NightscoutStatistics.swift
  55. 1 1
      FreeAPS/Sources/Models/Preferences.swift
  56. 1 6
      FreeAPS/Sources/Models/RawFetchedProfile.swift
  57. 22 0
      FreeAPS/Sources/Models/TotalInsulinDisplayType.swift
  58. 5 0
      FreeAPS/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsDataFlow.swift
  59. 64 0
      FreeAPS/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsProvider.swift
  60. 121 0
      FreeAPS/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsStateModel.swift
  61. 325 0
      FreeAPS/Sources/Modules/AlgorithmAdvancedSettings/View/AlgorithmAdvancedSettingsRootView.swift
  62. 5 0
      FreeAPS/Sources/Modules/AutosensSettings/AutosensSettingsDataFlow.swift
  63. 3 0
      FreeAPS/Sources/Modules/AutosensSettings/AutosensSettingsProvider.swift
  64. 51 0
      FreeAPS/Sources/Modules/AutosensSettings/AutosensSettingsStateModel.swift
  65. 119 0
      FreeAPS/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift
  66. 6 0
      FreeAPS/Sources/Modules/AutotuneConfig/AutotuneConfigStateModel.swift
  67. 84 20
      FreeAPS/Sources/Modules/AutotuneConfig/View/AutotuneConfigRootView.swift
  68. 4 3
      FreeAPS/Sources/Modules/BasalProfileEditor/BasalProfileEditorDataFlow.swift
  69. 2 2
      FreeAPS/Sources/Modules/BasalProfileEditor/BasalProfileEditorProvider.swift
  70. 39 8
      FreeAPS/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift
  71. 64 45
      FreeAPS/Sources/Modules/BasalProfileEditor/View/BasalProfileEditorRootView.swift
  72. 2 2
      FreeAPS/Sources/Modules/Bolus/BolusProvider.swift
  73. 115 85
      FreeAPS/Sources/Modules/Bolus/BolusStateModel.swift
  74. 57 66
      FreeAPS/Sources/Modules/Bolus/View/BolusRootView.swift
  75. 57 65
      FreeAPS/Sources/Modules/Bolus/View/ForeCastChart.swift
  76. 45 58
      FreeAPS/Sources/Modules/Bolus/View/PopupView.swift
  77. 9 4
      FreeAPS/Sources/Modules/BolusCalculatorConfig/BolusCalculatorStateModel.swift
  78. 84 60
      FreeAPS/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift
  79. 29 42
      FreeAPS/Sources/Modules/CGM/CGMStateModel.swift
  80. 128 64
      FreeAPS/Sources/Modules/CGM/View/CGMRootView.swift
  81. 5 0
      FreeAPS/Sources/Modules/CalendarEventSettings/CalendarEventSettingsDataFlow.swift
  82. 3 0
      FreeAPS/Sources/Modules/CalendarEventSettings/CalendarEventSettingsProvider.swift
  83. 65 0
      FreeAPS/Sources/Modules/CalendarEventSettings/CalendarEventSettingsStateModel.swift
  84. 132 0
      FreeAPS/Sources/Modules/CalendarEventSettings/View/CalendarEventSettingsRootView.swift
  85. 4 4
      FreeAPS/Sources/Modules/Calibrations/View/CalibrationsRootView.swift
  86. 4 3
      FreeAPS/Sources/Modules/CREditor/CREditorDataFlow.swift
  87. 2 2
      FreeAPS/Sources/Modules/CREditor/CREditorProvider.swift
  88. 31 2
      FreeAPS/Sources/Modules/CREditor/CREditorStateModel.swift
  89. 66 49
      FreeAPS/Sources/Modules/CREditor/View/CREditorRootView.swift
  90. 8 25
      FreeAPS/Sources/Modules/DataTable/DataTableStateModel.swift
  91. 15 3
      FreeAPS/Sources/Modules/DataTable/View/DataTableRootView.swift
  92. 0 5
      FreeAPS/Sources/Modules/Dynamic/DynamicDataFlow.swift
  93. 0 3
      FreeAPS/Sources/Modules/Dynamic/DynamicProvider.swift
  94. 0 112
      FreeAPS/Sources/Modules/Dynamic/View/DynamicRootView.swift
  95. 5 0
      FreeAPS/Sources/Modules/DynamicSettings/DynamicSettingsDataFlow.swift
  96. 3 0
      FreeAPS/Sources/Modules/DynamicSettings/DynamicSettingsProvider.swift
  97. 18 20
      FreeAPS/Sources/Modules/Dynamic/DynamicStateModel.swift
  98. 226 0
      FreeAPS/Sources/Modules/DynamicSettings/View/DynamicSettingsRootView.swift
  99. 0 5
      FreeAPS/Sources/Modules/FPUConfig/FPUConfigDataFlow.swift
  100. 0 0
      FreeAPS/Sources/Modules/FPUConfig/FPUConfigProvider.swift

Разница между файлами не показана из-за своего большого размера
+ 684 - 341
FreeAPS.xcodeproj/project.pbxproj


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

+ 10 - 11
FreeAPS/Resources/json/defaults/freeaps/freeaps_settings.json

@@ -1,5 +1,5 @@
 {
-  "units" : "mmol/L",
+  "units" : "mg/dL",
   "closedLoop" : false,
   "allowAnnouncements" : false,
   "useAutotune" : false,
@@ -9,8 +9,6 @@
   "useLocalGlucoseSource" : false,
   "localGlucosePort" : 8080,
   "debugOptions" : false,
-  "insulinReqPercentage" : 70,
-  "skipBolusScreenAfterCarbs" : false,
   "displayHR" : false,
   "cgm" : "none",
   "cgmManagerTypeByIdentifier":"",
@@ -25,9 +23,9 @@
   "lowGlucose" : 72,
   "highGlucose" : 270,
   "carbsRequiredThreshold" : 10,
-  "animatedBackground" : false,
+  "showCarbsRequiredBadge" : true,
   "useFPUconversion" : true,
-  "tins": false,
+  "totalInsulinDisplayType": "totalDailyDose",
   "individualAdjustmentFactor" : 0.5,
   "timeCap" : 8,
   "minuteInterval" : 30,
@@ -39,23 +37,24 @@
   "high" : 180,
   "low" : 70,
   "hours" : 6,
-  "dynamicGlucoseColor" : false,
+  "glucoseColorStyle" : "staticColor",
   "xGridLines" : true,
   "yGridLines" : true,
   "oneDimensionalGraph" : false,
   "rulerMarks" : true,
-  "displayForecastsAsLines": false,
+  "forecastDisplayType": "cone",
   "maxCarbs": 250,
   "maxFat": 250,
   "maxProtein": 250,
   "displayFatAndProteinOnWatch": false,
   "confirmBolusFaster": false,
   "overrideFactor": 0.8,
-  "useCalc": true,
   "fattyMeals": false,
   "fattyMealFactor": 0.7,
   "sweetMeals": false,
-  "sweetMealFactor": 2
-  "historyLayout": "twoTabs",
-  "lockScreenView": "simple"
+  "sweetMealFactor": 2,
+  "lockScreenView": "simple",
+  "useCalendar": false,
+  "displayCalendarIOBandCOB": false,
+  "displayCalendarEmojis": false
 }

+ 4 - 4
FreeAPS/Resources/json/defaults/settings/bg_targets.json

@@ -1,10 +1,10 @@
 {
-    "units": "mmol/L",
-    "user_preferred_units": "mmol/L",
+    "units": "mg/dL",
+    "user_preferred_units": "mg/dL",
     "targets": [
         {
-            "low": 5.5,
-            "high": 5.5,
+            "low": 100,
+            "high": 100,
             "start": "00:00:00",
             "offset": 0
         }

+ 3 - 3
FreeAPS/Resources/json/defaults/settings/insulin_sensitivities.json

@@ -1,9 +1,9 @@
 {
-    "units": "mmol/L",
-    "user_preferred_units": "mmol/L",
+    "units": "mg/dL",
+    "user_preferred_units": "mg/dL",
     "sensitivities": [
         {
-            "sensitivity": 3.0,
+            "sensitivity": 54,
             "offset": 0,
             "start": "00:00:00"
         }

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

@@ -131,6 +131,15 @@ final class BaseAPSManager: APSManager, Injectable {
     }
 
     private func subscribe() {
+        if settingsManager.settings.units == .mmolL {
+            let wasParsed = storage.parseOnFileSettingsToMgdL()
+            if wasParsed {
+                Task {
+                    try await makeProfiles()
+                }
+            }
+        }
+
         deviceDataManager.recommendsLoop
             .receive(on: processQueue)
             .sink { [weak self] in
@@ -930,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...
@@ -1018,7 +1029,7 @@ final class BaseAPSManager: APSManager, Injectable {
             }
 
             // Insulin placeholder
-            var insulin = Ins(
+            let insulin = Ins(
                 TDD: 0,
                 bolus: 0,
                 temp_basal: 0,
@@ -1057,7 +1068,6 @@ final class BaseAPSManager: APSManager, Injectable {
                 )
             )
             storage.save(dailystat, as: file)
-            await nightscout.uploadStatistics(dailystat: dailystat)
 
             await saveStatsToCoreData()
         }

+ 1 - 36
FreeAPS/Sources/APS/CGM/CGMType.swift

@@ -5,12 +5,7 @@ enum CGMType: String, JSON, CaseIterable, Identifiable {
     case none
     case nightscout
     case xdrip
-//    case dexcomG5
-//    case dexcomG6
-//    case dexcomG7
     case simulator
-//    case libreTransmitter
-    case glucoseDirect
     case enlite
     case plugin
 
@@ -22,18 +17,8 @@ enum CGMType: String, JSON, CaseIterable, Identifiable {
             return "Nightscout"
         case .xdrip:
             return "xDrip4iOS"
-        case .glucoseDirect:
-            return "Glucose Direct"
-//        case .dexcomG5:
-//            return "Dexcom G5"
-//        case .dexcomG6:
-//            return "Dexcom G6"
-//        case .dexcomG7:
-//            return "Dexcom G7"
         case .simulator:
             return NSLocalizedString("Glucose Simulator", comment: "Glucose Simulator CGM type")
-//        case .libreTransmitter:
-//            return NSLocalizedString("Libre Transmitter", comment: "Libre Transmitter type")
         case .enlite:
             return "Medtronic Enlite"
         case .plugin:
@@ -49,8 +34,6 @@ enum CGMType: String, JSON, CaseIterable, Identifiable {
             return nil
         case .xdrip:
             return URL(string: "xdripswift://")!
-        case .glucoseDirect:
-            return URL(string: "libredirect://")!
         case .simulator:
             return nil
         case .plugin:
@@ -61,9 +44,7 @@ enum CGMType: String, JSON, CaseIterable, Identifiable {
     var externalLink: URL? {
         switch self {
         case .xdrip:
-            return URL(string: "https://github.com/JohanDegraeve/xdripswift")!
-        case .glucoseDirect:
-            return URL(string: "https://github.com/creepymonster/GlucoseDirectApp")!
+            return URL(string: "https://xdrip4ios.readthedocs.io/")!
         default: return nil
         }
     }
@@ -79,24 +60,8 @@ enum CGMType: String, JSON, CaseIterable, Identifiable {
                 "Using shared app group with external CGM app xDrip4iOS",
                 comment: "Shared app group xDrip4iOS"
             )
-//        case .dexcomG5:
-//            return NSLocalizedString("Native G5 app", comment: "Native G5 app")
-//        case .dexcomG6:
-//            return NSLocalizedString("Dexcom G6 app", comment: "Dexcom G6 app")
-//        case .dexcomG7:
-//            return NSLocalizedString("Dexcom G7 app", comment: "Dexcom G76 app")
         case .simulator:
             return NSLocalizedString("Simple simulator", comment: "Simple simulator")
-//        case .libreTransmitter:
-//            return NSLocalizedString(
-//                "Direct connection with Libre 1 transmitters or European Libre 2 sensors",
-//                comment: "Direct connection with Libre 1 transmitters or European Libre 2 sensors"
-//            )
-        case .glucoseDirect:
-            return NSLocalizedString(
-                "Using shared app group with external CGM app GlucoseDirect",
-                comment: "Shared app group GlucoseDirect"
-            )
         case .enlite:
             return NSLocalizedString("Minilink transmitter", comment: "Minilink transmitter")
         case .plugin:

+ 37 - 0
FreeAPS/Sources/APS/DeviceDataManager.swift

@@ -1,5 +1,6 @@
 import Algorithms
 import Combine
+import CoreData
 import Foundation
 import LoopKit
 import LoopKitUI
@@ -81,6 +82,18 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
                 pumpDisplayState.value = PumpDisplayState(name: pumpManager.localizedTitle, image: pumpManager.smallImage)
                 pumpName.send(pumpManager.localizedTitle)
 
+                var modifiedPreferences = settingsManager.preferences
+                let bolusIncrement = Decimal(
+                    pumpManager.supportedBolusVolumes.first ??
+                        Double(
+                            settingsManager.preferences
+                                .bolusIncrement
+                        )
+                )
+                modifiedPreferences
+                    .bolusIncrement = bolusIncrement != 0.025 ? bolusIncrement : 0.1
+                storage.save(modifiedPreferences, as: OpenAPS.Settings.preferences)
+
                 if let omnipod = pumpManager as? OmnipodPumpManager {
                     guard let endTime = omnipod.state.podState?.expiresAt else {
                         pumpExpiresAtDate.send(nil)
@@ -140,6 +153,30 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
                 pumpDisplayState.value = nil
                 pumpExpiresAtDate.send(nil)
                 pumpName.send("")
+                // Reset bolusIncrement setting to default value, which is 0.1 U
+                var modifiedPreferences = settingsManager.preferences
+                modifiedPreferences.bolusIncrement = 0.1
+                storage.save(modifiedPreferences, as: OpenAPS.Settings.preferences)
+                // Remove OpenAPS_Battery entries
+                Task {
+                    await self.privateContext.perform {
+                        let fetchRequest: NSFetchRequest<OpenAPS_Battery> = OpenAPS_Battery.fetchRequest()
+
+                        do {
+                            let batteryEntries = try self.privateContext.fetch(fetchRequest)
+
+                            for entry in batteryEntries {
+                                self.privateContext.delete(entry)
+                            }
+
+                            guard self.privateContext.hasChanges else { return }
+                            try self.privateContext.save()
+
+                        } catch {
+                            print("Failed to delete OpenAPS_Battery entries: \(error.localizedDescription)")
+                        }
+                    }
+                }
             }
         }
     }

+ 0 - 2
FreeAPS/Sources/APS/FetchGlucoseManager.swift

@@ -136,8 +136,6 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
                 glucoseSource = nightscoutManager
             case .simulator:
                 glucoseSource = simulatorSource
-            case .glucoseDirect:
-                glucoseSource = AppGroupSource(from: "GlucoseDirect", cgmType: .glucoseDirect)
             case .enlite:
                 glucoseSource = deviceDataManager
             case .plugin:

+ 1 - 1
FreeAPS/Sources/APS/FetchTreatmentsManager.swift

@@ -33,7 +33,7 @@ final class BaseFetchTreatmentsManager: FetchTreatmentsManager, Injectable {
 
                     let filteredCarbs = await carbs.filter { !($0.enteredBy?.contains(CarbsEntry.manual) ?? false) }
                     if filteredCarbs.isNotEmpty {
-                        await self.carbsStorage.storeCarbs(filteredCarbs)
+                        await self.carbsStorage.storeCarbs(filteredCarbs, areFetchedFromRemote: true)
                     }
 
                     let filteredTargets = await tempTargets.filter { !($0.enteredBy?.contains(TempTarget.manual) ?? false) }

+ 26 - 25
FreeAPS/Sources/APS/OpenAPS/OpenAPS.swift

@@ -102,17 +102,19 @@ final class OpenAPS {
             fetchLimit: 2
         )
 
-        guard let previousDeterminations = results as? [OrefDetermination] else {
-            return
-        }
+        await context.perform {
+            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))
+            // 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 != self.decimalToNSDecimalNumber(determination.iob)
+                let cobChanged = previousDetermination.cob != Int16(Int(determination.cob ?? 0))
 
-            if iobChanged || cobChanged {
-                Foundation.NotificationCenter.default.post(name: .didUpdateCobIob, object: nil)
+                if iobChanged || cobChanged {
+                    Foundation.NotificationCenter.default.post(name: .didUpdateCobIob, object: nil)
+                }
             }
         }
     }
@@ -140,11 +142,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 +161,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 +211,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)
         }
     }
@@ -249,12 +251,12 @@ final class OpenAPS {
             if let bolusDTO = event.toBolusDTOEnum() {
                 eventDTOs.append(bolusDTO)
             }
-            if let tempBasalDTO = event.toTempBasalDTOEnum() {
-                eventDTOs.append(tempBasalDTO)
-            }
             if let tempBasalDurationDTO = event.toTempBasalDurationDTOEnum() {
                 eventDTOs.append(tempBasalDurationDTO)
             }
+            if let tempBasalDTO = event.toTempBasalDTOEnum() {
+                eventDTOs.append(tempBasalDTO)
+            }
             return eventDTOs
         }
         return dtos
@@ -466,7 +468,6 @@ final class OpenAPS {
             let weighted_average = weight * average2hours + (1 - weight) * average14
 
             var duration: Decimal = 0
-            var newDuration: Decimal = 0
             var overrideTarget: Decimal = 0
 
             if useOverride {

+ 2 - 2
FreeAPS/Sources/APS/PluginManager.swift

@@ -26,10 +26,10 @@ class BasePluginManager: Injectable, PluginManager {
                 {
                     if let bundle = Bundle(url: pluginURL) {
                         if let bname = bundle.object(forInfoDictionaryKey: "CFBundleName") as? String {
-                            debug(.deviceManager, "bundle name2:\(bname)")
+                            debug(.deviceManager, "bundle name: \(bname)")
                         }
                         if let bcgm = bundle.object(forInfoDictionaryKey: "com.loopkit.Loop.CGMManagerIdentifier") as? String {
-                            debug(.deviceManager, "bundle is CGM")
+                            debug(.deviceManager, "bundle is CGM: \(bcgm)")
                         }
 
                         if bundle.isLoopPlugin {

+ 108 - 18
FreeAPS/Sources/APS/Storage/CarbsStorage.swift

@@ -1,3 +1,4 @@
+import Combine
 import CoreData
 import Foundation
 import SwiftDate
@@ -8,7 +9,9 @@ protocol CarbsObserver {
 }
 
 protocol CarbsStorage {
-    func storeCarbs(_ carbs: [CarbsEntry]) async
+    var updatePublisher: AnyPublisher<Void, Never> { get }
+    func storeCarbs(_ carbs: [CarbsEntry], areFetchedFromRemote: Bool) async
+    func deleteCarbs(_ treatmentObjectID: NSManagedObjectID) async
     func syncDate() -> Date
     func recent() -> [CarbsEntry]
     func getCarbsNotYetUploadedToNightscout() async -> [NightscoutTreatment]
@@ -24,13 +27,53 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
 
     let coredataContext = CoreDataStack.shared.newTaskContext()
 
+    private let updateSubject = PassthroughSubject<Void, Never>()
+
+    var updatePublisher: AnyPublisher<Void, Never> {
+        updateSubject.eraseToAnyPublisher()
+    }
+
     init(resolver: Resolver) {
         injectServices(resolver)
     }
 
-    func storeCarbs(_ entries: [CarbsEntry]) async {
-        await saveCarbEquivalents(entries: entries)
-        await saveCarbsToCoreData(entries: entries)
+    func storeCarbs(_ entries: [CarbsEntry], areFetchedFromRemote: Bool) async {
+        var entriesToStore = entries
+
+        if areFetchedFromRemote {
+            entriesToStore = await filterRemoteEntries(entries: entriesToStore)
+        }
+
+        await saveCarbEquivalents(entries: entriesToStore, areFetchedFromRemote: areFetchedFromRemote)
+        await saveCarbsToCoreData(entries: entriesToStore, areFetchedFromRemote: areFetchedFromRemote)
+    }
+
+    private func filterRemoteEntries(entries: [CarbsEntry]) async -> [CarbsEntry] {
+        // Fetch only the date property from Core Data
+        guard let existing24hCarbEntries = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: CarbEntryStored.self,
+            onContext: coredataContext,
+            predicate: NSPredicate.predicateForOneDayAgo,
+            key: "date",
+            ascending: false,
+            batchSize: 50,
+            propertiesToFetch: ["date", "objectID"]
+        ) as? [[String: Any]] else {
+            return entries
+        }
+
+        // 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
+        var filteredEntries = entries
+        filteredEntries.removeAll { entry in
+            let entryDate = entry.actualDate ?? entry.createdAt
+            return existingTimestamps.contains(entryDate)
+        }
+
+        return filteredEntries
     }
 
     /**
@@ -129,7 +172,7 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         return (futureCarbArray, carbEquivalents)
     }
 
-    private func saveCarbEquivalents(entries: [CarbsEntry]) async {
+    private func saveCarbEquivalents(entries: [CarbsEntry], areFetchedFromRemote: Bool) async {
         guard let lastEntry = entries.last else { return }
 
         if let fat = lastEntry.fat, let protein = lastEntry.protein, fat > 0 || protein > 0 {
@@ -142,12 +185,12 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
             )
 
             if carbEquivalentCount > 0 {
-                await saveFPUToCoreDataAsBatchInsert(entries: futureCarbEquivalents)
+                await saveFPUToCoreDataAsBatchInsert(entries: futureCarbEquivalents, areFetchedFromRemote: areFetchedFromRemote)
             }
         }
     }
 
-    private func saveCarbsToCoreData(entries: [CarbsEntry]) async {
+    private func saveCarbsToCoreData(entries: [CarbsEntry], areFetchedFromRemote: Bool) async {
         guard let entry = entries.last, entry.carbs != 0 else { return }
 
         await coredataContext.perform {
@@ -159,7 +202,7 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
             newItem.note = entry.note
             newItem.id = UUID()
             newItem.isFPU = false
-            newItem.isUploadedToNS = false
+            newItem.isUploadedToNS = areFetchedFromRemote ? true : false
 
             do {
                 guard self.coredataContext.hasChanges else { return }
@@ -170,7 +213,7 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         }
     }
 
-    private func saveFPUToCoreDataAsBatchInsert(entries: [CarbsEntry]) async {
+    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
         var entrySlice = ArraySlice(entries) // convert to ArraySlice
@@ -185,7 +228,7 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
             carbEntry.id = UUID.init(uuidString: entryId)
             carbEntry.fpuID = commonFPUID
             carbEntry.isFPU = true
-            carbEntry.isUploadedToNS = false
+            carbEntry.isUploadedToNS = areFetchedFromRemote ? true : false
             return false // return false to continue
         }
         await coredataContext.perform {
@@ -193,8 +236,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
                 try self.coredataContext.execute(batchInsert)
                 debugPrint("Carbs Storage: \(DebuggingIdentifiers.succeeded) saved fpus to core data")
 
-                // Send notification for triggering a fetch in Home State Model to update the FPU Array
-                Foundation.NotificationCenter.default.post(name: .didPerformBatchInsert, object: nil)
+                // Notify subscriber in Home State Model to update the FPU Array
+                self.updateSubject.send(())
             } catch {
                 debugPrint("Carbs Storage: \(DebuggingIdentifiers.failed) error while saving fpus to core data")
             }
@@ -209,6 +252,53 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         storage.retrieve(OpenAPS.Monitor.carbHistory, as: [CarbsEntry].self)?.reversed() ?? []
     }
 
+    func deleteCarbs(_ treatmentObjectID: NSManagedObjectID) async {
+        let taskContext = CoreDataStack.shared.newTaskContext()
+        taskContext.name = "deleteContext"
+        taskContext.transactionAuthor = "deleteCarbs"
+
+        var carbEntry: CarbEntryStored?
+
+        await taskContext.perform {
+            do {
+                carbEntry = try taskContext.existingObject(with: treatmentObjectID) as? CarbEntryStored
+                guard let carbEntry = carbEntry else {
+                    debugPrint("Carb entry for batch delete not found. \(DebuggingIdentifiers.failed)")
+                    return
+                }
+
+                if carbEntry.isFPU, let fpuID = carbEntry.fpuID {
+                    // fetch request for all carb entries with the same id
+                    let fetchRequest: NSFetchRequest<NSFetchRequestResult> = CarbEntryStored.fetchRequest()
+                    fetchRequest.predicate = NSPredicate(format: "fpuID == %@", fpuID as CVarArg)
+
+                    // NSBatchDeleteRequest
+                    let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
+                    deleteRequest.resultType = .resultTypeCount
+
+                    // execute the batch delete request
+                    let result = try taskContext.execute(deleteRequest) as? NSBatchDeleteResult
+                    debugPrint("\(DebuggingIdentifiers.succeeded) Deleted \(result?.result ?? 0) items with FpuID \(fpuID)")
+
+                    // Notifiy subscribers of the batch delete
+                    self.updateSubject.send(())
+                } else {
+                    taskContext.delete(carbEntry)
+
+                    guard taskContext.hasChanges else { return }
+                    try taskContext.save()
+
+                    debugPrint(
+                        "Data Table State: \(#function) \(DebuggingIdentifiers.succeeded) deleted carb entry from core data"
+                    )
+                }
+
+            } catch {
+                debugPrint("\(DebuggingIdentifiers.failed) Error deleting carb entry: \(error.localizedDescription)")
+            }
+        }
+    }
+
     func deleteCarbs(at uniqueID: String, fpuID: String, complex: Bool) {
         processQueue.sync {
             var allValues = storage.retrieve(OpenAPS.Monitor.carbHistory, as: [CarbsEntry].self) ?? []
@@ -248,11 +338,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,
@@ -287,9 +377,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,

+ 38 - 6
FreeAPS/Sources/APS/Storage/DeterminationStorage.swift

@@ -1,3 +1,4 @@
+import Combine
 import CoreData
 import Foundation
 import Swinject
@@ -6,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?
 }
 
@@ -27,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)
         }
     }
 
@@ -38,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 []
                 }
@@ -72,6 +77,35 @@ 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]) {
+        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)
+    }
+
     // Convert NSDecimalNumber to Decimal
     func decimal(from nsDecimalNumber: NSDecimalNumber?) -> Decimal {
         nsDecimalNumber?.decimalValue ?? 0.0
@@ -126,8 +160,6 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable {
 
                 // Check if the fetched object is of the expected type
                 if let orefDetermination = orefDetermination {
-                    let forecastSet = orefDetermination.forecasts
-
                     result = Determination(
                         id: orefDetermination.id ?? UUID(),
                         reason: orefDetermination.reason ?? "",

+ 13 - 7
FreeAPS/Sources/APS/Storage/GlucoseStorage.swift

@@ -1,4 +1,5 @@
 import AVFAudio
+import Combine
 import CoreData
 import Foundation
 import SwiftDate
@@ -6,6 +7,7 @@ import SwiftUI
 import Swinject
 
 protocol GlucoseStorage {
+    var updatePublisher: AnyPublisher<Void, Never> { get }
     func storeGlucose(_ glucose: [BloodGlucose])
     func isGlucoseDataFresh(_ glucoseDate: Date?) -> Bool
     func syncDate() -> Date
@@ -26,6 +28,12 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
     let coredataContext = CoreDataStack.shared.newTaskContext()
 
+    private let updateSubject = PassthroughSubject<Void, Never>()
+
+    var updatePublisher: AnyPublisher<Void, Never> {
+        updateSubject.eraseToAnyPublisher()
+    }
+
     private enum Config {
         static let filterTime: TimeInterval = 3.5 * 60
     }
@@ -87,12 +95,10 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
                 // process batch insert
                 do {
                     try self.coredataContext.execute(batchInsert)
-//                    debugPrint("Glucose Storage: \(#function) \(DebuggingIdentifiers.succeeded) saved glucose to Core Data")
 
-                    // Send notification for triggering a fetch in Home State Model to update the Glucose Array
-                    /// This is necessary because changes only get merged automatically into the viewContext because of the Persistent History Tracking
-                    /// But I do not want to fetch on the Main Thread using the @FetchRequest property, I also can not use the FetchedResultsController because of the architecture of the State Model (it must inherit from BaseStateModel and therefore can not inherit from NSObject as well) and because of the fact that I am using a batch insert here there are no notifications sent from the managedObjectContext because changes are directly stored in the persistent container
-                    Foundation.NotificationCenter.default.post(name: .didPerformBatchInsert, object: nil)
+                    // Notify subscribers that there is a new glucose value
+                    // We need to do this because the due to the batch insert there is no ManagedObjectContext notification
+                    self.updateSubject.send(())
                 } catch {
                     debugPrint(
                         "Glucose Storage: \(#function) \(DebuggingIdentifiers.failed) failed to execute batch insert: \(error)"
@@ -246,9 +252,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,

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

@@ -45,9 +45,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 +62,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 +79,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 +220,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 +250,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

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

@@ -1,3 +1,4 @@
+import Combine
 import CoreData
 import Foundation
 import LoopKit
@@ -9,6 +10,7 @@ protocol PumpHistoryObserver {
 }
 
 protocol PumpHistoryStorage {
+    var updatePublisher: AnyPublisher<Void, Never> { get }
     func storePumpEvents(_ events: [NewPumpEvent])
     func storeExternalInsulinEvent(amount: Decimal, timestamp: Date) async
     func recent() -> [PumpHistoryEvent]
@@ -22,6 +24,12 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
     @Injected() private var broadcaster: Broadcaster!
     @Injected() private var settings: SettingsManager!
 
+    private let updateSubject = PassthroughSubject<Void, Never>()
+
+    var updatePublisher: AnyPublisher<Void, Never> {
+        updateSubject.eraseToAnyPublisher()
+    }
+
     init(resolver: Resolver) {
         injectServices(resolver)
     }
@@ -190,6 +198,8 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                 do {
                     guard self.context.hasChanges else { return }
                     try self.context.save()
+
+                    self.updateSubject.send(())
                     debugPrint("\(DebuggingIdentifiers.succeeded) stored pump events in Core Data")
                 } catch let error as NSError {
                     debugPrint("\(DebuggingIdentifiers.failed) failed to store pump events with error: \(error.userInfo)")
@@ -218,6 +228,8 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
             do {
                 guard self.context.hasChanges else { return }
                 try self.context.save()
+
+                self.updateSubject.send(())
             } catch {
                 print(error.localizedDescription)
             }
@@ -262,10 +274,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)

+ 15 - 0
FreeAPS/Sources/Application/FreeAPSApp.swift

@@ -10,6 +10,9 @@ import Swinject
 
     @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
 
+    // Read the color scheme preference from UserDefaults; defaults to system default setting
+    @AppStorage("colorSchemePreference") private var colorSchemePreference: ColorSchemeOption = .systemDefault
+
     let coreDataStack = CoreDataStack.shared
 
     // Dependencies Assembler
@@ -67,6 +70,7 @@ import Swinject
     var body: some Scene {
         WindowGroup {
             Main.RootView(resolver: resolver)
+                .preferredColorScheme(colorScheme(for: colorSchemePreference ?? .systemDefault) ?? nil)
                 .environment(\.managedObjectContext, coreDataStack.persistentContainer.viewContext)
                 .environmentObject(Icons())
                 .onOpenURL(perform: handleURL)
@@ -85,6 +89,17 @@ import Swinject
         }
     }
 
+    private func colorScheme(for colorScheme: ColorSchemeOption) -> ColorScheme? {
+        switch colorScheme {
+        case .systemDefault:
+            return nil // Uses the system theme.
+        case .light:
+            return .light
+        case .dark:
+            return .dark
+        }
+    }
+
     func scheduleDatabaseCleaning() {
         let request = BGAppRefreshTaskRequest(identifier: "com.openiaps.cleanup")
         request.earliestBeginDate = .now.addingTimeInterval(7 * 24 * 60 * 60) // 7 days

+ 2 - 3
FreeAPS/Sources/Helpers/DynamicGlucoseColor.swift

@@ -1,4 +1,3 @@
-import CoreData
 import Foundation
 import SwiftUI
 
@@ -8,7 +7,7 @@ public func getDynamicGlucoseColor(
     highGlucoseColorValue: Decimal,
     lowGlucoseColorValue: Decimal,
     targetGlucose: Decimal,
-    dynamicGlucoseColor: Bool,
+    glucoseColorStyle: GlucoseColorStyle,
     offset: Decimal
 ) -> Color {
     // Convert Decimal to Int for high and low glucose values
@@ -17,7 +16,7 @@ public func getDynamicGlucoseColor(
     let targetGlucose = targetGlucose
 
     // Only use calculateHueBasedGlucoseColor if the setting is enabled in preferences
-    if dynamicGlucoseColor {
+    if GlucoseColorStyle == .dynamicColor {
         return calculateHueBasedGlucoseColor(
             glucoseValue: glucoseValue,
             highGlucose: highGlucose,

+ 99 - 0
FreeAPS/Sources/Helpers/MainChartHelper.swift

@@ -0,0 +1,99 @@
+import CoreData
+import Foundation
+
+enum MainChartHelper {
+    // Calculates the glucose value thats the nearest to parameter 'time'
+    /// -Returns: A NSManagedObject of GlucoseStored
+    /// it is thread safe as everything is executed on the main thread
+    static func timeToNearestGlucose(glucoseValues: [GlucoseStored], time: TimeInterval) -> GlucoseStored? {
+        guard !glucoseValues.isEmpty else {
+            return nil
+        }
+
+        var low = 0
+        var high = glucoseValues.count - 1
+        var closestGlucose: GlucoseStored?
+
+        // binary search to find next glucose
+        while low <= high {
+            let mid = low + (high - low) / 2
+            let midTime = glucoseValues[mid].date?.timeIntervalSince1970 ?? 0
+
+            if midTime == time {
+                return glucoseValues[mid]
+            } else if midTime < time {
+                low = mid + 1
+            } else {
+                high = mid - 1
+            }
+
+            // update if necessary
+            if closestGlucose == nil || abs(midTime - time) < abs(closestGlucose!.date?.timeIntervalSince1970 ?? 0 - time) {
+                closestGlucose = glucoseValues[mid]
+            }
+        }
+
+        return closestGlucose
+    }
+
+    enum Config {
+        static let bolusSize: CGFloat = 5
+        static let bolusScale: CGFloat = 1.8
+        static let carbsSize: CGFloat = 5
+        static let maxCarbSize: CGFloat = 30
+        static let carbsScale: CGFloat = 0.3
+        static let fpuSize: CGFloat = 10
+        static let maxGlucose = 270
+        static let minGlucose = 45
+    }
+
+    static var bolusFormatter: NumberFormatter {
+        let formatter = NumberFormatter()
+        formatter.numberStyle = .decimal
+        formatter.minimumIntegerDigits = 0
+        formatter.maximumFractionDigits = 2
+        formatter.decimalSeparator = "."
+        return formatter
+    }
+
+    static var carbsFormatter: NumberFormatter {
+        let formatter = NumberFormatter()
+        formatter.numberStyle = .decimal
+        formatter.maximumFractionDigits = 0
+        return formatter
+    }
+
+    static func bolusOffset(units: GlucoseUnits) -> Decimal {
+        units == .mgdL ? 30 : 1.66
+    }
+
+    static func calculateDuration(objectID: NSManagedObjectID, context: NSManagedObjectContext) -> TimeInterval? {
+        do {
+            if let override = try context.existingObject(with: objectID) as? OverrideStored,
+               let overrideDuration = override.duration as? Double, overrideDuration != 0
+            {
+                return TimeInterval(overrideDuration * 60) // return seconds
+            }
+        } catch {
+            debugPrint(
+                "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to calculate Override Target with error: \(error.localizedDescription)"
+            )
+        }
+        return nil
+    }
+
+    static func calculateTarget(objectID: NSManagedObjectID, context: NSManagedObjectContext) -> Decimal? {
+        do {
+            if let override = try context.existingObject(with: objectID) as? OverrideStored,
+               let overrideTarget = override.target, overrideTarget != 0
+            {
+                return overrideTarget.decimalValue
+            }
+        } catch {
+            debugPrint(
+                "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to calculate Override Target with error: \(error.localizedDescription)"
+            )
+        }
+        return nil
+    }
+}

Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Sources/Localizations/Main/ar.lproj/Localizable.strings


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Sources/Localizations/Main/ca.lproj/Localizable.strings


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Sources/Localizations/Main/da.lproj/Localizable.strings


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Sources/Localizations/Main/de.lproj/Localizable.strings


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Sources/Localizations/Main/en.lproj/Localizable.strings


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Sources/Localizations/Main/es.lproj/Localizable.strings


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Sources/Localizations/Main/fi.lproj/Localizable.strings


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Sources/Localizations/Main/fr.lproj/Localizable.strings


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Sources/Localizations/Main/he.lproj/Localizable.strings


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Sources/Localizations/Main/hu.lproj/Localizable.strings


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Sources/Localizations/Main/it.lproj/Localizable.strings


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Sources/Localizations/Main/nb.lproj/Localizable.strings


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Sources/Localizations/Main/nl.lproj/Localizable.strings


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Sources/Localizations/Main/pl.lproj/Localizable.strings


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Sources/Localizations/Main/pt-BR.lproj/Localizable.strings


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Sources/Localizations/Main/pt-PT.lproj/Localizable.strings


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Sources/Localizations/Main/ru.lproj/Localizable.strings


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Sources/Localizations/Main/sk.lproj/Localizable.strings


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Sources/Localizations/Main/sv.lproj/Localizable.strings


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Sources/Localizations/Main/tr.lproj/Localizable.strings


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Sources/Localizations/Main/uk.lproj/Localizable.strings


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Sources/Localizations/Main/vi.lproj/Localizable.strings


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Sources/Localizations/Main/zh-Hans.lproj/Localizable.strings


+ 4 - 4
FreeAPS/Sources/Models/BGTargets.swift

@@ -1,15 +1,15 @@
 import Foundation
 
 struct BGTargets: JSON {
-    let units: GlucoseUnits
-    let userPrefferedUnits: GlucoseUnits
-    let targets: [BGTargetEntry]
+    var units: GlucoseUnits
+    var userPreferredUnits: GlucoseUnits
+    var targets: [BGTargetEntry]
 }
 
 extension BGTargets {
     private enum CodingKeys: String, CodingKey {
         case units
-        case userPrefferedUnits = "user_preferred_units"
+        case userPreferredUnits = "user_preferred_units"
         case targets
     }
 }

+ 0 - 10
FreeAPS/Sources/Models/BloodGlucose.swift

@@ -20,43 +20,33 @@ struct BloodGlucose: JSON, Identifiable, Hashable {
         init?(from string: String) {
             switch string {
             case "\u{2191}\u{2191}\u{2191}",
-                 "↑↑↑",
                  "TripleUp":
                 self = .tripleUp
             case "\u{2191}\u{2191}",
-                 "↑↑",
                  "DoubleUp":
                 self = .doubleUp
             case "\u{2191}",
-                 "↑",
                  "SingleUp":
                 self = .singleUp
             case "\u{2197}",
-                 "↗︎",
                  "FortyFiveUp":
                 self = .fortyFiveUp
             case "\u{2192}",
-                 "→",
                  "Flat":
                 self = .flat
             case "\u{2198}",
-                 "↘︎",
                  "FortyFiveDown":
                 self = .fortyFiveDown
             case "\u{2193}",
-                 "↓",
                  "SingleDown":
                 self = .singleDown
             case "\u{2193}\u{2193}",
-                 "↓↓",
                  "DoubleDown":
                 self = .doubleDown
             case "\u{2193}\u{2193}\u{2193}",
-                 "↓↓↓",
                  "TripleDown":
                 self = .tripleDown
             case "\u{2194}",
-                 "↔︎",
                  "NONE":
                 self = .none
             case "NOT COMPUTABLE":

+ 15 - 0
FreeAPS/Sources/Models/ColorSchemeOption.swift

@@ -0,0 +1,15 @@
+enum ColorSchemeOption: String, JSON, CaseIterable, Identifiable {
+    var id: String { rawValue }
+
+    case systemDefault
+    case light
+    case dark
+
+    var displayName: String {
+        switch self {
+        case .systemDefault: return "System Default"
+        case .light: return "Light"
+        case .dark: return "Dark"
+        }
+    }
+}

+ 158 - 0
FreeAPS/Sources/Models/DecimalPickerSettings.swift

@@ -0,0 +1,158 @@
+import SwiftUI
+
+class PickerSettingsProvider: ObservableObject {
+    static let shared = PickerSettingsProvider()
+
+    var settings = DecimalPickerSettings()
+
+    private init() {} // Private init to enforce singleton pattern
+
+    // Helper function to generate values for the picker
+    func generatePickerValues(from setting: PickerSetting, units: GlucoseUnits) -> [Decimal] {
+        var values: [Decimal] = []
+        var currentValue = setting.min
+
+        while currentValue <= setting.max {
+            values.append(currentValue)
+            currentValue += setting.step
+        }
+
+        // Glucose values are stored as mg/dl values, so Integers.
+        // Filter out odd numbers to avoid duplicate mmol/L values due to rounding.
+        if units == .mmolL, setting.type == PickerSetting.PickerSettingType.glucose {
+            values = values.filter { Int($0) % 2 == 0 }
+        }
+        return values
+    }
+}
+
+struct DecimalPickerSettings {
+    var lowGlucose = PickerSetting(value: 72, step: 1, min: 40, max: 100, type: PickerSetting.PickerSettingType.glucose)
+    var highGlucose = PickerSetting(value: 270, step: 1, min: 100, max: 500, type: PickerSetting.PickerSettingType.glucose)
+    var carbsRequiredThreshold = PickerSetting(value: 10, step: 1, min: 0, max: 100, type: PickerSetting.PickerSettingType.gramms)
+    var individualAdjustmentFactor = PickerSetting(
+        value: 0.5,
+        step: 0.05,
+        min: 0.1,
+        max: 2,
+        type: PickerSetting.PickerSettingType.factor
+    )
+    var high = PickerSetting(value: 180, step: 1, min: 100, max: 500, type: PickerSetting.PickerSettingType.glucose)
+    var low = PickerSetting(value: 70, step: 1, min: 40, max: 100, type: PickerSetting.PickerSettingType.glucose)
+    var maxCarbs = PickerSetting(value: 250, step: 5, min: 0, max: 300, type: PickerSetting.PickerSettingType.gramms)
+    var maxFat = PickerSetting(value: 250, step: 5, min: 0, max: 300, type: PickerSetting.PickerSettingType.gramms)
+    var maxProtein = PickerSetting(value: 250, step: 5, min: 0, max: 300, type: PickerSetting.PickerSettingType.gramms)
+    var overrideFactor = PickerSetting(value: 0.8, step: 0.05, min: 0.5, max: 1.5, type: PickerSetting.PickerSettingType.factor)
+    var fattyMealFactor = PickerSetting(value: 0.7, step: 0.05, min: 0.5, max: 2, type: PickerSetting.PickerSettingType.factor)
+    var sweetMealFactor = PickerSetting(value: 2, step: 0.05, min: 1, max: 3, type: PickerSetting.PickerSettingType.factor)
+    var maxIOB = PickerSetting(value: 0, step: 1, min: 0, max: 20, type: PickerSetting.PickerSettingType.insulinUnit)
+    var maxDailySafetyMultiplier = PickerSetting(
+        value: 3,
+        step: 0.1,
+        min: 1,
+        max: 5,
+        type: PickerSetting.PickerSettingType.factor
+    )
+    var currentBasalSafetyMultiplier = PickerSetting(
+        value: 4,
+        step: 0.1,
+        min: 1,
+        max: 5,
+        type: PickerSetting.PickerSettingType.factor
+    )
+    var autosensMax = PickerSetting(value: 1.2, step: 0.1, min: 0.5, max: 2, type: PickerSetting.PickerSettingType.factor)
+    var autosensMin = PickerSetting(value: 0.7, step: 0.1, min: 0.5, max: 1, type: PickerSetting.PickerSettingType.factor)
+    var smbDeliveryRatio = PickerSetting(value: 0.5, step: 0.05, min: 0.3, max: 0.7, type: PickerSetting.PickerSettingType.factor)
+    var halfBasalExerciseTarget = PickerSetting(
+        value: 160,
+        step: 1,
+        min: 100,
+        max: 200,
+        type: PickerSetting.PickerSettingType.glucose
+    )
+    var maxCOB = PickerSetting(value: 120, step: 5, min: 0, max: 300, type: PickerSetting.PickerSettingType.gramms)
+    var min5mCarbimpact = PickerSetting(value: 8, step: 1, min: 0, max: 20, type: PickerSetting.PickerSettingType.gramms)
+    var autotuneISFAdjustmentFraction = PickerSetting(
+        value: 1.0,
+        step: 0.05,
+        min: 0,
+        max: 1,
+        type: PickerSetting.PickerSettingType.factor
+    )
+    var remainingCarbsFraction = PickerSetting(
+        value: 1.0,
+        step: 0.05,
+        min: 0.5,
+        max: 1,
+        type: PickerSetting.PickerSettingType.factor
+    )
+    var remainingCarbsCap = PickerSetting(value: 90, step: 5, min: 0, max: 200, type: PickerSetting.PickerSettingType.gramms)
+    var maxSMBBasalMinutes = PickerSetting(value: 30, step: 5, min: 30, max: 180, type: PickerSetting.PickerSettingType.minute)
+    var maxUAMSMBBasalMinutes = PickerSetting(value: 30, step: 5, min: 30, max: 180, type: PickerSetting.PickerSettingType.minute)
+    var smbInterval = PickerSetting(value: 3, step: 1, min: 1, max: 10, type: PickerSetting.PickerSettingType.minute)
+    var bolusIncrement = PickerSetting(
+        value: 0.1,
+        step: 0.05,
+        min: 0.05,
+        max: 1,
+        type: PickerSetting.PickerSettingType.insulinUnit
+    )
+    var insulinPeakTime = PickerSetting(value: 75, step: 5, min: 35, max: 120, type: PickerSetting.PickerSettingType.minute)
+    var carbsReqThreshold = PickerSetting(value: 1.0, step: 0.1, min: 0, max: 10, type: PickerSetting.PickerSettingType.gramms)
+    var noisyCGMTargetMultiplier = PickerSetting(
+        value: 1.3,
+        step: 0.05,
+        min: 1,
+        max: 2,
+        type: PickerSetting.PickerSettingType.factor
+    )
+    var maxDeltaBGthreshold = PickerSetting(
+        value: 0.2,
+        step: 0.05,
+        min: 0.1,
+        max: 2,
+        type: PickerSetting.PickerSettingType.factor
+    )
+    var adjustmentFactor = PickerSetting(value: 0.8, step: 0.1, min: 0.5, max: 1.5, type: PickerSetting.PickerSettingType.factor)
+    var adjustmentFactorSigmoid = PickerSetting(
+        value: 0.5,
+        step: 0.1,
+        min: 0.5,
+        max: 2,
+        type: PickerSetting.PickerSettingType.factor
+    )
+    var weightPercentage = PickerSetting(value: 0.65, step: 0.1, min: 0.1, max: 1, type: PickerSetting.PickerSettingType.factor)
+    var enableSMB_high_bg_target = PickerSetting(
+        value: 110,
+        step: 1,
+        min: 70,
+        max: 200,
+        type: PickerSetting.PickerSettingType.glucose
+    )
+    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 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 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)
+    var maxBasal = PickerSetting(value: 10, step: 0.5, min: 1, max: 30, type: PickerSetting.PickerSettingType.insulinUnit)
+}
+
+struct PickerSetting {
+    var value: Decimal
+    var step: Decimal
+    var min: Decimal
+    var max: Decimal
+    var type: PickerSettingType
+
+    enum PickerSettingType {
+        case glucose
+        case factor
+        case gramms
+        case insulinUnit
+        case minute
+        case hour
+    }
+}

+ 10 - 10
FreeAPS/Sources/Models/Determination.swift

@@ -2,10 +2,10 @@ import Foundation
 
 struct Determination: JSON, Equatable {
     let id: UUID?
-    let reason: String
+    var reason: String
     let units: Decimal?
     let insulinReq: Decimal?
-    let eventualBG: Int?
+    var eventualBG: Int?
     let sensitivityRatio: Decimal?
     let rate: Decimal?
     let duration: Decimal?
@@ -15,20 +15,20 @@ struct Determination: JSON, Equatable {
     var deliverAt: Date?
     let carbsReq: Decimal?
     let temp: TempType?
-    let bg: Decimal?
+    var bg: Decimal?
     let reservoir: Decimal?
-    let isf: Decimal?
+    var isf: Decimal?
     var timestamp: Date?
     let tdd: Decimal?
     let insulin: Insulin?
-    let current_target: Decimal?
+    var current_target: Decimal?
     let insulinForManualBolus: Decimal?
     let manualBolusErrorString: Decimal?
-    let minDelta: Decimal?
-    let expectedDelta: Decimal?
-    let minGuardBG: Decimal?
-    let minPredBG: Decimal?
-    let threshold: Decimal?
+    var minDelta: Decimal?
+    var expectedDelta: Decimal?
+    var minGuardBG: Decimal?
+    var minPredBG: Decimal?
+    var threshold: Decimal?
     let carbRatio: Decimal?
     let received: Bool?
 }

+ 16 - 0
FreeAPS/Sources/Models/ForecastDisplayType.swift

@@ -0,0 +1,16 @@
+import Foundation
+
+enum ForecastDisplayType: String, JSON, CaseIterable, Identifiable, Codable, Hashable {
+    var id: String { rawValue }
+    case cone
+    case lines
+    var displayName: String {
+        switch self {
+        case .cone:
+            return NSLocalizedString("Cone", comment: "")
+
+        case .lines:
+            return NSLocalizedString("Lines", comment: "")
+        }
+    }
+}

+ 12 - 32
FreeAPS/Sources/Models/FreeAPSSettings.swift

@@ -25,8 +25,6 @@ struct FreeAPSSettings: JSON, Equatable {
     var useLocalGlucoseSource: Bool = false
     var localGlucosePort: Int = 8080
     var debugOptions: Bool = false
-    var insulinReqPercentage: Decimal = 70
-    var skipBolusScreenAfterCarbs: Bool = false
     var displayHR: Bool = false
     var cgm: CGMType = .none
     var cgmPluginIdentifier: String = ""
@@ -41,9 +39,9 @@ struct FreeAPSSettings: JSON, Equatable {
     var lowGlucose: Decimal = 72
     var highGlucose: Decimal = 270
     var carbsRequiredThreshold: Decimal = 10
-    var animatedBackground: Bool = false
+    var showCarbsRequiredBadge: Bool = true
     var useFPUconversion: Bool = true
-    var tins: Bool = false
+    var totalInsulinDisplayType: TotalInsulinDisplayType = .totalDailyDose
     var individualAdjustmentFactor: Decimal = 0.5
     var timeCap: Int = 8
     var minuteInterval: Int = 30
@@ -55,12 +53,12 @@ struct FreeAPSSettings: JSON, Equatable {
     var high: Decimal = 180
     var low: Decimal = 70
     var hours: Int = 6
-    var dynamicGlucoseColor: Bool = false
+    var glucoseColorStyle: GlucoseColorStyle = .staticColor
     var xGridLines: Bool = true
     var yGridLines: Bool = true
     var oneDimensionalGraph: Bool = false
     var rulerMarks: Bool = true
-    var displayForecastsAsLines: Bool = false
+    var forecastDisplayType: ForecastDisplayType = .cone
     var maxCarbs: Decimal = 250
     var maxFat: Decimal = 250
     var maxProtein: Decimal = 250
@@ -68,14 +66,12 @@ struct FreeAPSSettings: JSON, Equatable {
     var confirmBolusFaster: Bool = false
     var onlyAutotuneBasals: Bool = false
     var overrideFactor: Decimal = 0.8
-    var useCalc: Bool = true
     var fattyMeals: Bool = false
     var fattyMealFactor: Decimal = 0.7
     var sweetMeals: Bool = false
     var sweetMealFactor: Decimal = 2
     var displayPresets: Bool = true
     var useLiveActivity: Bool = false
-    var historyLayout: HistoryLayout = .twoTabs
     var lockScreenView: LockScreenView = .simple
     var bolusShortcut: BolusShortcutLimit = .notAllowed
 }
@@ -122,14 +118,6 @@ extension FreeAPSSettings: Decodable {
             settings.debugOptions = debugOptions
         }
 
-        if let insulinReqPercentage = try? container.decode(Decimal.self, forKey: .insulinReqPercentage) {
-            settings.insulinReqPercentage = insulinReqPercentage
-        }
-
-        if let skipBolusScreenAfterCarbs = try? container.decode(Bool.self, forKey: .skipBolusScreenAfterCarbs) {
-            settings.skipBolusScreenAfterCarbs = skipBolusScreenAfterCarbs
-        }
-
         if let displayHR = try? container.decode(Bool.self, forKey: .displayHR) {
             settings.displayHR = displayHR
             // compatibility if displayOnWatch is not available in json files
@@ -176,18 +164,14 @@ extension FreeAPSSettings: Decodable {
             settings.useFPUconversion = useFPUconversion
         }
 
-        if let tins = try? container.decode(Bool.self, forKey: .tins) {
-            settings.tins = tins
+        if let totalInsulinDisplayType = try? container.decode(TotalInsulinDisplayType.self, forKey: .totalInsulinDisplayType) {
+            settings.totalInsulinDisplayType = totalInsulinDisplayType
         }
 
         if let individualAdjustmentFactor = try? container.decode(Decimal.self, forKey: .individualAdjustmentFactor) {
             settings.individualAdjustmentFactor = individualAdjustmentFactor
         }
 
-        if let useCalc = try? container.decode(Bool.self, forKey: .useCalc) {
-            settings.useCalc = useCalc
-        }
-
         if let fattyMeals = try? container.decode(Bool.self, forKey: .fattyMeals) {
             settings.fattyMeals = fattyMeals
         }
@@ -247,8 +231,8 @@ extension FreeAPSSettings: Decodable {
             settings.carbsRequiredThreshold = carbsRequiredThreshold
         }
 
-        if let animatedBackground = try? container.decode(Bool.self, forKey: .animatedBackground) {
-            settings.animatedBackground = animatedBackground
+        if let showCarbsRequiredBadge = try? container.decode(Bool.self, forKey: .showCarbsRequiredBadge) {
+            settings.showCarbsRequiredBadge = showCarbsRequiredBadge
         }
 
         if let smoothGlucose = try? container.decode(Bool.self, forKey: .smoothGlucose) {
@@ -267,8 +251,8 @@ extension FreeAPSSettings: Decodable {
             settings.hours = hours
         }
 
-        if let dynamicGlucoseColor = try? container.decode(Bool.self, forKey: .dynamicGlucoseColor) {
-            settings.dynamicGlucoseColor = dynamicGlucoseColor
+        if let glucoseColorStyle = try? container.decode(GlucoseColorStyle.self, forKey: .glucoseColorStyle) {
+            settings.glucoseColorStyle = glucoseColorStyle
         }
 
         if let xGridLines = try? container.decode(Bool.self, forKey: .xGridLines) {
@@ -287,8 +271,8 @@ extension FreeAPSSettings: Decodable {
             settings.rulerMarks = rulerMarks
         }
 
-        if let displayForecastsAsLines = try? container.decode(Bool.self, forKey: .displayForecastsAsLines) {
-            settings.displayForecastsAsLines = displayForecastsAsLines
+        if let forecastDisplayType = try? container.decode(ForecastDisplayType.self, forKey: .forecastDisplayType) {
+            settings.forecastDisplayType = forecastDisplayType
         }
 
         if let overrideHbA1cUnit = try? container.decode(Bool.self, forKey: .overrideHbA1cUnit) {
@@ -327,10 +311,6 @@ extension FreeAPSSettings: Decodable {
             settings.useLiveActivity = useLiveActivity
         }
 
-        if let historyLayout = try? container.decode(HistoryLayout.self, forKey: .historyLayout) {
-            settings.historyLayout = historyLayout
-        }
-
         if let lockScreenView = try? container.decode(LockScreenView.self, forKey: .lockScreenView) {
             settings.lockScreenView = lockScreenView
         }

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

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

+ 0 - 16
FreeAPS/Sources/Models/HistoryLayout.swift

@@ -1,16 +0,0 @@
-import Foundation
-
-enum HistoryLayout: String, JSON, CaseIterable, Identifiable, Codable {
-    var id: String { rawValue }
-    case twoTabs
-    case threeTabs
-
-    var displayName: String {
-        switch self {
-        case .twoTabs:
-            return NSLocalizedString("2 Tabs", comment: "")
-        case .threeTabs:
-            return NSLocalizedString("3 Tabs", comment: "")
-        }
-    }
-}

+ 4 - 4
FreeAPS/Sources/Models/InsulinSensitivities.swift

@@ -1,15 +1,15 @@
 import Foundation
 
 struct InsulinSensitivities: JSON {
-    let units: GlucoseUnits
-    let userPrefferedUnits: GlucoseUnits
-    let sensitivities: [InsulinSensitivityEntry]
+    var units: GlucoseUnits
+    var userPreferredUnits: GlucoseUnits
+    var sensitivities: [InsulinSensitivityEntry]
 }
 
 extension InsulinSensitivities {
     private enum CodingKeys: String, CodingKey {
         case units
-        case userPrefferedUnits = "user_preferred_units"
+        case userPreferredUnits = "user_preferred_units"
         case sensitivities
     }
 }

+ 0 - 6
FreeAPS/Sources/Models/NightscoutStatistics.swift

@@ -1,6 +0,0 @@
-import Foundation
-
-struct NightscoutStatistics: JSON {
-    let report = "statistics"
-    let dailystats: Statistics?
-}

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

@@ -52,7 +52,7 @@ struct Preferences: JSON {
     var tddAdjBasal: Bool = false
     var enableSMB_high_bg: Bool = false
     var enableSMB_high_bg_target: Decimal = 110
-    var threshold_setting: Decimal = 65
+    var threshold_setting: Decimal = 60
     var updateInterval: Decimal = 20
 }
 

+ 1 - 6
FreeAPS/Sources/Models/RawFetchedProfile.swift

@@ -4,15 +4,10 @@ struct FetchedNightscoutProfileStore: JSON {
     let _id: String
     let defaultProfile: String
     let startDate: String
-    // TODO: what is this shit used for?
-    // <<<<<<< HEAD
     let mills: Decimal
     let enteredBy: String
-//    let store: [String: ScheduledNightscoutProfile]
+    let store: [String: ScheduledNightscoutProfile]
     let created_at: String
-    //=======
-    let store: [String: FetchedNightscoutProfile]
-    // >>>>>>> 9672da256c317a314acc76d6e4f6e82cc174d133
 }
 
 struct FetchedNightscoutProfile: JSON {

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

@@ -0,0 +1,22 @@
+//
+//  TotalInsulinDisplayType.swift
+//  FreeAPS
+//
+//  Created by Cengiz Deniz on 25.08.24.
+//
+import Foundation
+
+enum TotalInsulinDisplayType: String, JSON, CaseIterable, Identifiable, Codable, Hashable {
+    var id: String { rawValue }
+    case totalDailyDose
+    case totalInsulinInScope
+
+    var displayName: String {
+        switch self {
+        case .totalDailyDose:
+            return NSLocalizedString("Total Daily Dose", comment: "")
+        case .totalInsulinInScope:
+            return NSLocalizedString("Total Insulin in Scope", comment: "")
+        }
+    }
+}

+ 5 - 0
FreeAPS/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsDataFlow.swift

@@ -0,0 +1,5 @@
+enum AlgorithmAdvancedSettings {
+    enum Config {}
+}
+
+protocol AlgorithmAdvancedSettingsProvider: Provider {}

+ 64 - 0
FreeAPS/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsProvider.swift

@@ -0,0 +1,64 @@
+import Combine
+import Foundation
+import HealthKit
+import LoopKit
+import LoopKitUI
+
+extension AlgorithmAdvancedSettings {
+    final class Provider: BaseProvider, AlgorithmAdvancedSettingsProvider {
+        private let processQueue = DispatchQueue(label: "AlgorithmAdvancedSettingsProvider.processQueue")
+        @Injected() private var broadcaster: Broadcaster!
+
+        func settings() -> PumpSettings {
+            storage.retrieve(OpenAPS.Settings.settings, as: PumpSettings.self)
+                ?? PumpSettings(from: OpenAPS.defaults(for: OpenAPS.Settings.settings))
+                ?? PumpSettings(insulinActionCurve: 6.0, maxBolus: 10, maxBasal: 2)
+        }
+
+        func save(settings: PumpSettings) -> AnyPublisher<Void, Error> {
+            func save(_ settings: PumpSettings) {
+                storage.save(settings, as: OpenAPS.Settings.settings)
+                processQueue.async {
+                    self.broadcaster.notify(PumpSettingsObserver.self, on: self.processQueue) {
+                        $0.pumpSettingsDidChange(settings)
+                    }
+                }
+            }
+
+            guard let pump = deviceManager?.pumpManager else {
+                save(settings)
+                return Just(()).setFailureType(to: Error.self).eraseToAnyPublisher()
+            }
+            let limits = DeliveryLimits(
+                maximumBasalRate: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: Double(settings.maxBasal)),
+                maximumBolus: HKQuantity(unit: .internationalUnit(), doubleValue: Double(settings.maxBolus))
+            )
+            return Future { promise in
+                self.processQueue.async {
+                    pump.syncDeliveryLimits(limits: limits) { result in
+                        switch result {
+                        case let .success(actual):
+                            // Store the limits from the pumpManager to ensure the correct values
+                            // Example: Dana pumps don't allow to set these limits, only to fetch them
+                            // This will ensure we always have the correct values stored
+                            save(PumpSettings(
+                                insulinActionCurve: settings.insulinActionCurve,
+                                maxBolus: Decimal(
+                                    actual.maximumBolus?
+                                        .doubleValue(for: .internationalUnit()) ?? Double(settings.maxBolus)
+                                ),
+                                maxBasal: Decimal(
+                                    actual.maximumBasalRate?
+                                        .doubleValue(for: .internationalUnitsPerHour) ?? Double(settings.maxBasal)
+                                )
+                            ))
+                            promise(.success(()))
+                        case let .failure(error):
+                            promise(.failure(error))
+                        }
+                    }
+                }
+            }.eraseToAnyPublisher()
+        }
+    }
+}

+ 121 - 0
FreeAPS/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsStateModel.swift

@@ -0,0 +1,121 @@
+import Combine
+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
+
+        var preferences: Preferences {
+            settingsManager.preferences
+        }
+
+        var pumpSettings: PumpSettings {
+            provider.settings()
+        }
+
+        override func subscribe() {
+            units = settingsManager.settings.units
+
+            maxDailySafetyMultiplier = settings.preferences.maxDailySafetyMultiplier
+            currentBasalSafetyMultiplier = settings.preferences.currentBasalSafetyMultiplier
+            useCustomPeakTime = settings.preferences.useCustomPeakTime
+            insulinPeakTime = settings.preferences.insulinPeakTime
+            skipNeutralTemps = settings.preferences.skipNeutralTemps
+            unsuspendIfNoTemp = settings.preferences.unsuspendIfNoTemp
+            suspendZerosIOB = settings.preferences.suspendZerosIOB
+            min5mCarbimpact = settings.preferences.min5mCarbimpact
+            autotuneISFAdjustmentFraction = settings.preferences.autotuneISFAdjustmentFraction
+            remainingCarbsFraction = settings.preferences.remainingCarbsFraction
+            remainingCarbsCap = settings.preferences.remainingCarbsCap
+            noisyCGMTargetMultiplier = settings.preferences.noisyCGMTargetMultiplier
+
+            insulinActionCurve = pumpSettings.insulinActionCurve
+        }
+
+        var isPumpSettingUnchanged: Bool {
+            pumpSettings.insulinActionCurve == insulinActionCurve
+        }
+
+        var isSettingUnchanged: Bool {
+            preferences.maxDailySafetyMultiplier == maxDailySafetyMultiplier &&
+                preferences.currentBasalSafetyMultiplier == currentBasalSafetyMultiplier &&
+                preferences.useCustomPeakTime == useCustomPeakTime &&
+                preferences.insulinPeakTime == insulinPeakTime &&
+                preferences.skipNeutralTemps == skipNeutralTemps &&
+                preferences.unsuspendIfNoTemp == unsuspendIfNoTemp &&
+                preferences.suspendZerosIOB == suspendZerosIOB &&
+                preferences.min5mCarbimpact == min5mCarbimpact &&
+                preferences.autotuneISFAdjustmentFraction == autotuneISFAdjustmentFraction &&
+                preferences.remainingCarbsFraction == remainingCarbsFraction &&
+                preferences.remainingCarbsCap == remainingCarbsCap &&
+                preferences.noisyCGMTargetMultiplier == noisyCGMTargetMultiplier
+        }
+
+        func saveIfChanged() {
+            if !isSettingUnchanged {
+                var newSettings = storage.retrieve(OpenAPS.Settings.preferences, as: Preferences.self) ?? Preferences()
+
+                newSettings.maxDailySafetyMultiplier = maxDailySafetyMultiplier
+                newSettings.currentBasalSafetyMultiplier = currentBasalSafetyMultiplier
+                newSettings.useCustomPeakTime = useCustomPeakTime
+                newSettings.insulinPeakTime = insulinPeakTime
+                newSettings.skipNeutralTemps = skipNeutralTemps
+                newSettings.unsuspendIfNoTemp = unsuspendIfNoTemp
+                newSettings.suspendZerosIOB = suspendZerosIOB
+                newSettings.min5mCarbimpact = min5mCarbimpact
+                newSettings.autotuneISFAdjustmentFraction = autotuneISFAdjustmentFraction
+                newSettings.remainingCarbsFraction = remainingCarbsFraction
+                newSettings.remainingCarbsCap = remainingCarbsCap
+                newSettings.noisyCGMTargetMultiplier = noisyCGMTargetMultiplier
+
+                newSettings.timestamp = Date()
+                storage.save(newSettings, as: OpenAPS.Settings.preferences)
+            }
+
+            if !isPumpSettingUnchanged {
+                let settings = PumpSettings(
+                    insulinActionCurve: insulinActionCurve,
+                    maxBolus: pumpSettings.maxBolus,
+                    maxBasal: pumpSettings.maxBasal
+                )
+                provider.save(settings: settings)
+                    .receive(on: DispatchQueue.main)
+                    .sink { _ in
+                        let settings = self.provider.settings()
+                        self.insulinActionCurve = settings.insulinActionCurve
+
+                        Task.detached(priority: .low) {
+                            debug(.nightscout, "Attempting to upload DIA to Nightscout")
+                            await self.nightscout.uploadProfiles()
+                        }
+                    } receiveValue: {}
+                    .store(in: &lifetime)
+            }
+        }
+    }
+}
+
+extension AlgorithmAdvancedSettings.StateModel: SettingsObserver {
+    func settingsDidChange(_: FreeAPSSettings) {
+        units = settingsManager.settings.units
+    }
+}

+ 325 - 0
FreeAPS/Sources/Modules/AlgorithmAdvancedSettings/View/AlgorithmAdvancedSettingsRootView.swift

@@ -0,0 +1,325 @@
+import SwiftUI
+import Swinject
+
+extension AlgorithmAdvancedSettings {
+    struct RootView: BaseView {
+        let resolver: Resolver
+        @StateObject var state = StateModel()
+        @State private var shouldDisplayHint: Bool = false
+        @State var hintDetent = PresentationDetent.large
+        @State var selectedVerboseHint: String?
+        @State var hintLabel: String?
+        @State private var decimalPlaceholder: Decimal = 0.0
+        @State private var booleanPlaceholder: Bool = false
+
+        @Environment(\.colorScheme) var colorScheme
+        @EnvironmentObject var appIcons: Icons
+
+        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
+                )
+        }
+
+        var body: some View {
+            List {
+                Section(
+                    header: Text("DISCLAIMER"),
+                    content: {
+                        VStack(alignment: .leading) {
+                            Text(
+                                "The settings in this section are designed for advanced expert users and typically do not require ANY modifications."
+                            ).bold()
+                        }
+                    }
+
+                ).listRowBackground(Color.tabBar)
+
+                SettingInputSection(
+                    decimalValue: $state.maxDailySafetyMultiplier,
+                    booleanValue: $booleanPlaceholder,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0
+                            hintLabel = NSLocalizedString("Max Daily Safety Multiplier", comment: "Max Daily Safety Multiplier")
+                        }
+                    ),
+                    units: state.units,
+                    type: .decimal("maxDailySafetyMultiplier"),
+                    label: NSLocalizedString("Max Daily Safety Multiplier", comment: "Max Daily Safety Multiplier"),
+                    miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                    verboseHint: NSLocalizedString(
+                        "This is an important OpenAPS safety limit. The default setting (which is unlikely to need adjusting) is 3. This means that OpenAPS will never be allowed to set a temporary basal rate that is more than 3x the highest hourly basal rate programmed in a user’s pump, or, if enabled, determined by autotune.",
+                        comment: "Max Daily Safety Multiplier"
+                    )
+                )
+
+                SettingInputSection(
+                    decimalValue: $state.currentBasalSafetyMultiplier,
+                    booleanValue: $booleanPlaceholder,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0
+                            hintLabel = NSLocalizedString(
+                                "Current Basal Safety Multiplier",
+                                comment: "Current Basal Safety Multiplier"
+                            )
+                        }
+                    ),
+                    units: state.units,
+                    type: .decimal("currentBasalSafetyMultiplier"),
+                    label: NSLocalizedString("Current Basal Safety Multiplier", comment: "Current Basal Safety Multiplier"),
+                    miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                    verboseHint: NSLocalizedString(
+                        "This is another important OpenAPS safety limit. The default setting (which is also unlikely to need adjusting) is 4. This means that OpenAPS will never be allowed to set a temporary basal rate that is more than 4x the current hourly basal rate programmed in a user’s pump, or, if enabled, determined by autotune.",
+                        comment: "Current Basal Safety Multiplier"
+                    )
+                )
+
+                SettingInputSection(
+                    decimalValue: $state.insulinActionCurve,
+                    booleanValue: $booleanPlaceholder,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0
+                            hintLabel = "Duration of Insulin Action"
+                        }
+                    ),
+                    units: state.units,
+                    type: .decimal("dia"),
+                    label: "Duration of Insulin Action",
+                    miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                    verboseHint: "Duration of Insulin Action… bla bla bla"
+                )
+
+                SettingInputSection(
+                    decimalValue: $state.insulinPeakTime,
+                    booleanValue: $state.useCustomPeakTime,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0
+                            hintLabel = NSLocalizedString("Use Custom Peak Time", comment: "Use Custom Peak Time")
+                        }
+                    ),
+                    units: state.units,
+                    type: .conditionalDecimal("insulinPeakTime"),
+                    label: NSLocalizedString("Use Custom Peak Time", comment: "Use Custom Peak Time"),
+                    conditionalLabel: NSLocalizedString("Insulin Peak Time", comment: "Insulin Peak Time"),
+                    miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                    verboseHint: NSLocalizedString(
+                        "Defaults to false. Setting to true allows changing insulinPeakTime", comment: "Use Custom Peak Time"
+                    ) + NSLocalizedString(
+                        "Time of maximum blood glucose lowering effect of insulin, in minutes. Beware: Oref assumes for ultra-rapid (Lyumjev) & rapid-acting (Fiasp) curves minimal (35 & 50 min) and maximal (100 & 120 min) applicable insulinPeakTimes. Using a custom insulinPeakTime outside these bounds will result in issues with Trio, longer loop calculations and possible red loops.",
+                        comment: "Insulin Peak Time"
+                    )
+                )
+
+                SettingInputSection(
+                    decimalValue: $decimalPlaceholder,
+                    booleanValue: $state.skipNeutralTemps,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0
+                            hintLabel = NSLocalizedString("Skip Neutral Temps", comment: "Skip Neutral Temps")
+                        }
+                    ),
+                    units: state.units,
+                    type: .boolean,
+                    label: NSLocalizedString("Skip Neutral Temps", comment: "Skip Neutral Temps"),
+                    miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                    verboseHint: NSLocalizedString(
+                        "Defaults to false, so that Trio will set temps whenever it can, so it will be easier to see if the system is working, even when you are offline. This means Trio will set a “neutral” temp (same as your default basal) if no adjustments are needed. This is an old setting for OpenAPS to have the options to minimise sounds and notifications from the 'rig', that may wake you up during the night.",
+                        comment: "Skip Neutral Temps"
+                    )
+                )
+
+                SettingInputSection(
+                    decimalValue: $decimalPlaceholder,
+                    booleanValue: $state.unsuspendIfNoTemp,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0
+                            hintLabel = NSLocalizedString("Unsuspend If No Temp", comment: "Unsuspend If No Temp")
+                        }
+                    ),
+                    units: state.units,
+                    type: .boolean,
+                    label: NSLocalizedString("Unsuspend If No Temp", comment: "Unsuspend If No Temp"),
+                    miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                    verboseHint: NSLocalizedString(
+                        "Many people occasionally forget to resume / unsuspend their pump after reconnecting it. If you’re one of them, and you are willing to reliably set a zero temp basal whenever suspending and disconnecting your pump, this feature has your back. If enabled, it will automatically resume / unsuspend the pump if you forget to do so before your zero temp expires. As long as the zero temp is still running, it will leave the pump suspended.",
+                        comment: "Unsuspend If No Temp"
+                    )
+                )
+
+                SettingInputSection(
+                    decimalValue: $decimalPlaceholder,
+                    booleanValue: $state.suspendZerosIOB,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0
+                            hintLabel = NSLocalizedString("Suspend Zeros IOB", comment: "Suspend Zeros IOB")
+                        }
+                    ),
+                    units: state.units,
+                    type: .boolean,
+                    label: NSLocalizedString("Suspend Zeros IOB", comment: "Suspend Zeros IOB"),
+                    miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                    verboseHint: NSLocalizedString(
+                        "Default is false. Any existing temp basals during times the pump was suspended will be deleted and 0 temp basals to negate the profile basal rates during times pump is suspended will be added.",
+                        comment: "Suspend Zeros IOB"
+                    )
+                )
+
+                SettingInputSection(
+                    decimalValue: $state.autotuneISFAdjustmentFraction,
+                    booleanValue: $booleanPlaceholder,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0
+                            hintLabel = NSLocalizedString(
+                                "Autotune ISF Adjustment Fraction",
+                                comment: "Autotune ISF Adjustment Fraction"
+                            )
+                        }
+                    ),
+                    units: state.units,
+                    type: .decimal("autotuneISFAdjustmentFraction"),
+                    label: NSLocalizedString("Autotune ISF Adjustment Fraction", comment: "Autotune ISF Adjustment Fraction"),
+                    miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                    verboseHint: NSLocalizedString(
+                        "The default of 0.5 for this value keeps autotune ISF closer to pump ISF via a weighted average of fullNewISF and pumpISF. 1.0 allows full adjustment, 0 is no adjustment from pump ISF.",
+                        comment: "Autotune ISF Adjustment Fraction"
+                    )
+                )
+
+                SettingInputSection(
+                    decimalValue: $state.min5mCarbimpact,
+                    booleanValue: $booleanPlaceholder,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0
+                            hintLabel = NSLocalizedString("Min 5m Carbimpact", comment: "Min 5m Carbimpact")
+                        }
+                    ),
+                    units: state.units,
+                    type: .decimal("min5mCarbimpact"),
+                    label: NSLocalizedString("Min 5m Carbimpact", comment: "Min 5m Carbimpact"),
+                    miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                    verboseHint: NSLocalizedString(
+                        "This is a setting for default carb absorption impact per 5 minutes. The default is an expected 8 mg/dL/5min. This affects how fast COB is decayed in situations when carb absorption is not visible in BG deviations. The default of 8 mg/dL/5min corresponds to a minimum carb absorption rate of 24g/hr at a CSF of 4 mg/dL/g.",
+                        comment: "Min 5m Carbimpact"
+                    )
+                )
+
+                SettingInputSection(
+                    decimalValue: $state.remainingCarbsFraction,
+                    booleanValue: $booleanPlaceholder,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0
+                            hintLabel = NSLocalizedString("Remaining Carbs Fraction", comment: "Remaining Carbs Fraction")
+                        }
+                    ),
+                    units: state.units,
+                    type: .decimal("remainingCarbsFraction"),
+                    label: NSLocalizedString("Remaining Carbs Fraction", comment: "Remaining Carbs Fraction"),
+                    miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                    verboseHint: NSLocalizedString(
+                        "This is the fraction of carbs we’ll assume will absorb over 4h if we don’t yet see carb absorption.",
+                        comment: "Remaining Carbs Fraction"
+                    )
+                )
+
+                SettingInputSection(
+                    decimalValue: $state.remainingCarbsCap,
+                    booleanValue: $booleanPlaceholder,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0
+                            hintLabel = NSLocalizedString("Remaining Carbs Cap", comment: "Remaining Carbs Cap")
+                        }
+                    ),
+                    units: state.units,
+                    type: .decimal("remainingCarbsCap"),
+                    label: NSLocalizedString("Remaining Carbs Cap", comment: "Remaining Carbs Cap"),
+                    miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                    verboseHint: NSLocalizedString(
+                        "This is the amount of the maximum number of carbs we’ll assume will absorb over 4h if we don’t yet see carb absorption.",
+                        comment: "Remaining Carbs Cap"
+                    )
+                )
+
+                SettingInputSection(
+                    decimalValue: $state.noisyCGMTargetMultiplier,
+                    booleanValue: $booleanPlaceholder,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0
+                            hintLabel = NSLocalizedString("Noisy CGM Target Multiplier", comment: "Noisy CGM Target Multiplier")
+                        }
+                    ),
+                    units: state.units,
+                    type: .decimal("noisyCGMTargetMultiplier"),
+                    label: NSLocalizedString("Noisy CGM Target Multiplier", comment: "Noisy CGM Target Multiplier"),
+                    miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                    verboseHint: NSLocalizedString(
+                        "Defaults to 1.3. Increase target by this amount when looping off raw/noisy CGM data",
+                        comment: "Noisy CGM Target Multiplier"
+                    )
+                )
+            }
+            .sheet(isPresented: $shouldDisplayHint) {
+                SettingInputHintView(
+                    hintDetent: $hintDetent,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    hintLabel: hintLabel ?? "",
+                    hintText: selectedVerboseHint ?? "",
+                    sheetTitle: "Help"
+                )
+            }
+            .scrollContentBackground(.hidden).background(color)
+            .onAppear(perform: configureView)
+            .navigationTitle("Additionals")
+            .navigationBarTitleDisplayMode(.automatic)
+            .onDisappear {
+                state.saveIfChanged()
+            }
+        }
+    }
+}

+ 5 - 0
FreeAPS/Sources/Modules/AutosensSettings/AutosensSettingsDataFlow.swift

@@ -0,0 +1,5 @@
+enum AutosensSettings {
+    enum Config {}
+}
+
+protocol AutosensSettingsProvider: Provider {}

+ 3 - 0
FreeAPS/Sources/Modules/AutosensSettings/AutosensSettingsProvider.swift

@@ -0,0 +1,3 @@
+extension AutosensSettings {
+    final class Provider: BaseProvider, AutosensSettingsProvider {}
+}

+ 51 - 0
FreeAPS/Sources/Modules/AutosensSettings/AutosensSettingsStateModel.swift

@@ -0,0 +1,51 @@
+import SwiftUI
+
+extension AutosensSettings {
+    final class StateModel: BaseStateModel<Provider> {
+        @Injected() var settings: SettingsManager!
+        @Injected() var storage: FileStorage!
+
+        @Published var units: GlucoseUnits = .mgdL
+
+        @Published var autosensMax: Decimal = 1.2
+        @Published var autosensMin: Decimal = 0.7
+        @Published var rewindResetsAutosens: Bool = true
+
+        var preferences: Preferences {
+            settingsManager.preferences
+        }
+
+        override func subscribe() {
+            units = settingsManager.settings.units
+
+            autosensMax = settings.preferences.autosensMax
+            autosensMin = settings.preferences.autosensMin
+            rewindResetsAutosens = settings.preferences.rewindResetsAutosens
+        }
+
+        var isSettingUnchanged: Bool {
+            preferences.autosensMax == autosensMax &&
+                preferences.autosensMin == autosensMin &&
+                preferences.rewindResetsAutosens == rewindResetsAutosens
+        }
+
+        func saveIfChanged() {
+            if !isSettingUnchanged {
+                var newSettings = storage.retrieve(OpenAPS.Settings.preferences, as: Preferences.self) ?? Preferences()
+
+                newSettings.autosensMax = autosensMax
+                newSettings.autosensMin = autosensMin
+                newSettings.rewindResetsAutosens = rewindResetsAutosens
+
+                newSettings.timestamp = Date()
+                storage.save(newSettings, as: OpenAPS.Settings.preferences)
+            }
+        }
+    }
+}
+
+extension AutosensSettings.StateModel: SettingsObserver {
+    func settingsDidChange(_: FreeAPSSettings) {
+        units = settingsManager.settings.units
+    }
+}

+ 119 - 0
FreeAPS/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift

@@ -0,0 +1,119 @@
+import SwiftUI
+import Swinject
+
+extension AutosensSettings {
+    struct RootView: BaseView {
+        let resolver: Resolver
+        @StateObject var state = StateModel()
+        @State private var shouldDisplayHint: Bool = false
+        @State var hintDetent = PresentationDetent.large
+        @State var selectedVerboseHint: String?
+        @State var hintLabel: String?
+        @State private var decimalPlaceholder: Decimal = 0.0
+        @State private var booleanPlaceholder: Bool = false
+
+        @Environment(\.colorScheme) var colorScheme
+        @EnvironmentObject var appIcons: Icons
+
+        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
+                )
+        }
+
+        var body: some View {
+            List {
+                SettingInputSection(
+                    decimalValue: $state.autosensMax,
+                    booleanValue: $booleanPlaceholder,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0
+                            hintLabel = NSLocalizedString("Autosens Max", comment: "Autosens Max")
+                        }
+                    ),
+                    units: state.units,
+                    type: .decimal("autosensMax"),
+                    label: NSLocalizedString("Autosens Max", comment: "Autosens Max"),
+                    miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                    verboseHint: NSLocalizedString(
+                        "This is a multiplier cap for autosens (and autotune) to set a 20% max limit on how high the autosens ratio can be, which in turn determines how high autosens can adjust basals, how low it can adjust ISF, and how low it can set the BG target.",
+                        comment: "Autosens Max"
+                    ),
+                    headerText: "Glucose Deviations Algorithm"
+                )
+
+                SettingInputSection(
+                    decimalValue: $state.autosensMin,
+                    booleanValue: $booleanPlaceholder,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0
+                            hintLabel = NSLocalizedString("Autosens Min", comment: "Autosens Min")
+                        }
+                    ),
+                    units: state.units,
+                    type: .decimal("autosensMin"),
+                    label: NSLocalizedString("Autosens Min", comment: "Autosens Min"),
+                    miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                    verboseHint: NSLocalizedString(
+                        "The other side of the autosens safety limits, putting a cap on how low autosens can adjust basals, and how high it can adjust ISF and BG targets.",
+                        comment: "Autosens Min"
+                    )
+                )
+
+                SettingInputSection(
+                    decimalValue: $decimalPlaceholder,
+                    booleanValue: $state.rewindResetsAutosens,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0
+                            hintLabel = NSLocalizedString("Rewind Resets Autosens", comment: "Rewind Resets Autosens")
+                        }
+                    ),
+                    units: state.units,
+                    type: .boolean,
+                    label: NSLocalizedString("Rewind Resets Autosens", comment: "Rewind Resets Autosens"),
+                    miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                    verboseHint: NSLocalizedString(
+                        "This feature, enabled by default, resets the autosens ratio to neutral when you rewind your pump, on the assumption that this corresponds to a probable site change. Autosens will begin learning sensitivity anew from the time of the rewind, which may take up to 6 hours. If you usually rewind your pump independently of site changes, you may want to consider disabling this feature.",
+                        comment: "Rewind Resets Autosens"
+                    )
+                )
+            }
+            .sheet(isPresented: $shouldDisplayHint) {
+                SettingInputHintView(
+                    hintDetent: $hintDetent,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    hintLabel: hintLabel ?? "",
+                    hintText: selectedVerboseHint ?? "",
+                    sheetTitle: "Help"
+                )
+            }
+            .scrollContentBackground(.hidden).background(color)
+            .onAppear(perform: configureView)
+            .navigationTitle("Autosens")
+            .navigationBarTitleDisplayMode(.automatic)
+            .onDisappear {
+                state.saveIfChanged()
+            }
+        }
+    }
+}

+ 6 - 0
FreeAPS/Sources/Modules/AutotuneConfig/AutotuneConfigStateModel.swift

@@ -105,3 +105,9 @@ extension AutotuneConfig {
         }
     }
 }
+
+extension AutotuneConfig.StateModel: SettingsObserver {
+    func settingsDidChange(_: FreeAPSSettings) {
+        units = settingsManager.settings.units
+    }
+}

+ 84 - 20
FreeAPS/Sources/Modules/AutotuneConfig/View/AutotuneConfigRootView.swift

@@ -5,6 +5,14 @@ extension AutotuneConfig {
     struct RootView: BaseView {
         let resolver: Resolver
         @StateObject var state = StateModel()
+
+        @State private var shouldDisplayHint: Bool = false
+        @State var hintDetent = PresentationDetent.large
+        @State var selectedVerboseHint: String?
+        @State var hintLabel: String?
+        @State private var decimalPlaceholder: Decimal = 0.0
+        @State private var booleanPlaceholder: Bool = false
+
         @State var replaceAlert = false
 
         @Environment(\.colorScheme) var colorScheme
@@ -48,22 +56,62 @@ extension AutotuneConfig {
 
         var body: some View {
             Form {
-                Section {
-                    Toggle("Use Autotune", isOn: $state.useAutotune)
-                    if state.useAutotune {
-                        Toggle("Only Autotune Basal Insulin", isOn: $state.onlyAutotuneBasals)
-                    }
+                SettingInputSection(
+                    decimalValue: $decimalPlaceholder,
+                    booleanValue: $state.useAutotune,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0
+                            hintLabel = "Use Autotune"
+                        }
+                    ),
+                    units: state.units,
+                    type: .boolean,
+                    label: "Use Autotune",
+                    miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                    verboseHint: "Autotune… bla bla bla",
+                    headerText: "Data-driven Adjustments"
+                )
+
+                if state.useAutotune {
+                    SettingInputSection(
+                        decimalValue: $decimalPlaceholder,
+                        booleanValue: $state.onlyAutotuneBasals,
+                        shouldDisplayHint: $shouldDisplayHint,
+                        selectedVerboseHint: Binding(
+                            get: { selectedVerboseHint },
+                            set: {
+                                selectedVerboseHint = $0
+                                hintLabel = "Only Autotune Basal Insulin"
+                            }
+                        ),
+                        units: state.units,
+                        type: .boolean,
+                        label: "Only Autotune Basal Insulin",
+                        miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                        verboseHint: "Only Autotune Basal Insulin… bla bla bla"
+                    )
                 }
 
-                Section {
-                    HStack {
+                Section(
+                    header: HStack {
                         Text("Last run")
                         Spacer()
                         Text(dateFormatter.string(from: state.publishedDate))
+                    },
+                    content: {
+                        Button {
+                            state.run()
+                        } label: {
+                            Text("Run now")
+                        }
+                        .frame(maxWidth: .infinity, alignment: .center)
+                        .listRowBackground(Color(.systemBlue))
+                        .tint(.white)
                     }
-                    Button { state.run() }
-                    label: { Text("Run now") }
-                }
+                )
 
                 if let autotune = state.autotune {
                     if !state.onlyAutotuneBasals {
@@ -85,6 +133,7 @@ extension AutotuneConfig {
                                 Text(state.units.rawValue + "/U").foregroundColor(.secondary)
                             }
                         }
+                        .listRowBackground(Color.chart)
                     }
 
                     Section(header: Text("Basal profile")) {
@@ -107,27 +156,42 @@ extension AutotuneConfig {
                                 .foregroundColor(.secondary)
                         }
                     }
+                    .listRowBackground(Color.chart)
 
                     Section {
                         Button {
                             Task {
                                 await state.delete()
                             }
+                        } label: {
+                            Text("Delete Autotune Data")
                         }
-                        label: { Text("Delete autotune data") }
-                            .foregroundColor(.red)
+                        .frame(maxWidth: .infinity, alignment: .center)
+                        .listRowBackground(Color(.loopRed))
+                        .tint(.white)
                     }
 
-                    /* Section {
-                         Button {
-                             replaceAlert = true
-                         }
-                         label: { Text("Save as your Normal Basal Rates") }
-                     } header: {
-                         Text("Replace Normal Basal")
-                     } */
+                    // Section {
+                    //     Button {
+                    //         replaceAlert = true
+                    //     } label: {
+                    //         Text("Save as Normal Basal Rates")
+                    //     }
+                    //     .frame(maxWidth: .infinity, alignment: .center)
+                    //     .listRowBackground(Color(.systemGray4))
+                    //     .tint(.white)
+                    // }
                 }
             }
+            .sheet(isPresented: $shouldDisplayHint) {
+                SettingInputHintView(
+                    hintDetent: $hintDetent,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    hintLabel: hintLabel ?? "",
+                    hintText: selectedVerboseHint ?? "",
+                    sheetTitle: "Help"
+                )
+            }
             .scrollContentBackground(.hidden).background(color)
             .onAppear(perform: configureView)
             .navigationTitle("Autotune")

+ 4 - 3
FreeAPS/Sources/Modules/BasalProfileEditor/BasalProfileEditorDataFlow.swift

@@ -6,8 +6,8 @@ enum BasalProfileEditor {
 
     class Item: Identifiable, Hashable, Equatable {
         let id = UUID()
-        var rateIndex = 0
-        var timeIndex = 0
+        var rateIndex: Int
+        var timeIndex: Int
 
         init(rateIndex: Int, timeIndex: Int) {
             self.rateIndex = rateIndex
@@ -15,10 +15,11 @@ enum BasalProfileEditor {
         }
 
         static func == (lhs: Item, rhs: Item) -> Bool {
-            lhs.timeIndex == rhs.timeIndex
+            lhs.rateIndex == rhs.rateIndex && lhs.timeIndex == rhs.timeIndex
         }
 
         func hash(into hasher: inout Hasher) {
+            hasher.combine(rateIndex)
             hasher.combine(timeIndex)
         }
     }

+ 2 - 2
FreeAPS/Sources/Modules/BasalProfileEditor/BasalProfileEditorProvider.swift

@@ -18,8 +18,8 @@ extension BasalProfileEditor {
 
         func saveProfile(_ profile: [BasalProfileEntry]) -> AnyPublisher<Void, Error> {
             guard let pump = deviceManager?.pumpManager else {
-                storage.save(profile, as: OpenAPS.Settings.basalProfile)
-                return Just(()).setFailureType(to: Error.self).eraseToAnyPublisher()
+                debugPrint("\(DebuggingIdentifiers.failed) No pump found; cannot save basal profile!")
+                return Fail(error: NSError()).eraseToAnyPublisher()
             }
 
             let syncValues = profile.map {

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

@@ -2,9 +2,13 @@ import SwiftUI
 
 extension BasalProfileEditor {
     final class StateModel: BaseStateModel<Provider> {
-        @Published var syncInProgress = false
+        @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
 
         let timeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }
 
@@ -15,6 +19,10 @@ extension BasalProfileEditor {
             return lastItem.timeIndex < timeValues.count - 1
         }
 
+        var hasChanges: Bool {
+            initialItems != items
+        }
+
         override func subscribe() {
             rateValues = provider.supportedBasalRates ?? stride(from: 5.0, to: 1001.0, by: 5.0)
                 .map { ($0.decimal ?? .zero) / 100 }
@@ -23,6 +31,9 @@ extension BasalProfileEditor {
                 let rateIndex = rateValues.firstIndex(of: value.rate) ?? 0
                 return Item(rateIndex: rateIndex, timeIndex: timeIndex)
             }
+
+            initialItems = items.map { Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
+
             calcTotal()
         }
 
@@ -58,21 +69,39 @@ extension BasalProfileEditor {
         }
 
         func save() {
+            guard hasChanges else { return }
+
             syncInProgress = true
             let profile = items.map { item -> BasalProfileEntry in
-                let fotmatter = DateFormatter()
-                fotmatter.timeZone = TimeZone(secondsFromGMT: 0)
-                fotmatter.dateFormat = "HH:mm:ss"
+                let formatter = DateFormatter()
+                formatter.timeZone = TimeZone(secondsFromGMT: 0)
+                formatter.dateFormat = "HH:mm:ss"
                 let date = Date(timeIntervalSince1970: self.timeValues[item.timeIndex])
                 let minutes = Int(date.timeIntervalSince1970 / 60)
                 let rate = self.rateValues[item.rateIndex]
-                return BasalProfileEntry(start: fotmatter.string(from: date), minutes: minutes, rate: rate)
+                return BasalProfileEntry(start: formatter.string(from: date), minutes: minutes, rate: rate)
             }
             provider.saveProfile(profile)
                 .receive(on: DispatchQueue.main)
-                .sink { _ in
+                .sink { completion in
                     self.syncInProgress = false
-                } receiveValue: {}
+                    switch completion {
+                    case .finished:
+                        // Successfully saved and synced
+                        self.initialItems = self.items.map { Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
+
+                        Task.detached(priority: .low) {
+                            debug(.nightscout, "Attempting to upload basal rates to Nightscout")
+                            await self.nightscout.uploadProfiles()
+                        }
+                    case .failure:
+                        // Handle the error, show error message
+                        self.showAlert = true
+                    }
+                } receiveValue: {
+                    // Handle any successful value if needed
+                    print("We were successful")
+                }
                 .store(in: &lifetime)
         }
 
@@ -81,7 +110,9 @@ extension BasalProfileEditor {
                 let uniq = Array(Set(self.items))
                 let sorted = uniq.sorted { $0.timeIndex < $1.timeIndex }
                 sorted.first?.timeIndex = 0
-                self.items = sorted
+                if self.items != sorted {
+                    self.items = sorted
+                }
             }
         }
     }

+ 64 - 45
FreeAPS/Sources/Modules/BasalProfileEditor/View/BasalProfileEditorRootView.swift

@@ -40,10 +40,12 @@ extension BasalProfileEditor {
 
         var body: some View {
             Form {
+                let shouldDisableButton = state.syncInProgress || state.items.isEmpty || !state.hasChanges
+
                 Section(header: Text("Schedule")) {
                     list
-                    addButton
-                }
+                }.listRowBackground(Color.chart)
+
                 Section {
                     HStack {
                         Text("Total")
@@ -55,27 +57,48 @@ extension BasalProfileEditor {
                             Text(" U/day")
                             .foregroundColor(.secondary)
                     }
-                }
+                }.listRowBackground(Color.chart)
+
                 Section {
                     HStack {
                         if state.syncInProgress {
                             ProgressView().padding(.trailing, 10)
                         }
-                        Button { state.save() }
-                        label: {
-                            Text(state.syncInProgress ? "Saving..." : "Save on Pump")
+                        Button {
+                            let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
+                            impactHeavy.impactOccurred()
+                            state.save()
+                        } label: {
+                            Text(state.syncInProgress ? "Saving..." : "Save")
                         }
-                        .disabled(state.syncInProgress || state.items.isEmpty)
+                        .disabled(shouldDisableButton)
+                        .frame(maxWidth: .infinity, alignment: .center)
+                        .tint(.white)
                     }
-                }
+                }.listRowBackground(shouldDisableButton ? Color(.systemGray4) : Color(.systemBlue))
+            }
+            .alert(isPresented: $state.showAlert) {
+                Alert(
+                    title: Text("Unable to Save"),
+                    message: Text("Trio could not communicate with your pump. Changes to your basal profile were not saved."),
+                    dismissButton: .default(Text("Close"))
+                )
+            }
+            .onChange(of: state.items) { _ in
+                state.calcTotal()
             }
             .scrollContentBackground(.hidden).background(color)
             .onAppear(perform: configureView)
             .navigationTitle("Basal Profile")
             .navigationBarTitleDisplayMode(.automatic)
-            .navigationBarItems(
-                trailing: EditButton()
-            )
+            .toolbar(content: {
+                ToolbarItem(placement: .topBarTrailing) {
+                    EditButton()
+                }
+                ToolbarItem(placement: .topBarTrailing) {
+                    addButton
+                }
+            })
             .environment(\.editMode, $editMode)
             .onAppear {
                 state.validate()
@@ -83,44 +106,40 @@ extension BasalProfileEditor {
         }
 
         private func pickers(for index: Int) -> some View {
-            GeometryReader { geometry in
-                VStack {
-                    HStack {
-                        Text("Rate").frame(width: geometry.size.width / 2)
-                        Text("Time").frame(width: geometry.size.width / 2)
-                    }
-                    HStack(spacing: 0) {
-                        Picker(selection: $state.items[index].rateIndex, label: EmptyView()) {
-                            ForEach(0 ..< state.rateValues.count, id: \.self) { i in
-                                Text(
-                                    (
-                                        self.rateFormatter
-                                            .string(from: state.rateValues[i] as NSNumber) ?? ""
-                                    ) + " U/hr"
-                                ).tag(i)
-                            }
+            Form {
+                Section {
+                    Picker(selection: $state.items[index].rateIndex, label: Text("Rate")) {
+                        ForEach(0 ..< state.rateValues.count, id: \.self) { i in
+                            Text(
+                                (
+                                    self.rateFormatter
+                                        .string(from: state.rateValues[i] as NSNumber) ?? ""
+                                ) + " U/hr"
+                            ).tag(i)
                         }
-                        .onChange(of: state.items[index].rateIndex, perform: { _ in state.calcTotal() })
-                        .frame(maxWidth: geometry.size.width / 2)
-                        .clipped()
+                    }
+                    .onChange(of: state.items[index].rateIndex, perform: { _ in state.calcTotal() })
+                }.listRowBackground(Color.chart)
 
-                        Picker(selection: $state.items[index].timeIndex, label: EmptyView()) {
-                            ForEach(0 ..< state.timeValues.count, id: \.self) { i in
-                                Text(
-                                    self.dateFormatter
-                                        .string(from: Date(
-                                            timeIntervalSince1970: state
-                                                .timeValues[i]
-                                        ))
-                                ).tag(i)
-                            }
+                Section {
+                    Picker(selection: $state.items[index].timeIndex, label: Text("Time")) {
+                        ForEach(0 ..< state.timeValues.count, id: \.self) { i in
+                            Text(
+                                self.dateFormatter
+                                    .string(from: Date(
+                                        timeIntervalSince1970: state
+                                            .timeValues[i]
+                                    ))
+                            ).tag(i)
                         }
-                        .onChange(of: state.items[index].timeIndex, perform: { _ in state.calcTotal() })
-                        .frame(maxWidth: geometry.size.width / 2)
-                        .clipped()
                     }
-                }
+                    .onChange(of: state.items[index].timeIndex, perform: { _ in state.calcTotal() })
+                }.listRowBackground(Color.chart)
             }
+            .padding(.top)
+            .scrollContentBackground(.hidden).background(color)
+            .navigationTitle("Set Rate")
+            .navigationBarTitleDisplayMode(.automatic)
         }
 
         private var list: some View {
@@ -152,7 +171,7 @@ extension BasalProfileEditor {
 
             switch editMode {
             case .inactive:
-                return AnyView(Button(action: onAdd) { Text("Add") })
+                return AnyView(Button(action: onAdd) { Image(systemName: "plus") })
             default:
                 return AnyView(EmptyView())
             }

+ 2 - 2
FreeAPS/Sources/Modules/Bolus/BolusProvider.swift

@@ -21,7 +21,7 @@ extension Bolus {
         func getBGTarget() async -> BGTargets {
             await storage.retrieveAsync(OpenAPS.Settings.bgTargets, as: BGTargets.self)
                 ?? BGTargets(from: OpenAPS.defaults(for: OpenAPS.Settings.bgTargets))
-                ?? BGTargets(units: .mgdL, userPrefferedUnits: .mgdL, targets: [])
+                ?? BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: [])
         }
 
         func getISFValues() async -> InsulinSensitivities {
@@ -29,7 +29,7 @@ extension Bolus {
                 ?? InsulinSensitivities(from: OpenAPS.defaults(for: OpenAPS.Settings.insulinSensitivities))
                 ?? InsulinSensitivities(
                     units: .mgdL,
-                    userPrefferedUnits: .mgdL,
+                    userPreferredUnits: .mgdL,
                     sensitivities: []
                 )
         }

+ 115 - 85
FreeAPS/Sources/Modules/Bolus/BolusStateModel.swift

@@ -1,3 +1,4 @@
+import Combine
 import CoreData
 import Foundation
 import LoopKit
@@ -25,7 +26,6 @@ extension Bolus {
         @Published var insulinRecommended: Decimal = 0
         @Published var insulinRequired: Decimal = 0
         @Published var units: GlucoseUnits = .mgdL
-        @Published var percentage: Decimal = 0
         @Published var threshold: Decimal = 0
         @Published var maxBolus: Decimal = 0
         var maxExternal: Decimal { maxBolus * 3 }
@@ -61,7 +61,6 @@ extension Bolus {
         @Published var wholeCalc: Decimal = 0
         @Published var insulinCalculated: Decimal = 0
         @Published var fraction: Decimal = 0
-        @Published var useCalc: Bool = false
         @Published var basal: Decimal = 0
         @Published var fattyMeals: Bool = false
         @Published var fattyMealFactor: Decimal = 0
@@ -97,7 +96,6 @@ extension Bolus {
 
         @Published var id_: String = ""
         @Published var summary: String = ""
-        @Published var skipBolus: Bool = false
 
         @Published var externalInsulin: Bool = false
         @Published var showInfo: Bool = false
@@ -111,67 +109,59 @@ extension Bolus {
         @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 displayForecastsAsLines: Bool = false
-        @Published var smooth: Bool = false
+        @Published var forecastDisplayType: ForecastDisplayType = .cone
+        @Published var isSmoothingEnabled: Bool = false
+        @Published 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 coreDataObserver: CoreDataObserver?
+        private var coreDataPublisher: AnyPublisher<Set<NSManagedObject>, Never>?
+        private var subscriptions = Set<AnyCancellable>()
 
         typealias PumpEvent = PumpEventStored.EventType
 
         override func subscribe() {
-            setupGlucoseNotification()
-            coreDataObserver = CoreDataObserver()
+            coreDataPublisher =
+                changedObjectsOnManagedObjectContextDidSavePublisher()
+                    .receive(on: DispatchQueue.global(qos: .background))
+                    .share()
+                    .eraseToAnyPublisher()
             registerHandlers()
-            setupGlucoseArray()
+            registerSubscribers()
+            setupBolusStateConcurrently()
+        }
 
+        private func setupBolusStateConcurrently() {
             Task {
-                async let getAllSettingsDefaults: () = getAllSettingsValues()
-                async let setupDeterminations: () = setupDeterminationsArray()
-
-                await getAllSettingsDefaults
-                await setupDeterminations
-
-                // Determination has updated, so we can use this to draw the initial Forecast Chart
-                let forecastData = await mapForecastsForChart()
-                await updateForecasts(with: forecastData)
-            }
-
-            broadcaster.register(DeterminationObserver.self, observer: self)
-            broadcaster.register(BolusFailureObserver.self, observer: self)
-            units = settingsManager.settings.units
-            percentage = settingsManager.settings.insulinReqPercentage
-            fraction = settings.settings.overrideFactor
-            useCalc = settings.settings.useCalc
-            fattyMeals = settings.settings.fattyMeals
-            fattyMealFactor = settings.settings.fattyMealFactor
-            sweetMeals = settings.settings.sweetMeals
-            sweetMealFactor = settings.settings.sweetMealFactor
-            displayPresets = settings.settings.displayPresets
-
-            displayForecastsAsLines = settings.settings.displayForecastsAsLines
-
-            lowGlucose = units == .mgdL ? settingsManager.settings.low : settingsManager.settings.low.asMmolL
-            highGlucose = units == .mgdL ? settingsManager.settings.high : settingsManager.settings.high.asMmolL
+                await withTaskGroup(of: Void.self) { group in
+                    group.addTask {
+                        self.setupGlucoseArray()
+                    }
+                    group.addTask {
+                        self.setupDeterminationsAndForecasts()
+                    }
+                    group.addTask {
+                        await self.setupSettings()
+                    }
+                    group.addTask {
+                        self.registerObservers()
+                    }
 
-            maxCarbs = settings.settings.maxCarbs
-            maxFat = settings.settings.maxFat
-            maxProtein = settings.settings.maxProtein
-            skipBolus = settingsManager.settings.skipBolusScreenAfterCarbs
-            useFPUconversion = settingsManager.settings.useFPUconversion
-            smooth = settingsManager.settings.smoothGlucose
-
-            if waitForSuggestionInitial {
-                Task {
-                    let ok = await apsManager.determineBasal()
-                    if !ok {
-                        self.waitForSuggestion = false
-                        self.insulinRequired = 0
-                        self.insulinRecommended = 0
+                    if self.waitForSuggestionInitial {
+                        group.addTask {
+                            let isDetermineBasalSuccessful = await self.apsManager.determineBasal()
+                            if !isDetermineBasalSuccessful {
+                                await MainActor.run {
+                                    self.waitForSuggestion = false
+                                    self.insulinRequired = 0
+                                    self.insulinRecommended = 0
+                                }
+                            }
+                        }
                     }
                 }
             }
@@ -209,6 +199,43 @@ extension Bolus {
             }
         }
 
+        private func setupDeterminationsAndForecasts() {
+            Task {
+                async let getAllSettingsDefaults: () = getAllSettingsValues()
+                async let setupDeterminations: () = setupDeterminationsArray()
+
+                await getAllSettingsDefaults
+                await setupDeterminations
+
+                // Determination has updated, so we can use this to draw the initial Forecast Chart
+                let forecastData = await mapForecastsForChart()
+                await updateForecasts(with: forecastData)
+            }
+        }
+
+        private func registerObservers() {
+            broadcaster.register(DeterminationObserver.self, observer: self)
+            broadcaster.register(BolusFailureObserver.self, observer: self)
+        }
+
+        @MainActor private func setupSettings() async {
+            units = settingsManager.settings.units
+            fraction = settings.settings.overrideFactor
+            fattyMeals = settings.settings.fattyMeals
+            fattyMealFactor = settings.settings.fattyMealFactor
+            sweetMeals = settings.settings.sweetMeals
+            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
+            maxCarbs = settings.settings.maxCarbs
+            maxFat = settings.settings.maxFat
+            maxProtein = settings.settings.maxProtein
+            useFPUconversion = settingsManager.settings.useFPUconversion
+            isSmoothingEnabled = settingsManager.settings.smoothGlucose
+        }
+
         private func getCurrentSettingValue(for type: SettingType) async {
             let now = Date()
             let calendar = Calendar.current
@@ -284,7 +311,7 @@ extension Bolus {
 
         /// Calculate insulin recommendation
         func calculateInsulin() -> Decimal {
-            let isfForCalculation = units == .mmolL ? isf.asMgdL : isf
+            let isfForCalculation = isf
 
             // insulin needed for the current blood glucose
             targetDifference = currentBG - target
@@ -363,9 +390,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()
                 }
             }
@@ -467,7 +501,7 @@ extension Bolus {
                 enteredBy: CarbsEntry.manual,
                 isFPU: false, fpuID: UUID().uuidString
             )]
-            await carbsStorage.storeCarbs(carbsToStore)
+            await carbsStorage.storeCarbs(carbsToStore, areFetchedFromRemote: false)
 
             if carbs > 0 || fat > 0 || protein > 0 {
                 // only perform determine basal sync if the user doesn't use the pump bolus, otherwise the enact bolus func in the APSManger does a sync
@@ -542,33 +576,29 @@ extension Bolus.StateModel: DeterminationObserver, BolusFailureObserver {
 
 extension Bolus.StateModel {
     private func registerHandlers() {
-        coreDataObserver?.registerHandler(for: "OrefDetermination") { [weak self] in
+        coreDataPublisher?.filterByEntityName("OrefDetermination").sink { [weak self] _ in
             guard let self = self else { return }
             Task {
                 await self.setupDeterminationsArray()
                 await self.updateForecasts()
             }
-        }
+        }.store(in: &subscriptions)
 
         // Due to the Batch insert this only is used for observing Deletion of Glucose entries
-        coreDataObserver?.registerHandler(for: "GlucoseStored") { [weak self] in
+        coreDataPublisher?.filterByEntityName("GlucoseStored").sink { [weak self] _ in
             guard let self = self else { return }
             self.setupGlucoseArray()
-        }
+        }.store(in: &subscriptions)
     }
 
-    private func setupGlucoseNotification() {
-        /// custom notification that is sent when a batch insert of glucose objects is done
-        Foundation.NotificationCenter.default.addObserver(
-            self,
-            selector: #selector(handleBatchInsert),
-            name: .didPerformBatchInsert,
-            object: nil
-        )
-    }
-
-    @objc private func handleBatchInsert() {
-        setupGlucoseArray()
+    private func registerSubscribers() {
+        glucoseStorage.updatePublisher
+            .receive(on: DispatchQueue.global(qos: .background))
+            .sink { [weak self] _ in
+                guard let self = self else { return }
+                self.setupGlucoseArray()
+            }
+            .store(in: &subscriptions)
     }
 }
 
@@ -587,16 +617,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)
         }
     }
@@ -632,16 +662,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"),
@@ -730,20 +760,20 @@ extension Bolus.StateModel {
         minCount = max(12, nonEmptyArrays.map(\.count).min() ?? 0)
         guard minCount > 0 else { return }
 
-        let (minResult, maxResult) = await Task.detached {
-            let minForecast = (0 ..< self.minCount).map { index in
+        async let minForecastResult = Task.detached {
+            (0 ..< self.minCount).map { index in
                 nonEmptyArrays.compactMap { $0.indices.contains(index) ? $0[index] : nil }.min() ?? 0
             }
+        }.value
 
-            let maxForecast = (0 ..< self.minCount).map { index in
+        async let maxForecastResult = Task.detached {
+            (0 ..< self.minCount).map { index in
                 nonEmptyArrays.compactMap { $0.indices.contains(index) ? $0[index] : nil }.max() ?? 0
             }
-
-            return (minForecast, maxForecast)
         }.value
 
-        minForecast = minResult
-        maxForecast = maxResult
+        minForecast = await minForecastResult
+        maxForecast = await maxForecastResult
     }
 }
 

+ 57 - 66
FreeAPS/Sources/Modules/Bolus/View/BolusRootView.swift

@@ -171,39 +171,48 @@ extension Bolus {
                 VStack {
                     Form {
                         Section {
+                            ForeCastChart(state: state, units: $state.units)
+                                .padding(.vertical)
+                        }.listRowBackground(Color.chart)
+
+                        Section {
                             carbsTextField()
 
-                            if state.useFPUconversion {
-                                proteinAndFat()
-                            }
+                            DisclosureGroup("Extras") {
+                                if state.useFPUconversion {
+                                    proteinAndFat()
+                                }
 
-                            // Time
-                            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)
+                                // Time
+                                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)
                                     }
-                                    label: { Image(systemName: "plus.circle") }.tint(.blue).buttonStyle(.borderless)
                                 }
-                            }
-                            HStack {
-                                Image(systemName: "square.and.pencil").foregroundColor(.secondary)
-                                TextFieldWithToolBarString(text: $state.note, placeholder: "", maxLength: 25)
+
+                                // Notes
+                                HStack {
+                                    Image(systemName: "square.and.pencil").foregroundColor(.secondary)
+                                    TextFieldWithToolBarString(text: $state.note, placeholder: "", maxLength: 25)
+                                }
                             }
                         }.listRowBackground(Color.chart)
 
@@ -293,15 +302,10 @@ extension Bolus {
                             }
                         }.listRowBackground(Color.chart)
 
-                        Section {
-                            ForeCastChart(state: state, units: $state.units)
-                                .padding(.vertical)
-                        }.listRowBackground(Color.chart)
+                        treatmentButton
                     }
                 }
-                .safeAreaInset(edge: .bottom, spacing: 0) {
-                    stickyButton
-                }.blur(radius: state.waitForSuggestion ? 5 : 0)
+                .blur(radius: state.waitForSuggestion ? 5 : 0)
 
                 if state.waitForSuggestion {
                     CustomProgressView(text: progressText.rawValue)
@@ -365,46 +369,33 @@ extension Bolus {
             }
         }
 
-        var stickyButton: some View {
-            ZStack {
-                Rectangle()
-                    .frame(width: UIScreen.main.bounds.width, height: 120).offset(y: 40)
-                    .shadow(
-                        color: colorScheme == .dark ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) :
-                            Color.black.opacity(0.33),
-                        radius: 3
-                    )
-                    .foregroundStyle(Color.chart)
-
-                Button {
-                    state.invokeTreatmentsTask()
-                } label: {
-                    taskButtonLabel
-                        .font(.headline)
-                        .foregroundStyle(Color.white)
-                        .frame(maxWidth: .infinity, alignment: .center)
-                        .frame(minHeight: 50)
-                }
-                .disabled(disableTaskButton)
-                .background(limitExceeded ? Color(.systemRed) : Color(.systemBlue))
-                .shadow(radius: 3)
-                .clipShape(RoundedRectangle(cornerRadius: 8))
-                .padding()
-                .offset(y: 20)
+        var treatmentButton: some View {
+            Button {
+                state.invokeTreatmentsTask()
+            } label: {
+                taskButtonLabel
+                    .font(.headline)
+                    .foregroundStyle(Color.white)
+                    .frame(maxWidth: .infinity, alignment: .center)
+                    .frame(height: 35)
             }
+            .disabled(disableTaskButton)
+            .listRowBackground(limitExceeded ? Color(.systemRed) : Color(.systemBlue))
+            .shadow(radius: 3)
+            .clipShape(RoundedRectangle(cornerRadius: 8))
         }
 
         private var taskButtonLabel: some View {
             if pumpBolusLimitExceeded {
-                return Text("Max Bolus of \(state.maxBolus) U Exceeded")
+                return Text("Max Bolus of \(state.maxBolus.description) U Exceeded")
             } else if externalBolusLimitExceeded {
-                return Text("Max External Bolus of \(state.maxExternal) U Exceeded")
+                return Text("Max External Bolus of \(state.maxExternal.description) U Exceeded")
             } else if carbLimitExceeded {
-                return Text("Max Carbs of \(state.maxCarbs) g Exceeded")
+                return Text("Max Carbs of \(state.maxCarbs.description) g Exceeded")
             } else if fatLimitExceeded {
-                return Text("Max Fat of \(state.maxFat) g Exceeded")
+                return Text("Max Fat of \(state.maxFat.description) g Exceeded")
             } else if proteinLimitExceeded {
-                return Text("Max Protein of \(state.maxProtein) g Exceeded")
+                return Text("Max Protein of \(state.maxProtein.description) g Exceeded")
             }
 
             let hasInsulin = state.amount > 0

+ 57 - 65
FreeAPS/Sources/Modules/Bolus/View/ForeCastChart.swift

@@ -12,7 +12,7 @@ struct ForeCastChart: View {
 
     private var endMarker: Date {
         state
-            .displayForecastsAsLines ? Date(timeIntervalSinceNow: TimeInterval(hours: 3)) :
+            .forecastDisplayType == .lines ? Date(timeIntervalSinceNow: TimeInterval(hours: 3)) :
             Date(timeIntervalSinceNow: TimeInterval(
                 Int(1.5) * 5 * state
                     .minCount * 60
@@ -35,6 +35,42 @@ struct ForeCastChart: View {
 
     var body: some View {
         VStack {
+            HStack {
+                HStack {
+                    Text("Added carbs: ")
+                        .font(.footnote)
+                        .fontWeight(.bold)
+                        .foregroundStyle(.orange)
+
+                    Text("\(state.carbs.description) g")
+                        .font(.footnote)
+                        .foregroundStyle(.orange)
+                }
+                .padding(8)
+                .background {
+                    RoundedRectangle(cornerRadius: 10)
+                        .fill(Color.orange.opacity(0.2))
+                }
+
+                Spacer()
+
+                HStack {
+                    Text("Added insulin: ")
+                        .font(.footnote)
+                        .fontWeight(.bold)
+                        .foregroundStyle(.blue)
+
+                    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)
             HStack {
@@ -70,7 +106,7 @@ struct ForeCastChart: View {
             drawGlucose()
             drawCurrentTimeMarker()
 
-            if state.displayForecastsAsLines {
+            if state.forecastDisplayType == .lines {
                 drawForecastLines()
             } else {
                 drawForecastsCone()
@@ -83,74 +119,30 @@ struct ForeCastChart: View {
         .backport.chartForegroundStyleScale(state: state)
     }
 
-    private var stops: [Gradient.Stop] {
-        let low = Double(state.lowGlucose)
-        let high = Double(state.highGlucose)
-
-        let glucoseValues = state.glucoseFromPersistence
-            .map { units == .mgdL ? Decimal($0.glucose) : Decimal($0.glucose).asMmolL }
-
-        let minimum = glucoseValues.min() ?? 0.0
-        let maximum = glucoseValues.max() ?? 0.0
-
-        // Calculate positions for gradient
-        let lowPosition = (low - Double(truncating: minimum as NSNumber)) /
-            (Double(truncating: maximum as NSNumber) - Double(truncating: minimum as NSNumber))
-        let highPosition = (high - Double(truncating: minimum as NSNumber)) /
-            (Double(truncating: maximum as NSNumber) - Double(truncating: minimum as NSNumber))
-
-        // Ensure positions are in bounds [0, 1]
-        let clampedLowPosition = max(0.0, min(lowPosition, 1.0))
-        let clampedHighPosition = max(0.0, min(highPosition, 1.0))
-
-        // Ensure lowPosition is less than highPosition
-        let sortedPositions = [clampedLowPosition, clampedHighPosition].sorted()
-
-        return [
-            Gradient.Stop(color: .red, location: 0.0),
-            Gradient.Stop(color: .red, location: sortedPositions[0]), // draw red gradient till lowGlucose
-            Gradient.Stop(color: .green, location: sortedPositions[0] + 0.0001), // draw green above lowGlucose till highGlucose
-            Gradient.Stop(color: .green, location: sortedPositions[1]),
-            Gradient.Stop(color: .orange, location: sortedPositions[1] + 0.0001), // draw orange above highGlucose
-            Gradient.Stop(color: .orange, location: 1.0)
-        ]
-    }
-
     private func drawGlucose() -> some ChartContent {
         ForEach(state.glucoseFromPersistence) { item in
-            let glucoseToDisplay = units == .mgdL ? Decimal(item.glucose) : Decimal(item.glucose).asMmolL
-
-            if state.smooth {
-                LineMark(
-                    x: .value("Time", item.date ?? Date()),
+            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
+
+            if !state.isSmoothingEnabled {
+                PointMark(
+                    x: .value("Time", item.date ?? Date(), unit: .second),
                     y: .value("Value", glucoseToDisplay)
                 )
-                .foregroundStyle(
-                    .linearGradient(stops: stops, startPoint: .bottom, endPoint: .top)
-                )
-                .symbol(.circle).symbolSize(34)
+                .foregroundStyle(pointMarkColor)
+                .symbolSize(20)
             } else {
-                if item.glucose > Int(state.highGlucose) {
-                    PointMark(
-                        x: .value("Time", item.date ?? Date(), unit: .second),
-                        y: .value("Value", glucoseToDisplay)
-                    )
-                    .foregroundStyle(Color.orange.gradient)
-                    .symbolSize(20)
-                } else if item.glucose < Int(state.lowGlucose) {
-                    PointMark(
-                        x: .value("Time", item.date ?? Date(), unit: .second),
-                        y: .value("Value", glucoseToDisplay)
-                    )
-                    .foregroundStyle(Color.red.gradient)
-                    .symbolSize(20)
-                } else {
-                    PointMark(
-                        x: .value("Time", item.date ?? Date(), unit: .second),
-                        y: .value("Value", glucoseToDisplay)
-                    )
-                    .foregroundStyle(Color.green.gradient)
-                    .symbolSize(20)
+                PointMark(
+                    x: .value("Time", item.date ?? Date(), unit: .second),
+                    y: .value("Value", glucoseToDisplay)
+                )
+                .symbol {
+                    Image(systemName: "record.circle.fill")
+                        .font(.system(size: 8))
+                        .bold()
+                        .foregroundStyle(pointMarkColor)
                 }
             }
         }

+ 45 - 58
FreeAPS/Sources/Modules/Bolus/View/PopupView.swift

@@ -134,14 +134,15 @@ struct PopupView: View {
             Text(state.carbRatio.formatted() + " " + NSLocalizedString("g/U", comment: " grams per Unit"))
                 .gridCellAnchor(.leading)
 
+            let isf = state.units == .mmolL ? state.isf.formattedAsMmolL : state.isf.description
             Text(
-                state.isf.formatted() + " " + state.units
+                isf + " " + state.units
                     .rawValue + NSLocalizedString("/U", comment: "/Insulin unit")
             ).gridCellAnchor(.leading)
-            let target = state.units == .mmolL ? state.target.asMmolL : state.target
+
+            let target = state.units == .mmolL ? state.target.formattedAsMmolL : state.target.description
             Text(
-                target
-                    .formatted(.number.grouping(.never).rounded().precision(.fractionLength(fractionDigits))) +
+                target +
                     " " + state.units.rawValue
             ).gridCellAnchor(.leading)
         }
@@ -149,28 +150,25 @@ struct PopupView: View {
 
     var calcGlucoseFirstRow: some View {
         GridRow(alignment: .center) {
-            let currentBG = state.units == .mmolL ? state.currentBG.asMmolL : state.currentBG
-            let target = state.units == .mmolL ? state.target.asMmolL : state.target
+            let currentBG = state.units == .mmolL ? state.currentBG.formattedAsMmolL : state.currentBG.description
+            let target = state.units == .mmolL ? state.target.formattedAsMmolL : state.target.description
 
             Text("Glucose:").foregroundColor(.secondary)
 
-            let targetDifference = state.units == .mmolL ? state.targetDifference.asMmolL : state.targetDifference
+            let targetDifference = state.units == .mmolL ? state.targetDifference.formattedAsMmolL : state.targetDifference
+                .description
             let firstRow = currentBG
-                .formatted(.number.grouping(.never).rounded().precision(.fractionLength(fractionDigits)))
-
                 + " - " +
                 target
-                .formatted(.number.grouping(.never).rounded().precision(.fractionLength(fractionDigits)))
                 + " = " +
                 targetDifference
-                .formatted(.number.grouping(.never).rounded().precision(.fractionLength(fractionDigits)))
 
             Text(firstRow).frame(minWidth: 0, alignment: .leading).foregroundColor(.secondary)
                 .gridColumnAlignment(.leading)
 
             HStack {
                 Text(
-                    self.insulinRounder(state.targetDifferenceInsulin).formatted()
+                    self.insulinFormatter(state.targetDifferenceInsulin)
                 )
                 Text("U").foregroundColor(.secondary)
             }.fontWeight(.bold)
@@ -180,24 +178,18 @@ struct PopupView: View {
 
     var calcGlucoseSecondRow: some View {
         GridRow(alignment: .center) {
-            let currentBG = state.units == .mmolL ? state.currentBG.asMmolL : state.currentBG
+            let currentBG = state.units == .mmolL ? state.currentBG.formattedAsMmolL : state.currentBG.description
             Text(
                 currentBG
-                    .formatted(.number.grouping(.never).rounded().precision(.fractionLength(fractionDigits))) +
-                    " " +
+                    + " " +
                     state.units.rawValue
             )
 
-            let targetDifference = state.units == .mmolL ? state.targetDifference.asMmolL : state.targetDifference
-            let secondRow = targetDifference
-                .formatted(
-                    .number.grouping(.never).rounded()
-                        .precision(.fractionLength(fractionDigits))
-                )
-                + " / " +
-                state.isf.formatted()
-                + " ≈ " +
-                self.insulinRounder(state.targetDifferenceInsulin).formatted()
+            let targetDifference = state.units == .mmolL ? state.targetDifference.formattedAsMmolL : state.targetDifference
+                .description
+            let secondRow = targetDifference + " / " +
+                (state.units == .mmolL ? state.isf.formattedAsMmolL : state.isf.description)
+                .description + " ≈ " + self.insulinFormatter(state.targetDifferenceInsulin)
 
             Text(secondRow).foregroundColor(.secondary).gridColumnAlignment(.leading)
 
@@ -221,13 +213,13 @@ struct PopupView: View {
             HStack {
                 Text("IOB:").foregroundColor(.secondary)
                 Text(
-                    self.insulinRounder(state.iob).formatted()
+                    self.insulinFormatter(state.iob)
                 )
             }
 
             Text("Subtract IOB").foregroundColor(.secondary.opacity(colorScheme == .dark ? 0.65 : 0.8)).font(.footnote)
 
-            let iobFormatted = self.insulinRounder(state.iob).formatted()
+            let iobFormatted = self.insulinFormatter(state.iob)
             HStack {
                 Text((state.iob >= 0 ? "-" : "") + (state.iob >= 0 ? iobFormatted : "(" + iobFormatted + ")"))
                 Text("U").foregroundColor(.secondary)
@@ -253,14 +245,14 @@ struct PopupView: View {
                     + " / " +
                     state.carbRatio.formatted()
                     + " ≈ " +
-                    self.insulinRounder(state.wholeCobInsulin).formatted()
+                    self.insulinFormatter(state.wholeCobInsulin)
             )
             .foregroundColor(.secondary)
             .gridColumnAlignment(.leading)
 
             HStack {
                 Text(
-                    self.insulinRounder(state.wholeCobInsulin).formatted()
+                    self.insulinFormatter(state.wholeCobInsulin)
                 )
                 Text("U").foregroundColor(.secondary)
             }.fontWeight(.bold)
@@ -283,25 +275,19 @@ struct PopupView: View {
         GridRow(alignment: .center) {
             Text("Delta:").foregroundColor(.secondary)
 
-            let deltaBG = state.units == .mmolL ? state.deltaBG.asMmolL : state.deltaBG
+            let deltaBG = state.units == .mmolL ? state.deltaBG.formattedAsMmolL : state.deltaBG.description
+            let isf = state.units == .mmolL ? state.isf.formattedAsMmolL : state.isf.description
+
+            let fifteenMinInsulinFormatted = self.insulinFormatter(state.fifteenMinInsulin)
+
             Text(
-                deltaBG
-                    .formatted(
-                        .number.grouping(.never).rounded()
-                            .precision(.fractionLength(fractionDigits))
-                    )
-                    + " / " +
-                    state.isf.formatted()
-                    + " ≈ " +
-                    self.insulinRounder(state.fifteenMinInsulin).formatted()
+                deltaBG + " / " + isf + " ≈ " + fifteenMinInsulinFormatted
             )
             .foregroundColor(.secondary)
             .gridColumnAlignment(.leading)
 
             HStack {
-                Text(
-                    self.insulinRounder(state.fifteenMinInsulin).formatted()
-                )
+                Text(fifteenMinInsulinFormatted)
                 Text("U").foregroundColor(.secondary)
             }.fontWeight(.bold)
                 .gridColumnAlignment(.trailing)
@@ -310,13 +296,10 @@ struct PopupView: View {
 
     var calcDeltaFormulaRow: some View {
         GridRow(alignment: .center) {
-            let deltaBG = state.units == .mmolL ? state.deltaBG.asMmolL : state.deltaBG
+            let deltaBG = state.units == .mmolL ? state.deltaBG.formattedAsMmolL : state.deltaBG.description
             Text(
                 deltaBG
-                    .formatted(
-                        .number.grouping(.never).rounded()
-                            .precision(.fractionLength(fractionDigits))
-                    ) + " " +
+                    + " " +
                     state.units.rawValue
             )
 
@@ -334,7 +317,7 @@ struct PopupView: View {
             Color.clear.gridCellUnsizedAxes([.horizontal, .vertical])
 
             HStack {
-                Text(self.insulinRounder(state.wholeCalc).formatted())
+                Text(self.insulinFormatter(state.wholeCalc))
                     .foregroundStyle(state.wholeCalc < 0 ? Color.loopRed : Color.primary)
                 Text("U").foregroundColor(.secondary)
             }.gridColumnAlignment(.trailing)
@@ -350,7 +333,7 @@ struct PopupView: View {
             Text("Added to Result").foregroundColor(.secondary.opacity(colorScheme == .dark ? 0.65 : 0.8)).font(.footnote)
 
             HStack {
-                Text("+" + self.insulinRounder(state.superBolusInsulin).formatted())
+                Text("+" + self.insulinFormatter(state.superBolusInsulin))
                     .foregroundStyle(Color.loopRed)
                 Text("U").foregroundColor(.secondary)
             }.gridColumnAlignment(.trailing)
@@ -379,7 +362,7 @@ struct PopupView: View {
                     .foregroundColor(.secondary)
                     // endif fatty meal is chosen
 
-                    + Text(self.insulinRounder(state.wholeCalc).formatted())
+                    + Text(self.insulinFormatter(state.wholeCalc))
                     .foregroundColor(state.wholeCalc < 0 ? Color.loopRed : Color.primary)
 
                     // if superbolus is chosen
@@ -389,7 +372,7 @@ struct PopupView: View {
                     + Text(state.useSuperBolus ? " + " : "")
                     .foregroundColor(.secondary)
 
-                    + Text(state.useSuperBolus ? state.superBolusInsulin.formatted() : "")
+                    + Text(state.useSuperBolus ? self.insulinFormatter(state.superBolusInsulin) : "")
                     .foregroundColor(.loopRed)
                     // endif superbolus is chosen
 
@@ -399,7 +382,7 @@ struct PopupView: View {
             .gridColumnAlignment(.leading)
 
             HStack {
-                Text(self.insulinRounder(state.insulinCalculated).formatted())
+                Text(self.insulinFormatter(state.insulinCalculated))
                     .fontWeight(.bold)
                     .foregroundColor(state.wholeCalc >= state.maxBolus ? Color.loopRed : Color.blue)
                 Text("U").foregroundColor(.secondary)
@@ -447,9 +430,17 @@ struct PopupView: View {
         }
     }
 
-    private func insulinRounder(_ value: Decimal) -> Decimal {
+    private func insulinFormatter(_ value: Decimal) -> String {
         let toRound = NSDecimalNumber(decimal: value).doubleValue
-        return Decimal(floor(100 * toRound) / 100)
+        let roundedValue = Decimal(floor(100 * toRound) / 100)
+
+        let formatter = NumberFormatter()
+        formatter.numberStyle = .decimal
+        formatter.minimumFractionDigits = 2
+        formatter.maximumFractionDigits = 2
+        formatter.locale = Locale.current // Uses the user's locale
+
+        return formatter.string(from: roundedValue as NSNumber) ?? String(format: "%.2f", toRound)
     }
 
     struct DividerDouble: View {
@@ -476,7 +467,3 @@ struct PopupView: View {
         }
     }
 }
-
-// #Preview {
-//    PopupView()
-// }

+ 9 - 4
FreeAPS/Sources/Modules/BolusCalculatorConfig/BolusCalculatorStateModel.swift

@@ -2,23 +2,23 @@ import SwiftUI
 
 extension BolusCalculatorConfig {
     final class StateModel: BaseStateModel<Provider> {
+        @Published var units: GlucoseUnits = .mgdL
         @Published var overrideFactor: Decimal = 0
-        @Published var useCalc: Bool = false
         @Published var fattyMeals: Bool = false
         @Published var fattyMealFactor: Decimal = 0
         @Published var sweetMeals: Bool = false
         @Published var sweetMealFactor: Decimal = 0
-        @Published var insulinReqPercentage: Decimal = 70
         @Published var displayPresets: Bool = true
 
         override func subscribe() {
+            units = settingsManager.settings.units
+
             subscribeSetting(\.overrideFactor, on: $overrideFactor, initial: {
                 let value = max(min($0, 1.2), 0.1)
                 overrideFactor = value
             }, map: {
                 $0
             })
-            subscribeSetting(\.useCalc, on: $useCalc) { useCalc = $0 }
             subscribeSetting(\.fattyMeals, on: $fattyMeals) { fattyMeals = $0 }
             subscribeSetting(\.displayPresets, on: $displayPresets) { displayPresets = $0 }
             subscribeSetting(\.fattyMealFactor, on: $fattyMealFactor, initial: {
@@ -34,7 +34,12 @@ extension BolusCalculatorConfig {
             }, map: {
                 $0
             })
-            subscribeSetting(\.insulinReqPercentage, on: $insulinReqPercentage) { insulinReqPercentage = $0 }
         }
     }
 }
+
+extension BolusCalculatorConfig.StateModel: SettingsObserver {
+    func settingsDidChange(_: FreeAPSSettings) {
+        units = settingsManager.settings.units
+    }
+}

Разница между файлами не показана из-за своего большого размера
+ 84 - 60
FreeAPS/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift


+ 29 - 42
FreeAPS/Sources/Modules/CGM/CGMStateModel.swift

@@ -21,32 +21,43 @@ let cgmDefaultName = cgmName(
 extension CGM {
     final class StateModel: BaseStateModel<Provider> {
         @Injected() var cgmManager: FetchGlucoseManager!
-        @Injected() var calendarManager: CalendarManager!
         @Injected() var pluginCGMManager: PluginManager!
         @Injected() private var broadcaster: Broadcaster!
         @Injected() var nightscoutManager: NightscoutManager!
 
+        @Published var units: GlucoseUnits = .mgdL
         @Published var setupCGM: Bool = false
         @Published var cgmCurrent = cgmDefaultName
         @Published var smoothGlucose = false
-        @Published var createCalendarEvents = false
-        @Published var displayCalendarIOBandCOB = false
-        @Published var displayCalendarEmojis = false
-        @Published var calendarIDs: [String] = []
-        @Published var currentCalendarID: String = ""
-        @Persisted(key: "CalendarManager.currentCalendarID") var storedCalendarID: String? = nil
         @Published var cgmTransmitterDeviceAddress: String? = nil
         @Published var listOfCGM: [cgmName] = []
         @Published var url: URL?
 
         override func subscribe() {
+            units = settingsManager.settings.units
+
             // collect the list of CGM available with plugins and CGMType defined manually
-            listOfCGM = CGMType.allCases.filter { $0 != CGMType.plugin }.map {
-                cgmName(id: $0.id, type: $0, displayName: $0.displayName, subtitle: $0.subtitle)
-            } +
-                pluginCGMManager.availableCGMManagers.map {
-                    cgmName(id: $0.identifier, type: CGMType.plugin, displayName: $0.localizedTitle, subtitle: $0.localizedTitle)
+            listOfCGM = (
+                CGMType.allCases.filter { $0 != CGMType.plugin }.map {
+                    cgmName(id: $0.id, type: $0, displayName: $0.displayName, subtitle: $0.subtitle)
+                } +
+                    pluginCGMManager.availableCGMManagers.map {
+                        cgmName(
+                            id: $0.identifier,
+                            type: CGMType.plugin,
+                            displayName: $0.localizedTitle,
+                            subtitle: $0.localizedTitle
+                        )
+                    }
+            ).sorted(by: { lhs, rhs in
+                if lhs.displayName == "None" {
+                    return true
+                } else if rhs.displayName == "None" {
+                    return false
+                } else {
+                    return lhs.displayName < rhs.displayName
                 }
+            })
 
             switch settingsManager.settings.cgm {
             case .plugin:
@@ -81,13 +92,8 @@ extension CGM {
             default: break
             }
 
-            currentCalendarID = storedCalendarID ?? ""
-            calendarIDs = calendarManager.calendarIDs()
             cgmTransmitterDeviceAddress = UserDefaults.standard.cgmTransmitterDeviceAddress
 
-            subscribeSetting(\.useCalendar, on: $createCalendarEvents) { createCalendarEvents = $0 }
-            subscribeSetting(\.displayCalendarIOBandCOB, on: $displayCalendarIOBandCOB) { displayCalendarIOBandCOB = $0 }
-            subscribeSetting(\.displayCalendarEmojis, on: $displayCalendarEmojis) { displayCalendarEmojis = $0 }
             subscribeSetting(\.smoothGlucose, on: $smoothGlucose, initial: { smoothGlucose = $0 })
 
             $cgmCurrent
@@ -111,31 +117,6 @@ extension CGM {
                     }
                 }
                 .store(in: &lifetime)
-
-            $createCalendarEvents
-                .removeDuplicates()
-                .flatMap { [weak self] ok -> AnyPublisher<Bool, Never> in
-                    guard ok, let self = self else { return Just(false).eraseToAnyPublisher() }
-                    return self.calendarManager.requestAccessIfNeeded()
-                }
-                .map { [weak self] ok -> [String] in
-                    guard ok, let self = self else { return [] }
-                    return self.calendarManager.calendarIDs()
-                }
-                .receive(on: DispatchQueue.main)
-                .weakAssign(to: \.calendarIDs, on: self)
-                .store(in: &lifetime)
-
-            $currentCalendarID
-                .removeDuplicates()
-                .sink { [weak self] id in
-                    guard id.isNotEmpty else {
-                        self?.calendarManager.currentCalendarID = nil
-                        return
-                    }
-                    self?.calendarManager.currentCalendarID = id
-                }
-                .store(in: &lifetime)
         }
 
         func displayNameOfApp() -> String? {
@@ -199,3 +180,9 @@ extension CGM.StateModel: CGMManagerOnboardingDelegate {
         // nothing to do ?
     }
 }
+
+extension CGM.StateModel {
+    func settingsDidChange(_: FreeAPSSettings) {
+        units = settingsManager.settings.units
+    }
+}

+ 128 - 64
FreeAPS/Sources/Modules/CGM/View/CGMRootView.swift

@@ -9,6 +9,13 @@ extension CGM {
         @StateObject var state = StateModel()
         @State private var setupCGM = false
 
+        @State private var shouldDisplayHint: Bool = false
+        @State var hintDetent = PresentationDetent.large
+        @State var selectedVerboseHint: String?
+        @State var hintLabel: String?
+        @State private var decimalPlaceholder: Decimal = 0.0
+        @State private var booleanPlaceholder: Bool = false
+
         @Environment(\.colorScheme) var colorScheme
         var color: LinearGradient {
             colorScheme == .dark ? LinearGradient(
@@ -27,29 +34,75 @@ extension CGM {
                 )
         }
 
-        // @AppStorage(UserDefaults.BTKey.cgmTransmitterDeviceAddress.rawValue) private var cgmTransmitterDeviceAddress: String? = nil
-
         var body: some View {
             NavigationView {
                 Form {
-                    Section(header: Text("CGM")) {
-                        Picker("Type", selection: $state.cgmCurrent) {
-                            ForEach(state.listOfCGM) { type in
-                                VStack(alignment: .leading) {
-                                    Text(type.displayName)
-                                    Text(type.subtitle).font(.caption).foregroundColor(.secondary)
-                                }.tag(type)
+                    Section(
+                        header: Text("CGM Integration to Trio"),
+                        content: {
+                            VStack {
+                                Picker("Type", selection: $state.cgmCurrent) {
+                                    ForEach(state.listOfCGM) { type in
+                                        VStack(alignment: .leading) {
+                                            Text(type.displayName)
+                                            Text(type.subtitle).font(.caption).foregroundColor(.secondary)
+                                        }.tag(type)
+                                    }
+                                }.padding(.top)
+
+                                HStack(alignment: .top) {
+                                    Text(
+                                        "Lorem ipsum dolor sit amet, consetetur sadipscing elitr."
+                                    )
+                                    .font(.footnote)
+                                    .foregroundColor(.secondary)
+                                    .lineLimit(nil)
+                                    Spacer()
+                                    Button(
+                                        action: {
+                                            hintLabel = "Available CGM Types for Trio"
+                                            selectedVerboseHint =
+                                                "CGM Types… bla bla \n\nLorem ipsum dolor sit amet, consetetur sadipscing elitr."
+                                            shouldDisplayHint.toggle()
+                                        },
+                                        label: {
+                                            HStack {
+                                                Image(systemName: "questionmark.circle")
+                                            }
+                                        }
+                                    ).buttonStyle(BorderlessButtonStyle())
+                                }.padding(.top)
+                            }.padding(.bottom)
+
+                            if let link = state.cgmCurrent.type.externalLink {
+                                Button {
+                                    UIApplication.shared.open(link, options: [:], completionHandler: nil)
+                                } label: {
+                                    HStack {
+                                        Text("About this source")
+                                        Spacer()
+                                        Image(systemName: "chevron.right")
+                                    }
+                                }
+                                .frame(maxWidth: .infinity, alignment: .leading)
                             }
-                        }
-                        if let link = state.cgmCurrent.type.externalLink {
-                            Button("About this source") {
-                                UIApplication.shared.open(link, options: [:], completionHandler: nil)
+
+                            if state.cgmCurrent.type == .plugin {
+                                Button {
+                                    setupCGM.toggle()
+                                } label: {
+                                    HStack {
+                                        Text("CGM Configuration")
+                                        Spacer()
+                                        Image(systemName: "chevron.right")
+                                    }
+                                }
+                                .frame(maxWidth: .infinity, alignment: .leading)
                             }
                         }
-                    }
+                    ).listRowBackground(Color.chart)
 
-                    if let appURL = state.urlOfApp()
-                    {
+                    if let appURL = state.urlOfApp() {
                         Section {
                             Button {
                                 UIApplication.shared.open(appURL, options: [:]) { success in
@@ -61,7 +114,8 @@ extension CGM {
                             }
 
                             label: {
-                                Label(state.displayNameOfApp() ?? "-", systemImage: "waveform.path.ecg.rectangle").font(.title3) }
+                                Label(state.displayNameOfApp() ?? "-", systemImage: "waveform.path.ecg.rectangle").font(.title3)
+                                    .padding() }
                                 .frame(maxWidth: .infinity, alignment: .center)
                                 .buttonStyle(.bordered)
                         }
@@ -77,7 +131,7 @@ extension CGM {
                                         }
                                     }
                                 }
-                                label: { Label("Open URL", systemImage: "waveform.path.ecg.rectangle").font(.title3) }
+                                label: { Label("Open URL", systemImage: "waveform.path.ecg.rectangle").font(.title3).padding() }
                                     .frame(maxWidth: .infinity, alignment: .center)
                                     .buttonStyle(.bordered)
                             }
@@ -86,9 +140,9 @@ extension CGM {
                             Section {
                                 Button {
                                     state.showModal(for: .nighscoutConfigDirect)
-                                    // router.mainSecondaryModalView.send(router.view(for: .nighscoutConfigDirect))
                                 }
-                                label: { Label("Config Nightscout", systemImage: "waveform.path.ecg.rectangle").font(.title3)
+                                label: {
+                                    Label("Config Nightscout", systemImage: "waveform.path.ecg.rectangle").font(.title3).padding()
                                 }
                                 .frame(maxWidth: .infinity, alignment: .center)
                                 .buttonStyle(.bordered)
@@ -97,68 +151,78 @@ extension CGM {
                         }
                     }
 
-                    if state.cgmCurrent.type == .plugin {
-                        Section {
-                            Button("CGM Configuration") {
-                                setupCGM.toggle()
-                            }
-                        }
-                    }
                     if state.cgmCurrent.type == .xdrip {
                         Section(header: Text("Heartbeat")) {
                             VStack(alignment: .leading) {
                                 if let cgmTransmitterDeviceAddress = state.cgmTransmitterDeviceAddress {
-                                    Text("CGM address :")
+                                    Text("CGM address :").padding(.top)
                                     Text(cgmTransmitterDeviceAddress)
                                 } else {
-                                    Text("CGM is not used as heartbeat.")
+                                    Text("CGM is not used as heartbeat.").padding(.top)
                                 }
+
+                                HStack(alignment: .top) {
+                                    Text(
+                                        "Lorem ipsum dolor sit amet, consetetur sadipscing elitr."
+                                    )
+                                    .font(.footnote)
+                                    .foregroundColor(.secondary)
+                                    .lineLimit(nil)
+                                    Spacer()
+                                    Button(
+                                        action: {
+                                            hintLabel = "CGM Heartbeat"
+                                            selectedVerboseHint = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr."
+                                            shouldDisplayHint.toggle()
+                                        },
+                                        label: {
+                                            HStack {
+                                                Image(systemName: "questionmark.circle")
+                                            }
+                                        }
+                                    ).buttonStyle(BorderlessButtonStyle())
+                                }.padding(.vertical)
                             }
-                        }
+                        }.listRowBackground(Color.chart)
                     }
+
                     if state.cgmCurrent.type == .plugin && state.cgmCurrent.id.contains("Libre") {
-                        Section(header: Text("Calibrations")) {
-                            Text("Calibrations").navigationLink(to: .calibrations, from: self)
-                        }
+                        Section {
+                            Text("Libre Calibrations").navigationLink(to: .calibrations, from: self)
+                        }.listRowBackground(Color.chart)
                     }
 
-                    // }
-
-                    Section(header: Text("Calendar")) {
-                        Toggle("Create Events in Calendar", isOn: $state.createCalendarEvents)
-                        if state.calendarIDs.isNotEmpty {
-                            Picker("Calendar", selection: $state.currentCalendarID) {
-                                ForEach(state.calendarIDs, id: \.self) {
-                                    Text($0).tag($0)
-                                }
-                            }
-                            Toggle("Display Emojis as Labels", isOn: $state.displayCalendarEmojis)
-                            Toggle("Display IOB and COB", isOn: $state.displayCalendarIOBandCOB)
-                        } else if state.createCalendarEvents {
-                            if #available(iOS 17.0, *) {
-                                Text(
-                                    "If you are not seeing calendars to choose here, please go to Settings -> iAPS -> Calendars and change permissions to \"Full Access\""
-                                ).font(.footnote)
-
-                                Button("Open Settings") {
-                                    // Get the settings URL and open it
-                                    if let url = URL(string: UIApplication.openSettingsURLString) {
-                                        UIApplication.shared.open(url)
-                                    }
-                                }
+                    SettingInputSection(
+                        decimalValue: $decimalPlaceholder,
+                        booleanValue: $state.smoothGlucose,
+                        shouldDisplayHint: $shouldDisplayHint,
+                        selectedVerboseHint: Binding(
+                            get: { selectedVerboseHint },
+                            set: {
+                                selectedVerboseHint = $0
+                                hintLabel = "Smooth Glucose Value"
                             }
-                        }
-                    }
-
-                    Section(header: Text("Experimental")) {
-                        Toggle("Smooth Glucose Value", isOn: $state.smoothGlucose)
-                    }
+                        ),
+                        units: state.units,
+                        type: .boolean,
+                        label: "Smooth Glucose Value",
+                        miniHint: "Smooth CGM readings using Savitzky–Golay filtering.",
+                        verboseHint: "Smooth Glucose Value… bla bla bla"
+                    )
                 }
                 .scrollContentBackground(.hidden).background(color)
                 .onAppear(perform: configureView)
                 .navigationTitle("CGM")
                 .navigationBarTitleDisplayMode(.automatic)
-                .navigationBarItems(leading: displayClose ? Button("Close", action: state.hideModal) : nil)
+                .sheet(isPresented: $shouldDisplayHint) {
+                    SettingInputHintView(
+                        hintDetent: $hintDetent,
+                        shouldDisplayHint: $shouldDisplayHint,
+                        hintLabel: hintLabel ?? "",
+                        hintText: selectedVerboseHint ?? "",
+                        sheetTitle: "Help"
+                    )
+                }
                 .sheet(isPresented: $setupCGM) {
                     if let cgmFetchManager = state.cgmManager,
                        let cgmManager = cgmFetchManager.cgmManager,

+ 5 - 0
FreeAPS/Sources/Modules/CalendarEventSettings/CalendarEventSettingsDataFlow.swift

@@ -0,0 +1,5 @@
+enum CalendarEventSettings {
+    enum Config {}
+}
+
+protocol CalendarEventSettingsProvider: Provider {}

+ 3 - 0
FreeAPS/Sources/Modules/CalendarEventSettings/CalendarEventSettingsProvider.swift

@@ -0,0 +1,3 @@
+extension CalendarEventSettings {
+    final class Provider: BaseProvider, CalendarEventSettingsProvider {}
+}

+ 65 - 0
FreeAPS/Sources/Modules/CalendarEventSettings/CalendarEventSettingsStateModel.swift

@@ -0,0 +1,65 @@
+import Combine
+import SwiftUI
+
+extension CalendarEventSettings {
+    final class StateModel: BaseStateModel<Provider> {
+        @Injected() var settings: SettingsManager!
+        @Injected() var storage: FileStorage!
+        @Injected() var calendarManager: CalendarManager!
+
+        @Published var units: GlucoseUnits = .mgdL
+        @Published var useCalendar = false
+        @Published var displayCalendarIOBandCOB = false
+        @Published var displayCalendarEmojis = false
+        @Published var calendarIDs: [String] = []
+        @Published var currentCalendarID: String = ""
+        @Persisted(key: "CalendarManager.currentCalendarID") var storedCalendarID: String? = nil
+
+        override func subscribe() {
+            units = settingsManager.settings.units
+
+            currentCalendarID = storedCalendarID ?? ""
+            calendarIDs = calendarManager.calendarIDs()
+
+            subscribeSetting(\.useCalendar, on: $useCalendar) { useCalendar = $0 }
+            subscribeSetting(\.displayCalendarIOBandCOB, on: $displayCalendarIOBandCOB) { displayCalendarIOBandCOB = $0 }
+            subscribeSetting(\.displayCalendarEmojis, on: $displayCalendarEmojis) { displayCalendarEmojis = $0 }
+
+            observeCreateCalendarEvents()
+            observeCurrentCalendarID()
+        }
+
+        private func observeCreateCalendarEvents() {
+            Task {
+                for await ok in $useCalendar.removeDuplicates().values {
+                    guard ok else { continue }
+                    let accessGranted = await calendarManager.requestAccessIfNeeded()
+                    if accessGranted {
+                        let ids = calendarManager.calendarIDs()
+                        await MainActor.run {
+                            self.calendarIDs = ids
+                        }
+                    }
+                }
+            }
+        }
+
+        private func observeCurrentCalendarID() {
+            Task {
+                for await id in $currentCalendarID.removeDuplicates().values {
+                    if id.isEmpty {
+                        calendarManager.currentCalendarID = nil
+                    } else {
+                        calendarManager.currentCalendarID = id
+                    }
+                }
+            }
+        }
+    }
+}
+
+extension CalendarEventSettings.StateModel: SettingsObserver {
+    func settingsDidChange(_: FreeAPSSettings) {
+        units = settingsManager.settings.units
+    }
+}

+ 132 - 0
FreeAPS/Sources/Modules/CalendarEventSettings/View/CalendarEventSettingsRootView.swift

@@ -0,0 +1,132 @@
+import SwiftUI
+import Swinject
+
+extension CalendarEventSettings {
+    struct RootView: BaseView {
+        let resolver: Resolver
+        @StateObject var state = StateModel()
+        @State private var shouldDisplayHint: Bool = false
+        @State var hintDetent = PresentationDetent.large
+        @State var selectedVerboseHint: String?
+        @State var hintLabel: String?
+        @State private var decimalPlaceholder: Decimal = 0.0
+        @State private var booleanPlaceholder: Bool = false
+
+        @Environment(\.colorScheme) var colorScheme
+        @EnvironmentObject var appIcons: Icons
+
+        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
+                )
+        }
+
+        var body: some View {
+            List {
+                SettingInputSection(
+                    decimalValue: $decimalPlaceholder,
+                    booleanValue: $state.useCalendar,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0
+                            hintLabel = "Create Events in Calendar"
+                        }
+                    ),
+                    units: state.units,
+                    type: .boolean,
+                    label: "Create Events in Calendar",
+                    miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                    verboseHint: "Create Calendar Events… bla bla bla",
+                    headerText: "Diabetes Data as Calendar Event"
+                )
+
+                if state.calendarIDs.isNotEmpty, state.useCalendar {
+                    Section {
+                        VStack {
+                            Picker("Choose Calendar", selection: $state.currentCalendarID) {
+                                ForEach(state.calendarIDs, id: \.self) {
+                                    Text($0).tag($0)
+                                }
+                            }
+                        }
+                    }.listRowBackground(Color.chart)
+
+                    SettingInputSection(
+                        decimalValue: $decimalPlaceholder,
+                        booleanValue: $state.displayCalendarEmojis,
+                        shouldDisplayHint: $shouldDisplayHint,
+                        selectedVerboseHint: Binding(
+                            get: { selectedVerboseHint },
+                            set: {
+                                selectedVerboseHint = $0
+                                hintLabel = "Display Emojis as Labels"
+                            }
+                        ),
+                        units: state.units,
+                        type: .boolean,
+                        label: "Display Emojis as Labels",
+                        miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                        verboseHint: "Display Emojis as Labels… bla bla bla"
+                    )
+
+                    SettingInputSection(
+                        decimalValue: $decimalPlaceholder,
+                        booleanValue: $state.displayCalendarIOBandCOB,
+                        shouldDisplayHint: $shouldDisplayHint,
+                        selectedVerboseHint: Binding(
+                            get: { selectedVerboseHint },
+                            set: {
+                                selectedVerboseHint = $0
+                                hintLabel = "Display IOB and COB"
+                            }
+                        ),
+                        units: state.units,
+                        type: .boolean,
+                        label: "Display IOB and COB",
+                        miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                        verboseHint: "Display IOB and COB… bla bla bla"
+                    )
+                } else if state.useCalendar {
+                    if #available(iOS 17.0, *) {
+                        Text(
+                            "If you are not seeing calendars to choose here, please go to Settings -> Trio -> Calendars and change permissions to \"Full Access\""
+                        ).font(.footnote)
+
+                        Button("Open Settings") {
+                            // Get the settings URL and open it
+                            if let url = URL(string: UIApplication.openSettingsURLString) {
+                                UIApplication.shared.open(url)
+                            }
+                        }
+                    }
+                }
+            }
+            .sheet(isPresented: $shouldDisplayHint) {
+                SettingInputHintView(
+                    hintDetent: $hintDetent,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    hintLabel: hintLabel ?? "",
+                    hintText: selectedVerboseHint ?? "",
+                    sheetTitle: "Help"
+                )
+            }
+            .scrollContentBackground(.hidden).background(color)
+            .onAppear(perform: configureView)
+            .navigationTitle("Calendar Events")
+            .navigationBarTitleDisplayMode(.automatic)
+        }
+    }
+}

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

@@ -53,7 +53,7 @@ extension Calibrations {
                         }
                         label: { Text("Add") }
                             .disabled(state.newCalibration <= 0)
-                    }
+                    }.listRowBackground(Color.chart)
 
                     Section(header: Text("Info")) {
                         HStack {
@@ -66,7 +66,7 @@ extension Calibrations {
                             Spacer()
                             Text(formatter.string(from: state.intercept as NSNumber)!)
                         }
-                    }
+                    }.listRowBackground(Color.chart)
 
                     Section(header: Text("Remove")) {
                         Button {
@@ -97,13 +97,13 @@ extension Calibrations {
 
                             }.onDelete(perform: delete)
                         }
-                    }
+                    }.listRowBackground(Color.chart)
 
                     if state.calibrations.isNotEmpty {
                         Section(header: Text("Chart")) {
                             CalibrationsChart().environmentObject(state)
                                 .frame(minHeight: geo.size.width)
-                        }
+                        }.listRowBackground(Color.chart)
                     }
                 }
             }

+ 4 - 3
FreeAPS/Sources/Modules/CREditor/CREditorDataFlow.swift

@@ -1,6 +1,6 @@
 import Foundation
 
-enum CREditor {
+enum CarbRatioEditor {
     enum Config {}
 
     class Item: Identifiable, Hashable, Equatable {
@@ -14,16 +14,17 @@ enum CREditor {
         }
 
         static func == (lhs: Item, rhs: Item) -> Bool {
-            lhs.timeIndex == rhs.timeIndex
+            lhs.timeIndex == rhs.timeIndex && lhs.rateIndex == rhs.rateIndex
         }
 
         func hash(into hasher: inout Hasher) {
             hasher.combine(timeIndex)
+            hasher.combine(rateIndex)
         }
     }
 }
 
-protocol CREditorProvider: Provider {
+protocol CarbRatioEditorProvider: Provider {
     var profile: CarbRatios { get }
     func saveProfile(_ profile: CarbRatios)
     var autotune: Autotune? { get }

+ 2 - 2
FreeAPS/Sources/Modules/CREditor/CREditorProvider.swift

@@ -1,7 +1,7 @@
 import Combine
 
-extension CREditor {
-    final class Provider: BaseProvider, CREditorProvider {
+extension CarbRatioEditor {
+    final class Provider: BaseProvider, CarbRatioEditorProvider {
         var profile: CarbRatios {
             storage.retrieve(OpenAPS.Settings.carbRatios, as: CarbRatios.self)
                 ?? CarbRatios(from: OpenAPS.defaults(for: OpenAPS.Settings.carbRatios))

+ 31 - 2
FreeAPS/Sources/Modules/CREditor/CREditorStateModel.swift

@@ -1,9 +1,12 @@
 import SwiftUI
 
-extension CREditor {
+extension CarbRatioEditor {
     final class StateModel: BaseStateModel<Provider> {
+        @Injected() private var nightscout: NightscoutManager!
         @Published var items: [Item] = []
+        @Published var initialItems: [Item] = []
         @Published var autotune: Autotune?
+        @Published var shouldDisplaySaving: Bool = false
 
         let timeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }
 
@@ -14,6 +17,20 @@ extension CREditor {
             return lastItem.timeIndex < timeValues.count - 1
         }
 
+        var hasChanges: Bool {
+            if initialItems.count != items.count {
+                return true
+            }
+
+            for (initialItem, currentItem) in zip(initialItems, items) {
+                if initialItem.rateIndex != currentItem.rateIndex || initialItem.timeIndex != currentItem.timeIndex {
+                    return true
+                }
+            }
+
+            return false
+        }
+
         override func subscribe() {
             items = provider.profile.schedule.map { value in
                 let timeIndex = timeValues.firstIndex(of: Double(value.offset * 60)) ?? 0
@@ -21,6 +38,8 @@ extension CREditor {
                 return Item(rateIndex: rateIndex, timeIndex: timeIndex)
             }
 
+            initialItems = items.map { Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
+
             autotune = provider.autotune
         }
 
@@ -38,6 +57,9 @@ extension CREditor {
         }
 
         func save() {
+            guard hasChanges else { return }
+            shouldDisplaySaving = true
+
             let schedule = items.enumerated().map { _, item -> CarbRatioEntry in
                 let fotmatter = DateFormatter()
                 fotmatter.timeZone = TimeZone(secondsFromGMT: 0)
@@ -49,6 +71,11 @@ extension CREditor {
             }
             let profile = CarbRatios(units: .grams, schedule: schedule)
             provider.saveProfile(profile)
+            initialItems = items.map { Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
+            Task.detached(priority: .low) {
+                debug(.nightscout, "Attempting to upload CRs to Nightscout")
+                await self.nightscout.uploadProfiles()
+            }
         }
 
         func validate() {
@@ -56,7 +83,9 @@ extension CREditor {
                 let uniq = Array(Set(self.items))
                 let sorted = uniq.sorted { $0.timeIndex < $1.timeIndex }
                 sorted.first?.timeIndex = 0
-                self.items = sorted
+                if self.items != sorted {
+                    self.items = sorted
+                }
             }
         }
     }

+ 66 - 49
FreeAPS/Sources/Modules/CREditor/View/CREditorRootView.swift

@@ -1,7 +1,7 @@
 import SwiftUI
 import Swinject
 
-extension CREditor {
+extension CarbRatioEditor {
     struct RootView: BaseView {
         let resolver: Resolver
         @StateObject var state = StateModel()
@@ -40,6 +40,8 @@ extension CREditor {
 
         var body: some View {
             Form {
+                let shouldDisableButton = state.shouldDisplaySaving || state.items.isEmpty || !state.hasChanges
+
                 if let autotune = state.autotune, !state.settingsManager.settings.onlyAutotuneBasals {
                     Section(header: Text("Autotune")) {
                         HStack {
@@ -48,31 +50,49 @@ extension CREditor {
                             Text(rateFormatter.string(from: autotune.carbRatio as NSNumber) ?? "0")
                             Text("g/U").foregroundColor(.secondary)
                         }
-                    }
+                    }.listRowBackground(Color.chart)
                 }
+
                 Section(header: Text("Schedule")) {
                     list
-                    addButton
-                }
+                }.listRowBackground(Color.chart)
+
                 Section {
-                    Button {
-                        let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
-                        impactHeavy.impactOccurred()
-                        state.save()
-                    }
-                    label: {
-                        Text("Save")
+                    HStack {
+                        if state.shouldDisplaySaving {
+                            ProgressView().padding(.trailing, 10)
+                        }
+
+                        Button {
+                            let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
+                            impactHeavy.impactOccurred()
+                            state.save()
+
+                            // deactivate saving display after 1.25 seconds
+                            DispatchQueue.main.asyncAfter(deadline: .now() + 1.25) {
+                                state.shouldDisplaySaving = false
+                            }
+                        } label: {
+                            Text(state.shouldDisplaySaving ? "Saving..." : "Save")
+                        }
+                        .disabled(shouldDisableButton)
+                        .frame(maxWidth: .infinity, alignment: .center)
+                        .tint(.white)
                     }
-                    .disabled(state.items.isEmpty)
-                }
+                }.listRowBackground(shouldDisableButton ? Color(.systemGray4) : Color(.systemBlue))
             }
             .scrollContentBackground(.hidden).background(color)
             .onAppear(perform: configureView)
             .navigationTitle("Carb Ratios")
             .navigationBarTitleDisplayMode(.automatic)
-            .navigationBarItems(
-                trailing: EditButton()
-            )
+            .toolbar(content: {
+                ToolbarItem(placement: .topBarTrailing) {
+                    EditButton()
+                }
+                ToolbarItem(placement: .topBarTrailing) {
+                    addButton
+                }
+            })
             .environment(\.editMode, $editMode)
             .onAppear {
                 state.validate()
@@ -80,42 +100,39 @@ extension CREditor {
         }
 
         private func pickers(for index: Int) -> some View {
-            GeometryReader { geometry in
-                VStack {
-                    HStack {
-                        Text("Ratio").frame(width: geometry.size.width / 2)
-                        Text("Time").frame(width: geometry.size.width / 2)
-                    }
-                    HStack(spacing: 0) {
-                        Picker(selection: $state.items[index].rateIndex, label: EmptyView()) {
-                            ForEach(0 ..< state.rateValues.count, id: \.self) { i in
-                                Text(
-                                    (
-                                        self.rateFormatter
-                                            .string(from: state.rateValues[i] as NSNumber) ?? ""
-                                    ) + " g/U"
-                                ).tag(i)
-                            }
+            Form {
+                Section {
+                    Picker(selection: $state.items[index].rateIndex, label: Text("Ratio")) {
+                        ForEach(0 ..< state.rateValues.count, id: \.self) { i in
+                            Text(
+                                (
+                                    self.rateFormatter
+                                        .string(from: state.rateValues[i] as NSNumber) ?? ""
+                                ) + " g/U"
+                            ).tag(i)
                         }
-                        .frame(maxWidth: geometry.size.width / 2)
-                        .clipped()
-
-                        Picker(selection: $state.items[index].timeIndex, label: EmptyView()) {
-                            ForEach(0 ..< state.timeValues.count, id: \.self) { i in
-                                Text(
-                                    self.dateFormatter
-                                        .string(from: Date(
-                                            timeIntervalSince1970: state
-                                                .timeValues[i]
-                                        ))
-                                ).tag(i)
-                            }
+                    }
+                }.listRowBackground(Color.chart)
+
+                Section {
+                    Picker(selection: $state.items[index].timeIndex, label: Text("Time")) {
+                        ForEach(0 ..< state.timeValues.count, id: \.self) { i in
+                            Text(
+                                self.dateFormatter
+                                    .string(from: Date(
+                                        timeIntervalSince1970: state
+                                            .timeValues[i]
+                                    ))
+                            ).tag(i)
                         }
-                        .frame(maxWidth: geometry.size.width / 2)
-                        .clipped()
                     }
-                }
+
+                }.listRowBackground(Color.chart)
             }
+            .padding(.top)
+            .scrollContentBackground(.hidden).background(color)
+            .navigationTitle("Set Ratio")
+            .navigationBarTitleDisplayMode(.automatic)
         }
 
         private var list: some View {
@@ -147,7 +164,7 @@ extension CREditor {
 
             switch editMode {
             case .inactive:
-                return AnyView(Button(action: onAdd) { Text("Add") })
+                return AnyView(Button(action: onAdd) { Image(systemName: "plus") })
             default:
                 return AnyView(EmptyView())
             }

+ 8 - 25
FreeAPS/Sources/Modules/DataTable/DataTableStateModel.swift

@@ -10,6 +10,7 @@ extension DataTable {
         @Injected() var pumpHistoryStorage: PumpHistoryStorage!
         @Injected() var glucoseStorage: GlucoseStorage!
         @Injected() var healthKitManager: HealthKitManager!
+        @Injected() var carbsStorage: CarbsStorage!
 
         let coredataContext = CoreDataStack.shared.newTaskContext()
 
@@ -97,6 +98,7 @@ extension DataTable {
 
             var carbEntry: CarbEntryStored?
 
+            // Delete carbs or FPUs from Nightscout
             await taskContext.perform {
                 do {
                     carbEntry = try taskContext.existingObject(with: treatmentObjectID) as? CarbEntryStored
@@ -108,42 +110,23 @@ extension DataTable {
                     if carbEntry.isFPU, let fpuID = carbEntry.fpuID {
                         // Delete FPUs from Nightscout
                         self.provider.deleteCarbsFromNightscout(withID: fpuID.uuidString)
-
-                        // fetch request for all carb entries with the same id
-                        let fetchRequest: NSFetchRequest<NSFetchRequestResult> = CarbEntryStored.fetchRequest()
-                        fetchRequest.predicate = NSPredicate(format: "fpuID == %@", fpuID as CVarArg)
-
-                        // NSBatchDeleteRequest
-                        let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
-                        deleteRequest.resultType = .resultTypeCount
-
-                        // execute the batch delete request
-                        let result = try taskContext.execute(deleteRequest) as? NSBatchDeleteResult
-                        debugPrint("\(DebuggingIdentifiers.succeeded) Deleted \(result?.result ?? 0) items with FpuID \(fpuID)")
-
-                        Foundation.NotificationCenter.default.post(name: .didPerformBatchDelete, object: nil)
                     } else {
                         // Delete carbs from Nightscout
                         if let id = carbEntry.id?.uuidString {
                             self.provider.deleteCarbsFromNightscout(withID: id)
                         }
-
-                        // Now delete carbs also from the Database
-                        taskContext.delete(carbEntry)
-
-                        guard taskContext.hasChanges else { return }
-                        try taskContext.save()
-
-                        debugPrint(
-                            "Data Table State: \(#function) \(DebuggingIdentifiers.succeeded) deleted carb entry from core data"
-                        )
                     }
 
                 } catch {
-                    debugPrint("\(DebuggingIdentifiers.failed) Error deleting carb entry: \(error.localizedDescription)")
+                    debugPrint(
+                        "\(DebuggingIdentifiers.failed) Error deleting carb entry from Nightscout: \(error.localizedDescription)"
+                    )
                 }
             }
 
+            // Delete carbs from Core Data
+            await carbsStorage.deleteCarbs(treatmentObjectID)
+
             // Perform a determine basal sync to update cob
             await apsManager.determineBasalSync()
         }

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

@@ -70,7 +70,7 @@ extension DataTable {
                 formatter.minimumFractionDigits = 0
                 formatter.maximumFractionDigits = 1
             }
-            formatter.roundingMode = .down
+            formatter.roundingMode = .halfUp
             return formatter
         }
 
@@ -143,12 +143,24 @@ extension DataTable {
                 .navigationTitle("History")
                 .navigationBarTitleDisplayMode(.large)
                 .toolbar {
-                    ToolbarItem(placement: .topBarTrailing) {
+                    ToolbarItem(placement: .topBarLeading, content: {
+                        Button(
+                            action: { state.showModal(for: .statistics) },
+                            label: {
+                                HStack {
+                                    Text("Statistics")
+                                }
+                            }
+                        )
+                    })
+                }
+                .toolbar {
+                    ToolbarItem(placement: .topBarTrailing, content: {
                         addButton({
                             showManualGlucose = true
                             state.manualGlucose = 0
                         })
-                    }
+                    })
                 }
                 .sheet(isPresented: $showManualGlucose) {
                     addGlucoseView()

+ 0 - 5
FreeAPS/Sources/Modules/Dynamic/DynamicDataFlow.swift

@@ -1,5 +0,0 @@
-enum Dynamic {
-    enum Config {}
-}
-
-protocol DynamicProvider: Provider {}

+ 0 - 3
FreeAPS/Sources/Modules/Dynamic/DynamicProvider.swift

@@ -1,3 +0,0 @@
-extension Dynamic {
-    final class Provider: BaseProvider, DynamicProvider {}
-}

+ 0 - 112
FreeAPS/Sources/Modules/Dynamic/View/DynamicRootView.swift

@@ -1,112 +0,0 @@
-import SwiftUI
-import Swinject
-
-extension Dynamic {
-    struct RootView: BaseView {
-        let resolver: Resolver
-        @StateObject var state = StateModel()
-
-        private var conversionFormatter: NumberFormatter {
-            let formatter = NumberFormatter()
-            formatter.numberStyle = .decimal
-            formatter.maximumFractionDigits = 1
-
-            return formatter
-        }
-
-        @Environment(\.colorScheme) var colorScheme
-        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 var formatter: NumberFormatter {
-            let formatter = NumberFormatter()
-            formatter.numberStyle = .decimal
-            return formatter
-        }
-
-        private var glucoseFormatter: NumberFormatter {
-            let formatter = NumberFormatter()
-            formatter.numberStyle = .decimal
-            if state.unit == .mmolL {
-                formatter.maximumFractionDigits = 1
-            } else { formatter.maximumFractionDigits = 0 }
-            formatter.roundingMode = .halfUp
-            return formatter
-        }
-
-        var body: some View {
-            Form {
-                Section {
-                    HStack {
-                        Toggle("Activate Dynamic Sensitivity (ISF)", isOn: $state.useNewFormula)
-                    }
-                    if state.useNewFormula {
-                        HStack {
-                            Toggle("Activate Dynamic Carb Ratio (CR)", isOn: $state.enableDynamicCR)
-                        }
-                    }
-                } header: { Text("Enable") }
-
-                if state.useNewFormula {
-                    Section {
-                        HStack {
-                            Toggle("Use Sigmoid Formula", isOn: $state.sigmoid)
-                        }
-                    } header: { Text("Formula") }
-
-                    Section {
-                        HStack {
-                            Text("Adjustment Factor")
-                            Spacer()
-                            TextFieldWithToolBar(text: $state.adjustmentFactor, placeholder: "0", numberFormatter: formatter)
-                        }
-
-                        HStack {
-                            Text("Weighted Average of TDD. Weight of past 24 hours:")
-                            Spacer()
-                            TextFieldWithToolBar(text: $state.weightPercentage, placeholder: "0", numberFormatter: formatter)
-                        }
-
-                        HStack {
-                            Toggle("Adjust basal", isOn: $state.tddAdjBasal)
-                        }
-                    } header: { Text("Settings") }
-
-                    Section {
-                        HStack {
-                            Text("Threshold Setting")
-                            Spacer()
-                            TextFieldWithToolBar(
-                                text: $state.threshold_setting,
-                                placeholder: "0",
-                                numberFormatter: glucoseFormatter
-                            )
-                            Text(state.unit.rawValue)
-                        }
-                    } header: { Text("Safety") }
-                }
-            }
-            .scrollContentBackground(.hidden).background(color)
-            .onAppear(perform: configureView)
-            .navigationBarTitle("Dynamic ISF")
-            .navigationBarTitleDisplayMode(.automatic)
-            .onDisappear {
-                state.saveIfChanged()
-            }
-        }
-    }
-}

+ 5 - 0
FreeAPS/Sources/Modules/DynamicSettings/DynamicSettingsDataFlow.swift

@@ -0,0 +1,5 @@
+enum DynamicSettings {
+    enum Config {}
+}
+
+protocol DynamicSettingsProvider: Provider {}

+ 3 - 0
FreeAPS/Sources/Modules/DynamicSettings/DynamicSettingsProvider.swift

@@ -0,0 +1,3 @@
+extension DynamicSettings {
+    final class Provider: BaseProvider, DynamicSettingsProvider {}
+}

+ 18 - 20
FreeAPS/Sources/Modules/Dynamic/DynamicStateModel.swift

@@ -1,6 +1,6 @@
 import SwiftUI
 
-extension Dynamic {
+extension DynamicSettings {
     final class StateModel: BaseStateModel<Provider> {
         @Injected() var settings: SettingsManager!
         @Injected() var storage: FileStorage!
@@ -8,57 +8,49 @@ extension Dynamic {
         @Published var useNewFormula: Bool = false
         @Published var enableDynamicCR: Bool = false
         @Published var sigmoid: Bool = false
-        @Published var adjustmentFactor: Decimal = 0.5
+        @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 = 65
-        @Published var unit: GlucoseUnits = .mgdL
+        @Published var threshold_setting: Decimal = 60
+        @Published var units: GlucoseUnits = .mgdL
 
         var preferences: Preferences {
             settingsManager.preferences
         }
 
         override func subscribe() {
-            unit = settingsManager.settings.units
+            units = settingsManager.settings.units
             useNewFormula = settings.preferences.useNewFormula
             enableDynamicCR = settings.preferences.enableDynamicCR
             sigmoid = settings.preferences.sigmoid
             adjustmentFactor = settings.preferences.adjustmentFactor
+            adjustmentFactorSigmoid = settings.preferences.adjustmentFactorSigmoid
             weightPercentage = settings.preferences.weightPercentage
             tddAdjBasal = settings.preferences.tddAdjBasal
-
-            if unit == .mmolL {
-                threshold_setting = settings.preferences.threshold_setting.asMmolL
-            } else {
-                threshold_setting = settings.preferences.threshold_setting
-            }
+            threshold_setting = settings.preferences.threshold_setting
         }
 
         var unChanged: Bool {
             preferences.enableDynamicCR == enableDynamicCR &&
                 preferences.adjustmentFactor == adjustmentFactor &&
                 preferences.sigmoid == sigmoid &&
+                preferences.adjustmentFactorSigmoid == adjustmentFactorSigmoid &&
                 preferences.tddAdjBasal == tddAdjBasal &&
-                preferences.threshold_setting == convertBack(threshold_setting) &&
+                preferences.threshold_setting == threshold_setting &&
                 preferences.useNewFormula == useNewFormula &&
                 preferences.weightPercentage == weightPercentage
         }
 
-        func convertBack(_ glucose: Decimal) -> Decimal {
-            if unit == .mmolL {
-                return glucose.asMgdL
-            }
-            return glucose
-        }
-
         func saveIfChanged() {
             if !unChanged {
                 var newSettings = storage.retrieve(OpenAPS.Settings.preferences, as: Preferences.self) ?? Preferences()
                 newSettings.enableDynamicCR = enableDynamicCR
                 newSettings.adjustmentFactor = adjustmentFactor
                 newSettings.sigmoid = sigmoid
+                newSettings.adjustmentFactorSigmoid = adjustmentFactorSigmoid
                 newSettings.tddAdjBasal = tddAdjBasal
-                newSettings.threshold_setting = convertBack(threshold_setting)
+                newSettings.threshold_setting = threshold_setting
                 newSettings.useNewFormula = useNewFormula
                 newSettings.weightPercentage = weightPercentage
                 newSettings.timestamp = Date()
@@ -67,3 +59,9 @@ extension Dynamic {
         }
     }
 }
+
+extension DynamicSettings.StateModel: SettingsObserver {
+    func settingsDidChange(_: FreeAPSSettings) {
+        units = settingsManager.settings.units
+    }
+}

+ 226 - 0
FreeAPS/Sources/Modules/DynamicSettings/View/DynamicSettingsRootView.swift

@@ -0,0 +1,226 @@
+import SwiftUI
+import Swinject
+
+extension DynamicSettings {
+    struct RootView: BaseView {
+        let resolver: Resolver
+        @StateObject var state = StateModel()
+        @State private var shouldDisplayHint: Bool = false
+        @State var hintDetent = PresentationDetent.large
+        @State var selectedVerboseHint: String?
+        @State var hintLabel: String?
+        @State private var decimalPlaceholder: Decimal = 0.0
+        @State private var booleanPlaceholder: Bool = false
+
+        private var conversionFormatter: NumberFormatter {
+            let formatter = NumberFormatter()
+            formatter.numberStyle = .decimal
+            formatter.maximumFractionDigits = 1
+
+            return formatter
+        }
+
+        @Environment(\.colorScheme) var colorScheme
+        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 var formatter: NumberFormatter {
+            let formatter = NumberFormatter()
+            formatter.numberStyle = .decimal
+            return formatter
+        }
+
+        private var glucoseFormatter: NumberFormatter {
+            let formatter = NumberFormatter()
+            formatter.numberStyle = .decimal
+            if state.units == .mmolL {
+                formatter.maximumFractionDigits = 1
+            } else { formatter.maximumFractionDigits = 0 }
+            formatter.roundingMode = .halfUp
+            return formatter
+        }
+
+        var body: some View {
+            List {
+                SettingInputSection(
+                    decimalValue: $decimalPlaceholder,
+                    booleanValue: $state.useNewFormula,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0
+                            hintLabel = "Activate Dynamic Sensitivity (ISF)"
+                        }
+                    ),
+                    units: state.units,
+                    type: .boolean,
+                    label: "Activate Dynamic Sensitivity (ISF)",
+                    miniHint: "Trio calculates insulin sensitivity (ISF) each loop cycle based on current blood sugar, daily insulin use, and an adjustment factor, within set limits.",
+                    verboseHint: "DynamicISF",
+                    headerText: "Dynamic Insulin Sensitivity"
+                )
+
+                if state.useNewFormula {
+                    SettingInputSection(
+                        decimalValue: $decimalPlaceholder,
+                        booleanValue: $state.enableDynamicCR,
+                        shouldDisplayHint: $shouldDisplayHint,
+                        selectedVerboseHint: Binding(
+                            get: { selectedVerboseHint },
+                            set: {
+                                selectedVerboseHint = $0
+                                hintLabel = "Activate Dynamic Carb Ratio (CR)"
+                            }
+                        ),
+                        units: state.units,
+                        type: .boolean,
+                        label: "Activate Dynamic Carb Ratio (CR)",
+                        miniHint: "Similar to Dynamic Sensitivity, Trio calculates a dynamic carb ratio every loop cycle.",
+                        verboseHint: "Logarithmic Dynamic Insulin Sensitivity"
+                    )
+
+                    SettingInputSection(
+                        decimalValue: $decimalPlaceholder,
+                        booleanValue: $state.sigmoid,
+                        shouldDisplayHint: $shouldDisplayHint,
+                        selectedVerboseHint: Binding(
+                            get: { selectedVerboseHint },
+                            set: {
+                                selectedVerboseHint = $0
+                                hintLabel = "Use Sigmoid Formula"
+                            }
+                        ),
+                        units: state.units,
+                        type: .boolean,
+                        label: "Use Sigmoid Formula",
+                        miniHint: "Alternative formula for dynamic ISF, that alters ISF based on distance from target BG",
+                        verboseHint: "Sigmoid  Dynamic Insulin Sensitivity"
+                    )
+
+                    if !state.sigmoid {
+                        SettingInputSection(
+                            decimalValue: $state.adjustmentFactor,
+                            booleanValue: $booleanPlaceholder,
+                            shouldDisplayHint: $shouldDisplayHint,
+                            selectedVerboseHint: Binding(
+                                get: { selectedVerboseHint },
+                                set: {
+                                    selectedVerboseHint = $0
+                                    hintLabel = "Adjustment Factor"
+                                }
+                            ),
+                            units: state.units,
+                            type: .decimal("adjustmentFactor"),
+                            label: "Adjustment Factor",
+                            miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                            verboseHint: "Adjustment Factor for logarithmic dynamic sensitvity... bla bla bla"
+                        )
+                    } else {
+                        SettingInputSection(
+                            decimalValue: $state.adjustmentFactorSigmoid,
+                            booleanValue: $booleanPlaceholder,
+                            shouldDisplayHint: $shouldDisplayHint,
+                            selectedVerboseHint: Binding(
+                                get: { selectedVerboseHint },
+                                set: {
+                                    selectedVerboseHint = $0
+                                    hintLabel = "Sigmoid Adjustment Factor"
+                                }
+                            ),
+                            units: state.units,
+                            type: .decimal("adjustmentFactorSigmoid"),
+                            label: "Sigmoid Adjustment Factor",
+                            miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                            verboseHint: "Sigmoid Adjustment Factor… should be 0.5… bla bla ba"
+                        )
+                    }
+
+                    SettingInputSection(
+                        decimalValue: $state.weightPercentage,
+                        booleanValue: $booleanPlaceholder,
+                        shouldDisplayHint: $shouldDisplayHint,
+                        selectedVerboseHint: Binding(
+                            get: { selectedVerboseHint },
+                            set: {
+                                selectedVerboseHint = $0
+                                hintLabel = "Weighted Average of TDD"
+                            }
+                        ),
+                        units: state.units,
+                        type: .decimal("weightPercentage"),
+                        label: "Weighted Average of TDD",
+                        miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                        verboseHint: "Weight of past 24 hours"
+                    )
+
+                    SettingInputSection(
+                        decimalValue: $decimalPlaceholder,
+                        booleanValue: $state.tddAdjBasal,
+                        shouldDisplayHint: $shouldDisplayHint,
+                        selectedVerboseHint: Binding(
+                            get: { selectedVerboseHint },
+                            set: {
+                                selectedVerboseHint = $0
+                                hintLabel = "Adjust Basal"
+                            }
+                        ),
+                        units: state.units,
+                        type: .boolean,
+                        label: "Adjust Basal",
+                        miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                        verboseHint: "Adjust basal dynamically… bla bla"
+                    )
+
+                    SettingInputSection(
+                        decimalValue: $state.threshold_setting,
+                        booleanValue: $booleanPlaceholder,
+                        shouldDisplayHint: $shouldDisplayHint,
+                        selectedVerboseHint: Binding(
+                            get: { selectedVerboseHint },
+                            set: {
+                                selectedVerboseHint = $0
+                                hintLabel = "Minimum Safety Threshold"
+                            }
+                        ),
+                        units: state.units,
+                        type: .decimal("threshold_setting"),
+                        label: "Minimum Safety Threshold",
+                        miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                        verboseHint: "Minimum Safety Threshold… bla bla bla"
+                    )
+                }
+            }
+            .sheet(isPresented: $shouldDisplayHint) {
+                SettingInputHintView(
+                    hintDetent: $hintDetent,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    hintLabel: hintLabel ?? "",
+                    hintText: selectedVerboseHint ?? "",
+                    sheetTitle: "Help"
+                )
+            }
+            .scrollContentBackground(.hidden).background(color)
+            .onAppear(perform: configureView)
+            .navigationBarTitle("Dynamic Settings")
+            .navigationBarTitleDisplayMode(.automatic)
+            .onDisappear {
+                state.saveIfChanged()
+            }
+        }
+    }
+}

+ 0 - 5
FreeAPS/Sources/Modules/FPUConfig/FPUConfigDataFlow.swift

@@ -1,5 +0,0 @@
-enum FPUConfig {
-    enum Config {}
-}
-
-protocol FPUConfigProvider {}

+ 0 - 0
FreeAPS/Sources/Modules/FPUConfig/FPUConfigProvider.swift


Некоторые файлы не были показаны из-за большого количества измененных файлов