DeliveryLimitsEditor.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. //
  2. // DeliveryLimitsEditor.swift
  3. // LoopKitUI
  4. //
  5. // Created by Michael Pangburn on 6/22/20.
  6. // Copyright © 2020 LoopKit Authors. All rights reserved.
  7. //
  8. import SwiftUI
  9. import HealthKit
  10. import LoopKit
  11. public struct DeliveryLimitsEditor: View {
  12. let initialValue: DeliveryLimits
  13. let supportedBasalRates: [Double]
  14. let selectableMaxBasalRates: [Double]
  15. let scheduledBasalRange: ClosedRange<Double>?
  16. let supportedBolusVolumes: [Double]
  17. let selectableBolusVolumes: [Double]
  18. let save: (_ deliveryLimits: DeliveryLimits) -> Void
  19. let mode: SettingsPresentationMode
  20. @State var value: DeliveryLimits
  21. @State private var userDidTap: Bool = false
  22. @State var settingBeingEdited: DeliveryLimits.Setting?
  23. @State var showingConfirmationAlert = false
  24. @Environment(\.dismiss) var dismiss
  25. @Environment(\.authenticate) var authenticate
  26. @Environment(\.appName) var appName
  27. private let lowestCarbRatio: Double?
  28. public init(
  29. value: DeliveryLimits,
  30. supportedBasalRates: [Double],
  31. scheduledBasalRange: ClosedRange<Double>?,
  32. supportedBolusVolumes: [Double],
  33. lowestCarbRatio: Double?,
  34. onSave save: @escaping (_ deliveryLimits: DeliveryLimits) -> Void,
  35. mode: SettingsPresentationMode = .settings
  36. ) {
  37. self._value = State(initialValue: value)
  38. self.initialValue = value
  39. self.supportedBasalRates = supportedBasalRates
  40. self.selectableMaxBasalRates = Guardrail.selectableMaxBasalRates(supportedBasalRates: supportedBasalRates, scheduledBasalRange: scheduledBasalRange, lowestCarbRatio: lowestCarbRatio)
  41. self.scheduledBasalRange = scheduledBasalRange
  42. self.supportedBolusVolumes = supportedBolusVolumes
  43. self.selectableBolusVolumes = Guardrail.selectableBolusVolumes(supportedBolusVolumes: supportedBolusVolumes)
  44. self.save = save
  45. self.mode = mode
  46. self.lowestCarbRatio = lowestCarbRatio
  47. }
  48. public init(
  49. viewModel: TherapySettingsViewModel,
  50. didSave: (() -> Void)? = nil
  51. ) {
  52. precondition(viewModel.pumpSupportedIncrements != nil)
  53. let maxBasal = viewModel.therapySettings.maximumBasalRatePerHour.map {
  54. HKQuantity(unit: .internationalUnitsPerHour, doubleValue: $0)
  55. }
  56. let maxBolus = viewModel.therapySettings.maximumBolus.map {
  57. HKQuantity(unit: .internationalUnit(), doubleValue: $0)
  58. }
  59. self.init(
  60. value: DeliveryLimits(maximumBasalRate: maxBasal, maximumBolus: maxBolus),
  61. supportedBasalRates: viewModel.pumpSupportedIncrements!()!.basalRates,
  62. scheduledBasalRange: viewModel.therapySettings.basalRateSchedule?.valueRange(),
  63. supportedBolusVolumes: viewModel.pumpSupportedIncrements!()!.bolusVolumes,
  64. lowestCarbRatio: viewModel.therapySettings.carbRatioSchedule?.lowestValue(),
  65. onSave: { [weak viewModel] newLimits in
  66. viewModel?.saveDeliveryLimits(limits: newLimits)
  67. didSave?()
  68. },
  69. mode: viewModel.mode
  70. )
  71. }
  72. public var body: some View {
  73. switch mode {
  74. case .settings: return AnyView(contentWithCancel)
  75. case .acceptanceFlow: return AnyView(content)
  76. }
  77. }
  78. private var contentWithCancel: some View {
  79. if value == initialValue {
  80. return AnyView(content
  81. .navigationBarBackButtonHidden(false)
  82. .navigationBarItems(leading: EmptyView())
  83. )
  84. } else {
  85. return AnyView(content
  86. .navigationBarBackButtonHidden(true)
  87. .navigationBarItems(leading: cancelButton)
  88. )
  89. }
  90. }
  91. private var cancelButton: some View {
  92. Button(action: { self.dismiss() } ) { Text(LocalizedString("Cancel", comment: "Cancel editing settings button title")) }
  93. }
  94. private var content: some View {
  95. ConfigurationPage(
  96. title: Text(TherapySetting.deliveryLimits.title),
  97. actionButtonTitle: Text(mode.buttonText),
  98. actionButtonState: saveButtonState,
  99. cards: {
  100. maximumBasalRateCard
  101. maximumBolusCard
  102. },
  103. actionAreaContent: {
  104. instructionalContentIfNecessary
  105. guardrailWarningIfNecessary
  106. },
  107. action: {
  108. if self.crossedThresholds.isEmpty {
  109. self.startSaving()
  110. } else {
  111. self.showingConfirmationAlert = true
  112. }
  113. }
  114. )
  115. .alert(isPresented: $showingConfirmationAlert, content: confirmationAlert)
  116. .navigationBarTitle("", displayMode: .inline)
  117. .onTapGesture {
  118. self.userDidTap = true
  119. }
  120. }
  121. var saveButtonState: ConfigurationPageActionButtonState {
  122. guard value.maximumBasalRate != nil, value.maximumBolus != nil else {
  123. return .disabled
  124. }
  125. if mode == .acceptanceFlow {
  126. return .enabled
  127. }
  128. return value == initialValue && mode != .acceptanceFlow ? .disabled : .enabled
  129. }
  130. var maximumBasalRateGuardrail: Guardrail<HKQuantity> {
  131. return Guardrail.maximumBasalRate(supportedBasalRates: supportedBasalRates, scheduledBasalRange: scheduledBasalRange, lowestCarbRatio: lowestCarbRatio)
  132. }
  133. var maximumBasalRateCard: Card {
  134. Card {
  135. SettingDescription(text: Text(DeliveryLimits.Setting.maximumBasalRate.localizedDescriptiveText(appName: appName)),
  136. informationalContent: { TherapySetting.deliveryLimits.helpScreen() })
  137. ExpandableSetting(
  138. isEditing: Binding(
  139. get: { self.settingBeingEdited == .maximumBasalRate },
  140. set: { isEditing in
  141. withAnimation {
  142. self.settingBeingEdited = isEditing ? .maximumBasalRate : nil
  143. }
  144. }
  145. ),
  146. leadingValueContent: {
  147. Text(DeliveryLimits.Setting.maximumBasalRate.title)
  148. },
  149. trailingValueContent: {
  150. GuardrailConstrainedQuantityView(
  151. value: value.maximumBasalRate,
  152. unit: .internationalUnitsPerHour,
  153. guardrail: maximumBasalRateGuardrail,
  154. isEditing: settingBeingEdited == .maximumBasalRate,
  155. forceDisableAnimations: true
  156. )
  157. },
  158. expandedContent: {
  159. FractionalQuantityPicker(
  160. value: Binding(
  161. get: { self.value.maximumBasalRate ?? self.maximumBasalRateGuardrail.startingSuggestion ?? self.maximumBasalRateGuardrail.recommendedBounds.upperBound },
  162. set: { newValue in
  163. withAnimation {
  164. self.value.maximumBasalRate = newValue
  165. }
  166. }
  167. ),
  168. unit: .internationalUnitsPerHour,
  169. guardrail: self.maximumBasalRateGuardrail,
  170. selectableValues: self.selectableMaxBasalRates,
  171. usageContext: .independent
  172. )
  173. .accessibility(identifier: "max_basal_picker")
  174. }
  175. )
  176. }
  177. }
  178. var maximumBolusGuardrail: Guardrail<HKQuantity> {
  179. return Guardrail.maximumBolus(supportedBolusVolumes: supportedBolusVolumes)
  180. }
  181. var maximumBolusCard: Card {
  182. Card {
  183. SettingDescription(text: Text(DeliveryLimits.Setting.maximumBolus.localizedDescriptiveText(appName: appName)),
  184. informationalContent: { TherapySetting.deliveryLimits.helpScreen() })
  185. ExpandableSetting(
  186. isEditing: Binding(
  187. get: { self.settingBeingEdited == .maximumBolus },
  188. set: { isEditing in
  189. withAnimation {
  190. self.settingBeingEdited = isEditing ? .maximumBolus : nil
  191. }
  192. }
  193. ),
  194. leadingValueContent: {
  195. Text(DeliveryLimits.Setting.maximumBolus.title)
  196. },
  197. trailingValueContent: {
  198. GuardrailConstrainedQuantityView(
  199. value: value.maximumBolus,
  200. unit: .internationalUnit(),
  201. guardrail: maximumBolusGuardrail,
  202. isEditing: settingBeingEdited == .maximumBolus,
  203. forceDisableAnimations: true
  204. )
  205. },
  206. expandedContent: {
  207. FractionalQuantityPicker(
  208. value: Binding(
  209. get: { self.value.maximumBolus ?? self.maximumBolusGuardrail.startingSuggestion ?? self.maximumBolusGuardrail.recommendedBounds.upperBound },
  210. set: { newValue in
  211. withAnimation {
  212. self.value.maximumBolus = newValue
  213. }
  214. }
  215. ),
  216. unit: .internationalUnit(),
  217. guardrail: self.maximumBolusGuardrail,
  218. selectableValues: self.selectableBolusVolumes,
  219. usageContext: .independent
  220. )
  221. .accessibility(identifier: "max_bolus_picker")
  222. }
  223. )
  224. }
  225. }
  226. private var instructionalContentIfNecessary: some View {
  227. return Group {
  228. if mode == .acceptanceFlow && !userDidTap {
  229. instructionalContent
  230. }
  231. }
  232. }
  233. private var instructionalContent: some View {
  234. HStack { // to align with guardrail warning, if present
  235. Text(LocalizedString("You can edit a setting by tapping into any line item.", comment: "Description of how to edit setting"))
  236. .foregroundColor(.secondary)
  237. .font(.subheadline)
  238. Spacer()
  239. }
  240. }
  241. private var guardrailWarningIfNecessary: some View {
  242. let crossedThresholds = self.crossedThresholds
  243. return Group {
  244. if !crossedThresholds.isEmpty && (userDidTap || mode == .settings) {
  245. DeliveryLimitsGuardrailWarning(crossedThresholds: crossedThresholds, value: value)
  246. }
  247. }
  248. }
  249. private var crossedThresholds: [DeliveryLimits.Setting: SafetyClassification.Threshold] {
  250. var crossedThresholds: [DeliveryLimits.Setting: SafetyClassification.Threshold] = [:]
  251. switch value.maximumBasalRate.map(maximumBasalRateGuardrail.classification(for:)) {
  252. case nil, .withinRecommendedRange:
  253. break
  254. case .outsideRecommendedRange(let threshold):
  255. crossedThresholds[.maximumBasalRate] = threshold
  256. }
  257. switch value.maximumBolus.map(maximumBolusGuardrail.classification(for:)) {
  258. case nil, .withinRecommendedRange:
  259. break
  260. case .outsideRecommendedRange(let threshold):
  261. crossedThresholds[.maximumBolus] = threshold
  262. }
  263. return crossedThresholds
  264. }
  265. private func confirmationAlert() -> SwiftUI.Alert {
  266. SwiftUI.Alert(
  267. title: Text(LocalizedString("Save Delivery Limits?", comment: "Alert title for confirming delivery limits outside the recommended range")),
  268. message: Text(TherapySetting.deliveryLimits.guardrailSaveWarningCaption),
  269. primaryButton: .cancel(Text(LocalizedString("Go Back", comment: "Text for go back action on confirmation alert"))),
  270. secondaryButton: .default(
  271. Text(LocalizedString("Continue", comment: "Text for continue action on confirmation alert")),
  272. action: startSaving
  273. )
  274. )
  275. }
  276. private func startSaving() {
  277. guard mode == .settings else {
  278. self.continueSaving()
  279. return
  280. }
  281. authenticate(TherapySetting.deliveryLimits.authenticationChallengeDescription) {
  282. switch $0 {
  283. case .success: self.continueSaving()
  284. case .failure: break
  285. }
  286. }
  287. }
  288. private func continueSaving() {
  289. self.save(self.value)
  290. }
  291. }
  292. struct DeliveryLimitsGuardrailWarning: View {
  293. let crossedThresholds: [DeliveryLimits.Setting: SafetyClassification.Threshold]
  294. let value: DeliveryLimits
  295. var body: some View {
  296. switch crossedThresholds.count {
  297. case 0:
  298. preconditionFailure("A guardrail warning requires at least one crossed threshold")
  299. case 1:
  300. let (setting, threshold) = crossedThresholds.first!
  301. let title: Text, caption: Text?
  302. switch setting {
  303. case .maximumBasalRate:
  304. switch threshold {
  305. case .minimum, .belowRecommended:
  306. title = Text(LocalizedString("Low Maximum Basal Rate", comment: "Title text for low maximum basal rate warning"))
  307. caption = Text(TherapySetting.deliveryLimits.guardrailCaptionForLowValue)
  308. case .aboveRecommended, .maximum:
  309. title = Text(LocalizedString("High Maximum Basal Rate", comment: "Title text for high maximum basal rate warning"))
  310. caption = Text(TherapySetting.deliveryLimits.guardrailCaptionForHighValue)
  311. }
  312. case .maximumBolus:
  313. switch threshold {
  314. case .minimum, .belowRecommended:
  315. title = Text(LocalizedString("Low Maximum Bolus", comment: "Title text for low maximum bolus warning"))
  316. caption = Text(TherapySetting.deliveryLimits.guardrailCaptionForLowValue)
  317. case .aboveRecommended, .maximum:
  318. title = Text(LocalizedString("High Maximum Bolus", comment: "Title text for high maximum bolus warning"))
  319. caption = nil
  320. }
  321. }
  322. return GuardrailWarning(title: title, threshold: threshold, caption: caption)
  323. case 2:
  324. return GuardrailWarning(
  325. title: Text(LocalizedString("Delivery Limits", comment: "Title text for crossed thresholds guardrail warning")),
  326. thresholds: Array(crossedThresholds.values),
  327. caption: Text(TherapySetting.deliveryLimits.guardrailCaptionForOutsideValues)
  328. )
  329. default:
  330. preconditionFailure("Unreachable: only two delivery limit settings exist")
  331. }
  332. }
  333. }