| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809 |
- import Combine
- import DanaKit
- import FirebaseCrashlytics
- import Foundation
- import LoopKit
- import MinimedKit
- import Observation
- import OmniBLE
- import OmniKit
- import SwiftUI
- /// Model that holds the data collected during onboarding.
- extension Onboarding {
- @Observable final class StateModel: BaseStateModel<Provider> {
- @ObservationIgnored @Injected() var fileStorage: FileStorage!
- @ObservationIgnored @Injected() var deviceManager: DeviceDataManager!
- @ObservationIgnored @Injected() var broadcaster: Broadcaster!
- @ObservationIgnored @Injected() var keychain: Keychain!
- @ObservationIgnored @Injected() var nightscoutManager: NightscoutManager!
- @ObservationIgnored @Injected() var notificationsManager: UserNotificationsManager!
- @ObservationIgnored @Injected() var bluetoothManager: BluetoothStateManager!
- @ObservationIgnored @Injected() var apsManager: APSManager!
- private let settingsProvider = PickerSettingsProvider.shared
- // MARK: - App Diagnostics
- private var persistedDiagnosticsSharing: Bool? {
- get { PropertyPersistentFlags.shared.diagnosticsSharingEnabled }
- set { PropertyPersistentFlags.shared.diagnosticsSharingEnabled = newValue }
- }
- var diagnosticsSharingOption: DiagnosticsSharingOption = .enabled
- var hasAcceptedPrivacyPolicy: Bool = false
- func syncDiagnosticsOptionFromStorage() {
- diagnosticsSharingOption = (persistedDiagnosticsSharing ?? true) ? .enabled : .disabled
- }
- func updateDiagnosticsOption(to option: DiagnosticsSharingOption) {
- diagnosticsSharingOption = option
- persistedDiagnosticsSharing = (option == .enabled)
- }
- // MARK: - Determine Initial Build State
- /// Determines whether the app is in a fresh install state for Trio (new vs. returning/updating user).
- ///
- /// This check is based on the assumption that a truly clean install will only contain
- /// the `logs/` directory and the `preferences.json` file in the app's Documents directory.
- ///
- /// If this condition is met, the onboarding flow skips the `.returningUser` step and treats
- /// the user as new. If more files or directories are found, it is assumed the user is returning.
- ///
- /// Note: This check is not directly connected to a completed migration. However, if a migration
- /// has been triggered (whether successful or not), additional files such as treatment JSONs
- /// will exist, which naturally causes this check to return `false`.
- var isFreshTrioInstall: Bool {
- let fileManager = FileManager.default
- guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
- return false
- }
- let expectedLogsFolder = "logs"
- let expectedPreferencesFile = OpenAPS.Settings.preferences
- do {
- let contents = try fileManager.contentsOfDirectory(atPath: documentsURL.path)
- // Expect exactly 2 entries: "logs" and the preferences file
- guard contents.count == 2 else {
- debug(.default, "Trio install is not fresh; returning user.")
- return false
- }
- // Ensure they match exactly
- let expectedSet = Set([expectedLogsFolder, expectedPreferencesFile])
- let actualSet = Set(contents)
- let isFreshInstall = expectedSet == actualSet
- debug(.default, "Trio install is fresh; new user.")
- return isFreshInstall
- } catch {
- debug(.default, "Cannot determine Initial Build State. Failed to read documents directory: \(error)")
- return false
- }
- }
- // MARK: - Nightscout Setup
- var nightscoutSetupOption: NightscoutSetupOption = .noSelection
- var nightscoutImportOption: NightscoutImportOption = .noSelection
- var nightscoutUrl = ""
- var nightscoutSecret = ""
- var nightscoutResponseMessage = ""
- var isValidNightscoutURL: Bool = false
- var isConnectingToNS: Bool = false
- var isConnectedToNS: Bool = false
- var nightscoutImportError: NightscoutImportError?
- var nightscoutImportStatus: ImportStatus = .none
- var isUploadEnabled: Bool = true
- var uploadGlucose: Bool = true
- // MARK: - Units and Pump Omboarding Option
- var units: GlucoseUnits = .mgdL
- private var selectedPumpOption: PumpOptionForOnboardingUnits?
- var pumpOptionForOnboardingUnits: PumpOptionForOnboardingUnits {
- get {
- // let user edit selection and return user-selection, if present
- if let selected = selectedPumpOption {
- return selected
- }
- let defaultOption: PumpOptionForOnboardingUnits
- if let pumpManager = apsManager?.pumpManager {
- if pumpManager is OmniBLEPumpManager {
- defaultOption = .omnipodDash
- } else if pumpManager is OmnipodPumpManager {
- defaultOption = .omnipodEros
- } else if pumpManager is DanaKitPumpManager {
- defaultOption = .dana
- } else if pumpManager is MinimedPumpManager {
- defaultOption = .minimed
- } else {
- defaultOption = .omnipodDash
- }
- } else {
- defaultOption = .omnipodDash
- }
- // cache it so picker can stay in sync
- selectedPumpOption = defaultOption
- return defaultOption
- }
- set {
- selectedPumpOption = newValue
- }
- }
- // 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: 30, step: 0.1, min: 1, max: 50, type: .gram)
- var carbRatioItems: [CarbRatioEditor.Item] = []
- var initialCarbRatioItems: [CarbRatioEditor.Item] = []
- var carbRatioTimeValues: [TimeInterval] { sharedTimeValues }
- var carbRatioRateValues: [Decimal] { settingsProvider.generatePickerValues(from: carbRatioPickerSetting, units: units) }
- // MARK: - Basal Profile
- var basalRatePickerSetting: PickerSetting {
- switch selectedPumpOption {
- case .dana:
- return PickerSetting(value: 0.1, step: 0.05, min: 0, max: 3, type: .insulinUnitPerHour)
- case .minimed:
- return PickerSetting(value: 0.1, step: 0.05, min: 0, max: 35, type: .insulinUnitPerHour)
- case .omnipodDash:
- return PickerSetting(value: 0.1, step: 0.05, min: 0, max: 30, type: .insulinUnitPerHour)
- case .omnipodEros:
- return PickerSetting(value: 0.1, step: 0.05, min: 0.05, max: 30, type: .insulinUnitPerHour)
- case .none:
- // same as dash, as that is the fallback
- return PickerSetting(value: 0.1, step: 0.05, min: 0, max: 30, type: .insulinUnitPerHour)
- }
- }
- var basalProfileItems: [BasalProfileEditor.Item] = []
- var initialBasalProfileItems: [BasalProfileEditor.Item] = []
- var basalProfileTimeValues: [TimeInterval] { sharedTimeValues }
- var basalProfileRateValues: [Decimal] { settingsProvider.generatePickerValues(from: basalRatePickerSetting, units: units)
- }
- // MARK: - Insulin Sensitivity Factor (ISF)
- var sensitivityPickerSetting = PickerSetting(value: 200, step: 1, min: 9, max: 540, type: .glucose)
- var isfItems: [ISFEditor.Item] = []
- var initialISFItems: [ISFEditor.Item] = []
- var isfTimeValues: [TimeInterval] { sharedTimeValues }
- var isfRateValues: [Decimal] { settingsProvider.generatePickerValues(from: sensitivityPickerSetting, units: units) }
- // MARK: - Glucose Targets
- let letTargetPickerSetting = PickerSetting(value: 110, step: 1, min: 72, max: 180, type: .glucose)
- var targetItems: [TargetsEditor.Item] = []
- var initialTargetItems: [TargetsEditor.Item] = []
- var targetTimeValues: [TimeInterval] { sharedTimeValues }
- var targetRateValues: [Decimal] { settingsProvider.generatePickerValues(from: letTargetPickerSetting, units: units) }
- // MARK: - Delivery Limit Defaults
- var maxBolus: Decimal = 10
- var maxBasal: Decimal = 2
- var maxIOB: Decimal = 0
- var maxCOB: Decimal = 120
- var minimumSafetyThreshold: Decimal = 60
- // MARK: - Algorithm Settings Defaults
- // Autosens Settings
- var autosensMin: Decimal = 0.7
- var autosensMax: Decimal = 1.2
- var rewindResetsAutosens: Bool = true
- var filteredAutosensSettingsSubsteps: [AutosensSettingsSubstep] {
- if pumpOptionForOnboardingUnits == .minimed || pumpOptionForOnboardingUnits == .dana {
- return AutosensSettingsSubstep.allCases
- } else {
- return [AutosensSettingsSubstep.autosensMin, AutosensSettingsSubstep.autosensMax]
- }
- }
- // SMB Settings
- var enableSMBAlways: Bool = false
- var enableSMBWithCOB: Bool = false
- var enableSMBWithTempTarget: Bool = false
- var enableSMBAfterCarbs: Bool = false
- var enableSMBWithHighGlucoseTarget: Bool = false
- var highGlucoseTarget: Decimal = 110
- var allowSMBWithHighTempTarget: Bool = false
- var enableUAM: Bool = false
- var maxSMBMinutes: Decimal = 30
- var maxUAMMinutes: Decimal = 30
- var maxDeltaGlucoseThreshold: Decimal = 0.2
- // Target Behavior
- var highTempTargetRaisesSensitivity: Bool = false
- var lowTempTargetLowersSensitivity: Bool = false
- var sensitivityRaisesTarget: Bool = false
- var resistanceLowersTarget: Bool = false
- var halfBasalTarget: Decimal = 160
- // MARK: - Permission Requests
- var hasNotificationsGranted = false
- var shouldDisplayCustomNotificationAlert: Bool = false
- var shouldDisplayBluetoothRequestAlert: Bool = false
- var hasBluetoothGranted = false
- // MARK: - Subscribe
- override func subscribe() {
- // Keychain items are not removed, even after uninstalling the app. Attempt to read them initially.
- nightscoutUrl = keychain.getValue(String.self, forKey: NightscoutConfig.Config.urlKey) ?? ""
- nightscoutSecret = keychain.getValue(String.self, forKey: NightscoutConfig.Config.secretKey) ?? ""
- isConnectedToNS = false
- isConnectingToNS = false
- isValidNightscoutURL = false
- if !isFreshTrioInstall {
- // Attempt to fetch existing units, therapy settings and delivery limits from file
- units = settingsManager.settings.units
- fetchExistingTherapySettingsFromFile()
- fetchExistingDeliveryLimtisFromFile()
- }
- }
- // 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.
- func closestIndex(for value: Decimal, in array: [Decimal]) -> Int {
- array.enumerated().min(by: {
- abs($0.element - value) < abs($1.element - value)
- })?.offset ?? 0
- }
- /// 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.
- func closestIndex(for value: TimeInterval, in array: [TimeInterval]) -> Int {
- array.enumerated().min(by: {
- abs($0.element - value) < abs($1.element - value)
- })?.offset ?? 0
- }
- /// 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
- }
- /// Remaps therapy items affected by a glucose unit change (mg/dL vs mmol/L).
- ///
- /// This function updates glucose target and insulin sensitivity (ISF) items to use the closest valid index
- /// from the newly available rate arrays, preserving the original value intent.
- ///
- /// Call this after the user changes the unit selection.
- ///
- /// See also: `UnitSelectionStepView` `.onChange()` handlers.
- func remapTherapyItemsForChangedUnits() {
- // Targets
- targetItems = targetItems.map { item in
- let newLowIndex = closestIndex(for: targetRateValues[item.lowIndex], in: targetRateValues)
- let newTimeIndex = closestIndex(for: targetTimeValues[item.timeIndex], in: targetTimeValues)
- return TargetsEditor.Item(lowIndex: newLowIndex, highIndex: newLowIndex, timeIndex: newTimeIndex)
- }
- // ISF
- isfItems = isfItems.map { item in
- let newRateIndex = closestIndex(for: isfRateValues[item.rateIndex], in: isfRateValues)
- let newTimeIndex = closestIndex(for: isfTimeValues[item.timeIndex], in: isfTimeValues)
- return ISFEditor.Item(rateIndex: newRateIndex, timeIndex: newTimeIndex)
- }
- }
- /// Remaps therapy items affected by a pump model change.
- ///
- /// Updates basal profile items to use the closest valid index from
- /// the updated basal rate and time arrays, preserving the user's settings
- /// as closely as possible when switching between pump models.
- ///
- /// If an imported item's `rateIndex` or `timeIndex` exceeds the bounds of the
- /// current pump's allowed values, it is clamped to the last valid index to avoid
- /// crashes and preserve data integrity. A debug message is logged if clamping occurs.
- ///
- /// Call this after the user selects a new pump model.
- ///
- /// See also: `UnitSelectionStepView` `.onChange()` handlers.
- func remapTherapyItemsForChangedPumpModel() {
- let maxValidRateIndex = max(basalProfileRateValues.count - 1, 0)
- let maxValidTimeIndex = max(basalProfileTimeValues.count - 1, 0)
- basalProfileItems = basalProfileItems.map { item in
- let safeRateIndex = min(item.rateIndex, maxValidRateIndex)
- let safeTimeIndex = min(item.timeIndex, maxValidTimeIndex)
- let originalRate = basalProfileRateValues[safeRateIndex]
- let originalTime = basalProfileTimeValues[safeTimeIndex]
- let newRateIndex = closestIndex(for: originalRate, in: basalProfileRateValues)
- let newTimeIndex = closestIndex(for: originalTime, in: basalProfileTimeValues)
- if safeRateIndex != item.rateIndex {
- debug(.default, "⚠️ rateIndex \(item.rateIndex) out of bounds; clamped to \(safeRateIndex)")
- }
- return BasalProfileEditor.Item(rateIndex: newRateIndex, timeIndex: newTimeIndex)
- }
- }
- // MARK: - Fetch existing therapy settings from file
- /// Loads existing therapy settings from the provider and maps them into UI editor items.
- ///
- /// This function processes therapy-related configurations (glucose targets, basal rates,
- /// carb ratios, and insulin sensitivity factors) stored in file-backed models from the provider.
- /// It calculates the closest matching indices for time and rate values to map them to corresponding
- /// `Editor.Item` models for use in the UI.
- ///
- /// - Populates:
- /// - `targetItems` and `initialTargetItems` with glucose target entries.
- /// - `basalProfileItems` and `initialBasalProfileItems` with basal rate entries.
- /// - `carbRatioItems` and `initialCarbRatioItems` with carbohydrate ratio entries.
- /// - `isfItems` and `initialISFItems` with insulin sensitivity factor entries.
- func fetchExistingTherapySettingsFromFile() {
- targetItems = provider.glucoseTargetsOnFile.targets.map { value in
- let timeIndex = closestIndex(for: TimeInterval(Double(value.offset * 60)), in: targetTimeValues)
- let lowIndex = closestIndex(for: value.low, in: targetRateValues)
- let highIndex = closestIndex(for: value.high, in: targetRateValues)
- return TargetsEditor.Item(lowIndex: lowIndex, highIndex: highIndex, timeIndex: timeIndex)
- }
- initialTargetItems = targetItems
- .map { TargetsEditor.Item(lowIndex: $0.lowIndex, highIndex: $0.highIndex, timeIndex: $0.timeIndex) }
- basalProfileItems = provider.basalProfileOnFile.map { value in
- let timeIndex = closestIndex(for: TimeInterval(Double(value.minutes * 60)), in: basalProfileTimeValues)
- let rateIndex = closestIndex(for: value.rate, in: basalProfileRateValues)
- return BasalProfileEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
- }
- initialBasalProfileItems = basalProfileItems
- .map { BasalProfileEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
- carbRatioItems = provider.carbRatiosOnFile.schedule.map { value in
- let timeIndex = closestIndex(for: TimeInterval(Double(value.offset * 60)), in: carbRatioTimeValues)
- let rateIndex = closestIndex(for: value.ratio, in: carbRatioRateValues)
- return CarbRatioEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
- }
- initialCarbRatioItems = carbRatioItems.map { CarbRatioEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
- isfItems = provider.isfOnFile.sensitivities.map { value in
- let timeIndex = closestIndex(for: TimeInterval(Double(value.offset * 60)), in: isfTimeValues)
- let rateIndex = closestIndex(for: value.sensitivity, in: isfRateValues)
- return ISFEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
- }
- initialISFItems = isfItems.map { ISFEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
- }
- /// Loads delivery limit settings (Units, Max IOB, Max COB, Max Bolus, Max Basal) from the provider.
- ///
- /// Retrieves pump-related safety and delivery limits from both the provider's
- /// file-backed pump settings and app-specific preferences. These values are used
- /// to pre-fill the delivery limits editor in the onboarding or settings UI.
- ///
- /// - Populates:
- /// - `maxBolus` and `maxBasal` from file-based pump settings.
- /// - `maxIOB`, `maxCOB`, and `minimumSafetyThreshold` from app preferences.
- /// - `units` from app settings.
- func fetchExistingDeliveryLimtisFromFile() {
- let pumpSettingsFromFile = provider.pumpSettingsFromFile
- let providedSettings = settingsProvider.settings
- if let pumpSettingsFromFile = pumpSettingsFromFile {
- maxBolus = pumpSettingsFromFile.maxBolus.clamp(to: providedSettings.maxBolus)
- maxBasal = pumpSettingsFromFile.maxBasal.clamp(to: providedSettings.maxBasal)
- }
- let preferences = settingsManager.preferences
- maxIOB = preferences.maxIOB.clamp(to: providedSettings.maxIOB)
- maxCOB = preferences.maxCOB.clamp(to: providedSettings.maxCOB)
- minimumSafetyThreshold = preferences.threshold_setting
- }
- // MARK: - Get Therapy Items
- /// 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)
- }
- /// 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
- )
- }
- /// 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)
- }
- /// 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]
- )
- }.sorted { $0.time < $1.time }
- }
- /// 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: timeValues[$0.timeIndex],
- value: rateValues[$0.rateIndex]
- )
- }.sorted { $0.time < $1.time }
- }
- // MARK: - Unified Update Methods
- /// 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 }
- }
- /// 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.timeIndex < $1.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 }
- }
- /// 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.timeIndex < $1.timeIndex }
- }
- // MARK: - Add Initials
- /// Adds a default ISF editor item at 00:00 with a standard sensitivity value.
- func addInitialISF() {
- addInitialItem(
- defaultValue: 200,
- rateValues: isfRateValues,
- assign: { isfItems = $0 },
- makeItem: ISFEditor.Item.init
- )
- }
- /// 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
- )
- }
- /// Adds a default carb ratio editor item at 00:00 with a standard ratio.
- func addInitialCarbRatio() {
- addInitialItem(
- defaultValue: 30,
- rateValues: carbRatioRateValues,
- assign: { carbRatioItems = $0 },
- makeItem: CarbRatioEditor.Item.init
- )
- }
- /// Adds a default glucose target item at 00:00 with a typical target value.
- func addInitialTarget() {
- let timeIndex = 0
- let rateIndex = closestIndex(for: 110, in: targetRateValues)
- targetItems = [TargetsEditor.Item(lowIndex: rateIndex, highIndex: rateIndex, timeIndex: timeIndex)]
- }
- /// 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)])
- }
- // MARK: - Validate
- /// 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)
- }
- /// 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)
- }
- /// 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)
- }
- /// 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)
- }
- /// 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: - Save
- /// 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
- }
- /// 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
- }
- /// 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
- }
- /// 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
- }
- /// Persists all onboarding data by applying settings and saving therapy values.
- func saveOnboardingData() {
- applyDiagnostics()
- applyToSettings()
- applyToPreferences()
- applyToPumpSettings()
- saveTargets()
- saveBasalProfile()
- saveCarbRatios()
- saveISFValues()
- }
- /// Persists the current diagnostics sharing option to UserDefaults as a boolean.
- func applyDiagnostics() {
- let booleanValue = diagnosticsSharingOption == .enabled
- PropertyPersistentFlags.shared.diagnosticsSharingEnabled = booleanValue
- Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(booleanValue)
- }
- /// Applies the selected glucose units to the app's settings.
- func applyToSettings() {
- var settingsCopy = settingsManager.settings
- settingsCopy.units = units
- if nightscoutSetupOption == .setupNightscout {
- settingsCopy.isUploadEnabled = isUploadEnabled
- settingsCopy.uploadGlucose = uploadGlucose
- }
- // ensure existing values cannot exceed new guardrails
- if !isFreshTrioInstall {
- let providedSettings = settingsProvider.settings
- settingsCopy.lowGlucose = settingsCopy.lowGlucose.clamp(to: providedSettings.lowGlucose)
- settingsCopy.highGlucose = settingsCopy.highGlucose.clamp(to: providedSettings.highGlucose)
- settingsCopy.carbsRequiredThreshold = settingsCopy.carbsRequiredThreshold
- .clamp(to: providedSettings.carbsRequiredThreshold)
- settingsCopy.individualAdjustmentFactor = settingsCopy.individualAdjustmentFactor
- .clamp(to: providedSettings.individualAdjustmentFactor)
- settingsCopy.timeCap = settingsCopy.timeCap.clamp(to: providedSettings.timeCap)
- settingsCopy.minuteInterval = settingsCopy.minuteInterval.clamp(to: providedSettings.minuteInterval)
- settingsCopy.delay = settingsCopy.delay.clamp(to: providedSettings.delay)
- settingsCopy.high = settingsCopy.high.clamp(to: providedSettings.high)
- settingsCopy.low = settingsCopy.low.clamp(to: providedSettings.low)
- settingsCopy.maxCarbs = settingsCopy.maxCarbs.clamp(to: providedSettings.maxCarbs)
- settingsCopy.maxFat = settingsCopy.maxFat.clamp(to: providedSettings.maxFat)
- settingsCopy.maxProtein = settingsCopy.maxProtein.clamp(to: providedSettings.maxProtein)
- settingsCopy.overrideFactor = settingsCopy.overrideFactor.clamp(to: providedSettings.overrideFactor)
- settingsCopy.fattyMealFactor = settingsCopy.fattyMealFactor.clamp(to: providedSettings.fattyMealFactor)
- settingsCopy.sweetMealFactor = settingsCopy.sweetMealFactor.clamp(to: providedSettings.sweetMealFactor)
- }
- settingsManager.settings = settingsCopy
- }
- /// Applies the selected delivery preferences to the app's settings.
- func applyToPreferences() {
- var preferences = Preferences()
- // delivery limits (those that are preference-bound, not pump-settings-bound
- preferences.maxIOB = maxIOB
- preferences.maxCOB = maxCOB
- preferences.threshold_setting = minimumSafetyThreshold
- // autosens
- preferences.autosensMin = autosensMin
- preferences.autosensMax = autosensMax
- preferences.rewindResetsAutosens = rewindResetsAutosens
- // smb settings
- preferences.enableSMBAlways = enableSMBAlways
- preferences.enableSMBWithCOB = enableSMBWithCOB
- preferences.enableSMBWithTemptarget = enableSMBWithTempTarget
- preferences.enableSMBAfterCarbs = enableSMBAfterCarbs
- preferences.enableSMB_high_bg = enableSMBWithHighGlucoseTarget
- preferences.enableSMB_high_bg_target = highGlucoseTarget
- preferences.allowSMBWithHighTemptarget = allowSMBWithHighTempTarget
- preferences.enableUAM = enableUAM
- preferences.maxSMBBasalMinutes = maxSMBMinutes
- preferences.maxUAMSMBBasalMinutes = maxUAMMinutes
- preferences.maxDeltaBGthreshold = maxDeltaGlucoseThreshold
- // target behavior
- preferences.highTemptargetRaisesSensitivity = highTempTargetRaisesSensitivity
- preferences.lowTemptargetLowersSensitivity = lowTempTargetLowersSensitivity
- preferences.sensitivityRaisesTarget = sensitivityRaisesTarget
- preferences.resistanceLowersTarget = resistanceLowersTarget
- preferences.halfBasalExerciseTarget = halfBasalTarget
- // default suspendZeroesIOB to true
- if !preferences.suspendZerosIOB {
- preferences.suspendZerosIOB = true
- }
- // ensure correct bolusIncrement is set, if user is onboarding with paired pump
- if let pumpManager = apsManager?.pumpManager {
- let bolusIncrement = Decimal(
- pumpManager.supportedBolusVolumes.first ??
- Double(
- settingsManager.preferences
- .bolusIncrement
- )
- )
- preferences.bolusIncrement = bolusIncrement > 0 ? bolusIncrement : 0.1
- }
- settingsManager.preferences = preferences
- }
- /// Saves pump delivery limits to persistent storage and broadcasts changes.
- func applyToPumpSettings() {
- let defaultDIA = settingsProvider.settings.dia.value
- let pumpSettings = PumpSettings(insulinActionCurve: defaultDIA, maxBolus: maxBolus, maxBasal: maxBasal)
- fileStorage.save(pumpSettings, as: OpenAPS.Settings.settings)
- }
- }
- }
- // 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 {}
|