SuspendThresholdEditor.swift 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. //
  2. // SuspendThresholdEditor.swift
  3. // LoopKitUI
  4. //
  5. // Created by Michael Pangburn on 4/10/20.
  6. // Copyright © 2020 LoopKit Authors. All rights reserved.
  7. //
  8. import SwiftUI
  9. import HealthKit
  10. import LoopKit
  11. // Also known as "Glucose Safety Limit"
  12. public struct SuspendThresholdEditor: View {
  13. @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference
  14. @Environment(\.dismissAction) var dismiss
  15. @Environment(\.authenticate) var authenticate
  16. @Environment(\.appName) private var appName
  17. let mode: SettingsPresentationMode
  18. let viewModel: SuspendThresholdEditorViewModel
  19. @State private var userDidTap: Bool = false
  20. @State var value: HKQuantity
  21. @State var isEditing = false
  22. @State var showingConfirmationAlert = false
  23. private var initialValue: HKQuantity? {
  24. viewModel.suspendThreshold
  25. }
  26. public init(
  27. mode: SettingsPresentationMode,
  28. therapySettingsViewModel: TherapySettingsViewModel,
  29. didSave: (() -> Void)? = nil
  30. ) {
  31. self.mode = mode
  32. let viewModel = SuspendThresholdEditorViewModel(therapySettingsViewModel: therapySettingsViewModel,
  33. mode: mode,
  34. didSave: didSave)
  35. self._value = State(initialValue: viewModel.suspendThreshold ?? Self.defaultValue(for: viewModel.suspendThresholdUnit))
  36. self.viewModel = viewModel
  37. }
  38. private static func defaultValue(for unit: HKUnit) -> HKQuantity {
  39. switch unit {
  40. case .milligramsPerDeciliter:
  41. return HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 80)
  42. case .millimolesPerLiter:
  43. return HKQuantity(unit: .millimolesPerLiter, doubleValue: 4.5)
  44. default:
  45. fatalError("Unsupported glucose unit \(unit)")
  46. }
  47. }
  48. public var body: some View {
  49. switch mode {
  50. case .acceptanceFlow:
  51. content
  52. case .settings:
  53. contentWithCancel
  54. .navigationBarTitle("", displayMode: .inline)
  55. }
  56. }
  57. private var contentWithCancel: some View {
  58. content
  59. .navigationBarBackButtonHidden(value != initialValue)
  60. .toolbar {
  61. ToolbarItem(placement: .navigationBarLeading) {
  62. leadingNavigationBarItem
  63. }
  64. }
  65. }
  66. @ViewBuilder
  67. private var leadingNavigationBarItem: some View {
  68. if value != initialValue {
  69. cancelButton
  70. } else {
  71. EmptyView()
  72. }
  73. }
  74. private var cancelButton: some View {
  75. Button(action: { self.dismiss() } ) { Text(LocalizedString("Cancel", comment: "Cancel editing settings button title")) }
  76. }
  77. private var picker: GlucoseValuePicker {
  78. GlucoseValuePicker(
  79. value: self.$value.animation(),
  80. unit: displayGlucosePreference.unit,
  81. guardrail: viewModel.guardrail,
  82. bounds: viewModel.guardrail.absoluteBounds.lowerBound...viewModel.maxSuspendThresholdValue
  83. )
  84. }
  85. private var content: some View {
  86. ConfigurationPage(
  87. title: Text(TherapySetting.suspendThreshold.title),
  88. actionButtonTitle: Text(mode.buttonText()),
  89. actionButtonState: saveButtonState,
  90. cards: {
  91. Card {
  92. SettingDescription(text: description, informationalContent: { TherapySetting.suspendThreshold.helpScreen() })
  93. ExpandableSetting(
  94. isEditing: $isEditing,
  95. valueContent: {
  96. GuardrailConstrainedQuantityView(
  97. value: value,
  98. unit: displayGlucosePreference.unit,
  99. guardrail: viewModel.guardrail,
  100. isEditing: isEditing,
  101. // Workaround for strange animation behavior on appearance
  102. forceDisableAnimations: true
  103. )
  104. },
  105. expandedContent: {
  106. // Prevent the picker from expanding the card's width on small devices
  107. picker.frame(maxWidth: 200)
  108. }
  109. )
  110. }
  111. },
  112. actionAreaContent: {
  113. instructionalContentIfNecessary
  114. if warningThreshold != nil && (userDidTap || mode != .acceptanceFlow) {
  115. SuspendThresholdGuardrailWarning(safetyClassificationThreshold: warningThreshold!)
  116. }
  117. },
  118. action: {
  119. if self.warningThreshold == nil {
  120. self.startSaving()
  121. } else {
  122. self.showingConfirmationAlert = true
  123. }
  124. }
  125. )
  126. .alert(isPresented: $showingConfirmationAlert, content: confirmationAlert)
  127. .simultaneousGesture(TapGesture().onEnded {
  128. withAnimation {
  129. self.userDidTap = true
  130. }
  131. })
  132. }
  133. var description: Text {
  134. Text(TherapySetting.suspendThreshold.descriptiveText(appName: appName))
  135. }
  136. private var instructionalContentIfNecessary: some View {
  137. return Group {
  138. if mode == .acceptanceFlow && !userDidTap {
  139. instructionalContent
  140. }
  141. }
  142. }
  143. private var instructionalContent: some View {
  144. HStack { // to align with guardrail warning, if present
  145. Text(LocalizedString("You can edit the setting by tapping into the line item.", comment: "Description of how to edit setting"))
  146. .foregroundColor(.secondary)
  147. .font(.subheadline)
  148. Spacer()
  149. }
  150. }
  151. private var saveButtonState: ConfigurationPageActionButtonState {
  152. let selectableValues = picker.selectableValues
  153. let adjustedBounds = (selectableValues.first!)...(selectableValues.last!)
  154. guard adjustedBounds.contains(value.doubleValue(for: displayGlucosePreference.unit)) else {
  155. return .disabled
  156. }
  157. return initialValue == nil || value != initialValue || mode == .acceptanceFlow ? .enabled : .disabled
  158. }
  159. private var warningThreshold: SafetyClassification.Threshold? {
  160. switch viewModel.guardrail.classification(for: value) {
  161. case .withinRecommendedRange:
  162. return nil
  163. case .outsideRecommendedRange(let threshold):
  164. return threshold
  165. }
  166. }
  167. private func confirmationAlert() -> SwiftUI.Alert {
  168. SwiftUI.Alert(
  169. title: Text(LocalizedString("Save Glucose Safety Limit?", comment: "Alert title for confirming a glucose safety limit outside the recommended range")),
  170. message: Text(TherapySetting.suspendThreshold.guardrailSaveWarningCaption),
  171. primaryButton: .cancel(Text(LocalizedString("Go Back", comment: "Text for go back action on confirmation alert"))),
  172. secondaryButton: .default(
  173. Text(LocalizedString("Continue", comment: "Text for continue action on confirmation alert")),
  174. action: startSaving
  175. )
  176. )
  177. }
  178. private func startSaving() {
  179. guard mode == .settings else {
  180. self.continueSaving()
  181. return
  182. }
  183. authenticate(TherapySetting.suspendThreshold.authenticationChallengeDescription) {
  184. switch $0 {
  185. case .success: self.continueSaving()
  186. case .failure: break
  187. }
  188. }
  189. }
  190. private func continueSaving() {
  191. viewModel.saveSuspendThreshold(self.value, displayGlucosePreference.unit)
  192. }
  193. }
  194. struct SuspendThresholdGuardrailWarning: View {
  195. var safetyClassificationThreshold: SafetyClassification.Threshold
  196. var body: some View {
  197. GuardrailWarning(therapySetting: .suspendThreshold, title: title, threshold: safetyClassificationThreshold)
  198. }
  199. private var title: Text {
  200. switch safetyClassificationThreshold {
  201. case .minimum, .belowRecommended:
  202. return Text(LocalizedString("Low Glucose Safety Limit", comment: "Title text for the low glucose safety limit warning"))
  203. case .aboveRecommended, .maximum:
  204. return Text(LocalizedString("High Glucose Safety Limit", comment: "Title text for the high glucose safety limit warning"))
  205. }
  206. }
  207. }
  208. struct SuspendThresholdView_Previews: PreviewProvider {
  209. static var previews: some View {
  210. let therapySettingsViewModel = TherapySettingsViewModel(therapySettings: TherapySettings())
  211. return SuspendThresholdEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: nil)
  212. .environmentObject(DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter))
  213. }
  214. }