OnboardingStateModel.swift 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809
  1. import Combine
  2. import DanaKit
  3. import FirebaseCrashlytics
  4. import Foundation
  5. import LoopKit
  6. import MinimedKit
  7. import Observation
  8. import OmniBLE
  9. import OmniKit
  10. import SwiftUI
  11. /// Model that holds the data collected during onboarding.
  12. extension Onboarding {
  13. @Observable final class StateModel: BaseStateModel<Provider> {
  14. @ObservationIgnored @Injected() var fileStorage: FileStorage!
  15. @ObservationIgnored @Injected() var deviceManager: DeviceDataManager!
  16. @ObservationIgnored @Injected() var broadcaster: Broadcaster!
  17. @ObservationIgnored @Injected() var keychain: Keychain!
  18. @ObservationIgnored @Injected() var nightscoutManager: NightscoutManager!
  19. @ObservationIgnored @Injected() var notificationsManager: UserNotificationsManager!
  20. @ObservationIgnored @Injected() var bluetoothManager: BluetoothStateManager!
  21. @ObservationIgnored @Injected() var apsManager: APSManager!
  22. private let settingsProvider = PickerSettingsProvider.shared
  23. // MARK: - App Diagnostics
  24. private var persistedDiagnosticsSharing: Bool? {
  25. get { PropertyPersistentFlags.shared.diagnosticsSharingEnabled }
  26. set { PropertyPersistentFlags.shared.diagnosticsSharingEnabled = newValue }
  27. }
  28. var diagnosticsSharingOption: DiagnosticsSharingOption = .enabled
  29. var hasAcceptedPrivacyPolicy: Bool = false
  30. func syncDiagnosticsOptionFromStorage() {
  31. diagnosticsSharingOption = (persistedDiagnosticsSharing ?? true) ? .enabled : .disabled
  32. }
  33. func updateDiagnosticsOption(to option: DiagnosticsSharingOption) {
  34. diagnosticsSharingOption = option
  35. persistedDiagnosticsSharing = (option == .enabled)
  36. }
  37. // MARK: - Determine Initial Build State
  38. /// Determines whether the app is in a fresh install state for Trio (new vs. returning/updating user).
  39. ///
  40. /// This check is based on the assumption that a truly clean install will only contain
  41. /// the `logs/` directory and the `preferences.json` file in the app's Documents directory.
  42. ///
  43. /// If this condition is met, the onboarding flow skips the `.returningUser` step and treats
  44. /// the user as new. If more files or directories are found, it is assumed the user is returning.
  45. ///
  46. /// Note: This check is not directly connected to a completed migration. However, if a migration
  47. /// has been triggered (whether successful or not), additional files such as treatment JSONs
  48. /// will exist, which naturally causes this check to return `false`.
  49. var isFreshTrioInstall: Bool {
  50. let fileManager = FileManager.default
  51. guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
  52. return false
  53. }
  54. let expectedLogsFolder = "logs"
  55. let expectedPreferencesFile = OpenAPS.Settings.preferences
  56. do {
  57. let contents = try fileManager.contentsOfDirectory(atPath: documentsURL.path)
  58. // Expect exactly 2 entries: "logs" and the preferences file
  59. guard contents.count == 2 else {
  60. debug(.default, "Trio install is not fresh; returning user.")
  61. return false
  62. }
  63. // Ensure they match exactly
  64. let expectedSet = Set([expectedLogsFolder, expectedPreferencesFile])
  65. let actualSet = Set(contents)
  66. let isFreshInstall = expectedSet == actualSet
  67. debug(.default, "Trio install is fresh; new user.")
  68. return isFreshInstall
  69. } catch {
  70. debug(.default, "Cannot determine Initial Build State. Failed to read documents directory: \(error)")
  71. return false
  72. }
  73. }
  74. // MARK: - Nightscout Setup
  75. var nightscoutSetupOption: NightscoutSetupOption = .noSelection
  76. var nightscoutImportOption: NightscoutImportOption = .noSelection
  77. var nightscoutUrl = ""
  78. var nightscoutSecret = ""
  79. var nightscoutResponseMessage = ""
  80. var isValidNightscoutURL: Bool = false
  81. var isConnectingToNS: Bool = false
  82. var isConnectedToNS: Bool = false
  83. var nightscoutImportError: NightscoutImportError?
  84. var nightscoutImportStatus: ImportStatus = .none
  85. var isUploadEnabled: Bool = true
  86. var uploadGlucose: Bool = true
  87. // MARK: - Units and Pump Omboarding Option
  88. var units: GlucoseUnits = .mgdL
  89. private var selectedPumpOption: PumpOptionForOnboardingUnits?
  90. var pumpOptionForOnboardingUnits: PumpOptionForOnboardingUnits {
  91. get {
  92. // let user edit selection and return user-selection, if present
  93. if let selected = selectedPumpOption {
  94. return selected
  95. }
  96. let defaultOption: PumpOptionForOnboardingUnits
  97. if let pumpManager = apsManager?.pumpManager {
  98. if pumpManager is OmniBLEPumpManager {
  99. defaultOption = .omnipodDash
  100. } else if pumpManager is OmnipodPumpManager {
  101. defaultOption = .omnipodEros
  102. } else if pumpManager is DanaKitPumpManager {
  103. defaultOption = .dana
  104. } else if pumpManager is MinimedPumpManager {
  105. defaultOption = .minimed
  106. } else {
  107. defaultOption = .omnipodDash
  108. }
  109. } else {
  110. defaultOption = .omnipodDash
  111. }
  112. // cache it so picker can stay in sync
  113. selectedPumpOption = defaultOption
  114. return defaultOption
  115. }
  116. set {
  117. selectedPumpOption = newValue
  118. }
  119. }
  120. // MARK: - Time Values (shared)
  121. let sharedTimeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }.sorted()
  122. // MARK: - Carb Ratio
  123. let carbRatioPickerSetting = PickerSetting(value: 30, step: 0.1, min: 1, max: 50, type: .gram)
  124. var carbRatioItems: [CarbRatioEditor.Item] = []
  125. var initialCarbRatioItems: [CarbRatioEditor.Item] = []
  126. var carbRatioTimeValues: [TimeInterval] { sharedTimeValues }
  127. var carbRatioRateValues: [Decimal] { settingsProvider.generatePickerValues(from: carbRatioPickerSetting, units: units) }
  128. // MARK: - Basal Profile
  129. var basalRatePickerSetting: PickerSetting {
  130. switch selectedPumpOption {
  131. case .dana:
  132. return PickerSetting(value: 0.1, step: 0.05, min: 0, max: 3, type: .insulinUnitPerHour)
  133. case .minimed:
  134. return PickerSetting(value: 0.1, step: 0.05, min: 0, max: 35, type: .insulinUnitPerHour)
  135. case .omnipodDash:
  136. return PickerSetting(value: 0.1, step: 0.05, min: 0, max: 30, type: .insulinUnitPerHour)
  137. case .omnipodEros:
  138. return PickerSetting(value: 0.1, step: 0.05, min: 0.05, max: 30, type: .insulinUnitPerHour)
  139. case .none:
  140. // same as dash, as that is the fallback
  141. return PickerSetting(value: 0.1, step: 0.05, min: 0, max: 30, type: .insulinUnitPerHour)
  142. }
  143. }
  144. var basalProfileItems: [BasalProfileEditor.Item] = []
  145. var initialBasalProfileItems: [BasalProfileEditor.Item] = []
  146. var basalProfileTimeValues: [TimeInterval] { sharedTimeValues }
  147. var basalProfileRateValues: [Decimal] { settingsProvider.generatePickerValues(from: basalRatePickerSetting, units: units)
  148. }
  149. // MARK: - Insulin Sensitivity Factor (ISF)
  150. var sensitivityPickerSetting = PickerSetting(value: 200, step: 1, min: 9, max: 540, type: .glucose)
  151. var isfItems: [ISFEditor.Item] = []
  152. var initialISFItems: [ISFEditor.Item] = []
  153. var isfTimeValues: [TimeInterval] { sharedTimeValues }
  154. var isfRateValues: [Decimal] { settingsProvider.generatePickerValues(from: sensitivityPickerSetting, units: units) }
  155. // MARK: - Glucose Targets
  156. let letTargetPickerSetting = PickerSetting(value: 110, step: 1, min: 72, max: 180, type: .glucose)
  157. var targetItems: [TargetsEditor.Item] = []
  158. var initialTargetItems: [TargetsEditor.Item] = []
  159. var targetTimeValues: [TimeInterval] { sharedTimeValues }
  160. var targetRateValues: [Decimal] { settingsProvider.generatePickerValues(from: letTargetPickerSetting, units: units) }
  161. // MARK: - Delivery Limit Defaults
  162. var maxBolus: Decimal = 10
  163. var maxBasal: Decimal = 2
  164. var maxIOB: Decimal = 0
  165. var maxCOB: Decimal = 120
  166. var minimumSafetyThreshold: Decimal = 60
  167. // MARK: - Algorithm Settings Defaults
  168. // Autosens Settings
  169. var autosensMin: Decimal = 0.7
  170. var autosensMax: Decimal = 1.2
  171. var rewindResetsAutosens: Bool = true
  172. var filteredAutosensSettingsSubsteps: [AutosensSettingsSubstep] {
  173. if pumpOptionForOnboardingUnits == .minimed || pumpOptionForOnboardingUnits == .dana {
  174. return AutosensSettingsSubstep.allCases
  175. } else {
  176. return [AutosensSettingsSubstep.autosensMin, AutosensSettingsSubstep.autosensMax]
  177. }
  178. }
  179. // SMB Settings
  180. var enableSMBAlways: Bool = false
  181. var enableSMBWithCOB: Bool = false
  182. var enableSMBWithTempTarget: Bool = false
  183. var enableSMBAfterCarbs: Bool = false
  184. var enableSMBWithHighGlucoseTarget: Bool = false
  185. var highGlucoseTarget: Decimal = 110
  186. var allowSMBWithHighTempTarget: Bool = false
  187. var enableUAM: Bool = false
  188. var maxSMBMinutes: Decimal = 30
  189. var maxUAMMinutes: Decimal = 30
  190. var maxDeltaGlucoseThreshold: Decimal = 0.2
  191. // Target Behavior
  192. var highTempTargetRaisesSensitivity: Bool = false
  193. var lowTempTargetLowersSensitivity: Bool = false
  194. var sensitivityRaisesTarget: Bool = false
  195. var resistanceLowersTarget: Bool = false
  196. var halfBasalTarget: Decimal = 160
  197. // MARK: - Permission Requests
  198. var hasNotificationsGranted = false
  199. var shouldDisplayCustomNotificationAlert: Bool = false
  200. var shouldDisplayBluetoothRequestAlert: Bool = false
  201. var hasBluetoothGranted = false
  202. // MARK: - Subscribe
  203. override func subscribe() {
  204. // Keychain items are not removed, even after uninstalling the app. Attempt to read them initially.
  205. nightscoutUrl = keychain.getValue(String.self, forKey: NightscoutConfig.Config.urlKey) ?? ""
  206. nightscoutSecret = keychain.getValue(String.self, forKey: NightscoutConfig.Config.secretKey) ?? ""
  207. isConnectedToNS = false
  208. isConnectingToNS = false
  209. isValidNightscoutURL = false
  210. if !isFreshTrioInstall {
  211. // Attempt to fetch existing units, therapy settings and delivery limits from file
  212. units = settingsManager.settings.units
  213. fetchExistingTherapySettingsFromFile()
  214. fetchExistingDeliveryLimtisFromFile()
  215. }
  216. }
  217. // MARK: - Helpers
  218. /// Finds the index of the closest `Decimal` value in the given array.
  219. /// - Parameters:
  220. /// - value: The value to match.
  221. /// - array: The array to search in.
  222. /// - Returns: Closest index in array.
  223. func closestIndex(for value: Decimal, in array: [Decimal]) -> Int {
  224. array.enumerated().min(by: {
  225. abs($0.element - value) < abs($1.element - value)
  226. })?.offset ?? 0
  227. }
  228. /// Finds the index of the closest `TimeInterval` value in the given array.
  229. /// - Parameters:
  230. /// - value: The time value to match.
  231. /// - array: The array to search in.
  232. /// - Returns: Closest index in array.
  233. func closestIndex(for value: TimeInterval, in array: [TimeInterval]) -> Int {
  234. array.enumerated().min(by: {
  235. abs($0.element - value) < abs($1.element - value)
  236. })?.offset ?? 0
  237. }
  238. /// A date formatter for time strings used in saved settings.
  239. private var timeFormatter: DateFormatter {
  240. let formatter = DateFormatter()
  241. formatter.timeZone = TimeZone(secondsFromGMT: 0)
  242. formatter.dateFormat = "HH:mm:ss"
  243. return formatter
  244. }
  245. /// Remaps therapy items affected by a glucose unit change (mg/dL vs mmol/L).
  246. ///
  247. /// This function updates glucose target and insulin sensitivity (ISF) items to use the closest valid index
  248. /// from the newly available rate arrays, preserving the original value intent.
  249. ///
  250. /// Call this after the user changes the unit selection.
  251. ///
  252. /// See also: `UnitSelectionStepView` `.onChange()` handlers.
  253. func remapTherapyItemsForChangedUnits() {
  254. // Targets
  255. targetItems = targetItems.map { item in
  256. let newLowIndex = closestIndex(for: targetRateValues[item.lowIndex], in: targetRateValues)
  257. let newTimeIndex = closestIndex(for: targetTimeValues[item.timeIndex], in: targetTimeValues)
  258. return TargetsEditor.Item(lowIndex: newLowIndex, highIndex: newLowIndex, timeIndex: newTimeIndex)
  259. }
  260. // ISF
  261. isfItems = isfItems.map { item in
  262. let newRateIndex = closestIndex(for: isfRateValues[item.rateIndex], in: isfRateValues)
  263. let newTimeIndex = closestIndex(for: isfTimeValues[item.timeIndex], in: isfTimeValues)
  264. return ISFEditor.Item(rateIndex: newRateIndex, timeIndex: newTimeIndex)
  265. }
  266. }
  267. /// Remaps therapy items affected by a pump model change.
  268. ///
  269. /// Updates basal profile items to use the closest valid index from
  270. /// the updated basal rate and time arrays, preserving the user's settings
  271. /// as closely as possible when switching between pump models.
  272. ///
  273. /// If an imported item's `rateIndex` or `timeIndex` exceeds the bounds of the
  274. /// current pump's allowed values, it is clamped to the last valid index to avoid
  275. /// crashes and preserve data integrity. A debug message is logged if clamping occurs.
  276. ///
  277. /// Call this after the user selects a new pump model.
  278. ///
  279. /// See also: `UnitSelectionStepView` `.onChange()` handlers.
  280. func remapTherapyItemsForChangedPumpModel() {
  281. let maxValidRateIndex = max(basalProfileRateValues.count - 1, 0)
  282. let maxValidTimeIndex = max(basalProfileTimeValues.count - 1, 0)
  283. basalProfileItems = basalProfileItems.map { item in
  284. let safeRateIndex = min(item.rateIndex, maxValidRateIndex)
  285. let safeTimeIndex = min(item.timeIndex, maxValidTimeIndex)
  286. let originalRate = basalProfileRateValues[safeRateIndex]
  287. let originalTime = basalProfileTimeValues[safeTimeIndex]
  288. let newRateIndex = closestIndex(for: originalRate, in: basalProfileRateValues)
  289. let newTimeIndex = closestIndex(for: originalTime, in: basalProfileTimeValues)
  290. if safeRateIndex != item.rateIndex {
  291. debug(.default, "⚠️ rateIndex \(item.rateIndex) out of bounds; clamped to \(safeRateIndex)")
  292. }
  293. return BasalProfileEditor.Item(rateIndex: newRateIndex, timeIndex: newTimeIndex)
  294. }
  295. }
  296. // MARK: - Fetch existing therapy settings from file
  297. /// Loads existing therapy settings from the provider and maps them into UI editor items.
  298. ///
  299. /// This function processes therapy-related configurations (glucose targets, basal rates,
  300. /// carb ratios, and insulin sensitivity factors) stored in file-backed models from the provider.
  301. /// It calculates the closest matching indices for time and rate values to map them to corresponding
  302. /// `Editor.Item` models for use in the UI.
  303. ///
  304. /// - Populates:
  305. /// - `targetItems` and `initialTargetItems` with glucose target entries.
  306. /// - `basalProfileItems` and `initialBasalProfileItems` with basal rate entries.
  307. /// - `carbRatioItems` and `initialCarbRatioItems` with carbohydrate ratio entries.
  308. /// - `isfItems` and `initialISFItems` with insulin sensitivity factor entries.
  309. func fetchExistingTherapySettingsFromFile() {
  310. targetItems = provider.glucoseTargetsOnFile.targets.map { value in
  311. let timeIndex = closestIndex(for: TimeInterval(Double(value.offset * 60)), in: targetTimeValues)
  312. let lowIndex = closestIndex(for: value.low, in: targetRateValues)
  313. let highIndex = closestIndex(for: value.high, in: targetRateValues)
  314. return TargetsEditor.Item(lowIndex: lowIndex, highIndex: highIndex, timeIndex: timeIndex)
  315. }
  316. initialTargetItems = targetItems
  317. .map { TargetsEditor.Item(lowIndex: $0.lowIndex, highIndex: $0.highIndex, timeIndex: $0.timeIndex) }
  318. basalProfileItems = provider.basalProfileOnFile.map { value in
  319. let timeIndex = closestIndex(for: TimeInterval(Double(value.minutes * 60)), in: basalProfileTimeValues)
  320. let rateIndex = closestIndex(for: value.rate, in: basalProfileRateValues)
  321. return BasalProfileEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
  322. }
  323. initialBasalProfileItems = basalProfileItems
  324. .map { BasalProfileEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
  325. carbRatioItems = provider.carbRatiosOnFile.schedule.map { value in
  326. let timeIndex = closestIndex(for: TimeInterval(Double(value.offset * 60)), in: carbRatioTimeValues)
  327. let rateIndex = closestIndex(for: value.ratio, in: carbRatioRateValues)
  328. return CarbRatioEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
  329. }
  330. initialCarbRatioItems = carbRatioItems.map { CarbRatioEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
  331. isfItems = provider.isfOnFile.sensitivities.map { value in
  332. let timeIndex = closestIndex(for: TimeInterval(Double(value.offset * 60)), in: isfTimeValues)
  333. let rateIndex = closestIndex(for: value.sensitivity, in: isfRateValues)
  334. return ISFEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
  335. }
  336. initialISFItems = isfItems.map { ISFEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
  337. }
  338. /// Loads delivery limit settings (Units, Max IOB, Max COB, Max Bolus, Max Basal) from the provider.
  339. ///
  340. /// Retrieves pump-related safety and delivery limits from both the provider's
  341. /// file-backed pump settings and app-specific preferences. These values are used
  342. /// to pre-fill the delivery limits editor in the onboarding or settings UI.
  343. ///
  344. /// - Populates:
  345. /// - `maxBolus` and `maxBasal` from file-based pump settings.
  346. /// - `maxIOB`, `maxCOB`, and `minimumSafetyThreshold` from app preferences.
  347. /// - `units` from app settings.
  348. func fetchExistingDeliveryLimtisFromFile() {
  349. let pumpSettingsFromFile = provider.pumpSettingsFromFile
  350. let providedSettings = settingsProvider.settings
  351. if let pumpSettingsFromFile = pumpSettingsFromFile {
  352. maxBolus = pumpSettingsFromFile.maxBolus.clamp(to: providedSettings.maxBolus)
  353. maxBasal = pumpSettingsFromFile.maxBasal.clamp(to: providedSettings.maxBasal)
  354. }
  355. let preferences = settingsManager.preferences
  356. maxIOB = preferences.maxIOB.clamp(to: providedSettings.maxIOB)
  357. maxCOB = preferences.maxCOB.clamp(to: providedSettings.maxCOB)
  358. minimumSafetyThreshold = preferences.threshold_setting
  359. }
  360. // MARK: - Get Therapy Items
  361. /// Converts ISF editor items to a list of `TherapySettingItem`.
  362. /// - Returns: Sorted list of therapy setting items based on ISF.
  363. func getISFTherapyItems() -> [TherapySettingItem] {
  364. getTherapyItems(from: isfItems, rateValues: isfRateValues, timeValues: isfTimeValues)
  365. }
  366. /// Converts basal profile editor items to a list of `TherapySettingItem`.
  367. /// - Returns: Sorted list of therapy setting items based on basal rates.
  368. func getBasalTherapyItems() -> [TherapySettingItem] {
  369. getTherapyItems(
  370. from: basalProfileItems,
  371. rateValues: basalProfileRateValues,
  372. timeValues: basalProfileTimeValues
  373. )
  374. }
  375. /// Converts carb ratio editor items to a list of `TherapySettingItem`.
  376. /// - Returns: Sorted list of therapy setting items based on carb ratios.
  377. func getCarbRatioTherapyItems() -> [TherapySettingItem] {
  378. getTherapyItems(from: carbRatioItems, rateValues: carbRatioRateValues, timeValues: carbRatioTimeValues)
  379. }
  380. /// Converts glucose target editor items to a list of `TherapySettingItem`.
  381. /// - Returns: Sorted list of therapy setting items based on glucose targets.
  382. func getTargetTherapyItems() -> [TherapySettingItem] {
  383. targetItems.map {
  384. TherapySettingItem(
  385. time: targetTimeValues[$0.timeIndex],
  386. value: targetRateValues[$0.lowIndex]
  387. )
  388. }.sorted { $0.time < $1.time }
  389. }
  390. /// Generic helper to convert any type of editor item into therapy setting items.
  391. /// - Parameters:
  392. /// - items: An array of items conforming to `TherapyItemConvertible`.
  393. /// - rateValues: The rate values to be used.
  394. /// - timeValues: The time values to be used.
  395. /// - Returns: A sorted array of `TherapySettingItem`.
  396. private func getTherapyItems<T: TherapyItemConvertible>(
  397. from items: [T],
  398. rateValues: [Decimal],
  399. timeValues: [TimeInterval]
  400. ) -> [TherapySettingItem] {
  401. items.map {
  402. TherapySettingItem(
  403. time: timeValues[$0.timeIndex],
  404. value: rateValues[$0.rateIndex]
  405. )
  406. }.sorted { $0.time < $1.time }
  407. }
  408. // MARK: - Unified Update Methods
  409. /// Updates the ISF editor items based on the provided therapy setting items.
  410. /// - Parameter therapyItems: The list of therapy items to update from.
  411. func updateISF(from therapyItems: [TherapySettingItem]) {
  412. isfItems = therapyItems.map {
  413. ISFEditor.Item(
  414. rateIndex: closestIndex(for: $0.value, in: isfRateValues),
  415. timeIndex: closestIndex(for: $0.time, in: isfTimeValues)
  416. )
  417. }.sorted { $0.timeIndex < $1.timeIndex }
  418. }
  419. /// Updates the basal rate editor items based on the provided therapy setting items.
  420. /// - Parameter therapyItems: The list of therapy items to update from.
  421. func updateBasal(from therapyItems: [TherapySettingItem]) {
  422. basalProfileItems = therapyItems.map {
  423. BasalProfileEditor.Item(
  424. rateIndex: closestIndex(for: $0.value, in: basalProfileRateValues),
  425. timeIndex: closestIndex(for: $0.time, in: basalProfileTimeValues)
  426. )
  427. }.sorted { $0.timeIndex < $1.timeIndex }
  428. }
  429. /// Updates the carb ratio editor items based on the provided therapy setting items.
  430. /// - Parameter therapyItems: The list of therapy items to update from.
  431. func updateCarbRatio(from therapyItems: [TherapySettingItem]) {
  432. carbRatioItems = therapyItems.map {
  433. CarbRatioEditor.Item(
  434. rateIndex: closestIndex(for: $0.value, in: carbRatioRateValues),
  435. timeIndex: closestIndex(for: $0.time, in: carbRatioTimeValues)
  436. )
  437. }.sorted { $0.timeIndex < $1.timeIndex }
  438. }
  439. /// Updates the glucose target editor items based on the provided therapy setting items.
  440. /// - Parameter therapyItems: The list of therapy items to update from.
  441. func updateTargets(from therapyItems: [TherapySettingItem]) {
  442. targetItems = therapyItems.map {
  443. let rateIndex = closestIndex(for: $0.value, in: targetRateValues)
  444. let timeIndex = closestIndex(for: $0.time, in: targetTimeValues)
  445. return TargetsEditor.Item(
  446. lowIndex: rateIndex,
  447. highIndex: rateIndex,
  448. timeIndex: timeIndex
  449. )
  450. }.sorted { $0.timeIndex < $1.timeIndex }
  451. }
  452. // MARK: - Add Initials
  453. /// Adds a default ISF editor item at 00:00 with a standard sensitivity value.
  454. func addInitialISF() {
  455. addInitialItem(
  456. defaultValue: 200,
  457. rateValues: isfRateValues,
  458. assign: { isfItems = $0 },
  459. makeItem: ISFEditor.Item.init
  460. )
  461. }
  462. /// Adds a default basal rate editor item at 00:00 with a typical rate value.
  463. func addInitialBasalRate() {
  464. addInitialItem(
  465. defaultValue: 0.1,
  466. rateValues: basalProfileRateValues,
  467. assign: { basalProfileItems = $0 },
  468. makeItem: BasalProfileEditor.Item.init
  469. )
  470. }
  471. /// Adds a default carb ratio editor item at 00:00 with a standard ratio.
  472. func addInitialCarbRatio() {
  473. addInitialItem(
  474. defaultValue: 30,
  475. rateValues: carbRatioRateValues,
  476. assign: { carbRatioItems = $0 },
  477. makeItem: CarbRatioEditor.Item.init
  478. )
  479. }
  480. /// Adds a default glucose target item at 00:00 with a typical target value.
  481. func addInitialTarget() {
  482. let timeIndex = 0
  483. let rateIndex = closestIndex(for: 110, in: targetRateValues)
  484. targetItems = [TargetsEditor.Item(lowIndex: rateIndex, highIndex: rateIndex, timeIndex: timeIndex)]
  485. }
  486. /// Adds an initial therapy setting item for a given editor item type.
  487. /// - Parameters:
  488. /// - defaultValue: The expected default value to use.
  489. /// - rateValues: The array of rate values for the item.
  490. /// - assign: A closure that assigns the newly created array to the correct property.
  491. private func addInitialItem<ItemType>(
  492. defaultValue: Decimal,
  493. rateValues: [Decimal],
  494. assign: ([ItemType]) -> Void,
  495. makeItem: (Int, Int) -> ItemType
  496. ) {
  497. let timeIndex = 0
  498. let rateIndex = closestIndex(for: defaultValue, in: rateValues)
  499. assign([makeItem(rateIndex, timeIndex)])
  500. }
  501. // MARK: - Validate
  502. /// Removes duplicate entries from `carbRatioItems`, ensures sorting by time index,
  503. /// and forces the first entry to start at 00:00 (timeIndex 0).
  504. func validateCarbRatios() {
  505. carbRatioItems = validated(items: carbRatioItems, timeIndexKeyPath: \.timeIndex)
  506. }
  507. /// Removes duplicate entries from `basalProfileItems`, ensures sorting by time index,
  508. /// and forces the first entry to start at 00:00 (timeIndex 0).
  509. func validateBasal() {
  510. basalProfileItems = validated(items: basalProfileItems, timeIndexKeyPath: \.timeIndex)
  511. }
  512. /// Removes duplicate entries from `isfItems`, ensures sorting by time index,
  513. /// and forces the first entry to start at 00:00 (timeIndex 0).
  514. func validateISF() {
  515. isfItems = validated(items: isfItems, timeIndexKeyPath: \.timeIndex)
  516. }
  517. /// Removes duplicate entries from `targetItems`, ensures sorting by time index,
  518. /// and forces the first entry to start at 00:00 (timeIndex 0).
  519. func validateTarget() {
  520. targetItems = validated(items: targetItems, timeIndexKeyPath: \.timeIndex)
  521. }
  522. /// Removes duplicates, sorts by time, and ensures the first entry starts at 00:00.
  523. /// - Parameters:
  524. /// - items: The list of items to validate.
  525. /// - timeIndexKeyPath: A writable key path to the timeIndex property.
  526. /// - Returns: A validated and sorted list of items with the first entry at 00:00.
  527. private func validated<T: Hashable>(items: [T], timeIndexKeyPath: WritableKeyPath<T, Int>) -> [T] {
  528. var result = Array(Set(items)).sorted { $0[keyPath: timeIndexKeyPath] < $1[keyPath: timeIndexKeyPath] }
  529. if !result.isEmpty, result[0][keyPath: timeIndexKeyPath] != 0 {
  530. result[0][keyPath: timeIndexKeyPath] = 0
  531. }
  532. return result
  533. }
  534. // MARK: - Save
  535. /// Saves the carb ratio items to file storage and sets them as initial values.
  536. func saveCarbRatios() {
  537. let schedule = carbRatioItems.map { item in
  538. let time = timeFormatter.string(from: Date(timeIntervalSince1970: carbRatioTimeValues[item.timeIndex]))
  539. let offset = Int(carbRatioTimeValues[item.timeIndex] / 60)
  540. let value = carbRatioRateValues[item.rateIndex]
  541. return CarbRatioEntry(start: time, offset: offset, ratio: value)
  542. }
  543. fileStorage.save(CarbRatios(units: .grams, schedule: schedule), as: OpenAPS.Settings.carbRatios)
  544. initialCarbRatioItems = carbRatioItems
  545. }
  546. /// Saves the basal profile items to file storage and sets them as initial values.
  547. func saveBasalProfile() {
  548. let profile = basalProfileItems.map { item in
  549. let time = timeFormatter.string(from: Date(timeIntervalSince1970: basalProfileTimeValues[item.timeIndex]))
  550. let offset = Int(basalProfileTimeValues[item.timeIndex] / 60)
  551. let rate = basalProfileRateValues[item.rateIndex]
  552. return BasalProfileEntry(start: time, minutes: offset, rate: rate)
  553. }
  554. fileStorage.save(profile, as: OpenAPS.Settings.basalProfile)
  555. initialBasalProfileItems = basalProfileItems
  556. }
  557. /// Saves the insulin sensitivity (ISF) items to file storage and sets them as initial values.
  558. func saveISFValues() {
  559. let sensitivities = isfItems.map { item in
  560. let time = timeFormatter.string(from: Date(timeIntervalSince1970: isfTimeValues[item.timeIndex]))
  561. let offset = Int(isfTimeValues[item.timeIndex] / 60)
  562. let value = isfRateValues[item.rateIndex]
  563. return InsulinSensitivityEntry(sensitivity: value, offset: offset, start: time)
  564. }
  565. let profile = InsulinSensitivities(units: .mgdL, userPreferredUnits: .mgdL, sensitivities: sensitivities)
  566. fileStorage.save(profile, as: OpenAPS.Settings.insulinSensitivities)
  567. initialISFItems = isfItems
  568. }
  569. /// Saves the glucose target items to file storage and sets them as initial values.
  570. func saveTargets() {
  571. let targets = targetItems.map { item in
  572. let time = timeFormatter.string(from: Date(timeIntervalSince1970: targetTimeValues[item.timeIndex]))
  573. let offset = Int(targetTimeValues[item.timeIndex] / 60)
  574. let value = targetRateValues[item.lowIndex]
  575. return BGTargetEntry(low: value, high: value, start: time, offset: offset)
  576. }
  577. let profile = BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: targets)
  578. fileStorage.save(profile, as: OpenAPS.Settings.bgTargets)
  579. initialTargetItems = targetItems
  580. }
  581. /// Persists all onboarding data by applying settings and saving therapy values.
  582. func saveOnboardingData() {
  583. applyDiagnostics()
  584. applyToSettings()
  585. applyToPreferences()
  586. applyToPumpSettings()
  587. saveTargets()
  588. saveBasalProfile()
  589. saveCarbRatios()
  590. saveISFValues()
  591. }
  592. /// Persists the current diagnostics sharing option to UserDefaults as a boolean.
  593. func applyDiagnostics() {
  594. let booleanValue = diagnosticsSharingOption == .enabled
  595. PropertyPersistentFlags.shared.diagnosticsSharingEnabled = booleanValue
  596. Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(booleanValue)
  597. }
  598. /// Applies the selected glucose units to the app's settings.
  599. func applyToSettings() {
  600. var settingsCopy = settingsManager.settings
  601. settingsCopy.units = units
  602. if nightscoutSetupOption == .setupNightscout {
  603. settingsCopy.isUploadEnabled = isUploadEnabled
  604. settingsCopy.uploadGlucose = uploadGlucose
  605. }
  606. // ensure existing values cannot exceed new guardrails
  607. if !isFreshTrioInstall {
  608. let providedSettings = settingsProvider.settings
  609. settingsCopy.lowGlucose = settingsCopy.lowGlucose.clamp(to: providedSettings.lowGlucose)
  610. settingsCopy.highGlucose = settingsCopy.highGlucose.clamp(to: providedSettings.highGlucose)
  611. settingsCopy.carbsRequiredThreshold = settingsCopy.carbsRequiredThreshold
  612. .clamp(to: providedSettings.carbsRequiredThreshold)
  613. settingsCopy.individualAdjustmentFactor = settingsCopy.individualAdjustmentFactor
  614. .clamp(to: providedSettings.individualAdjustmentFactor)
  615. settingsCopy.timeCap = settingsCopy.timeCap.clamp(to: providedSettings.timeCap)
  616. settingsCopy.minuteInterval = settingsCopy.minuteInterval.clamp(to: providedSettings.minuteInterval)
  617. settingsCopy.delay = settingsCopy.delay.clamp(to: providedSettings.delay)
  618. settingsCopy.high = settingsCopy.high.clamp(to: providedSettings.high)
  619. settingsCopy.low = settingsCopy.low.clamp(to: providedSettings.low)
  620. settingsCopy.maxCarbs = settingsCopy.maxCarbs.clamp(to: providedSettings.maxCarbs)
  621. settingsCopy.maxFat = settingsCopy.maxFat.clamp(to: providedSettings.maxFat)
  622. settingsCopy.maxProtein = settingsCopy.maxProtein.clamp(to: providedSettings.maxProtein)
  623. settingsCopy.overrideFactor = settingsCopy.overrideFactor.clamp(to: providedSettings.overrideFactor)
  624. settingsCopy.fattyMealFactor = settingsCopy.fattyMealFactor.clamp(to: providedSettings.fattyMealFactor)
  625. settingsCopy.sweetMealFactor = settingsCopy.sweetMealFactor.clamp(to: providedSettings.sweetMealFactor)
  626. }
  627. settingsManager.settings = settingsCopy
  628. }
  629. /// Applies the selected delivery preferences to the app's settings.
  630. func applyToPreferences() {
  631. var preferences = Preferences()
  632. // delivery limits (those that are preference-bound, not pump-settings-bound
  633. preferences.maxIOB = maxIOB
  634. preferences.maxCOB = maxCOB
  635. preferences.threshold_setting = minimumSafetyThreshold
  636. // autosens
  637. preferences.autosensMin = autosensMin
  638. preferences.autosensMax = autosensMax
  639. preferences.rewindResetsAutosens = rewindResetsAutosens
  640. // smb settings
  641. preferences.enableSMBAlways = enableSMBAlways
  642. preferences.enableSMBWithCOB = enableSMBWithCOB
  643. preferences.enableSMBWithTemptarget = enableSMBWithTempTarget
  644. preferences.enableSMBAfterCarbs = enableSMBAfterCarbs
  645. preferences.enableSMB_high_bg = enableSMBWithHighGlucoseTarget
  646. preferences.enableSMB_high_bg_target = highGlucoseTarget
  647. preferences.allowSMBWithHighTemptarget = allowSMBWithHighTempTarget
  648. preferences.enableUAM = enableUAM
  649. preferences.maxSMBBasalMinutes = maxSMBMinutes
  650. preferences.maxUAMSMBBasalMinutes = maxUAMMinutes
  651. preferences.maxDeltaBGthreshold = maxDeltaGlucoseThreshold
  652. // target behavior
  653. preferences.highTemptargetRaisesSensitivity = highTempTargetRaisesSensitivity
  654. preferences.lowTemptargetLowersSensitivity = lowTempTargetLowersSensitivity
  655. preferences.sensitivityRaisesTarget = sensitivityRaisesTarget
  656. preferences.resistanceLowersTarget = resistanceLowersTarget
  657. preferences.halfBasalExerciseTarget = halfBasalTarget
  658. // default suspendZeroesIOB to true
  659. if !preferences.suspendZerosIOB {
  660. preferences.suspendZerosIOB = true
  661. }
  662. // ensure correct bolusIncrement is set, if user is onboarding with paired pump
  663. if let pumpManager = apsManager?.pumpManager {
  664. let bolusIncrement = Decimal(
  665. pumpManager.supportedBolusVolumes.first ??
  666. Double(
  667. settingsManager.preferences
  668. .bolusIncrement
  669. )
  670. )
  671. preferences.bolusIncrement = bolusIncrement > 0 ? bolusIncrement : 0.1
  672. }
  673. settingsManager.preferences = preferences
  674. }
  675. /// Saves pump delivery limits to persistent storage and broadcasts changes.
  676. func applyToPumpSettings() {
  677. let defaultDIA = settingsProvider.settings.dia.value
  678. let pumpSettings = PumpSettings(insulinActionCurve: defaultDIA, maxBolus: maxBolus, maxBasal: maxBasal)
  679. fileStorage.save(pumpSettings, as: OpenAPS.Settings.settings)
  680. }
  681. }
  682. }
  683. // MARK: - Protocol (optional) to unify type mapping
  684. protocol TherapyItemConvertible {
  685. var rateIndex: Int { get }
  686. var timeIndex: Int { get }
  687. }
  688. extension ISFEditor.Item: TherapyItemConvertible {}
  689. extension CarbRatioEditor.Item: TherapyItemConvertible {}
  690. extension BasalProfileEditor.Item: TherapyItemConvertible {}