OnboardingStateModel.swift 39 KB

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