OnboardingStateModel.swift 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. import Combine
  2. import Foundation
  3. import LoopKit
  4. import Observation
  5. import SwiftUI
  6. /// Represents the different steps in the onboarding process.
  7. enum OnboardingStep: Int, CaseIterable, Identifiable {
  8. case welcome
  9. case glucoseTarget
  10. case basalProfile
  11. case carbRatio
  12. case insulinSensitivity
  13. case completed
  14. var id: Int { rawValue }
  15. /// The title to display for this onboarding step.
  16. var title: String {
  17. switch self {
  18. case .welcome:
  19. return "Welcome to Trio"
  20. case .glucoseTarget:
  21. return "Glucose Target"
  22. case .basalProfile:
  23. return "Basal Profile"
  24. case .carbRatio:
  25. return "Carbohydrate Ratio"
  26. case .insulinSensitivity:
  27. return "Insulin Sensitivity"
  28. case .completed:
  29. return "All Set!"
  30. }
  31. }
  32. /// A detailed description of what this onboarding step is about.
  33. var description: String {
  34. switch self {
  35. case .welcome:
  36. return "Trio is a powerful app that helps you manage your diabetes. Let's get started by setting up a few important parameters that will help Trio work effectively for you."
  37. case .glucoseTarget:
  38. return "Your glucose target is the blood glucose level you aim to maintain. Trio will use this to calculate insulin doses and provide recommendations."
  39. case .basalProfile:
  40. return "Your basal profile represents the amount of background insulin you need throughout the day. This helps Trio calculate your insulin needs."
  41. case .carbRatio:
  42. return "Your carb ratio tells how many grams of carbohydrates one unit of insulin will cover. This is essential for accurate meal bolus calculations."
  43. case .insulinSensitivity:
  44. return "Your insulin sensitivity factor (ISF) indicates how much one unit of insulin will lower your blood glucose. This helps calculate correction boluses."
  45. case .completed:
  46. return "Great job! You've completed the initial setup of Trio. You can always adjust these settings later in the app."
  47. }
  48. }
  49. /// The system icon name associated with this step.
  50. var iconName: String {
  51. switch self {
  52. case .welcome:
  53. return "hand.wave.fill"
  54. case .glucoseTarget:
  55. return "target"
  56. case .basalProfile:
  57. return "chart.xyaxis.line"
  58. case .carbRatio:
  59. return "fork.knife"
  60. case .insulinSensitivity:
  61. return "drop.fill"
  62. case .completed:
  63. return "checkmark.circle.fill"
  64. }
  65. }
  66. /// Returns the next step in the onboarding process, or nil if this is the last step.
  67. var next: OnboardingStep? {
  68. let allCases = OnboardingStep.allCases
  69. let currentIndex = allCases.firstIndex(of: self) ?? 0
  70. let nextIndex = currentIndex + 1
  71. return nextIndex < allCases.count ? allCases[nextIndex] : nil
  72. }
  73. /// Returns the previous step in the onboarding process, or nil if this is the first step.
  74. var previous: OnboardingStep? {
  75. let allCases = OnboardingStep.allCases
  76. let currentIndex = allCases.firstIndex(of: self) ?? 0
  77. let previousIndex = currentIndex - 1
  78. return previousIndex >= 0 ? allCases[previousIndex] : nil
  79. }
  80. /// The accent color to use for this step.
  81. var accentColor: Color {
  82. switch self {
  83. case .welcome:
  84. return Color.blue
  85. case .glucoseTarget:
  86. return Color.green
  87. case .basalProfile:
  88. return Color.purple
  89. case .carbRatio:
  90. return Color.orange
  91. case .insulinSensitivity:
  92. return Color.red
  93. case .completed:
  94. return Color.blue
  95. }
  96. }
  97. }
  98. /// Model that holds the data collected during onboarding.
  99. extension Onboarding {
  100. @Observable final class StateModel: BaseStateModel<Provider> {
  101. @ObservationIgnored @Injected() var storage: FileStorage!
  102. @ObservationIgnored @Injected() var deviceManager: DeviceDataManager!
  103. // Carb Ratio related
  104. var carbRatioItems: [CarbRatioEditor.Item] = []
  105. var initialCarbRatioItems: [CarbRatioEditor.Item] = []
  106. let carbRatioTimeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }
  107. let carbRatioRateValues = stride(from: 30.0, to: 501.0, by: 1.0).map { ($0.decimal ?? .zero) / 10 }
  108. // Basal Profile related
  109. var initialBasalProfileItems: [BasalProfileEditor.Item] = []
  110. var basalProfileItems: [BasalProfileEditor.Item] = []
  111. let basalProfileTimeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }
  112. var basalProfileRateValues: [Decimal] = stride(from: 0.05, to: 3.05, by: 0.05).map { Decimal($0) }
  113. // ISF related
  114. var isfItems: [ISFEditor.Item] = []
  115. var initialISFItems: [ISFEditor.Item] = []
  116. let isfTimeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }
  117. var isfRateValues: [Decimal] {
  118. var values = stride(from: 9, to: 540.01, by: 1.0).map { Decimal($0) }
  119. if units == .mmolL {
  120. values = values.filter { Int(truncating: $0 as NSNumber) % 2 == 0 }
  121. }
  122. return values
  123. }
  124. // Target related
  125. var targetItems: [TargetsEditor.Item] = []
  126. var initialTargetItems: [TargetsEditor.Item] = []
  127. let targetTimeValues = stride(from: 0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }
  128. var targetRateValues: [Decimal] {
  129. let settingsProvider = PickerSettingsProvider.shared
  130. let glucoseSetting = PickerSetting(value: 0, step: 1, min: 72, max: 180, type: .glucose)
  131. return settingsProvider.generatePickerValues(from: glucoseSetting, units: units)
  132. }
  133. // Basal Profile
  134. var basalRates: [BasalRateEntry] = [BasalRateEntry(startTime: 0, rate: 1.0)]
  135. // Carb Ratio
  136. var carbRatio: Decimal = 10
  137. // Insulin Sensitivity Factor
  138. var isf: Decimal = 40
  139. // Blood Glucose Units
  140. var units: GlucoseUnits = .mgdL
  141. struct BasalRateEntry: Identifiable {
  142. var id = UUID()
  143. var startTime: Int // Minutes from midnight
  144. var rate: Decimal
  145. var timeFormatted: String {
  146. let hours = startTime / 60
  147. let minutes = startTime % 60
  148. return String(format: "%02d:%02d", hours, minutes)
  149. }
  150. }
  151. override func subscribe() {
  152. applyToSettings()
  153. }
  154. /// Applies the onboarding data to the app's settings.
  155. func applyToSettings() {
  156. // Make a copy of the current settings that we can mutate
  157. var settingsCopy = settingsManager.settings
  158. // Apply glucose units
  159. settingsCopy.units = units
  160. // Apply targets
  161. saveTargets()
  162. // Apply basal profile
  163. // TODO: - should we use the return value or modify the function to not return anything?
  164. _ = saveBasalProfile()
  165. // Apply carb ratio
  166. saveCarbRatios()
  167. // Apply ISF values
  168. saveISFValues()
  169. // Instead of using updateSettings which doesn't exist,
  170. // we'll directly set the settings property which will trigger the didSet observer
  171. settingsManager.settings = settingsCopy
  172. }
  173. // TODO: clean up these function and unify them
  174. func getTargetTherapyItems(from targets: [TargetsEditor.Item]) -> [TherapySettingItem] {
  175. targets.map {
  176. TherapySettingItem(
  177. id: UUID(),
  178. time: targetTimeValues[$0.timeIndex],
  179. value: Double(targetRateValues[$0.lowIndex])
  180. )
  181. }
  182. }
  183. func updateTargets(from therapyItems: [TherapySettingItem]) {
  184. targetItems = therapyItems.map { item in
  185. let timeIndex = targetTimeValues.firstIndex(where: { $0 == item.time }) ?? 0
  186. let closestRate = targetRateValues.enumerated().min(by: {
  187. abs(Double($0.element) - item.value) < abs(Double($1.element) - item.value)
  188. })?.offset ?? 0
  189. return TargetsEditor.Item(lowIndex: closestRate, highIndex: closestRate, timeIndex: timeIndex)
  190. }
  191. }
  192. func getBasalTherapyItems(from basalRates: [BasalProfileEditor.Item]) -> [TherapySettingItem] {
  193. basalRates.map {
  194. TherapySettingItem(
  195. id: UUID(),
  196. time: basalProfileTimeValues[$0.timeIndex],
  197. value: Double(basalProfileRateValues[$0.rateIndex])
  198. )
  199. }
  200. }
  201. func updateBasalRates(from therapyItems: [TherapySettingItem]) {
  202. basalProfileItems = therapyItems.map { item in
  203. let timeIndex = basalProfileTimeValues.firstIndex(where: { $0 == item.time }) ?? 0
  204. let closestRate = basalProfileRateValues.enumerated().min(by: {
  205. abs(Double($0.element) - item.value) < abs(Double($1.element) - item.value)
  206. })?.offset ?? 0
  207. return BasalProfileEditor.Item(rateIndex: closestRate, timeIndex: timeIndex)
  208. }
  209. }
  210. func getCarbRatioTherapyItems(from carbRatios: [CarbRatioEditor.Item]) -> [TherapySettingItem] {
  211. carbRatios.map {
  212. TherapySettingItem(
  213. id: UUID(),
  214. time: carbRatioTimeValues[$0.timeIndex],
  215. value: Double(carbRatioRateValues[$0.rateIndex])
  216. )
  217. }
  218. }
  219. func updateCarbRatios(from therapyItems: [TherapySettingItem]) {
  220. carbRatioItems = therapyItems.map { item in
  221. let timeIndex = carbRatioTimeValues.firstIndex(where: { $0 == item.time }) ?? 0
  222. let closestRate = carbRatioRateValues.enumerated().min(by: {
  223. abs(Double($0.element) - item.value) < abs(Double($1.element) - item.value)
  224. })?.offset ?? 0
  225. return CarbRatioEditor.Item(rateIndex: closestRate, timeIndex: timeIndex)
  226. }
  227. }
  228. func getSensitivityTherapyItems(from sensitivities: [ISFEditor.Item]) -> [TherapySettingItem] {
  229. sensitivities.map {
  230. TherapySettingItem(
  231. id: UUID(),
  232. time: isfTimeValues[$0.timeIndex],
  233. value: Double(isfRateValues[$0.rateIndex])
  234. )
  235. }
  236. }
  237. func updateSensitivies(from therapyItems: [TherapySettingItem]) {
  238. isfItems = therapyItems.map { item in
  239. let timeIndex = isfTimeValues.firstIndex(where: { $0 == item.time }) ?? 0
  240. let closestRate = isfRateValues.enumerated().min(by: {
  241. abs(Double($0.element) - item.value) < abs(Double($1.element) - item.value)
  242. })?.offset ?? 0
  243. return ISFEditor.Item(rateIndex: closestRate, timeIndex: timeIndex)
  244. }
  245. }
  246. // 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
  247. }
  248. }
  249. // MARK: - Setup Carb Ratios
  250. extension Onboarding.StateModel {
  251. var carbRatiosHaveChanges: Bool {
  252. if initialCarbRatioItems.count != carbRatioItems.count {
  253. return true
  254. }
  255. for (initialItem, currentItem) in zip(initialCarbRatioItems, carbRatioItems) {
  256. if initialItem.rateIndex != currentItem.rateIndex || initialItem.timeIndex != currentItem.timeIndex {
  257. return true
  258. }
  259. }
  260. return false
  261. }
  262. func addCarbRatio() {
  263. var time = 0
  264. var rate = 0
  265. if let last = carbRatioItems.last {
  266. time = last.timeIndex + 1
  267. rate = last.rateIndex
  268. }
  269. let newItem = CarbRatioEditor.Item(rateIndex: rate, timeIndex: time)
  270. carbRatioItems.append(newItem)
  271. }
  272. func saveCarbRatios() {
  273. guard carbRatiosHaveChanges else { return }
  274. let schedule = carbRatioItems.enumerated().map { _, item -> CarbRatioEntry in
  275. let fotmatter = DateFormatter()
  276. fotmatter.timeZone = TimeZone(secondsFromGMT: 0)
  277. fotmatter.dateFormat = "HH:mm:ss"
  278. let date = Date(timeIntervalSince1970: self.carbRatioTimeValues[item.timeIndex])
  279. let minutes = Int(date.timeIntervalSince1970 / 60)
  280. let rate = self.carbRatioRateValues[item.rateIndex]
  281. return CarbRatioEntry(start: fotmatter.string(from: date), offset: minutes, ratio: rate)
  282. }
  283. let profile = CarbRatios(units: .grams, schedule: schedule)
  284. saveCarbRatioProfile(profile)
  285. initialCarbRatioItems = carbRatioItems.map { CarbRatioEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
  286. }
  287. // func validate() {
  288. // DispatchQueue.main.async {
  289. // let uniq = Array(Set(self.items))
  290. // let sorted = uniq.sorted { $0.timeIndex < $1.timeIndex }
  291. // sorted.first?.timeIndex = 0
  292. // if self.items != sorted {
  293. // self.items = sorted
  294. // }
  295. // }
  296. // }
  297. func saveCarbRatioProfile(_ profile: CarbRatios) {
  298. storage.save(profile, as: OpenAPS.Settings.carbRatios)
  299. }
  300. }
  301. // MARK: - Setup glucose targets
  302. extension Onboarding.StateModel {
  303. var targetsHaveChanged: Bool {
  304. initialTargetItems != targetItems
  305. }
  306. func addTarget() {
  307. var time = 0
  308. var low = 0
  309. var high = 0
  310. if let last = targetItems.last {
  311. time = last.timeIndex + 1
  312. low = last.lowIndex
  313. high = low
  314. }
  315. let newItem = TargetsEditor.Item(lowIndex: low, highIndex: high, timeIndex: time)
  316. targetItems.append(newItem)
  317. }
  318. func saveTargets() {
  319. guard targetsHaveChanged else { return }
  320. let targets = targetItems.map { item -> BGTargetEntry in
  321. let formatter = DateFormatter()
  322. formatter.timeZone = TimeZone(secondsFromGMT: 0)
  323. formatter.dateFormat = "HH:mm:ss"
  324. let date = Date(timeIntervalSince1970: self.targetTimeValues[item.timeIndex])
  325. let minutes = Int(date.timeIntervalSince1970 / 60)
  326. let low = self.isfRateValues[item.lowIndex]
  327. let high = low
  328. return BGTargetEntry(low: low, high: high, start: formatter.string(from: date), offset: minutes)
  329. }
  330. let profile = BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: targets)
  331. saveTargets(profile)
  332. initialTargetItems = targetItems
  333. .map { TargetsEditor.Item(lowIndex: $0.lowIndex, highIndex: $0.highIndex, timeIndex: $0.timeIndex) }
  334. }
  335. // func validateTarget() {
  336. // DispatchQueue.main.async {
  337. // let uniq = Array(Set(self.items))
  338. // let sorted = uniq.sorted { $0.timeIndex < $1.timeIndex }
  339. // .map { item -> Item in
  340. // Item(lowIndex: item.lowIndex, highIndex: item.highIndex, timeIndex: item.timeIndex)
  341. // }
  342. // sorted.first?.timeIndex = 0
  343. // self.items = sorted
  344. //
  345. // if self.items.isEmpty {
  346. // self.units = self.settingsManager.settings.units
  347. // }
  348. // }
  349. // }
  350. func saveTargets(_ profile: BGTargets) {
  351. storage.save(profile, as: OpenAPS.Settings.bgTargets)
  352. }
  353. }
  354. // MARK: - Setup ISF values
  355. extension Onboarding.StateModel {
  356. var isfValuesHaveChanges: Bool {
  357. initialISFItems != isfItems
  358. }
  359. func addISFValue() {
  360. var time = 0
  361. var rate = 0
  362. if let last = isfItems.last {
  363. time = last.timeIndex + 1
  364. rate = last.rateIndex
  365. }
  366. let newItem = ISFEditor.Item(rateIndex: rate, timeIndex: time)
  367. isfItems.append(newItem)
  368. }
  369. func saveISFValues() {
  370. guard isfValuesHaveChanges else { return }
  371. let sensitivities = isfItems.map { item -> InsulinSensitivityEntry in
  372. let fotmatter = DateFormatter()
  373. fotmatter.timeZone = TimeZone(secondsFromGMT: 0)
  374. fotmatter.dateFormat = "HH:mm:ss"
  375. let date = Date(timeIntervalSince1970: self.isfTimeValues[item.timeIndex])
  376. let minutes = Int(date.timeIntervalSince1970 / 60)
  377. let rate = self.isfRateValues[item.rateIndex]
  378. return InsulinSensitivityEntry(sensitivity: rate, offset: minutes, start: fotmatter.string(from: date))
  379. }
  380. let profile = InsulinSensitivities(
  381. units: .mgdL,
  382. userPreferredUnits: .mgdL,
  383. sensitivities: sensitivities
  384. )
  385. saveISFProfile(profile)
  386. initialISFItems = isfItems.map { ISFEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
  387. }
  388. // func validate() {
  389. // DispatchQueue.main.async {
  390. // DispatchQueue.main.async {
  391. // let uniq = Array(Set(self.items))
  392. // let sorted = uniq.sorted { $0.timeIndex < $1.timeIndex }
  393. // sorted.first?.timeIndex = 0
  394. // if self.items != sorted {
  395. // self.items = sorted
  396. // }
  397. // if self.items.isEmpty {
  398. // self.units = self.settingsManager.settings.units
  399. // }
  400. // }
  401. // }
  402. // }
  403. func saveISFProfile(_ profile: InsulinSensitivities) {
  404. storage.save(profile, as: OpenAPS.Settings.insulinSensitivities)
  405. }
  406. }
  407. // MARK: - Setup Basal Profile
  408. extension Onboarding.StateModel {
  409. var hasBasalProfileChanges: Bool {
  410. if initialBasalProfileItems.count != basalProfileItems.count {
  411. return true
  412. }
  413. for (initialItem, currentItem) in zip(initialBasalProfileItems, basalProfileItems) {
  414. if initialItem.rateIndex != currentItem.rateIndex || initialItem.timeIndex != currentItem.timeIndex {
  415. return true
  416. }
  417. }
  418. return false
  419. }
  420. func addBasalRate() {
  421. var time = 0
  422. var rate = 20 // Default to 1.0 U/h (index 20 if basalProfileRateValues starts at 0.05 and increments by 0.05)
  423. if let last = basalProfileItems.last {
  424. time = last.timeIndex + 1
  425. rate = last.rateIndex
  426. }
  427. let newItem = BasalProfileEditor.Item(rateIndex: rate, timeIndex: time)
  428. basalProfileItems.append(newItem)
  429. }
  430. func saveBasalProfile() -> AnyPublisher<Void, Error> {
  431. let profile = basalProfileItems.map { item -> BasalProfileEntry in
  432. let formatter = DateFormatter()
  433. formatter.timeZone = TimeZone(secondsFromGMT: 0)
  434. formatter.dateFormat = "HH:mm:ss"
  435. let date = Date(timeIntervalSince1970: self.basalProfileTimeValues[item.timeIndex])
  436. let minutes = Int(date.timeIntervalSince1970 / 60)
  437. let rate = self.basalProfileRateValues[item.rateIndex]
  438. return BasalProfileEntry(start: formatter.string(from: date), minutes: minutes, rate: rate)
  439. }
  440. guard let pump = deviceManager?.pumpManager else {
  441. debugPrint("\(DebuggingIdentifiers.failed) No pump found; cannot save basal profile!")
  442. return Fail(error: NSError()).eraseToAnyPublisher()
  443. }
  444. let syncValues = profile.map {
  445. RepeatingScheduleValue(startTime: TimeInterval($0.minutes * 60), value: Double($0.rate))
  446. }
  447. return Future { promise in
  448. pump.syncBasalRateSchedule(items: syncValues) { result in
  449. switch result {
  450. case .success:
  451. self.storage.save(profile, as: OpenAPS.Settings.basalProfile)
  452. promise(.success(()))
  453. case let .failure(error):
  454. promise(.failure(error))
  455. }
  456. }
  457. }.eraseToAnyPublisher()
  458. }
  459. }