Kaynağa Gözat

Cleanup, major refactoring and docstrings

Deniz Cengiz 1 yıl önce
ebeveyn
işleme
75041e925c

+ 3 - 3
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -6378,9 +6378,6 @@
         }
       }
     },
-    "%.2f" : {
-
-    },
     "%.2f U of %.2f U" : {
       "comment" : "Format for showing delivered and active bolus amounts, 'x U of y U' on watch",
       "localizations" : {
@@ -206975,6 +206972,9 @@
         }
       }
     },
+    "U/day" : {
+
+    },
     "U/hr" : {
       "comment" : "Insulin unit per hour",
       "localizations" : {

+ 300 - 232
Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift

@@ -15,10 +15,12 @@ extension Onboarding {
 
         private let settingsProvider = PickerSettingsProvider.shared
 
-        // App diagnostics sharing
+        // MARK: - App Diagnostics
+
         var diagnostisSharingOption: DiagnostisSharingOption = .enabled
 
-        // Nightscout Setup
+        // MARK: - Nightscout Setup
+
         var nightscoutSetupOption: NightscoutSetupOption = .noSelection
         var nightscoutImportOption: NightscoutImportOption = .noSelection
         var url = ""
@@ -30,15 +32,25 @@ extension Onboarding {
         var nightscoutImportErrors: [String] = []
         var nightscoutImportStatus: ImportStatus = .finished
 
-        // Carb Ratio related
+        // MARK: - Units and Pump Model
+
+        var units: GlucoseUnits = .mgdL
+        var pumpModel: PumpOptionsForOnboardingUnits = .omnipodDash
+
+        // MARK: - Time Values (shared)
+
+        let sharedTimeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }.sorted()
+
+        // MARK: - Carb Ratio
+
         let carbRatioPickerSetting = PickerSetting(value: 3, step: 0.1, min: 3, max: 50, type: .gram)
         var carbRatioItems: [CarbRatioEditor.Item] = []
         var initialCarbRatioItems: [CarbRatioEditor.Item] = []
-        let carbRatioTimeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }
-            .sorted { $0 < $1 }
+        var carbRatioTimeValues: [TimeInterval] { sharedTimeValues }
         var carbRatioRateValues: [Decimal] { settingsProvider.generatePickerValues(from: carbRatioPickerSetting, units: units) }
 
-        // Basal Profile related
+        // MARK: - Basal Profile
+
         var basalRatePickerSetting: PickerSetting {
             switch pumpModel {
             case .dana,
@@ -46,124 +58,107 @@ extension Onboarding {
                 return PickerSetting(value: 0.1, step: 0.1, min: 0.1, max: 30, type: .insulinUnit)
             case .omnipodDash,
                  .omnipodEros:
-                return PickerSetting(value: 0.5, step: 0.05, min: 0.5, max: 30, type: .insulinUnit)
+                return PickerSetting(value: 0.5, step: 0.05, min: 0.05, max: 30, type: .insulinUnit)
             }
         }
 
-        var initialBasalProfileItems: [BasalProfileEditor.Item] = []
         var basalProfileItems: [BasalProfileEditor.Item] = []
-        let basalProfileTimeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }
-            .sorted { $0 < $1 }
-        var basalProfileRateValues: [Decimal] {
-            switch pumpModel {
-            case .dana,
-                 .minimed:
-                return settingsProvider.generatePickerValues(from: basalRatePickerSetting, units: units)
-            case .omnipodDash,
-                 .omnipodEros:
-                return settingsProvider.generatePickerValues(from: basalRatePickerSetting, units: units)
-            }
+        var initialBasalProfileItems: [BasalProfileEditor.Item] = []
+        var basalProfileTimeValues: [TimeInterval] { sharedTimeValues }
+        var basalProfileRateValues: [Decimal] { settingsProvider.generatePickerValues(from: basalRatePickerSetting, units: units)
         }
 
-        // ISF related
+        // MARK: - Insulin Sensitivity Factor (ISF)
+
         var sensitivityPickerSetting = PickerSetting(value: 100, step: 1, min: 9, max: 540, type: .glucose)
         var isfItems: [ISFEditor.Item] = []
         var initialISFItems: [ISFEditor.Item] = []
-        let isfTimeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }.sorted { $0 < $1 }
+        var isfTimeValues: [TimeInterval] { sharedTimeValues }
         var isfRateValues: [Decimal] { settingsProvider.generatePickerValues(from: sensitivityPickerSetting, units: units) }
 
-        // Target related
+        // MARK: - Glucose Targets
+
         let letTargetPickerSetting = PickerSetting(value: 100, step: 1, min: 72, max: 180, type: .glucose)
         var targetItems: [TargetsEditor.Item] = []
         var initialTargetItems: [TargetsEditor.Item] = []
-        let targetTimeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }
-            .sorted { $0 < $1 }
+        var targetTimeValues: [TimeInterval] { sharedTimeValues }
         var targetRateValues: [Decimal] { settingsProvider.generatePickerValues(from: letTargetPickerSetting, units: units) }
 
-        // Basal Profile
-        var basalRates: [BasalRateEntry] = [BasalRateEntry(startTime: 0, rate: 1.0)]
-
-        // Carb Ratio
-        var carbRatio: Decimal = 10
-
-        // Insulin Sensitivity Factor
-        var isf: Decimal = 40
-
-        // Blood Glucose Units
-        var units: GlucoseUnits = .mgdL
-
-        var pumpModel: PumpOptionsForOnboardingUnits = .omnipodDash
+        // MARK: - Delivery Limit Defaults
 
-        // Delivery Limit defaults
         var maxBolus: Decimal = 10
         var maxBasal: Decimal = 2
         var maxIOB: Decimal = 0
         var maxCOB: Decimal = 120
 
-        struct BasalRateEntry: Identifiable {
-            var id = UUID()
-            var startTime: Int // Minutes from midnight
-            var rate: Decimal
+        // MARK: - Subscribe
 
-            var timeFormatted: String {
-                let hours = startTime / 60
-                let minutes = startTime % 60
-                return String(format: "%02d:%02d", hours, minutes)
-            }
+        override func subscribe() {
+            // Keychain items are not removed, even after uninstalling the app. Attempt to read them initially.
+            url = keychain.getValue(String.self, forKey: NightscoutConfig.Config.urlKey) ?? ""
+            secret = keychain.getValue(String.self, forKey: NightscoutConfig.Config.secretKey) ?? ""
         }
 
-        override func subscribe() {}
-
-        func saveOnboardingData() {
-            applyToSettings()
-            applyToPreferences()
-            applyToPumpSettings()
-
-            // Store therapy settings on file
-            saveTargets()
-            saveBasalProfile()
-            saveCarbRatios()
-            saveISFValues()
+        // MARK: - Helpers
+
+        /// Finds the index of the closest Decimal value in the given array.
+        /// - Parameters:
+        ///   - value: The value to match.
+        ///   - array: The array to search in.
+        /// - Returns: Closest index in array.
+        private func closestIndex(for value: Decimal, in array: [Decimal]) -> Int {
+            array.enumerated().min(by: {
+                abs($0.element - value) < abs($1.element - value)
+            })?.offset ?? 0
         }
 
-        /// Applies the onboarding data to the app's settings.
-        func applyToSettings() {
-            // Make a copy of the current settings that we can mutate
-            var settingsCopy = settingsManager.settings
-
-            settingsCopy.units = units
-
-            // We'll directly set the settings property which will trigger the didSet observer
-            settingsManager.settings = settingsCopy
+        /// Finds the index of the closest TimeInterval value in the given array.
+        /// - Parameters:
+        ///   - value: The time value to match.
+        ///   - array: The array to search in.
+        /// - Returns: Closest index in array.
+        private func closestIndex(for value: TimeInterval, in array: [TimeInterval]) -> Int {
+            array.enumerated().min(by: {
+                abs($0.element - value) < abs($1.element - value)
+            })?.offset ?? 0
         }
 
-        func applyToPreferences() {
-            var preferencesCopy = settingsManager.preferences
+        /// A date formatter for time strings used in saved settings.
+        private var timeFormatter: DateFormatter {
+            let formatter = DateFormatter()
+            formatter.timeZone = TimeZone(secondsFromGMT: 0)
+            formatter.dateFormat = "HH:mm:ss"
+            return formatter
+        }
 
-            preferencesCopy.maxIOB = maxIOB
-            preferencesCopy.maxCOB = maxCOB
+        // MARK: - Get Therapy Items
 
-            // We'll directly set the preferences property which will trigger the didSet observer
-            settingsManager.preferences = preferencesCopy
+        /// Converts ISF editor items to a list of `TherapySettingItem`.
+        /// - Returns: Sorted list of therapy setting items based on ISF.
+        func getISFTherapyItems() -> [TherapySettingItem] {
+            getTherapyItems(from: isfItems, rateValues: isfRateValues, timeValues: isfTimeValues)
         }
 
-        func applyToPumpSettings() {
-            let defaultDIA = settingsProvider.settings.insulinPeakTime.value
-            let pumpSettings = PumpSettings(insulinActionCurve: defaultDIA, maxBolus: maxBolus, maxBasal: maxBasal)
-
-            fileStorage.save(pumpSettings, as: OpenAPS.Settings.settings)
+        /// Converts basal profile editor items to a list of `TherapySettingItem`.
+        /// - Returns: Sorted list of therapy setting items based on basal rates.
+        func getBasalTherapyItems() -> [TherapySettingItem] {
+            getTherapyItems(
+                from: basalProfileItems,
+                rateValues: basalProfileRateValues,
+                timeValues: basalProfileTimeValues
+            )
+        }
 
-            // TODO: is this actually necessary at this point? Nothing is set up yet, nothing is subscribed to this observer...
-            DispatchQueue.main.async {
-                self.broadcaster.notify(PumpSettingsObserver.self, on: DispatchQueue.main) {
-                    $0.pumpSettingsDidChange(pumpSettings)
-                }
-            }
+        /// Converts carb ratio editor items to a list of `TherapySettingItem`.
+        /// - Returns: Sorted list of therapy setting items based on carb ratios.
+        func getCarbRatioTherapyItems() -> [TherapySettingItem] {
+            getTherapyItems(from: carbRatioItems, rateValues: carbRatioRateValues, timeValues: carbRatioTimeValues)
         }
 
-        // TODO: clean up these function and unify them
-        func getTargetTherapyItems(from targets: [TargetsEditor.Item]) -> [TherapySettingItem] {
-            targets.map {
+        /// Converts glucose target editor items to a list of `TherapySettingItem`.
+        /// - Returns: Sorted list of therapy setting items based on glucose targets.
+        func getTargetTherapyItems() -> [TherapySettingItem] {
+            targetItems.map {
                 TherapySettingItem(
                     time: targetTimeValues[$0.timeIndex],
                     value: targetRateValues[$0.lowIndex]
@@ -171,197 +166,270 @@ extension Onboarding {
             }.sorted { $0.time < $1.time }
         }
 
-        func updateTargets(from therapyItems: [TherapySettingItem]) {
-            targetItems = therapyItems.map { item in
-                let timeIndex = targetTimeValues.firstIndex(where: { $0 == item.time }) ?? 0
-                let closestTargetIndex = targetRateValues.firstIndex(of: item.value) ?? 0
-
-                return TargetsEditor.Item(lowIndex: closestTargetIndex, highIndex: closestTargetIndex, timeIndex: timeIndex)
-            }.sorted { $0.timeIndex < $1.timeIndex }
-        }
-
-        func getBasalTherapyItems(from basalRates: [BasalProfileEditor.Item]) -> [TherapySettingItem] {
-            basalRates.map {
+        /// Generic helper to convert any type of editor item into therapy setting items.
+        /// - Parameters:
+        ///   - items: An array of items conforming to `TherapyItemConvertible`.
+        ///   - rateValues: The rate values to be used.
+        ///   - timeValues: The time values to be used.
+        /// - Returns: A sorted array of `TherapySettingItem`.
+        private func getTherapyItems<T: TherapyItemConvertible>(
+            from items: [T],
+            rateValues: [Decimal],
+            timeValues: [TimeInterval]
+        ) -> [TherapySettingItem] {
+            items.map {
                 TherapySettingItem(
-                    time: basalProfileTimeValues[$0.timeIndex],
-                    value: basalProfileRateValues[$0.rateIndex]
+                    time: timeValues[$0.timeIndex],
+                    value: rateValues[$0.rateIndex]
                 )
             }.sorted { $0.time < $1.time }
         }
 
-        func updateBasalRates(from therapyItems: [TherapySettingItem]) {
-            basalProfileItems = therapyItems.map { item in
-                let timeIndex = basalProfileTimeValues.firstIndex(where: { $0 == item.time }) ?? 0
-                let closestRateIndex = basalProfileRateValues.firstIndex(of: item.value) ?? 0
+        // MARK: - Unified Update Methods
 
-                return BasalProfileEditor.Item(rateIndex: closestRateIndex, timeIndex: timeIndex)
+        /// Updates the ISF editor items based on the provided therapy setting items.
+        /// - Parameter therapyItems: The list of therapy items to update from.
+        func updateISF(from therapyItems: [TherapySettingItem]) {
+            isfItems = therapyItems.map {
+                ISFEditor.Item(
+                    rateIndex: closestIndex(for: $0.value, in: isfRateValues),
+                    timeIndex: closestIndex(for: $0.time, in: isfTimeValues)
+                )
             }.sorted { $0.timeIndex < $1.timeIndex }
         }
 
-        func getCarbRatioTherapyItems(from carbRatios: [CarbRatioEditor.Item]) -> [TherapySettingItem] {
-            carbRatios.map {
-                TherapySettingItem(
-                    time: carbRatioTimeValues[$0.timeIndex],
-                    value: carbRatioRateValues[$0.rateIndex]
+        /// Updates the basal rate editor items based on the provided therapy setting items.
+        /// - Parameter therapyItems: The list of therapy items to update from.
+        func updateBasal(from therapyItems: [TherapySettingItem]) {
+            basalProfileItems = therapyItems.map {
+                BasalProfileEditor.Item(
+                    rateIndex: closestIndex(for: $0.value, in: basalProfileRateValues),
+                    timeIndex: closestIndex(for: $0.time, in: basalProfileTimeValues)
                 )
-            }.sorted { $0.time < $1.time }
+            }.sorted { $0.timeIndex < $1.timeIndex }
         }
 
-        func updateCarbRatios(from therapyItems: [TherapySettingItem]) {
-            carbRatioItems = therapyItems.map { item in
-                let timeIndex = carbRatioTimeValues.firstIndex(where: { $0 == item.time }) ?? 0
-                let closestRateIndex = carbRatioRateValues.firstIndex(of: item.value) ?? 0
-
-                return CarbRatioEditor.Item(rateIndex: closestRateIndex, timeIndex: timeIndex)
+        /// Updates the carb ratio editor items based on the provided therapy setting items.
+        /// - Parameter therapyItems: The list of therapy items to update from.
+        func updateCarbRatio(from therapyItems: [TherapySettingItem]) {
+            carbRatioItems = therapyItems.map {
+                CarbRatioEditor.Item(
+                    rateIndex: closestIndex(for: $0.value, in: carbRatioRateValues),
+                    timeIndex: closestIndex(for: $0.time, in: carbRatioTimeValues)
+                )
             }.sorted { $0.timeIndex < $1.timeIndex }
         }
 
-        func getSensitivityTherapyItems(from sensitivities: [ISFEditor.Item]) -> [TherapySettingItem] {
-            sensitivities.map {
-                TherapySettingItem(
-                    time: isfTimeValues[$0.timeIndex],
-                    value: isfRateValues[$0.rateIndex]
+        /// Updates the glucose target editor items based on the provided therapy setting items.
+        /// - Parameter therapyItems: The list of therapy items to update from.
+        func updateTargets(from therapyItems: [TherapySettingItem]) {
+            targetItems = therapyItems.map {
+                let rateIndex = closestIndex(for: $0.value, in: targetRateValues)
+                let timeIndex = closestIndex(for: $0.time, in: targetTimeValues)
+
+                return TargetsEditor.Item(
+                    lowIndex: rateIndex,
+                    highIndex: rateIndex,
+                    timeIndex: timeIndex
                 )
-            }.sorted { $0.time < $1.time }
+            }.sorted { $0.timeIndex < $1.timeIndex }
         }
 
-        func updateSensitivies(from therapyItems: [TherapySettingItem]) {
-            isfItems = therapyItems.map { item in
-                let timeIndex = isfTimeValues.firstIndex(where: { $0 == item.time }) ?? 0
-                let closestRateIndex = isfRateValues.firstIndex(of: item.value) ?? 0
+        // MARK: - Add Initials
 
-                return ISFEditor.Item(rateIndex: closestRateIndex, timeIndex: timeIndex)
-            }.sorted { $0.timeIndex < $1.timeIndex }
+        /// Adds a default ISF editor item at 00:00 with a standard sensitivity value.
+        func addInitialISF() {
+            addInitialItem(
+                defaultValue: 50,
+                rateValues: isfRateValues,
+                assign: { isfItems = $0 },
+                makeItem: ISFEditor.Item.init
+            )
         }
 
-        // TODO: add update handler for all therapy items to automatically fill in time gaps and ensure schedule always starts at 00:00 and ends at 23:30
-    }
-}
+        /// Adds a default basal rate editor item at 00:00 with a typical rate value.
+        func addInitialBasalRate() {
+            addInitialItem(
+                defaultValue: 0.1,
+                rateValues: basalProfileRateValues,
+                assign: { basalProfileItems = $0 },
+                makeItem: BasalProfileEditor.Item.init
+            )
+        }
 
-// MARK: - Setup Carb Ratios
+        /// Adds a default carb ratio editor item at 00:00 with a standard ratio.
+        func addInitialCarbRatio() {
+            addInitialItem(
+                defaultValue: 10,
+                rateValues: carbRatioRateValues,
+                assign: { carbRatioItems = $0 },
+                makeItem: CarbRatioEditor.Item.init
+            )
+        }
 
-extension Onboarding.StateModel {
-    func saveCarbRatios() {
-        let schedule = carbRatioItems.enumerated().map { _, item -> CarbRatioEntry in
-            let fotmatter = DateFormatter()
-            fotmatter.timeZone = TimeZone(secondsFromGMT: 0)
-            fotmatter.dateFormat = "HH:mm:ss"
-            let date = Date(timeIntervalSince1970: self.carbRatioTimeValues[item.timeIndex])
-            let minutes = Int(date.timeIntervalSince1970 / 60)
-            let rate = self.carbRatioRateValues[item.rateIndex]
-            return CarbRatioEntry(start: fotmatter.string(from: date), offset: minutes, ratio: rate)
+        /// Adds a default glucose target item at 00:00 with a typical target value.
+        func addInitialTarget() {
+            let timeIndex = 0
+            let rateIndex = closestIndex(for: 100, in: targetRateValues)
+            targetItems = [TargetsEditor.Item(lowIndex: rateIndex, highIndex: rateIndex, timeIndex: timeIndex)]
         }
-        let profile = CarbRatios(units: .grams, schedule: schedule)
 
-        fileStorage.save(profile, as: OpenAPS.Settings.carbRatios)
+        /// Adds an initial therapy setting item for a given editor item type.
+        /// - Parameters:
+        ///   - defaultValue: The expected default value to use.
+        ///   - rateValues: The array of rate values for the item.
+        ///   - assign: A closure that assigns the newly created array to the correct property.
+        private func addInitialItem<ItemType>(
+            defaultValue: Decimal,
+            rateValues: [Decimal],
+            assign: ([ItemType]) -> Void,
+            makeItem: (Int, Int) -> ItemType
+        ) {
+            let timeIndex = 0
+            let rateIndex = closestIndex(for: defaultValue, in: rateValues)
+            assign([makeItem(rateIndex, timeIndex)])
+        }
 
-        initialCarbRatioItems = carbRatioItems.map { CarbRatioEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
-    }
+        // MARK: - Validate
 
-    func validateCarbRatios() {
-        let uniq = Array(Set(carbRatioItems))
-        let sorted = uniq.sorted { $0.timeIndex < $1.timeIndex }
-        sorted.first?.timeIndex = 0
-        if carbRatioItems != sorted {
-            carbRatioItems = sorted
+        /// Removes duplicate entries from `carbRatioItems`, ensures sorting by time index,
+        /// and forces the first entry to start at 00:00 (timeIndex 0).
+        func validateCarbRatios() {
+            carbRatioItems = validated(items: carbRatioItems, timeIndexKeyPath: \.timeIndex)
         }
-    }
-}
-
-// MARK: - Setup glucose targets
 
-extension Onboarding.StateModel {
-    func saveTargets() {
-        let targets = targetItems.map { item -> BGTargetEntry in
-            let formatter = DateFormatter()
-            formatter.timeZone = TimeZone(secondsFromGMT: 0)
-            formatter.dateFormat = "HH:mm:ss"
-            let date = Date(timeIntervalSince1970: self.targetTimeValues[item.timeIndex])
-            let minutes = Int(date.timeIntervalSince1970 / 60)
-            let low = self.targetRateValues[item.lowIndex]
-            let high = low
-            return BGTargetEntry(low: low, high: high, start: formatter.string(from: date), offset: minutes)
+        /// Removes duplicate entries from `basalProfileItems`, ensures sorting by time index,
+        /// and forces the first entry to start at 00:00 (timeIndex 0).
+        func validateBasal() {
+            basalProfileItems = validated(items: basalProfileItems, timeIndexKeyPath: \.timeIndex)
         }
-        let profile = BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: targets)
 
-        fileStorage.save(profile, as: OpenAPS.Settings.bgTargets)
+        /// Removes duplicate entries from `isfItems`, ensures sorting by time index,
+        /// and forces the first entry to start at 00:00 (timeIndex 0).
+        func validateISF() {
+            isfItems = validated(items: isfItems, timeIndexKeyPath: \.timeIndex)
+        }
 
-        initialTargetItems = targetItems
-            .map { TargetsEditor.Item(lowIndex: $0.lowIndex, highIndex: $0.highIndex, timeIndex: $0.timeIndex) }
-    }
+        /// Removes duplicate entries from `targetItems`, ensures sorting by time index,
+        /// and forces the first entry to start at 00:00 (timeIndex 0).
+        func validateTarget() {
+            targetItems = validated(items: targetItems, timeIndexKeyPath: \.timeIndex)
+        }
 
-    func validateTarget() {
-        let uniq = Array(Set(targetItems))
-        let sorted = uniq.sorted { $0.timeIndex < $1.timeIndex }
-        sorted.first?.timeIndex = 0
-        if targetItems != sorted {
-            targetItems = sorted
+        /// Removes duplicates, sorts by time, and ensures the first entry starts at 00:00.
+        /// - Parameters:
+        ///   - items: The list of items to validate.
+        ///   - timeIndexKeyPath: A writable key path to the timeIndex property.
+        /// - Returns: A validated and sorted list of items with the first entry at 00:00.
+        private func validated<T: Hashable>(items: [T], timeIndexKeyPath: WritableKeyPath<T, Int>) -> [T] {
+            var result = Array(Set(items)).sorted { $0[keyPath: timeIndexKeyPath] < $1[keyPath: timeIndexKeyPath] }
+            if !result.isEmpty, result[0][keyPath: timeIndexKeyPath] != 0 {
+                result[0][keyPath: timeIndexKeyPath] = 0
+            }
+            return result
         }
-    }
-}
 
-// MARK: - Setup ISF values
+        // MARK: - Save
 
-extension Onboarding.StateModel {
-    func saveISFValues() {
-        let sensitivities = isfItems.map { item -> InsulinSensitivityEntry in
-            let fotmatter = DateFormatter()
-            fotmatter.timeZone = TimeZone(secondsFromGMT: 0)
-            fotmatter.dateFormat = "HH:mm:ss"
-            let date = Date(timeIntervalSince1970: self.isfTimeValues[item.timeIndex])
-            let minutes = Int(date.timeIntervalSince1970 / 60)
-            let rate = self.isfRateValues[item.rateIndex]
-            return InsulinSensitivityEntry(sensitivity: rate, offset: minutes, start: fotmatter.string(from: date))
+        /// Saves the carb ratio items to file storage and sets them as initial values.
+        func saveCarbRatios() {
+            let schedule = carbRatioItems.map { item in
+                let time = timeFormatter.string(from: Date(timeIntervalSince1970: carbRatioTimeValues[item.timeIndex]))
+                let offset = Int(carbRatioTimeValues[item.timeIndex] / 60)
+                let value = carbRatioRateValues[item.rateIndex]
+                return CarbRatioEntry(start: time, offset: offset, ratio: value)
+            }
+            fileStorage.save(CarbRatios(units: .grams, schedule: schedule), as: OpenAPS.Settings.carbRatios)
+            initialCarbRatioItems = carbRatioItems
         }
-        let profile = InsulinSensitivities(
-            units: .mgdL,
-            userPreferredUnits: .mgdL,
-            sensitivities: sensitivities
-        )
 
-        fileStorage.save(profile, as: OpenAPS.Settings.insulinSensitivities)
+        /// Saves the basal profile items to file storage and sets them as initial values.
+        func saveBasalProfile() {
+            let profile = basalProfileItems.map { item in
+                let time = timeFormatter.string(from: Date(timeIntervalSince1970: basalProfileTimeValues[item.timeIndex]))
+                let offset = Int(basalProfileTimeValues[item.timeIndex] / 60)
+                let rate = basalProfileRateValues[item.rateIndex]
+                return BasalProfileEntry(start: time, minutes: offset, rate: rate)
+            }
+            fileStorage.save(profile, as: OpenAPS.Settings.basalProfile)
+            initialBasalProfileItems = basalProfileItems
+        }
 
-        initialISFItems = isfItems.map { ISFEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
-    }
+        /// Saves the insulin sensitivity (ISF) items to file storage and sets them as initial values.
+        func saveISFValues() {
+            let sensitivities = isfItems.map { item in
+                let time = timeFormatter.string(from: Date(timeIntervalSince1970: isfTimeValues[item.timeIndex]))
+                let offset = Int(isfTimeValues[item.timeIndex] / 60)
+                let value = isfRateValues[item.rateIndex]
+                return InsulinSensitivityEntry(sensitivity: value, offset: offset, start: time)
+            }
+            let profile = InsulinSensitivities(units: .mgdL, userPreferredUnits: .mgdL, sensitivities: sensitivities)
+            fileStorage.save(profile, as: OpenAPS.Settings.insulinSensitivities)
+            initialISFItems = isfItems
+        }
 
-    func validateISF() {
-        let uniq = Array(Set(isfItems))
-        let sorted = uniq.sorted { $0.timeIndex < $1.timeIndex }
-        sorted.first?.timeIndex = 0
-        if isfItems != sorted {
-            isfItems = sorted
+        /// Saves the glucose target items to file storage and sets them as initial values.
+        func saveTargets() {
+            let targets = targetItems.map { item in
+                let time = timeFormatter.string(from: Date(timeIntervalSince1970: targetTimeValues[item.timeIndex]))
+                let offset = Int(targetTimeValues[item.timeIndex] / 60)
+                let value = targetRateValues[item.lowIndex]
+                return BGTargetEntry(low: value, high: value, start: time, offset: offset)
+            }
+            let profile = BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: targets)
+            fileStorage.save(profile, as: OpenAPS.Settings.bgTargets)
+            initialTargetItems = targetItems
         }
-    }
-}
 
-// MARK: - Setup Basal Profile
+        /// Persists all onboarding data by applying settings and saving therapy values.
+        func saveOnboardingData() {
+            applyToSettings()
+            applyToPreferences()
+            applyToPumpSettings()
+            saveTargets()
+            saveBasalProfile()
+            saveCarbRatios()
+            saveISFValues()
+        }
 
-extension Onboarding.StateModel {
-    func saveBasalProfile() {
-        let profile = basalProfileItems.map { item -> BasalProfileEntry in
-            let formatter = DateFormatter()
-            formatter.timeZone = TimeZone(secondsFromGMT: 0)
-            formatter.dateFormat = "HH:mm:ss"
-            let date = Date(timeIntervalSince1970: self.basalProfileTimeValues[item.timeIndex])
-            let minutes = Int(date.timeIntervalSince1970 / 60)
-            let rate = self.basalProfileRateValues[item.rateIndex]
-            return BasalProfileEntry(start: formatter.string(from: date), minutes: minutes, rate: rate)
+        /// Applies the selected glucose units to the app's settings.
+        func applyToSettings() {
+            var settingsCopy = settingsManager.settings
+            settingsCopy.units = units
+            settingsManager.settings = settingsCopy
         }
 
-        fileStorage.save(profile, as: OpenAPS.Settings.basalProfile)
+        /// Applies the selected delivery preferences to the app's settings.
+        func applyToPreferences() {
+            var preferencesCopy = settingsManager.preferences
+            preferencesCopy.maxIOB = maxIOB
+            preferencesCopy.maxCOB = maxCOB
+            settingsManager.preferences = preferencesCopy
+        }
 
-        initialBasalProfileItems = basalProfileItems
-            .map { BasalProfileEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
-    }
+        /// Saves pump delivery limits to persistent storage and broadcasts changes.
+        func applyToPumpSettings() {
+            let defaultDIA = settingsProvider.settings.insulinPeakTime.value
+            let pumpSettings = PumpSettings(insulinActionCurve: defaultDIA, maxBolus: maxBolus, maxBasal: maxBasal)
+            fileStorage.save(pumpSettings, as: OpenAPS.Settings.settings)
 
-    func validateBasal() {
-        let uniq = Array(Set(basalProfileItems))
-        let sorted = uniq.sorted { $0.timeIndex < $1.timeIndex }
-        if let first = sorted.first, first.timeIndex != 0 {
-            sorted[0].timeIndex = 0
-        }
-        if basalProfileItems != sorted {
-            basalProfileItems = sorted
+            // TODO: Evaluate whether broadcasting this early is needed
+            // DispatchQueue.main.async {
+            //     self.broadcaster.notify(PumpSettingsObserver.self, on: DispatchQueue.main) {
+            //         $0.pumpSettingsDidChange(pumpSettings)
+            //     }
+            // }
         }
     }
 }
+
+// MARK: - Protocol (optional) to unify type mapping
+
+protocol TherapyItemConvertible {
+    var rateIndex: Int { get }
+    var timeIndex: Int { get }
+}
+
+extension ISFEditor.Item: TherapyItemConvertible {}
+extension CarbRatioEditor.Item: TherapyItemConvertible {}
+extension BasalProfileEditor.Item: TherapyItemConvertible {}

+ 6 - 17
Trio/Sources/Modules/Onboarding/View/OnboardingSteps/BasalProfileStepView.swift

@@ -18,10 +18,9 @@ struct BasalProfileStepView: View {
     private let chartScale = Calendar.current
         .date(from: DateComponents(year: 2001, month: 01, day: 01, hour: 0, minute: 0, second: 0))
 
-    private var formatter: NumberFormatter {
+    private var rateFormatter: NumberFormatter {
         let formatter = NumberFormatter()
         formatter.numberStyle = .decimal
-        formatter.maximumFractionDigits = 2
         return formatter
     }
 
@@ -74,8 +73,8 @@ struct BasalProfileStepView: View {
                             Spacer()
 
                             HStack {
-                                Text("\(calculateTotalDailyBasal(), specifier: "%.2f")")
-                                Text("U/hr")
+                                Text(rateFormatter.string(from: calculateTotalDailyBasal() as NSNumber) ?? "0")
+                                Text("U/day")
                                     .foregroundStyle(Color.secondary)
                             }
                             .id(refreshUI) // Erzwingt die Aktualisierung des Totals
@@ -89,26 +88,16 @@ struct BasalProfileStepView: View {
         }
         .onAppear {
             if state.basalProfileItems.isEmpty {
-                addBasalRate()
+                state.addInitialBasalRate()
             }
             state.validateBasal()
-            therapyItems = state.getBasalTherapyItems(from: state.basalProfileItems)
+            therapyItems = state.getBasalTherapyItems()
         }.onChange(of: therapyItems) { _, newItems in
-            state.updateBasalRates(from: newItems)
+            state.updateBasal(from: newItems)
             refreshUI = UUID()
         }
     }
 
-    // Add initial basal rate
-    private func addBasalRate() {
-        // Default to midnight (00:00) and 1.0 U/h rate
-        let timeIndex = state.basalProfileTimeValues.firstIndex { abs($0 - 0) < 1 } ?? 0
-        let rateIndex = state.basalProfileRateValues.firstIndex { abs(Double($0) - 1.0) < 0.05 } ?? 20
-
-        let newItem = BasalProfileEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
-        state.basalProfileItems.append(newItem)
-    }
-
     // Calculate the total daily basal insulin
     private func calculateTotalDailyBasal() -> Double {
         let items = state.basalProfileItems

+ 3 - 13
Trio/Sources/Modules/Onboarding/View/OnboardingSteps/CarbRatioStepView.swift

@@ -117,26 +117,16 @@ struct CarbRatioStepView: View {
         }
         .onAppear {
             if state.carbRatioItems.isEmpty {
-                addCarbRatio()
+                state.addInitialCarbRatio()
             }
             state.validateCarbRatios()
-            therapyItems = state.getCarbRatioTherapyItems(from: state.carbRatioItems)
+            therapyItems = state.getCarbRatioTherapyItems()
         }.onChange(of: therapyItems) { _, newItems in
-            state.updateCarbRatios(from: newItems)
+            state.updateCarbRatio(from: newItems)
             refreshUI = UUID()
         }
     }
 
-    // Add initial carb ratio
-    private func addCarbRatio() {
-        // Default to midnight (00:00) and 10 g/U
-        let timeIndex = state.carbRatioTimeValues.firstIndex { abs($0 - 0) < 1 } ?? 0
-        let rateIndex = state.carbRatioRateValues.firstIndex { abs(Double($0) - 10.0) < 0.05 } ?? 10
-
-        let newItem = CarbRatioEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
-        state.carbRatioItems.append(newItem)
-    }
-
     // Chart for visualizing carb ratios
     private var carbRatioChart: some View {
         Chart {

+ 2 - 15
Trio/Sources/Modules/Onboarding/View/OnboardingSteps/GlucoseTargetStepView.swift

@@ -67,29 +67,16 @@ struct GlucoseTargetStepView: View {
         }
         .onAppear {
             if state.targetItems.isEmpty {
-                addTarget()
+                state.addInitialTarget()
             }
             state.validateTarget()
-            therapyItems = state.getTargetTherapyItems(from: state.targetItems)
+            therapyItems = state.getTargetTherapyItems()
         }.onChange(of: therapyItems) { _, newItems in
             state.updateTargets(from: newItems)
             refreshUI = UUID()
         }
     }
 
-    // Add initial target
-    private func addTarget() {
-        let timeIndex = state.targetTimeValues.firstIndex { abs($0 - 0) < 1 } ?? 0
-        let expectedDefault = Decimal(100)
-
-        let targetIndex = state.targetRateValues.enumerated()
-            .min(by: { abs($0.element - expectedDefault) < abs($1.element - expectedDefault) })?
-            .offset ?? 0
-
-        let newItem = TargetsEditor.Item(lowIndex: targetIndex, highIndex: targetIndex, timeIndex: timeIndex)
-        state.targetItems.append(newItem)
-    }
-
     // Chart for visualizing glucose targets
     private var glucoseTargetChart: some View {
         Chart {

+ 4 - 17
Trio/Sources/Modules/Onboarding/View/OnboardingSteps/InsulinSensitivityStepView.swift

@@ -76,7 +76,7 @@ struct InsulinSensitivityStepView: View {
                             let aboveTarget = state.units == .mgdL ? Decimal(40) : 40.asMmolL
 
                             let isfValue = state.isfRateValues.isEmpty || state.isfItems.isEmpty ?
-                                Double(truncating: state.isf as NSNumber) :
+                                Double(truncating: 50 as NSNumber) :
                                 Double(
                                     truncating: state
                                         .isfRateValues[state.isfItems.first!.rateIndex] as NSNumber
@@ -129,29 +129,16 @@ struct InsulinSensitivityStepView: View {
         }
         .onAppear {
             if state.isfItems.isEmpty {
-                addInitialISF()
+                state.addInitialISF()
             }
             state.validateISF()
-            therapyItems = state.getSensitivityTherapyItems(from: state.isfItems)
+            therapyItems = state.getISFTherapyItems()
         }.onChange(of: therapyItems) { _, newItems in
-            state.updateSensitivies(from: newItems)
+            state.updateISF(from: newItems)
             refreshUI = UUID()
         }
     }
 
-    // Add initial ISF value
-    private func addInitialISF() {
-        let timeIndex = state.isfTimeValues.firstIndex { abs($0 - 0) < 1 } ?? 0
-        let expectedDefault = Decimal(50)
-
-        let rateIndex = state.isfRateValues.enumerated()
-            .min(by: { abs($0.element - expectedDefault) < abs($1.element - expectedDefault) })?
-            .offset ?? 0
-
-        let newItem = ISFEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
-        state.isfItems.append(newItem)
-    }
-
     // Chart for visualizing ISF profile
     private var isfChart: some View {
         Chart {

+ 29 - 9
Trio/Sources/Modules/Onboarding/View/TherapySettingEditorView.swift

@@ -8,6 +8,7 @@ struct TherapySettingEditorView: View {
     var validateOnDelete: (() -> Void)?
 
     @State private var selectedItemID: UUID?
+    @State private var refreshUI = UUID() // to update list and UI picker value(s) change(s)
 
     var body: some View {
         List {
@@ -15,11 +16,17 @@ struct TherapySettingEditorView: View {
                 Text("Entries").bold()
                 Spacer()
                 Button {
+                    // Prepare and add new entry
                     let lastTime = items.last?.time ?? 0
                     let newTime = min(lastTime + 1800, 23 * 3600 + 1800)
                     let newValue = items.last?.value ?? 1.0
                     items.append(TherapySettingItem(time: newTime, value: newValue))
+
+                    // Reset selected item to close picker
                     selectedItemID = nil
+
+                    // Sort items, in case user has changed time of one item, then taps 'Add'
+                    sortTherapyItems()
                 } label: {
                     HStack {
                         Image(systemName: "plus.circle.fill")
@@ -35,11 +42,7 @@ struct TherapySettingEditorView: View {
                 VStack(spacing: 0) {
                     Button {
                         selectedItemID = selectedItemID == item.id ? nil : item.id
-                        Task { @MainActor in
-                            withAnimation {
-                                items = items.sorted { $0.time < $1.time }
-                            }
-                        }
+                        sortTherapyItems()
                     } label: {
                         HStack {
                             HStack {
@@ -60,7 +63,9 @@ struct TherapySettingEditorView: View {
                                 Text(timeString)
                                     .foregroundStyle(selectedItemID == item.id ? Color.accentColor : Color.primary)
                             }
-                        }.contentShape(Rectangle())
+                        }
+                        .contentShape(Rectangle())
+                        .id(refreshUI) // force list update
                     }
                     .buttonStyle(.plain)
 
@@ -118,7 +123,10 @@ struct TherapySettingEditorView: View {
             HStack {
                 Picker("Value", selection: Binding(
                     get: { Double(item.wrappedValue.value) },
-                    set: { item.wrappedValue.value = Decimal($0) }
+                    set: {
+                        item.wrappedValue.value = Decimal($0)
+                        refreshUI = UUID()
+                    }
                 )) {
                     ForEach(valueOptions, id: \.self) { value in
                         Text("\(displayText(for: unit, decimalValue: value)) \(unit.displayName)").tag(Double(value))
@@ -129,7 +137,11 @@ struct TherapySettingEditorView: View {
 
                 Picker("Time", selection: Binding(
                     get: { item.wrappedValue.time },
-                    set: { item.wrappedValue.time = $0 }
+                    set: {
+                        item.wrappedValue.time = $0
+                        validateTherapySettingItems()
+                        refreshUI = UUID()
+                    }
                 )) {
                     ForEach(timeOptions, id: \.self) { time in
                         Text(timeFormatter.string(from: Date(timeIntervalSince1970: time)))
@@ -144,9 +156,17 @@ struct TherapySettingEditorView: View {
         .padding(.vertical, 8)
     }
 
+    private func sortTherapyItems() {
+        Task { @MainActor in
+            withAnimation {
+                items = items.sorted { $0.time < $1.time }
+            }
+        }
+    }
+
     private func validateTherapySettingItems() {
         // validates therapy items (i.e. parsed therapy settings into wrapper class)
-        var newItems = Array(Set(items)).sorted { $0.time < $1.time }
+        let newItems = Array(Set(items)).sorted { $0.time < $1.time }
         if let first = newItems.first {
             first.time = 0
         }