OnboardingStateModel.swift 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  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. // Nightscout Setup
  16. var nightscoutSetupOption: NightscoutSetupOption = .noSelection
  17. var nightscoutImportOption: NightscoutImportOption = .noSelection
  18. var url = ""
  19. var secret = ""
  20. var message = ""
  21. var isValidURL: Bool = false
  22. var connecting: Bool = false
  23. var isConnectedToNS: Bool = false
  24. var nightscoutImportErrors: [String] = []
  25. var nightscoutImportStatus: ImportStatus = .finished
  26. // Carb Ratio related
  27. var carbRatioItems: [CarbRatioEditor.Item] = []
  28. var initialCarbRatioItems: [CarbRatioEditor.Item] = []
  29. let carbRatioTimeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }
  30. .sorted { $0 < $1 }
  31. let carbRatioRateValues = stride(from: 30.0, to: 501.0, by: 1.0).map { ($0.decimal ?? .zero) / 10 }
  32. // Basal Profile related
  33. var initialBasalProfileItems: [BasalProfileEditor.Item] = []
  34. var basalProfileItems: [BasalProfileEditor.Item] = []
  35. let basalProfileTimeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }
  36. .sorted { $0 < $1 }
  37. var basalProfileRateValues: [Decimal] {
  38. switch pumpModel {
  39. case .dana,
  40. .minimed:
  41. return stride(from: 0.1, to: 30.0, by: 0.1).map { Decimal($0) }
  42. case .omnipodDash,
  43. .omnipodEros:
  44. return stride(from: 0.05, to: 30.0, by: 0.05).map { Decimal($0) }
  45. }
  46. }
  47. // ISF related
  48. var isfItems: [ISFEditor.Item] = []
  49. var initialISFItems: [ISFEditor.Item] = []
  50. let isfTimeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }.sorted { $0 < $1 }
  51. var isfRateValues: [Decimal] {
  52. var values = stride(from: 9, to: 540.01, by: 1.0).map { Decimal($0) }
  53. if units == .mmolL {
  54. values = values.filter { Int(truncating: $0 as NSNumber) % 2 == 0 }
  55. }
  56. return values
  57. }
  58. // Target related
  59. var targetItems: [TargetsEditor.Item] = []
  60. var initialTargetItems: [TargetsEditor.Item] = []
  61. let targetTimeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }
  62. .sorted { $0 < $1 }
  63. var targetRateValues: [Decimal] {
  64. let glucoseSetting = PickerSetting(value: 0, step: 1, min: 72, max: 180, type: .glucose)
  65. return settingsProvider.generatePickerValues(from: glucoseSetting, units: units)
  66. }
  67. // Basal Profile
  68. var basalRates: [BasalRateEntry] = [BasalRateEntry(startTime: 0, rate: 1.0)]
  69. // Carb Ratio
  70. var carbRatio: Decimal = 10
  71. // Insulin Sensitivity Factor
  72. var isf: Decimal = 40
  73. // Blood Glucose Units
  74. var units: GlucoseUnits = .mgdL
  75. var pumpModel: PumpOptionsForOnboardingUnits = .omnipodDash
  76. var maxBolus: Decimal = 10
  77. var maxBasal: Decimal = 2
  78. var maxIOB: Decimal = 0
  79. var maxCOB: Decimal = 120
  80. struct BasalRateEntry: Identifiable {
  81. var id = UUID()
  82. var startTime: Int // Minutes from midnight
  83. var rate: Decimal
  84. var timeFormatted: String {
  85. let hours = startTime / 60
  86. let minutes = startTime % 60
  87. return String(format: "%02d:%02d", hours, minutes)
  88. }
  89. }
  90. override func subscribe() {
  91. // TODO: why are we immediately storing to settings?
  92. // saveOnboardingData()
  93. }
  94. func saveOnboardingData() {
  95. applyToSettings()
  96. applyToPreferences()
  97. applyToPumpSettings()
  98. }
  99. /// Applies the onboarding data to the app's settings.
  100. func applyToSettings() {
  101. // Make a copy of the current settings that we can mutate
  102. var settingsCopy = settingsManager.settings
  103. settingsCopy.units = units
  104. // Store therapy settings
  105. saveTargets()
  106. saveBasalProfile()
  107. saveCarbRatios()
  108. saveISFValues()
  109. // We'll directly set the settings property which will trigger the didSet observer
  110. settingsManager.settings = settingsCopy
  111. }
  112. func applyToPreferences() {
  113. var preferencesCopy = settingsManager.preferences
  114. preferencesCopy.maxIOB = maxIOB
  115. preferencesCopy.maxCOB = maxCOB
  116. // We'll directly set the preferences property which will trigger the didSet observer
  117. settingsManager.preferences = preferencesCopy
  118. }
  119. func applyToPumpSettings() {
  120. let defaultDIA = settingsProvider.settings.insulinPeakTime.value
  121. let pumpSettings = PumpSettings(insulinActionCurve: defaultDIA, maxBolus: maxBolus, maxBasal: maxBasal)
  122. fileStorage.save(pumpSettings, as: OpenAPS.Settings.settings)
  123. // TODO: is this actually necessary at this point? Nothing is set up yet, nothing is subscribed to this observer...
  124. DispatchQueue.main.async {
  125. self.broadcaster.notify(PumpSettingsObserver.self, on: DispatchQueue.main) {
  126. $0.pumpSettingsDidChange(pumpSettings)
  127. }
  128. }
  129. }
  130. // TODO: clean up these function and unify them
  131. func getTargetTherapyItems(from targets: [TargetsEditor.Item]) -> [TherapySettingItem] {
  132. targets.map {
  133. TherapySettingItem(
  134. id: UUID(),
  135. time: targetTimeValues[$0.timeIndex],
  136. value: Double(targetRateValues[$0.lowIndex])
  137. )
  138. }.sorted { $0.time < $1.time }
  139. }
  140. func updateTargets(from therapyItems: [TherapySettingItem]) {
  141. targetItems = therapyItems.map { item in
  142. let timeIndex = targetTimeValues.firstIndex(where: { $0 == item.time }) ?? 0
  143. let closestTargetIndex = targetRateValues.firstIndex(of: Decimal(item.value)) ?? 0
  144. return TargetsEditor.Item(lowIndex: closestTargetIndex, highIndex: closestTargetIndex, timeIndex: timeIndex)
  145. }.sorted { $0.timeIndex < $1.timeIndex }
  146. }
  147. func getBasalTherapyItems(from basalRates: [BasalProfileEditor.Item]) -> [TherapySettingItem] {
  148. basalRates.map {
  149. TherapySettingItem(
  150. id: UUID(),
  151. time: basalProfileTimeValues[$0.timeIndex],
  152. value: Double(basalProfileRateValues[$0.rateIndex])
  153. )
  154. }.sorted { $0.time < $1.time }
  155. }
  156. func updateBasalRates(from therapyItems: [TherapySettingItem]) {
  157. basalProfileItems = therapyItems.map { item in
  158. let timeIndex = basalProfileTimeValues.firstIndex(where: { $0 == item.time }) ?? 0
  159. let closestRateIndex = basalProfileRateValues.firstIndex(of: Decimal(item.value)) ?? 0
  160. return BasalProfileEditor.Item(rateIndex: closestRateIndex, timeIndex: timeIndex)
  161. }.sorted { $0.timeIndex < $1.timeIndex }
  162. }
  163. func getCarbRatioTherapyItems(from carbRatios: [CarbRatioEditor.Item]) -> [TherapySettingItem] {
  164. carbRatios.map {
  165. TherapySettingItem(
  166. id: UUID(),
  167. time: carbRatioTimeValues[$0.timeIndex],
  168. value: Double(carbRatioRateValues[$0.rateIndex])
  169. )
  170. }.sorted { $0.time < $1.time }
  171. }
  172. func updateCarbRatios(from therapyItems: [TherapySettingItem]) {
  173. carbRatioItems = therapyItems.map { item in
  174. let timeIndex = carbRatioTimeValues.firstIndex(where: { $0 == item.time }) ?? 0
  175. let closestRateIndex = carbRatioRateValues.firstIndex(of: Decimal(item.value)) ?? 0
  176. return CarbRatioEditor.Item(rateIndex: closestRateIndex, timeIndex: timeIndex)
  177. }.sorted { $0.timeIndex < $1.timeIndex }
  178. }
  179. func getSensitivityTherapyItems(from sensitivities: [ISFEditor.Item]) -> [TherapySettingItem] {
  180. sensitivities.map {
  181. TherapySettingItem(
  182. id: UUID(),
  183. time: isfTimeValues[$0.timeIndex],
  184. value: Double(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: Decimal(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. var carbRatiosHaveChanges: Bool {
  201. if initialCarbRatioItems.count != carbRatioItems.count {
  202. return true
  203. }
  204. for (initialItem, currentItem) in zip(initialCarbRatioItems, carbRatioItems) {
  205. if initialItem.rateIndex != currentItem.rateIndex || initialItem.timeIndex != currentItem.timeIndex {
  206. return true
  207. }
  208. }
  209. return false
  210. }
  211. func saveCarbRatios() {
  212. guard carbRatiosHaveChanges else { return }
  213. let schedule = carbRatioItems.enumerated().map { _, item -> CarbRatioEntry in
  214. let fotmatter = DateFormatter()
  215. fotmatter.timeZone = TimeZone(secondsFromGMT: 0)
  216. fotmatter.dateFormat = "HH:mm:ss"
  217. let date = Date(timeIntervalSince1970: self.carbRatioTimeValues[item.timeIndex])
  218. let minutes = Int(date.timeIntervalSince1970 / 60)
  219. let rate = self.carbRatioRateValues[item.rateIndex]
  220. return CarbRatioEntry(start: fotmatter.string(from: date), offset: minutes, ratio: rate)
  221. }
  222. let profile = CarbRatios(units: .grams, schedule: schedule)
  223. fileStorage.save(profile, as: OpenAPS.Settings.carbRatios)
  224. initialCarbRatioItems = carbRatioItems.map { CarbRatioEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
  225. }
  226. func validateCarbRatios() {
  227. DispatchQueue.main.async {
  228. let uniq = Array(Set(self.carbRatioItems))
  229. let sorted = uniq.sorted { $0.timeIndex < $1.timeIndex }
  230. sorted.first?.timeIndex = 0
  231. if self.carbRatioItems != sorted {
  232. self.carbRatioItems = sorted
  233. }
  234. }
  235. }
  236. }
  237. // MARK: - Setup glucose targets
  238. extension Onboarding.StateModel {
  239. var targetsHaveChanged: Bool {
  240. initialTargetItems != targetItems
  241. }
  242. func saveTargets() {
  243. guard targetsHaveChanged else { return }
  244. let targets = targetItems.map { item -> BGTargetEntry in
  245. let formatter = DateFormatter()
  246. formatter.timeZone = TimeZone(secondsFromGMT: 0)
  247. formatter.dateFormat = "HH:mm:ss"
  248. let date = Date(timeIntervalSince1970: self.targetTimeValues[item.timeIndex])
  249. let minutes = Int(date.timeIntervalSince1970 / 60)
  250. let low = self.isfRateValues[item.lowIndex]
  251. let high = low
  252. return BGTargetEntry(low: low, high: high, start: formatter.string(from: date), offset: minutes)
  253. }
  254. let profile = BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: targets)
  255. fileStorage.save(profile, as: OpenAPS.Settings.bgTargets)
  256. initialTargetItems = targetItems
  257. .map { TargetsEditor.Item(lowIndex: $0.lowIndex, highIndex: $0.highIndex, timeIndex: $0.timeIndex) }
  258. }
  259. func validateTarget() {
  260. DispatchQueue.main.async {
  261. let uniq = Array(Set(self.targetItems))
  262. let sorted = uniq.sorted { $0.timeIndex < $1.timeIndex }
  263. sorted.first?.timeIndex = 0
  264. if self.targetItems != sorted {
  265. self.targetItems = sorted
  266. }
  267. }
  268. }
  269. }
  270. // MARK: - Setup ISF values
  271. extension Onboarding.StateModel {
  272. var isfValuesHaveChanges: Bool {
  273. initialISFItems != isfItems
  274. }
  275. func saveISFValues() {
  276. guard isfValuesHaveChanges else { return }
  277. let sensitivities = isfItems.map { item -> InsulinSensitivityEntry in
  278. let fotmatter = DateFormatter()
  279. fotmatter.timeZone = TimeZone(secondsFromGMT: 0)
  280. fotmatter.dateFormat = "HH:mm:ss"
  281. let date = Date(timeIntervalSince1970: self.isfTimeValues[item.timeIndex])
  282. let minutes = Int(date.timeIntervalSince1970 / 60)
  283. let rate = self.isfRateValues[item.rateIndex]
  284. return InsulinSensitivityEntry(sensitivity: rate, offset: minutes, start: fotmatter.string(from: date))
  285. }
  286. let profile = InsulinSensitivities(
  287. units: .mgdL,
  288. userPreferredUnits: .mgdL,
  289. sensitivities: sensitivities
  290. )
  291. fileStorage.save(profile, as: OpenAPS.Settings.insulinSensitivities)
  292. initialISFItems = isfItems.map { ISFEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
  293. }
  294. func validateISF() {
  295. DispatchQueue.main.async {
  296. let uniq = Array(Set(self.isfItems))
  297. let sorted = uniq.sorted { $0.timeIndex < $1.timeIndex }
  298. sorted.first?.timeIndex = 0
  299. if self.isfItems != sorted {
  300. self.isfItems = sorted
  301. }
  302. }
  303. }
  304. }
  305. // MARK: - Setup Basal Profile
  306. extension Onboarding.StateModel {
  307. var hasBasalProfileChanges: Bool {
  308. if initialBasalProfileItems.count != basalProfileItems.count {
  309. return true
  310. }
  311. for (initialItem, currentItem) in zip(initialBasalProfileItems, basalProfileItems) {
  312. if initialItem.rateIndex != currentItem.rateIndex || initialItem.timeIndex != currentItem.timeIndex {
  313. return true
  314. }
  315. }
  316. return false
  317. }
  318. func saveBasalProfile() {
  319. let profile = basalProfileItems.map { item -> BasalProfileEntry in
  320. let formatter = DateFormatter()
  321. formatter.timeZone = TimeZone(secondsFromGMT: 0)
  322. formatter.dateFormat = "HH:mm:ss"
  323. let date = Date(timeIntervalSince1970: self.basalProfileTimeValues[item.timeIndex])
  324. let minutes = Int(date.timeIntervalSince1970 / 60)
  325. let rate = self.basalProfileRateValues[item.rateIndex]
  326. return BasalProfileEntry(start: formatter.string(from: date), minutes: minutes, rate: rate)
  327. }
  328. fileStorage.save(profile, as: OpenAPS.Settings.basalProfile)
  329. }
  330. func validateBasal() {
  331. DispatchQueue.main.async {
  332. let uniq = Array(Set(self.basalProfileItems))
  333. let sorted = uniq.sorted { $0.timeIndex < $1.timeIndex }
  334. if let first = sorted.first, first.timeIndex != 0 {
  335. sorted[0].timeIndex = 0
  336. }
  337. if self.basalProfileItems != sorted {
  338. self.basalProfileItems = sorted
  339. }
  340. }
  341. }
  342. }