DeliveryLimitsEditor.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  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 typealias SyncDeliveryLimits = (_ deliveryLimits: DeliveryLimits, _ completion: @escaping (Swift.Result<DeliveryLimits, Error>) -> Void) -> Void
  12. public struct DeliveryLimitsEditor: View {
  13. fileprivate enum PresentedAlert: Error {
  14. case saveConfirmation(AlertContent)
  15. case saveError(Error)
  16. }
  17. let initialValue: DeliveryLimits
  18. let supportedBasalRates: [Double]
  19. let selectableMaximumBasalRates: [Double]
  20. let scheduledBasalRange: ClosedRange<Double>?
  21. let supportedMaximumBolusVolumes: [Double]
  22. let selectableMaximumBolusVolumes: [Double]
  23. let syncDeliveryLimits: SyncDeliveryLimits?
  24. let save: (_ deliveryLimits: DeliveryLimits) -> Void
  25. let mode: SettingsPresentationMode
  26. @State var value: DeliveryLimits
  27. @State private var userDidTap: Bool = false
  28. @State var settingBeingEdited: DeliveryLimits.Setting?
  29. @State private var isSyncing = false
  30. @State private var presentedAlert: PresentedAlert?
  31. @Environment(\.dismissAction) var dismiss
  32. @Environment(\.authenticate) var authenticate
  33. @Environment(\.appName) var appName
  34. private let lowestCarbRatio: Double?
  35. public init(
  36. value: DeliveryLimits,
  37. supportedBasalRates: [Double],
  38. scheduledBasalRange: ClosedRange<Double>?,
  39. supportedMaximumBolusVolumes: [Double],
  40. lowestCarbRatio: Double?,
  41. syncDeliveryLimits: @escaping SyncDeliveryLimits,
  42. onSave save: @escaping (_ deliveryLimits: DeliveryLimits) -> Void,
  43. mode: SettingsPresentationMode = .settings
  44. ) {
  45. self._value = State(initialValue: value)
  46. self.initialValue = value
  47. self.supportedBasalRates = supportedBasalRates
  48. self.selectableMaximumBasalRates = Guardrail.selectableMaxBasalRates(supportedBasalRates: supportedBasalRates, scheduledBasalRange: scheduledBasalRange, lowestCarbRatio: lowestCarbRatio)
  49. self.scheduledBasalRange = scheduledBasalRange
  50. self.supportedMaximumBolusVolumes = supportedMaximumBolusVolumes
  51. self.selectableMaximumBolusVolumes = Guardrail.selectableBolusVolumes(supportedBolusVolumes: supportedMaximumBolusVolumes)
  52. self.syncDeliveryLimits = syncDeliveryLimits
  53. self.save = save
  54. self.mode = mode
  55. self.lowestCarbRatio = lowestCarbRatio
  56. }
  57. public init(
  58. mode: SettingsPresentationMode,
  59. therapySettingsViewModel: TherapySettingsViewModel,
  60. didSave: (() -> Void)? = nil
  61. ) {
  62. precondition(therapySettingsViewModel.pumpSupportedIncrements() != nil)
  63. let maxBasal = therapySettingsViewModel.therapySettings.maximumBasalRatePerHour.map {
  64. HKQuantity(unit: .internationalUnitsPerHour, doubleValue: $0)
  65. }
  66. let maxBolus = therapySettingsViewModel.therapySettings.maximumBolus.map {
  67. HKQuantity(unit: .internationalUnit(), doubleValue: $0)
  68. }
  69. self.init(
  70. value: DeliveryLimits(maximumBasalRate: maxBasal, maximumBolus: maxBolus),
  71. supportedBasalRates: therapySettingsViewModel.pumpSupportedIncrements()?.basalRates ?? [],
  72. scheduledBasalRange: therapySettingsViewModel.therapySettings.basalRateSchedule?.valueRange(),
  73. supportedMaximumBolusVolumes: therapySettingsViewModel.pumpSupportedIncrements()?.maximumBolusVolumes ?? [],
  74. lowestCarbRatio: therapySettingsViewModel.therapySettings.carbRatioSchedule?.lowestValue(),
  75. syncDeliveryLimits: therapySettingsViewModel.syncDeliveryLimits,
  76. onSave: { [weak therapySettingsViewModel] newLimits in
  77. therapySettingsViewModel?.saveDeliveryLimits(limits: newLimits)
  78. didSave?()
  79. },
  80. mode: mode
  81. )
  82. }
  83. public var body: some View {
  84. switch mode {
  85. case .acceptanceFlow:
  86. content
  87. .disabled(isSyncing)
  88. case .settings:
  89. contentWithCancel
  90. .disabled(isSyncing)
  91. .navigationBarTitle("", displayMode: .inline)
  92. }
  93. }
  94. private var contentWithCancel: some View {
  95. if value == initialValue {
  96. return AnyView(content
  97. .navigationBarBackButtonHidden(false)
  98. .navigationBarItems(leading: EmptyView())
  99. )
  100. } else {
  101. return AnyView(content
  102. .navigationBarBackButtonHidden(true)
  103. .navigationBarItems(leading: cancelButton)
  104. )
  105. }
  106. }
  107. private var cancelButton: some View {
  108. Button(action: { self.dismiss() } ) { Text(LocalizedString("Cancel", comment: "Cancel editing settings button title")) }
  109. }
  110. private var content: some View {
  111. ConfigurationPage(
  112. title: Text(TherapySetting.deliveryLimits.title),
  113. actionButtonTitle: Text(mode.buttonText(isSaving: isSyncing)),
  114. actionButtonState: saveButtonState,
  115. cards: {
  116. maximumBasalRateCard
  117. maximumBolusCard
  118. },
  119. actionAreaContent: {
  120. instructionalContentIfNecessary
  121. guardrailWarningIfNecessary
  122. },
  123. action: {
  124. if self.crossedThresholds.isEmpty {
  125. self.startSaving()
  126. } else {
  127. self.presentedAlert = .saveConfirmation(confirmationAlertContent)
  128. }
  129. }
  130. )
  131. .alert(item: $presentedAlert, content: alert(for:))
  132. .simultaneousGesture(TapGesture().onEnded {
  133. withAnimation {
  134. self.userDidTap = true
  135. }
  136. })
  137. }
  138. var saveButtonState: ConfigurationPageActionButtonState {
  139. guard value.maximumBasalRate != nil, value.maximumBolus != nil else {
  140. return .disabled
  141. }
  142. if isSyncing {
  143. return .loading
  144. }
  145. if mode == .acceptanceFlow {
  146. return .enabled
  147. }
  148. return value == initialValue && mode != .acceptanceFlow ? .disabled : .enabled
  149. }
  150. var maximumBasalRateGuardrail: Guardrail<HKQuantity> {
  151. return Guardrail.maximumBasalRate(supportedBasalRates: supportedBasalRates, scheduledBasalRange: scheduledBasalRange, lowestCarbRatio: lowestCarbRatio)
  152. }
  153. var maximumBasalRateCard: Card {
  154. Card {
  155. SettingDescription(text: Text(DeliveryLimits.Setting.maximumBasalRate.localizedDescriptiveText(appName: appName)),
  156. informationalContent: { TherapySetting.deliveryLimits.helpScreen() })
  157. ExpandableSetting(
  158. isEditing: Binding(
  159. get: { self.settingBeingEdited == .maximumBasalRate },
  160. set: { isEditing in
  161. withAnimation {
  162. self.settingBeingEdited = isEditing ? .maximumBasalRate : nil
  163. }
  164. }
  165. ),
  166. leadingValueContent: {
  167. Text(DeliveryLimits.Setting.maximumBasalRate.title)
  168. },
  169. trailingValueContent: {
  170. GuardrailConstrainedQuantityView(
  171. value: value.maximumBasalRate,
  172. unit: .internationalUnitsPerHour,
  173. guardrail: maximumBasalRateGuardrail,
  174. isEditing: settingBeingEdited == .maximumBasalRate,
  175. forceDisableAnimations: true
  176. )
  177. },
  178. expandedContent: {
  179. FractionalQuantityPicker(
  180. value: Binding(
  181. get: { self.value.maximumBasalRate ?? self.maximumBasalRateGuardrail.startingSuggestion ?? self.maximumBasalRateGuardrail.recommendedBounds.upperBound },
  182. set: { newValue in
  183. withAnimation {
  184. self.value.maximumBasalRate = newValue
  185. }
  186. }
  187. ),
  188. unit: .internationalUnitsPerHour,
  189. guardrail: self.maximumBasalRateGuardrail,
  190. selectableValues: self.selectableMaximumBasalRates,
  191. usageContext: .independent
  192. )
  193. .accessibility(identifier: "max_basal_picker")
  194. }
  195. )
  196. }
  197. }
  198. var maximumBolusGuardrail: Guardrail<HKQuantity> {
  199. return Guardrail.maximumBolus(supportedBolusVolumes: supportedMaximumBolusVolumes)
  200. }
  201. var maximumBolusCard: Card {
  202. Card {
  203. SettingDescription(text: Text(DeliveryLimits.Setting.maximumBolus.localizedDescriptiveText(appName: appName)),
  204. informationalContent: { TherapySetting.deliveryLimits.helpScreen() })
  205. ExpandableSetting(
  206. isEditing: Binding(
  207. get: { self.settingBeingEdited == .maximumBolus },
  208. set: { isEditing in
  209. withAnimation {
  210. self.settingBeingEdited = isEditing ? .maximumBolus : nil
  211. }
  212. }
  213. ),
  214. leadingValueContent: {
  215. Text(DeliveryLimits.Setting.maximumBolus.title)
  216. },
  217. trailingValueContent: {
  218. GuardrailConstrainedQuantityView(
  219. value: value.maximumBolus,
  220. unit: .internationalUnit(),
  221. guardrail: maximumBolusGuardrail,
  222. isEditing: settingBeingEdited == .maximumBolus,
  223. forceDisableAnimations: true
  224. )
  225. },
  226. expandedContent: {
  227. FractionalQuantityPicker(
  228. value: Binding(
  229. get: { self.value.maximumBolus ?? self.maximumBolusGuardrail.startingSuggestion ?? self.maximumBolusGuardrail.recommendedBounds.upperBound },
  230. set: { newValue in
  231. withAnimation {
  232. self.value.maximumBolus = newValue
  233. }
  234. }
  235. ),
  236. unit: .internationalUnit(),
  237. guardrail: self.maximumBolusGuardrail,
  238. selectableValues: self.selectableMaximumBolusVolumes,
  239. usageContext: .independent
  240. )
  241. .accessibility(identifier: "max_bolus_picker")
  242. }
  243. )
  244. }
  245. }
  246. private var instructionalContentIfNecessary: some View {
  247. return Group {
  248. if mode == .acceptanceFlow && !userDidTap {
  249. instructionalContent
  250. }
  251. }
  252. }
  253. private var instructionalContent: some View {
  254. HStack { // to align with guardrail warning, if present
  255. Text(LocalizedString("You can edit a setting by tapping into any line item.", comment: "Description of how to edit setting"))
  256. .foregroundColor(.secondary)
  257. .font(.subheadline)
  258. Spacer()
  259. }
  260. }
  261. private var guardrailWarningIfNecessary: some View {
  262. let crossedThresholds = self.crossedThresholds
  263. return Group {
  264. if !crossedThresholds.isEmpty && (userDidTap || mode == .settings) {
  265. DeliveryLimitsGuardrailWarning(crossedThresholds: crossedThresholds, value: value)
  266. }
  267. }
  268. }
  269. private var crossedThresholds: [DeliveryLimits.Setting: SafetyClassification.Threshold] {
  270. var crossedThresholds: [DeliveryLimits.Setting: SafetyClassification.Threshold] = [:]
  271. switch value.maximumBasalRate.map(maximumBasalRateGuardrail.classification(for:)) {
  272. case nil, .withinRecommendedRange:
  273. break
  274. case .outsideRecommendedRange(let threshold):
  275. crossedThresholds[.maximumBasalRate] = threshold
  276. }
  277. switch value.maximumBolus.map(maximumBolusGuardrail.classification(for:)) {
  278. case nil, .withinRecommendedRange:
  279. break
  280. case .outsideRecommendedRange(let threshold):
  281. crossedThresholds[.maximumBolus] = threshold
  282. }
  283. return crossedThresholds
  284. }
  285. private func startSaving() {
  286. guard mode == .settings else {
  287. self.monitorSavingMechanism(savingMechanism: .synchronous { deliveryLimits in
  288. save(deliveryLimits)
  289. })
  290. return
  291. }
  292. authenticate(TherapySetting.deliveryLimits.authenticationChallengeDescription) {
  293. switch $0 {
  294. case .success:
  295. self.monitorSavingMechanism(savingMechanism: .asynchronous { deliveryLimits, completion in
  296. synchronizeDeliveryLimits(deliveryLimits, completion)
  297. })
  298. case .failure: break
  299. }
  300. }
  301. }
  302. private func synchronizeDeliveryLimits(_ deliveryLimits: DeliveryLimits, _ completion: @escaping (Error?) -> Void) {
  303. guard let syncDeliveryLimits = self.syncDeliveryLimits else {
  304. actuallySave(deliveryLimits)
  305. return
  306. }
  307. syncDeliveryLimits(deliveryLimits) { result in
  308. switch result {
  309. case .success(let deliveryLimits):
  310. actuallySave(deliveryLimits)
  311. completion(nil)
  312. case .failure(let error):
  313. completion(PresentedAlert.saveError(error))
  314. }
  315. }
  316. }
  317. private func actuallySave(_ deliveryLimits: DeliveryLimits) {
  318. DispatchQueue.main.async {
  319. self.save(deliveryLimits)
  320. }
  321. }
  322. private func monitorSavingMechanism(savingMechanism: SavingMechanism<DeliveryLimits>) {
  323. switch savingMechanism {
  324. case .synchronous(let save):
  325. save(value)
  326. case .asynchronous(let save):
  327. withAnimation {
  328. self.isSyncing = true
  329. }
  330. save(value) { error in
  331. DispatchQueue.main.async {
  332. if let error = error {
  333. withAnimation {
  334. self.isSyncing = false
  335. }
  336. self.presentedAlert = (error as? PresentedAlert) ?? .saveError(error)
  337. }
  338. }
  339. }
  340. }
  341. }
  342. private func alert(for presentedAlert: PresentedAlert) -> SwiftUI.Alert {
  343. switch presentedAlert {
  344. case .saveConfirmation(let content):
  345. return SwiftUI.Alert(
  346. title: content.title,
  347. message: content.message,
  348. primaryButton: .cancel(
  349. content.cancel ??
  350. Text(LocalizedString("Go Back", comment: "Button text to return to editing a schedule after from alert popup when some schedule values are outside the recommended range"))),
  351. secondaryButton: .default(
  352. content.ok ??
  353. Text(LocalizedString("Continue", comment: "Button text to confirm saving from alert popup when some schedule values are outside the recommended range")),
  354. action: startSaving
  355. )
  356. )
  357. case .saveError(let error):
  358. return SwiftUI.Alert(
  359. title: Text(LocalizedString("Unable to Save", comment: "Alert title when error occurs while saving a schedule")),
  360. message: Text(error.localizedDescription)
  361. )
  362. }
  363. }
  364. private var confirmationAlertContent: AlertContent {
  365. AlertContent(
  366. title: Text(LocalizedString("Save Delivery Limits?", comment: "Alert title for confirming delivery limits outside the recommended range")),
  367. message: Text(TherapySetting.deliveryLimits.guardrailSaveWarningCaption),
  368. cancel: Text(LocalizedString("Go Back", comment: "Text for go back action on confirmation alert")),
  369. ok: Text(LocalizedString("Continue", comment: "Text for continue action on confirmation alert")
  370. )
  371. )
  372. }
  373. }
  374. struct DeliveryLimitsGuardrailWarning: View {
  375. let crossedThresholds: [DeliveryLimits.Setting: SafetyClassification.Threshold]
  376. let value: DeliveryLimits
  377. var body: some View {
  378. switch crossedThresholds.count {
  379. case 0:
  380. preconditionFailure("A guardrail warning requires at least one crossed threshold")
  381. case 1:
  382. let (setting, threshold) = crossedThresholds.first!
  383. let title: Text
  384. switch setting {
  385. case .maximumBasalRate:
  386. switch threshold {
  387. case .minimum, .belowRecommended:
  388. title = Text(LocalizedString("Low Maximum Basal Rate", comment: "Title text for low maximum basal rate warning"))
  389. case .aboveRecommended, .maximum:
  390. title = Text(LocalizedString("High Maximum Basal Rate", comment: "Title text for high maximum basal rate warning"))
  391. }
  392. case .maximumBolus:
  393. switch threshold {
  394. case .minimum, .belowRecommended:
  395. title = Text(LocalizedString("Low Maximum Bolus", comment: "Title text for low maximum bolus warning"))
  396. case .aboveRecommended, .maximum:
  397. title = Text(LocalizedString("High Maximum Bolus", comment: "Title text for high maximum bolus warning"))
  398. }
  399. }
  400. return GuardrailWarning(therapySetting: .deliveryLimits, title: title, threshold: threshold)
  401. case 2:
  402. return GuardrailWarning(
  403. therapySetting: .deliveryLimits,
  404. title: Text(LocalizedString("Delivery Limits", comment: "Title text for crossed thresholds guardrail warning")),
  405. thresholds: Array(crossedThresholds.values))
  406. default:
  407. preconditionFailure("Unreachable: only two delivery limit settings exist")
  408. }
  409. }
  410. }
  411. extension DeliveryLimitsEditor.PresentedAlert: Identifiable {
  412. var id: Int {
  413. switch self {
  414. case .saveConfirmation:
  415. return 0
  416. case .saveError:
  417. return 1
  418. }
  419. }
  420. }