BasalRateScheduleEditor.swift 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. //
  2. // BasalRateScheduleEditor.swift
  3. // LoopKitUI
  4. //
  5. // Created by Michael Pangburn on 4/20/20.
  6. // Copyright © 2020 LoopKit Authors. All rights reserved.
  7. //
  8. import SwiftUI
  9. import HealthKit
  10. import LoopKit
  11. public typealias SyncBasalRateSchedule = (_ items: [RepeatingScheduleValue<Double>], _ completion: @escaping (Result<BasalRateSchedule, Error>) -> Void) -> Void
  12. public struct BasalRateScheduleEditor: View {
  13. var schedule: DailyQuantitySchedule<Double>?
  14. var supportedBasalRates: [Double]
  15. var guardrail: Guardrail<HKQuantity>
  16. var maximumScheduleEntryCount: Int
  17. var syncBasalRateSchedule: SyncBasalRateSchedule?
  18. var save: (BasalRateSchedule) -> Void
  19. let mode: SettingsPresentationMode
  20. @Environment(\.appName) private var appName
  21. /// - Precondition: `supportedBasalRates` is nonempty and sorted in ascending order.
  22. public init(
  23. schedule: BasalRateSchedule?,
  24. supportedBasalRates: [Double],
  25. maximumBasalRate: Double?,
  26. maximumScheduleEntryCount: Int,
  27. syncBasalRateSchedule: SyncBasalRateSchedule?,
  28. onSave save: @escaping (BasalRateSchedule) -> Void,
  29. mode: SettingsPresentationMode = .settings
  30. ) {
  31. self.schedule = schedule.map { schedule in
  32. DailyQuantitySchedule(
  33. unit: .internationalUnitsPerHour,
  34. dailyItems: schedule.items
  35. )!
  36. }
  37. if let maxBasal = maximumBasalRate {
  38. let partitioningIndex = supportedBasalRates.partitioningIndex(where: { $0 > maxBasal })
  39. self.supportedBasalRates = Array(supportedBasalRates[..<partitioningIndex])
  40. } else {
  41. self.supportedBasalRates = supportedBasalRates
  42. }
  43. self.guardrail = Guardrail.basalRate(supportedBasalRates: supportedBasalRates)
  44. self.maximumScheduleEntryCount = maximumScheduleEntryCount
  45. self.syncBasalRateSchedule = syncBasalRateSchedule
  46. self.save = save
  47. self.mode = mode
  48. self.supportedBasalRates.removeAll(where: {
  49. !self.guardrail.absoluteBounds.contains(HKQuantity(unit: .internationalUnitsPerHour, doubleValue: $0))
  50. })
  51. }
  52. public init(
  53. mode: SettingsPresentationMode,
  54. therapySettingsViewModel: TherapySettingsViewModel,
  55. didSave: (() -> Void)? = nil
  56. ) {
  57. self.init(
  58. schedule: therapySettingsViewModel.therapySettings.basalRateSchedule,
  59. supportedBasalRates: therapySettingsViewModel.pumpSupportedIncrements()?.basalRates ?? [],
  60. maximumBasalRate: therapySettingsViewModel.therapySettings.maximumBasalRatePerHour,
  61. maximumScheduleEntryCount: therapySettingsViewModel.pumpSupportedIncrements()?.maximumBasalScheduleEntryCount ?? 0,
  62. syncBasalRateSchedule: therapySettingsViewModel.syncBasalRateSchedule,
  63. onSave: { [weak therapySettingsViewModel] newBasalRates in
  64. therapySettingsViewModel?.saveBasalRates(basalRates: newBasalRates)
  65. didSave?()
  66. },
  67. mode: mode
  68. )
  69. }
  70. public var body: some View {
  71. QuantityScheduleEditor(
  72. title: Text(TherapySetting.basalRate.title),
  73. description: description,
  74. schedule: schedule,
  75. unit: .internationalUnitsPerHour,
  76. selectableValues: supportedBasalRates,
  77. guardrail: guardrail,
  78. quantitySelectionMode: .fractional,
  79. defaultFirstScheduleItemValue: guardrail.absoluteBounds.lowerBound,
  80. scheduleItemLimit: maximumScheduleEntryCount,
  81. confirmationAlertContent: confirmationAlertContent,
  82. guardrailWarning: {
  83. BasalRateGuardrailWarning(
  84. crossedThresholds: $0,
  85. isZeroUnitRateSelectable: self.supportedBasalRates.first! == 0
  86. )
  87. },
  88. onSave: savingMechanism,
  89. mode: mode,
  90. settingType: .basalRate(maximumScheduleEntryCount)
  91. )
  92. }
  93. private var description: Text {
  94. Text(TherapySetting.basalRate.descriptiveText(appName: appName))
  95. }
  96. private var confirmationAlertContent: AlertContent {
  97. AlertContent(
  98. title: Text(LocalizedString("Save Basal Rates?", comment: "Alert title for confirming basal rates outside the recommended range")),
  99. message: Text(TherapySetting.basalRate.guardrailSaveWarningCaption)
  100. )
  101. }
  102. private var savingMechanism: SavingMechanism<DailyQuantitySchedule<Double>> {
  103. switch mode {
  104. case .settings:
  105. return .asynchronous { quantitySchedule, completion in
  106. precondition(self.syncBasalRateSchedule != nil)
  107. self.syncBasalRateSchedule?(quantitySchedule.items) { result in
  108. switch result {
  109. case .success(let syncedSchedule):
  110. DispatchQueue.main.async {
  111. self.save(syncedSchedule)
  112. }
  113. completion(nil)
  114. case .failure(let error):
  115. completion(error)
  116. }
  117. }
  118. }
  119. case .acceptanceFlow:
  120. // TODO: get timezone from pump
  121. return .synchronous { quantitySchedule in
  122. let schedule = BasalRateSchedule(dailyItems: quantitySchedule.items, timeZone: .currentFixed)!
  123. self.save(schedule)
  124. }
  125. }
  126. }
  127. }
  128. private struct BasalRateGuardrailWarning: View {
  129. var crossedThresholds: [SafetyClassification.Threshold]
  130. var isZeroUnitRateSelectable: Bool
  131. var body: some View {
  132. assert(!crossedThresholds.isEmpty)
  133. let caption = self.isZeroUnitRateSelectable && crossedThresholds.allSatisfy({ $0 == .minimum })
  134. ? Text(LocalizedString("A value of 0 U/hr means you will be scheduled to receive no basal insulin.", comment: "Warning text for basal rate of 0 U/hr"))
  135. : nil
  136. return GuardrailWarning(
  137. therapySetting: .basalRate,
  138. title: crossedThresholds.count == 1 ? singularWarningTitle(for: crossedThresholds.first!) : multipleWarningTitle,
  139. thresholds: crossedThresholds,
  140. caption: caption
  141. )
  142. }
  143. private func singularWarningTitle(for threshold: SafetyClassification.Threshold) -> Text {
  144. switch threshold {
  145. case .minimum where isZeroUnitRateSelectable:
  146. return Text(LocalizedString("No Basal Insulin", comment: "Title text for the zero basal rate warning"))
  147. case .minimum, .belowRecommended:
  148. return Text(LocalizedString("Low Basal Rate", comment: "Title text for the low basal rate warning"))
  149. case .aboveRecommended, .maximum:
  150. return Text(LocalizedString("High Basal Rate", comment: "Title text for the high basal rate warning"))
  151. }
  152. }
  153. private var multipleWarningTitle: Text {
  154. isZeroUnitRateSelectable && crossedThresholds.allSatisfy({ $0 == .minimum })
  155. ? Text(LocalizedString("No Basal Insulin", comment: "Title text for the zero basal rate warning"))
  156. : Text(LocalizedString("Basal Rates", comment: "Title text for multi-value basal rate warning"))
  157. }
  158. }