OnboardingStateModel.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. import Combine
  2. import Foundation
  3. import LoopKit
  4. import Observation
  5. import SwiftUI
  6. /// Model that holds the data collected during onboarding.
  7. extension Onboarding {
  8. @Observable final class StateModel: BaseStateModel<Provider> {
  9. @ObservationIgnored @Injected() var fileStorage: FileStorage!
  10. @ObservationIgnored @Injected() var deviceManager: DeviceDataManager!
  11. @ObservationIgnored @Injected() var broadcaster: Broadcaster!
  12. @ObservationIgnored @Injected() var keychain: Keychain!
  13. @ObservationIgnored @Injected() var nightscoutManager: NightscoutManager!
  14. private let settingsProvider = PickerSettingsProvider.shared
  15. // App diagnostics sharing
  16. var diagnostisSharingOption: DiagnostisSharingOption = .enabled
  17. // Nightscout Setup
  18. var nightscoutSetupOption: NightscoutSetupOption = .noSelection
  19. var nightscoutImportOption: NightscoutImportOption = .noSelection
  20. var url = ""
  21. var secret = ""
  22. var message = ""
  23. var isValidURL: Bool = false
  24. var connecting: Bool = false
  25. var isConnectedToNS: Bool = false
  26. var nightscoutImportErrors: [String] = []
  27. var nightscoutImportStatus: ImportStatus = .finished
  28. // Carb Ratio related
  29. let carbRatioPickerSetting = PickerSetting(value: 3, step: 0.1, min: 3, max: 50, type: .gram)
  30. var carbRatioItems: [CarbRatioEditor.Item] = []
  31. var initialCarbRatioItems: [CarbRatioEditor.Item] = []
  32. let carbRatioTimeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }
  33. .sorted { $0 < $1 }
  34. var carbRatioRateValues: [Decimal] { settingsProvider.generatePickerValues(from: carbRatioPickerSetting, units: units) }
  35. // Basal Profile related
  36. var basalRatePickerSetting: PickerSetting {
  37. switch pumpModel {
  38. case .dana,
  39. .minimed:
  40. return PickerSetting(value: 0.1, step: 0.1, min: 0.1, max: 30, type: .insulinUnit)
  41. case .omnipodDash,
  42. .omnipodEros:
  43. return PickerSetting(value: 0.5, step: 0.05, min: 0.5, max: 30, type: .insulinUnit)
  44. }
  45. }
  46. var initialBasalProfileItems: [BasalProfileEditor.Item] = []
  47. var basalProfileItems: [BasalProfileEditor.Item] = []
  48. let basalProfileTimeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }
  49. .sorted { $0 < $1 }
  50. var basalProfileRateValues: [Decimal] {
  51. switch pumpModel {
  52. case .dana,
  53. .minimed:
  54. return settingsProvider.generatePickerValues(from: basalRatePickerSetting, units: units)
  55. case .omnipodDash,
  56. .omnipodEros:
  57. return settingsProvider.generatePickerValues(from: basalRatePickerSetting, units: units)
  58. }
  59. }
  60. // ISF related
  61. var sensitivityPickerSetting = PickerSetting(value: 100, step: 1, min: 9, max: 540, type: .glucose)
  62. var isfItems: [ISFEditor.Item] = []
  63. var initialISFItems: [ISFEditor.Item] = []
  64. let isfTimeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }.sorted { $0 < $1 }
  65. var isfRateValues: [Decimal] { settingsProvider.generatePickerValues(from: sensitivityPickerSetting, units: units) }
  66. // Target related
  67. let letTargetPickerSetting = PickerSetting(value: 100, step: 1, min: 72, max: 180, type: .glucose)
  68. var targetItems: [TargetsEditor.Item] = []
  69. var initialTargetItems: [TargetsEditor.Item] = []
  70. let targetTimeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }
  71. .sorted { $0 < $1 }
  72. var targetRateValues: [Decimal] { settingsProvider.generatePickerValues(from: letTargetPickerSetting, units: units) }
  73. // Basal Profile
  74. var basalRates: [BasalRateEntry] = [BasalRateEntry(startTime: 0, rate: 1.0)]
  75. // Carb Ratio
  76. var carbRatio: Decimal = 10
  77. // Insulin Sensitivity Factor
  78. var isf: Decimal = 40
  79. // Blood Glucose Units
  80. var units: GlucoseUnits = .mgdL
  81. var pumpModel: PumpOptionsForOnboardingUnits = .omnipodDash
  82. // Delivery Limit defaults
  83. var maxBolus: Decimal = 10
  84. var maxBasal: Decimal = 2
  85. var maxIOB: Decimal = 0
  86. var maxCOB: Decimal = 120
  87. struct BasalRateEntry: Identifiable {
  88. var id = UUID()
  89. var startTime: Int // Minutes from midnight
  90. var rate: Decimal
  91. var timeFormatted: String {
  92. let hours = startTime / 60
  93. let minutes = startTime % 60
  94. return String(format: "%02d:%02d", hours, minutes)
  95. }
  96. }
  97. override func subscribe() {}
  98. func saveOnboardingData() {
  99. applyToSettings()
  100. applyToPreferences()
  101. applyToPumpSettings()
  102. // Store therapy settings on file
  103. saveTargets()
  104. saveBasalProfile()
  105. saveCarbRatios()
  106. saveISFValues()
  107. }
  108. /// Applies the onboarding data to the app's settings.
  109. func applyToSettings() {
  110. // Make a copy of the current settings that we can mutate
  111. var settingsCopy = settingsManager.settings
  112. settingsCopy.units = units
  113. // We'll directly set the settings property which will trigger the didSet observer
  114. settingsManager.settings = settingsCopy
  115. }
  116. func applyToPreferences() {
  117. var preferencesCopy = settingsManager.preferences
  118. preferencesCopy.maxIOB = maxIOB
  119. preferencesCopy.maxCOB = maxCOB
  120. // We'll directly set the preferences property which will trigger the didSet observer
  121. settingsManager.preferences = preferencesCopy
  122. }
  123. func applyToPumpSettings() {
  124. let defaultDIA = settingsProvider.settings.insulinPeakTime.value
  125. let pumpSettings = PumpSettings(insulinActionCurve: defaultDIA, maxBolus: maxBolus, maxBasal: maxBasal)
  126. fileStorage.save(pumpSettings, as: OpenAPS.Settings.settings)
  127. // TODO: is this actually necessary at this point? Nothing is set up yet, nothing is subscribed to this observer...
  128. DispatchQueue.main.async {
  129. self.broadcaster.notify(PumpSettingsObserver.self, on: DispatchQueue.main) {
  130. $0.pumpSettingsDidChange(pumpSettings)
  131. }
  132. }
  133. }
  134. // TODO: clean up these function and unify them
  135. func getTargetTherapyItems(from targets: [TargetsEditor.Item]) -> [TherapySettingItem] {
  136. targets.map {
  137. TherapySettingItem(
  138. time: targetTimeValues[$0.timeIndex],
  139. value: targetRateValues[$0.lowIndex]
  140. )
  141. }.sorted { $0.time < $1.time }
  142. }
  143. func updateTargets(from therapyItems: [TherapySettingItem]) {
  144. targetItems = therapyItems.map { item in
  145. let timeIndex = targetTimeValues.firstIndex(where: { $0 == item.time }) ?? 0
  146. let closestTargetIndex = targetRateValues.firstIndex(of: item.value) ?? 0
  147. return TargetsEditor.Item(lowIndex: closestTargetIndex, highIndex: closestTargetIndex, timeIndex: timeIndex)
  148. }.sorted { $0.timeIndex < $1.timeIndex }
  149. }
  150. func getBasalTherapyItems(from basalRates: [BasalProfileEditor.Item]) -> [TherapySettingItem] {
  151. basalRates.map {
  152. TherapySettingItem(
  153. time: basalProfileTimeValues[$0.timeIndex],
  154. value: basalProfileRateValues[$0.rateIndex]
  155. )
  156. }.sorted { $0.time < $1.time }
  157. }
  158. func updateBasalRates(from therapyItems: [TherapySettingItem]) {
  159. basalProfileItems = therapyItems.map { item in
  160. let timeIndex = basalProfileTimeValues.firstIndex(where: { $0 == item.time }) ?? 0
  161. let closestRateIndex = basalProfileRateValues.firstIndex(of: item.value) ?? 0
  162. return BasalProfileEditor.Item(rateIndex: closestRateIndex, timeIndex: timeIndex)
  163. }.sorted { $0.timeIndex < $1.timeIndex }
  164. }
  165. func getCarbRatioTherapyItems(from carbRatios: [CarbRatioEditor.Item]) -> [TherapySettingItem] {
  166. carbRatios.map {
  167. TherapySettingItem(
  168. time: carbRatioTimeValues[$0.timeIndex],
  169. value: carbRatioRateValues[$0.rateIndex]
  170. )
  171. }.sorted { $0.time < $1.time }
  172. }
  173. func updateCarbRatios(from therapyItems: [TherapySettingItem]) {
  174. carbRatioItems = therapyItems.map { item in
  175. let timeIndex = carbRatioTimeValues.firstIndex(where: { $0 == item.time }) ?? 0
  176. let closestRateIndex = carbRatioRateValues.firstIndex(of: item.value) ?? 0
  177. return CarbRatioEditor.Item(rateIndex: closestRateIndex, timeIndex: timeIndex)
  178. }.sorted { $0.timeIndex < $1.timeIndex }
  179. }
  180. func getSensitivityTherapyItems(from sensitivities: [ISFEditor.Item]) -> [TherapySettingItem] {
  181. sensitivities.map {
  182. TherapySettingItem(
  183. time: isfTimeValues[$0.timeIndex],
  184. value: isfRateValues[$0.rateIndex]
  185. )
  186. }.sorted { $0.time < $1.time }
  187. }
  188. func updateSensitivies(from therapyItems: [TherapySettingItem]) {
  189. isfItems = therapyItems.map { item in
  190. let timeIndex = isfTimeValues.firstIndex(where: { $0 == item.time }) ?? 0
  191. let closestRateIndex = isfRateValues.firstIndex(of: item.value) ?? 0
  192. return ISFEditor.Item(rateIndex: closestRateIndex, timeIndex: timeIndex)
  193. }.sorted { $0.timeIndex < $1.timeIndex }
  194. }
  195. // TODO: add update handler for all therapy items to automatically fill in time gaps and ensure schedule always starts at 00:00 and ends at 23:30
  196. }
  197. }
  198. // MARK: - Setup Carb Ratios
  199. extension Onboarding.StateModel {
  200. func saveCarbRatios() {
  201. let schedule = carbRatioItems.enumerated().map { _, item -> CarbRatioEntry in
  202. let fotmatter = DateFormatter()
  203. fotmatter.timeZone = TimeZone(secondsFromGMT: 0)
  204. fotmatter.dateFormat = "HH:mm:ss"
  205. let date = Date(timeIntervalSince1970: self.carbRatioTimeValues[item.timeIndex])
  206. let minutes = Int(date.timeIntervalSince1970 / 60)
  207. let rate = self.carbRatioRateValues[item.rateIndex]
  208. return CarbRatioEntry(start: fotmatter.string(from: date), offset: minutes, ratio: rate)
  209. }
  210. let profile = CarbRatios(units: .grams, schedule: schedule)
  211. fileStorage.save(profile, as: OpenAPS.Settings.carbRatios)
  212. initialCarbRatioItems = carbRatioItems.map { CarbRatioEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
  213. }
  214. func validateCarbRatios() {
  215. let uniq = Array(Set(carbRatioItems))
  216. let sorted = uniq.sorted { $0.timeIndex < $1.timeIndex }
  217. sorted.first?.timeIndex = 0
  218. if carbRatioItems != sorted {
  219. carbRatioItems = sorted
  220. }
  221. }
  222. }
  223. // MARK: - Setup glucose targets
  224. extension Onboarding.StateModel {
  225. func saveTargets() {
  226. let targets = targetItems.map { item -> BGTargetEntry in
  227. let formatter = DateFormatter()
  228. formatter.timeZone = TimeZone(secondsFromGMT: 0)
  229. formatter.dateFormat = "HH:mm:ss"
  230. let date = Date(timeIntervalSince1970: self.targetTimeValues[item.timeIndex])
  231. let minutes = Int(date.timeIntervalSince1970 / 60)
  232. let low = self.targetRateValues[item.lowIndex]
  233. let high = low
  234. return BGTargetEntry(low: low, high: high, start: formatter.string(from: date), offset: minutes)
  235. }
  236. let profile = BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: targets)
  237. fileStorage.save(profile, as: OpenAPS.Settings.bgTargets)
  238. initialTargetItems = targetItems
  239. .map { TargetsEditor.Item(lowIndex: $0.lowIndex, highIndex: $0.highIndex, timeIndex: $0.timeIndex) }
  240. }
  241. func validateTarget() {
  242. let uniq = Array(Set(targetItems))
  243. let sorted = uniq.sorted { $0.timeIndex < $1.timeIndex }
  244. sorted.first?.timeIndex = 0
  245. if targetItems != sorted {
  246. targetItems = sorted
  247. }
  248. }
  249. }
  250. // MARK: - Setup ISF values
  251. extension Onboarding.StateModel {
  252. func saveISFValues() {
  253. let sensitivities = isfItems.map { item -> InsulinSensitivityEntry in
  254. let fotmatter = DateFormatter()
  255. fotmatter.timeZone = TimeZone(secondsFromGMT: 0)
  256. fotmatter.dateFormat = "HH:mm:ss"
  257. let date = Date(timeIntervalSince1970: self.isfTimeValues[item.timeIndex])
  258. let minutes = Int(date.timeIntervalSince1970 / 60)
  259. let rate = self.isfRateValues[item.rateIndex]
  260. return InsulinSensitivityEntry(sensitivity: rate, offset: minutes, start: fotmatter.string(from: date))
  261. }
  262. let profile = InsulinSensitivities(
  263. units: .mgdL,
  264. userPreferredUnits: .mgdL,
  265. sensitivities: sensitivities
  266. )
  267. fileStorage.save(profile, as: OpenAPS.Settings.insulinSensitivities)
  268. initialISFItems = isfItems.map { ISFEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
  269. }
  270. func validateISF() {
  271. let uniq = Array(Set(isfItems))
  272. let sorted = uniq.sorted { $0.timeIndex < $1.timeIndex }
  273. sorted.first?.timeIndex = 0
  274. if isfItems != sorted {
  275. isfItems = sorted
  276. }
  277. }
  278. }
  279. // MARK: - Setup Basal Profile
  280. extension Onboarding.StateModel {
  281. func saveBasalProfile() {
  282. let profile = basalProfileItems.map { item -> BasalProfileEntry in
  283. let formatter = DateFormatter()
  284. formatter.timeZone = TimeZone(secondsFromGMT: 0)
  285. formatter.dateFormat = "HH:mm:ss"
  286. let date = Date(timeIntervalSince1970: self.basalProfileTimeValues[item.timeIndex])
  287. let minutes = Int(date.timeIntervalSince1970 / 60)
  288. let rate = self.basalProfileRateValues[item.rateIndex]
  289. return BasalProfileEntry(start: formatter.string(from: date), minutes: minutes, rate: rate)
  290. }
  291. fileStorage.save(profile, as: OpenAPS.Settings.basalProfile)
  292. initialBasalProfileItems = basalProfileItems
  293. .map { BasalProfileEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
  294. }
  295. func validateBasal() {
  296. let uniq = Array(Set(basalProfileItems))
  297. let sorted = uniq.sorted { $0.timeIndex < $1.timeIndex }
  298. if let first = sorted.first, first.timeIndex != 0 {
  299. sorted[0].timeIndex = 0
  300. }
  301. if basalProfileItems != sorted {
  302. basalProfileItems = sorted
  303. }
  304. }
  305. }