OnboardingStateModel.swift 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  1. import Combine
  2. import FirebaseCrashlytics
  3. import Foundation
  4. import LoopKit
  5. import Observation
  6. import SwiftUI
  7. /// Model that holds the data collected during onboarding.
  8. extension Onboarding {
  9. @Observable final class StateModel: BaseStateModel<Provider> {
  10. @ObservationIgnored @Injected() var fileStorage: FileStorage!
  11. @ObservationIgnored @Injected() var deviceManager: DeviceDataManager!
  12. @ObservationIgnored @Injected() var broadcaster: Broadcaster!
  13. @ObservationIgnored @Injected() var keychain: Keychain!
  14. @ObservationIgnored @Injected() var nightscoutManager: NightscoutManager!
  15. @ObservationIgnored @Injected() var notificationsManager: UserNotificationsManager!
  16. @ObservationIgnored @Injected() var bluetoothManager: BluetoothStateManager!
  17. private let settingsProvider = PickerSettingsProvider.shared
  18. // MARK: - App Diagnostics
  19. var diagnosticsSharingOption: DiagnosticsSharingOption = .enabled
  20. var hasAcceptedPrivacyPolicy: Bool = false
  21. // MARK: - Nightscout Setup
  22. var nightscoutSetupOption: NightscoutSetupOption = .noSelection
  23. var nightscoutImportOption: NightscoutImportOption = .noSelection
  24. var nightscoutUrl = ""
  25. var nightscoutSecret = ""
  26. var nightscoutResponseMessage = ""
  27. var isValidNightscoutURL: Bool = false
  28. var isConnectingToNS: Bool = false
  29. var isConnectedToNS: Bool = false
  30. var nightscoutImportErrors: [String] = []
  31. var nightscoutImportStatus: ImportStatus = .finished
  32. // MARK: - Units and Pump Omboarding Option
  33. var units: GlucoseUnits = .mgdL
  34. var pumpOptionForOnboardingUnits: PumpOptionForOnboardingUnits = .omnipodDash
  35. // MARK: - Time Values (shared)
  36. let sharedTimeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }.sorted()
  37. // MARK: - Carb Ratio
  38. let carbRatioPickerSetting = PickerSetting(value: 10, step: 0.1, min: 1, max: 50, type: .gram)
  39. var carbRatioItems: [CarbRatioEditor.Item] = []
  40. var initialCarbRatioItems: [CarbRatioEditor.Item] = []
  41. var carbRatioTimeValues: [TimeInterval] { sharedTimeValues }
  42. var carbRatioRateValues: [Decimal] { settingsProvider.generatePickerValues(from: carbRatioPickerSetting, units: units) }
  43. // MARK: - Basal Profile
  44. var basalRatePickerSetting: PickerSetting {
  45. switch pumpOptionForOnboardingUnits {
  46. case .dana:
  47. return PickerSetting(value: 0.05, step: 0.05, min: 0, max: 3, type: .insulinUnitPerHour)
  48. case .minimed:
  49. return PickerSetting(value: 0.05, step: 0.05, min: 0, max: 35, type: .insulinUnitPerHour)
  50. case .omnipodDash:
  51. return PickerSetting(value: 0.05, step: 0.05, min: 0, max: 30, type: .insulinUnitPerHour)
  52. case .omnipodEros:
  53. return PickerSetting(value: 0.05, step: 0.05, min: 0.05, max: 30, type: .insulinUnitPerHour)
  54. }
  55. }
  56. var basalProfileItems: [BasalProfileEditor.Item] = []
  57. var initialBasalProfileItems: [BasalProfileEditor.Item] = []
  58. var basalProfileTimeValues: [TimeInterval] { sharedTimeValues }
  59. var basalProfileRateValues: [Decimal] { settingsProvider.generatePickerValues(from: basalRatePickerSetting, units: units)
  60. }
  61. // MARK: - Insulin Sensitivity Factor (ISF)
  62. var sensitivityPickerSetting = PickerSetting(value: 100, step: 1, min: 9, max: 540, type: .glucose)
  63. var isfItems: [ISFEditor.Item] = []
  64. var initialISFItems: [ISFEditor.Item] = []
  65. var isfTimeValues: [TimeInterval] { sharedTimeValues }
  66. var isfRateValues: [Decimal] { settingsProvider.generatePickerValues(from: sensitivityPickerSetting, units: units) }
  67. // MARK: - Glucose Targets
  68. let letTargetPickerSetting = PickerSetting(value: 100, step: 1, min: 72, max: 180, type: .glucose)
  69. var targetItems: [TargetsEditor.Item] = []
  70. var initialTargetItems: [TargetsEditor.Item] = []
  71. var targetTimeValues: [TimeInterval] { sharedTimeValues }
  72. var targetRateValues: [Decimal] { settingsProvider.generatePickerValues(from: letTargetPickerSetting, units: units) }
  73. // MARK: - Delivery Limit Defaults
  74. var maxBolus: Decimal = 10
  75. var maxBasal: Decimal = 2
  76. var maxIOB: Decimal = 0
  77. var maxCOB: Decimal = 120
  78. var minimumSafetyThreshold: Decimal = 60
  79. // MARK: - Algorithm Settings Defaults
  80. // Autosens Settings
  81. var autosensMin: Decimal = 0.7
  82. var autosensMax: Decimal = 1.2
  83. var rewindResetsAutosens: Bool = true
  84. var filteredAutosensSettingsSubsteps: [AutosensSettingsSubstep] {
  85. if pumpOptionForOnboardingUnits == .minimed || pumpOptionForOnboardingUnits == .dana {
  86. return AutosensSettingsSubstep.allCases
  87. } else {
  88. return [AutosensSettingsSubstep.autosensMin, AutosensSettingsSubstep.autosensMax]
  89. }
  90. }
  91. // SMB Settings
  92. var enableSMBAlways: Bool = false
  93. var enableSMBWithCOB: Bool = false
  94. var enableSMBWithTempTarget: Bool = false
  95. var enableSMBAfterCarbs: Bool = false
  96. var enableSMBWithHighGlucoseTarget: Bool = false
  97. var highGlucoseTarget: Decimal = 110
  98. var allowSMBWithHighTempTarget: Bool = false
  99. var enableUAM: Bool = false
  100. var maxSMBMinutes: Decimal = 30
  101. var maxUAMMinutes: Decimal = 30
  102. var maxDeltaGlucoseThreshold: Decimal = 0.2
  103. // Target Behavior
  104. var highTempTargetRaisesSensitivity: Bool = false
  105. var lowTempTargetLowersSensitivity: Bool = false
  106. var sensitivityRaisesTarget: Bool = false
  107. var resistanceLowersTarget: Bool = false
  108. var halfBasalTarget: Decimal = 160
  109. // MARK: - Permission Requests
  110. var hasNotificationsGranted = false
  111. var shouldDisplayCustomNotificationAlert: Bool = false
  112. var shouldDisplayBluetoothRequestAlert: Bool = false
  113. var hasBluetoothGranted = false
  114. // MARK: - Subscribe
  115. override func subscribe() {
  116. // Keychain items are not removed, even after uninstalling the app. Attempt to read them initially.
  117. nightscoutUrl = keychain.getValue(String.self, forKey: NightscoutConfig.Config.urlKey) ?? ""
  118. nightscoutSecret = keychain.getValue(String.self, forKey: NightscoutConfig.Config.secretKey) ?? ""
  119. isConnectedToNS = false
  120. isConnectingToNS = false
  121. isValidNightscoutURL = false
  122. // Attempt to fetch existing units, therapy settings and delivery limits from file
  123. units = settingsManager.settings.units
  124. fetchExistingTherapySettingsFromFile()
  125. fetchExistingDeliveryLimtisFromFile()
  126. }
  127. // MARK: - Helpers
  128. /// Finds the index of the closest `Decimal` value in the given array.
  129. /// - Parameters:
  130. /// - value: The value to match.
  131. /// - array: The array to search in.
  132. /// - Returns: Closest index in array.
  133. func closestIndex(for value: Decimal, in array: [Decimal]) -> Int {
  134. array.enumerated().min(by: {
  135. abs($0.element - value) < abs($1.element - value)
  136. })?.offset ?? 0
  137. }
  138. /// Finds the index of the closest `TimeInterval` value in the given array.
  139. /// - Parameters:
  140. /// - value: The time value to match.
  141. /// - array: The array to search in.
  142. /// - Returns: Closest index in array.
  143. func closestIndex(for value: TimeInterval, in array: [TimeInterval]) -> Int {
  144. array.enumerated().min(by: {
  145. abs($0.element - value) < abs($1.element - value)
  146. })?.offset ?? 0
  147. }
  148. /// A date formatter for time strings used in saved settings.
  149. private var timeFormatter: DateFormatter {
  150. let formatter = DateFormatter()
  151. formatter.timeZone = TimeZone(secondsFromGMT: 0)
  152. formatter.dateFormat = "HH:mm:ss"
  153. return formatter
  154. }
  155. /// Remaps therapy items affected by a glucose unit change (mg/dL vs mmol/L).
  156. ///
  157. /// This function updates glucose target and insulin sensitivity (ISF) items to use the closest valid index
  158. /// from the newly available rate arrays, preserving the original value intent.
  159. ///
  160. /// Call this after the user changes the unit selection.
  161. ///
  162. /// See also: `UnitSelectionStepView` `.onChange()` handlers.
  163. func remapTherapyItemsForChangedUnits() {
  164. // Targets
  165. targetItems = targetItems.map { item in
  166. let newLowIndex = closestIndex(for: targetRateValues[item.lowIndex], in: targetRateValues)
  167. let newTimeIndex = closestIndex(for: targetTimeValues[item.timeIndex], in: targetTimeValues)
  168. return TargetsEditor.Item(lowIndex: newLowIndex, highIndex: newLowIndex, timeIndex: newTimeIndex)
  169. }
  170. // ISF
  171. isfItems = isfItems.map { item in
  172. let newRateIndex = closestIndex(for: isfRateValues[item.rateIndex], in: isfRateValues)
  173. let newTimeIndex = closestIndex(for: isfTimeValues[item.timeIndex], in: isfTimeValues)
  174. return ISFEditor.Item(rateIndex: newRateIndex, timeIndex: newTimeIndex)
  175. }
  176. }
  177. /// Remaps therapy items affected by a pump model change.
  178. ///
  179. /// This function updates basal profile items to use the closest valid index
  180. /// from the updated basal rate and time arrays, preserving the user's settings.
  181. ///
  182. /// Call this after the user selects a new pump model.
  183. ///
  184. /// See also: `UnitSelectionStepView` `.onChange()` handlers.
  185. func remapTherapyItemsForChangedPumpModel() {
  186. basalProfileItems = basalProfileItems.map { item in
  187. let newRateIndex = closestIndex(for: basalProfileRateValues[item.rateIndex], in: basalProfileRateValues)
  188. let newTimeIndex = closestIndex(for: basalProfileTimeValues[item.timeIndex], in: basalProfileTimeValues)
  189. return BasalProfileEditor.Item(rateIndex: newRateIndex, timeIndex: newTimeIndex)
  190. }
  191. }
  192. // MARK: - Fetch existing therapy settings from file
  193. /// Loads existing therapy settings from the provider and maps them into UI editor items.
  194. ///
  195. /// This function processes therapy-related configurations (glucose targets, basal rates,
  196. /// carb ratios, and insulin sensitivity factors) stored in file-backed models from the provider.
  197. /// It calculates the closest matching indices for time and rate values to map them to corresponding
  198. /// `Editor.Item` models for use in the UI.
  199. ///
  200. /// - Populates:
  201. /// - `targetItems` and `initialTargetItems` with glucose target entries.
  202. /// - `basalProfileItems` and `initialBasalProfileItems` with basal rate entries.
  203. /// - `carbRatioItems` and `initialCarbRatioItems` with carbohydrate ratio entries.
  204. /// - `isfItems` and `initialISFItems` with insulin sensitivity factor entries.
  205. func fetchExistingTherapySettingsFromFile() {
  206. targetItems = provider.glucoseTargetsOnFile.targets.map { value in
  207. let timeIndex = closestIndex(for: TimeInterval(Double(value.offset * 60)), in: targetTimeValues)
  208. let lowIndex = closestIndex(for: value.low, in: targetRateValues)
  209. let highIndex = closestIndex(for: value.high, in: targetRateValues)
  210. return TargetsEditor.Item(lowIndex: lowIndex, highIndex: highIndex, timeIndex: timeIndex)
  211. }
  212. initialTargetItems = targetItems
  213. .map { TargetsEditor.Item(lowIndex: $0.lowIndex, highIndex: $0.highIndex, timeIndex: $0.timeIndex) }
  214. basalProfileItems = provider.basalProfileOnFile.map { value in
  215. let timeIndex = closestIndex(for: TimeInterval(Double(value.minutes * 60)), in: basalProfileTimeValues)
  216. let rateIndex = closestIndex(for: value.rate, in: basalProfileRateValues)
  217. return BasalProfileEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
  218. }
  219. initialBasalProfileItems = basalProfileItems
  220. .map { BasalProfileEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
  221. carbRatioItems = provider.carbRatiosOnFile.schedule.map { value in
  222. let timeIndex = closestIndex(for: TimeInterval(Double(value.offset * 60)), in: carbRatioTimeValues)
  223. let rateIndex = closestIndex(for: value.ratio, in: carbRatioRateValues)
  224. return CarbRatioEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
  225. }
  226. initialCarbRatioItems = carbRatioItems.map { CarbRatioEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
  227. isfItems = provider.isfOnFile.sensitivities.map { value in
  228. let timeIndex = closestIndex(for: TimeInterval(Double(value.offset * 60)), in: isfTimeValues)
  229. let rateIndex = closestIndex(for: value.sensitivity, in: isfRateValues)
  230. return ISFEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
  231. }
  232. initialISFItems = isfItems.map { ISFEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
  233. }
  234. /// Loads delivery limit settings (Units, Max IOB, Max COB, Max Bolus, Max Basal) from the provider.
  235. ///
  236. /// Retrieves pump-related safety and delivery limits from both the provider's
  237. /// file-backed pump settings and app-specific preferences. These values are used
  238. /// to pre-fill the delivery limits editor in the onboarding or settings UI.
  239. ///
  240. /// - Populates:
  241. /// - `maxBolus` and `maxBasal` from file-based pump settings.
  242. /// - `maxIOB`, `maxCOB`, and `minimumSafetyThreshold` from app preferences.
  243. /// - `units` from app settings.
  244. func fetchExistingDeliveryLimtisFromFile() {
  245. let pumpSettingsFromFile = provider.pumpSettingsFromFile
  246. if let pumpSettingsFromFile = pumpSettingsFromFile {
  247. maxBolus = pumpSettingsFromFile.maxBolus
  248. maxBasal = pumpSettingsFromFile.maxBasal
  249. }
  250. let preferences = settingsManager.preferences
  251. maxIOB = preferences.maxIOB
  252. maxCOB = preferences.maxCOB
  253. minimumSafetyThreshold = preferences.threshold_setting
  254. }
  255. // MARK: - Get Therapy Items
  256. /// Converts ISF editor items to a list of `TherapySettingItem`.
  257. /// - Returns: Sorted list of therapy setting items based on ISF.
  258. func getISFTherapyItems() -> [TherapySettingItem] {
  259. getTherapyItems(from: isfItems, rateValues: isfRateValues, timeValues: isfTimeValues)
  260. }
  261. /// Converts basal profile editor items to a list of `TherapySettingItem`.
  262. /// - Returns: Sorted list of therapy setting items based on basal rates.
  263. func getBasalTherapyItems() -> [TherapySettingItem] {
  264. getTherapyItems(
  265. from: basalProfileItems,
  266. rateValues: basalProfileRateValues,
  267. timeValues: basalProfileTimeValues
  268. )
  269. }
  270. /// Converts carb ratio editor items to a list of `TherapySettingItem`.
  271. /// - Returns: Sorted list of therapy setting items based on carb ratios.
  272. func getCarbRatioTherapyItems() -> [TherapySettingItem] {
  273. getTherapyItems(from: carbRatioItems, rateValues: carbRatioRateValues, timeValues: carbRatioTimeValues)
  274. }
  275. /// Converts glucose target editor items to a list of `TherapySettingItem`.
  276. /// - Returns: Sorted list of therapy setting items based on glucose targets.
  277. func getTargetTherapyItems() -> [TherapySettingItem] {
  278. targetItems.map {
  279. TherapySettingItem(
  280. time: targetTimeValues[$0.timeIndex],
  281. value: targetRateValues[$0.lowIndex]
  282. )
  283. }.sorted { $0.time < $1.time }
  284. }
  285. /// Generic helper to convert any type of editor item into therapy setting items.
  286. /// - Parameters:
  287. /// - items: An array of items conforming to `TherapyItemConvertible`.
  288. /// - rateValues: The rate values to be used.
  289. /// - timeValues: The time values to be used.
  290. /// - Returns: A sorted array of `TherapySettingItem`.
  291. private func getTherapyItems<T: TherapyItemConvertible>(
  292. from items: [T],
  293. rateValues: [Decimal],
  294. timeValues: [TimeInterval]
  295. ) -> [TherapySettingItem] {
  296. items.map {
  297. TherapySettingItem(
  298. time: timeValues[$0.timeIndex],
  299. value: rateValues[$0.rateIndex]
  300. )
  301. }.sorted { $0.time < $1.time }
  302. }
  303. // MARK: - Unified Update Methods
  304. /// Updates the ISF editor items based on the provided therapy setting items.
  305. /// - Parameter therapyItems: The list of therapy items to update from.
  306. func updateISF(from therapyItems: [TherapySettingItem]) {
  307. isfItems = therapyItems.map {
  308. ISFEditor.Item(
  309. rateIndex: closestIndex(for: $0.value, in: isfRateValues),
  310. timeIndex: closestIndex(for: $0.time, in: isfTimeValues)
  311. )
  312. }.sorted { $0.timeIndex < $1.timeIndex }
  313. }
  314. /// Updates the basal rate editor items based on the provided therapy setting items.
  315. /// - Parameter therapyItems: The list of therapy items to update from.
  316. func updateBasal(from therapyItems: [TherapySettingItem]) {
  317. basalProfileItems = therapyItems.map {
  318. BasalProfileEditor.Item(
  319. rateIndex: closestIndex(for: $0.value, in: basalProfileRateValues),
  320. timeIndex: closestIndex(for: $0.time, in: basalProfileTimeValues)
  321. )
  322. }.sorted { $0.timeIndex < $1.timeIndex }
  323. }
  324. /// Updates the carb ratio editor items based on the provided therapy setting items.
  325. /// - Parameter therapyItems: The list of therapy items to update from.
  326. func updateCarbRatio(from therapyItems: [TherapySettingItem]) {
  327. carbRatioItems = therapyItems.map {
  328. CarbRatioEditor.Item(
  329. rateIndex: closestIndex(for: $0.value, in: carbRatioRateValues),
  330. timeIndex: closestIndex(for: $0.time, in: carbRatioTimeValues)
  331. )
  332. }.sorted { $0.timeIndex < $1.timeIndex }
  333. }
  334. /// Updates the glucose target editor items based on the provided therapy setting items.
  335. /// - Parameter therapyItems: The list of therapy items to update from.
  336. func updateTargets(from therapyItems: [TherapySettingItem]) {
  337. targetItems = therapyItems.map {
  338. let rateIndex = closestIndex(for: $0.value, in: targetRateValues)
  339. let timeIndex = closestIndex(for: $0.time, in: targetTimeValues)
  340. return TargetsEditor.Item(
  341. lowIndex: rateIndex,
  342. highIndex: rateIndex,
  343. timeIndex: timeIndex
  344. )
  345. }.sorted { $0.timeIndex < $1.timeIndex }
  346. }
  347. // MARK: - Add Initials
  348. /// Adds a default ISF editor item at 00:00 with a standard sensitivity value.
  349. func addInitialISF() {
  350. addInitialItem(
  351. defaultValue: 50,
  352. rateValues: isfRateValues,
  353. assign: { isfItems = $0 },
  354. makeItem: ISFEditor.Item.init
  355. )
  356. }
  357. /// Adds a default basal rate editor item at 00:00 with a typical rate value.
  358. func addInitialBasalRate() {
  359. addInitialItem(
  360. defaultValue: 0.1,
  361. rateValues: basalProfileRateValues,
  362. assign: { basalProfileItems = $0 },
  363. makeItem: BasalProfileEditor.Item.init
  364. )
  365. }
  366. /// Adds a default carb ratio editor item at 00:00 with a standard ratio.
  367. func addInitialCarbRatio() {
  368. addInitialItem(
  369. defaultValue: 10,
  370. rateValues: carbRatioRateValues,
  371. assign: { carbRatioItems = $0 },
  372. makeItem: CarbRatioEditor.Item.init
  373. )
  374. }
  375. /// Adds a default glucose target item at 00:00 with a typical target value.
  376. func addInitialTarget() {
  377. let timeIndex = 0
  378. let rateIndex = closestIndex(for: 100, in: targetRateValues)
  379. targetItems = [TargetsEditor.Item(lowIndex: rateIndex, highIndex: rateIndex, timeIndex: timeIndex)]
  380. }
  381. /// Adds an initial therapy setting item for a given editor item type.
  382. /// - Parameters:
  383. /// - defaultValue: The expected default value to use.
  384. /// - rateValues: The array of rate values for the item.
  385. /// - assign: A closure that assigns the newly created array to the correct property.
  386. private func addInitialItem<ItemType>(
  387. defaultValue: Decimal,
  388. rateValues: [Decimal],
  389. assign: ([ItemType]) -> Void,
  390. makeItem: (Int, Int) -> ItemType
  391. ) {
  392. let timeIndex = 0
  393. let rateIndex = closestIndex(for: defaultValue, in: rateValues)
  394. assign([makeItem(rateIndex, timeIndex)])
  395. }
  396. // MARK: - Validate
  397. /// Removes duplicate entries from `carbRatioItems`, ensures sorting by time index,
  398. /// and forces the first entry to start at 00:00 (timeIndex 0).
  399. func validateCarbRatios() {
  400. carbRatioItems = validated(items: carbRatioItems, timeIndexKeyPath: \.timeIndex)
  401. }
  402. /// Removes duplicate entries from `basalProfileItems`, ensures sorting by time index,
  403. /// and forces the first entry to start at 00:00 (timeIndex 0).
  404. func validateBasal() {
  405. basalProfileItems = validated(items: basalProfileItems, timeIndexKeyPath: \.timeIndex)
  406. }
  407. /// Removes duplicate entries from `isfItems`, ensures sorting by time index,
  408. /// and forces the first entry to start at 00:00 (timeIndex 0).
  409. func validateISF() {
  410. isfItems = validated(items: isfItems, timeIndexKeyPath: \.timeIndex)
  411. }
  412. /// Removes duplicate entries from `targetItems`, ensures sorting by time index,
  413. /// and forces the first entry to start at 00:00 (timeIndex 0).
  414. func validateTarget() {
  415. targetItems = validated(items: targetItems, timeIndexKeyPath: \.timeIndex)
  416. }
  417. /// Removes duplicates, sorts by time, and ensures the first entry starts at 00:00.
  418. /// - Parameters:
  419. /// - items: The list of items to validate.
  420. /// - timeIndexKeyPath: A writable key path to the timeIndex property.
  421. /// - Returns: A validated and sorted list of items with the first entry at 00:00.
  422. private func validated<T: Hashable>(items: [T], timeIndexKeyPath: WritableKeyPath<T, Int>) -> [T] {
  423. var result = Array(Set(items)).sorted { $0[keyPath: timeIndexKeyPath] < $1[keyPath: timeIndexKeyPath] }
  424. if !result.isEmpty, result[0][keyPath: timeIndexKeyPath] != 0 {
  425. result[0][keyPath: timeIndexKeyPath] = 0
  426. }
  427. return result
  428. }
  429. // MARK: - Save
  430. /// Saves the carb ratio items to file storage and sets them as initial values.
  431. func saveCarbRatios() {
  432. let schedule = carbRatioItems.map { item in
  433. let time = timeFormatter.string(from: Date(timeIntervalSince1970: carbRatioTimeValues[item.timeIndex]))
  434. let offset = Int(carbRatioTimeValues[item.timeIndex] / 60)
  435. let value = carbRatioRateValues[item.rateIndex]
  436. return CarbRatioEntry(start: time, offset: offset, ratio: value)
  437. }
  438. fileStorage.save(CarbRatios(units: .grams, schedule: schedule), as: OpenAPS.Settings.carbRatios)
  439. initialCarbRatioItems = carbRatioItems
  440. }
  441. /// Saves the basal profile items to file storage and sets them as initial values.
  442. func saveBasalProfile() {
  443. let profile = basalProfileItems.map { item in
  444. let time = timeFormatter.string(from: Date(timeIntervalSince1970: basalProfileTimeValues[item.timeIndex]))
  445. let offset = Int(basalProfileTimeValues[item.timeIndex] / 60)
  446. let rate = basalProfileRateValues[item.rateIndex]
  447. return BasalProfileEntry(start: time, minutes: offset, rate: rate)
  448. }
  449. fileStorage.save(profile, as: OpenAPS.Settings.basalProfile)
  450. initialBasalProfileItems = basalProfileItems
  451. }
  452. /// Saves the insulin sensitivity (ISF) items to file storage and sets them as initial values.
  453. func saveISFValues() {
  454. let sensitivities = isfItems.map { item in
  455. let time = timeFormatter.string(from: Date(timeIntervalSince1970: isfTimeValues[item.timeIndex]))
  456. let offset = Int(isfTimeValues[item.timeIndex] / 60)
  457. let value = isfRateValues[item.rateIndex]
  458. return InsulinSensitivityEntry(sensitivity: value, offset: offset, start: time)
  459. }
  460. let profile = InsulinSensitivities(units: .mgdL, userPreferredUnits: .mgdL, sensitivities: sensitivities)
  461. fileStorage.save(profile, as: OpenAPS.Settings.insulinSensitivities)
  462. initialISFItems = isfItems
  463. }
  464. /// Saves the glucose target items to file storage and sets them as initial values.
  465. func saveTargets() {
  466. let targets = targetItems.map { item in
  467. let time = timeFormatter.string(from: Date(timeIntervalSince1970: targetTimeValues[item.timeIndex]))
  468. let offset = Int(targetTimeValues[item.timeIndex] / 60)
  469. let value = targetRateValues[item.lowIndex]
  470. return BGTargetEntry(low: value, high: value, start: time, offset: offset)
  471. }
  472. let profile = BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: targets)
  473. fileStorage.save(profile, as: OpenAPS.Settings.bgTargets)
  474. initialTargetItems = targetItems
  475. }
  476. /// Persists all onboarding data by applying settings and saving therapy values.
  477. func saveOnboardingData() {
  478. applyDiagnostics()
  479. applyToSettings()
  480. applyToPreferences()
  481. applyToPumpSettings()
  482. saveTargets()
  483. saveBasalProfile()
  484. saveCarbRatios()
  485. saveISFValues()
  486. }
  487. /// Persists the current diagnostics sharing option to UserDefaults as a boolean.
  488. func applyDiagnostics() {
  489. let booleanValue: Bool = diagnosticsSharingOption == .enabled
  490. UserDefaults.standard.set(booleanValue, forKey: "DiagnosticsSharing")
  491. Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(booleanValue)
  492. }
  493. /// Applies the selected glucose units to the app's settings.
  494. func applyToSettings() {
  495. var settingsCopy = settingsManager.settings
  496. settingsCopy.units = units
  497. settingsManager.settings = settingsCopy
  498. }
  499. /// Applies the selected delivery preferences to the app's settings.
  500. func applyToPreferences() {
  501. var preferences = Preferences()
  502. // delivery limits (those that are preference-bound, not pump-settings-bound
  503. preferences.maxIOB = maxIOB
  504. preferences.maxCOB = maxCOB
  505. preferences.threshold_setting = minimumSafetyThreshold
  506. // autosens
  507. preferences.autosensMin = autosensMin
  508. preferences.autosensMax = autosensMax
  509. preferences.rewindResetsAutosens = rewindResetsAutosens
  510. // smb settings
  511. preferences.enableSMBAlways = enableSMBAlways
  512. preferences.enableSMBWithCOB = enableSMBWithCOB
  513. preferences.enableSMBWithTemptarget = enableSMBWithTempTarget
  514. preferences.enableSMBAfterCarbs = enableSMBAfterCarbs
  515. preferences.enableSMB_high_bg = enableSMBWithHighGlucoseTarget
  516. preferences.enableSMB_high_bg_target = highGlucoseTarget
  517. preferences.allowSMBWithHighTemptarget = allowSMBWithHighTempTarget
  518. preferences.enableUAM = enableUAM
  519. preferences.maxSMBBasalMinutes = maxSMBMinutes
  520. preferences.maxUAMSMBBasalMinutes = maxUAMMinutes
  521. preferences.maxDeltaBGthreshold = maxDeltaGlucoseThreshold
  522. // target behavior
  523. preferences.highTemptargetRaisesSensitivity = highTempTargetRaisesSensitivity
  524. preferences.lowTemptargetLowersSensitivity = lowTempTargetLowersSensitivity
  525. preferences.sensitivityRaisesTarget = sensitivityRaisesTarget
  526. preferences.resistanceLowersTarget = resistanceLowersTarget
  527. preferences.halfBasalExerciseTarget = halfBasalTarget
  528. settingsManager.preferences = preferences
  529. }
  530. /// Saves pump delivery limits to persistent storage and broadcasts changes.
  531. func applyToPumpSettings() {
  532. let defaultDIA = settingsProvider.settings.dia.value
  533. let pumpSettings = PumpSettings(insulinActionCurve: defaultDIA, maxBolus: maxBolus, maxBasal: maxBasal)
  534. fileStorage.save(pumpSettings, as: OpenAPS.Settings.settings)
  535. }
  536. }
  537. }
  538. // MARK: - Protocol (optional) to unify type mapping
  539. protocol TherapyItemConvertible {
  540. var rateIndex: Int { get }
  541. var timeIndex: Int { get }
  542. }
  543. extension ISFEditor.Item: TherapyItemConvertible {}
  544. extension CarbRatioEditor.Item: TherapyItemConvertible {}
  545. extension BasalProfileEditor.Item: TherapyItemConvertible {}