OnboardingStateModel.swift 35 KB

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