Skip to content

Commit 443f4fd

Browse files
authored
LOOP-3785 Limits on bg input, additional warnings. (#444)
* Add glucose range restriction, and move max bolus warning earlier * Add carbohydrateEntryTooLarge warning * Cleanup alerts that happened after action button; now showing warnings instead * Reorder enum to be alphabetical
1 parent 9e322df commit 443f4fd

File tree

3 files changed

+178
-79
lines changed

3 files changed

+178
-79
lines changed

Loop/View Models/SimpleBolusViewModel.swift

Lines changed: 75 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import LoopCore
1616
import Intents
1717
import LocalAuthentication
1818

19-
protocol SimpleBolusViewModelDelegate: class {
19+
protocol SimpleBolusViewModelDelegate: AnyObject {
2020

2121
func addGlucose(_ samples: [NewGlucoseSample], completion: @escaping (Error?) -> Void)
2222

@@ -43,20 +43,21 @@ class SimpleBolusViewModel: ObservableObject {
4343
var authenticate: AuthenticationChallenge = LocalAuthentication.deviceOwnerCheck
4444

4545
enum Alert: Int {
46-
case maxBolusExceeded
4746
case carbEntryPersistenceFailure
48-
case carbEntrySizeTooLarge
49-
case manualGlucoseEntryOutOfAcceptableRange
5047
case manualGlucoseEntryPersistenceFailure
5148
case infoPopup
5249
}
5350

5451
@Published var activeAlert: Alert?
5552

5653
enum Notice: Int {
57-
case glucoseBelowSuspendThreshold
54+
case carbohydrateEntryTooLarge
5855
case glucoseBelowRecommendationLimit
56+
case glucoseBelowSuspendThreshold
57+
case glucoseOutOfAllowedInputRange
5958
case glucoseWarning
59+
case maxBolusExceeded
60+
case recommendationExceedsMaxBolus
6061
}
6162

6263
@Published var activeNotice: Notice?
@@ -106,17 +107,48 @@ class SimpleBolusViewModel: ObservableObject {
106107
}
107108

108109
private func updateNotice() {
109-
let minRecommendationGlucose = displayMealEntry ? LoopConstants.simpleBolusCalculatorMinGlucoseMealBolusRecommendation : LoopConstants.simpleBolusCalculatorMinGlucoseBolusRecommendation
110+
111+
if let carbs = self.carbQuantity {
112+
guard carbs <= LoopConstants.maxCarbEntryQuantity else {
113+
activeNotice = .carbohydrateEntryTooLarge
114+
return
115+
}
116+
}
117+
118+
if let bolus = bolus {
119+
guard bolus.doubleValue(for: .internationalUnit()) <= delegate.maximumBolus else {
120+
activeNotice = .maxBolusExceeded
121+
return
122+
}
123+
}
124+
125+
let isAddingCarbs: Bool
126+
if let carbQuantity = carbQuantity, carbQuantity.doubleValue(for: .gram()) > 0 {
127+
isAddingCarbs = true
128+
} else {
129+
isAddingCarbs = false
130+
}
131+
132+
let minRecommendationGlucose =
133+
isAddingCarbs ?
134+
LoopConstants.simpleBolusCalculatorMinGlucoseMealBolusRecommendation :
135+
LoopConstants.simpleBolusCalculatorMinGlucoseBolusRecommendation
110136

111137
switch manualGlucoseQuantity {
138+
case let .some(g) where !LoopConstants.validManualGlucoseEntryRange.contains(g):
139+
activeNotice = .glucoseOutOfAllowedInputRange
112140
case let g? where g < minRecommendationGlucose:
113141
activeNotice = .glucoseBelowRecommendationLimit
114142
case let g? where g < LoopConstants.simpleBolusCalculatorGlucoseWarningLimit:
115143
activeNotice = .glucoseWarning
116144
case let g? where g < suspendThreshold:
117145
activeNotice = .glucoseBelowSuspendThreshold
118146
default:
119-
activeNotice = nil
147+
if let recommendation = recommendation, recommendation > delegate.maximumBolus {
148+
activeNotice = .recommendationExceedsMaxBolus
149+
} else {
150+
activeNotice = nil
151+
}
120152
}
121153

122154
}
@@ -139,13 +171,14 @@ class SimpleBolusViewModel: ObservableObject {
139171
}
140172
}
141173

142-
@Published var enteredBolusAmount: String {
174+
@Published var enteredBolusString: String {
143175
didSet {
144-
if let enteredBolusAmount = Self.doseAmountFormatter.number(from: enteredBolusAmount)?.doubleValue, enteredBolusAmount > 0 {
176+
if let enteredBolusAmount = Self.doseAmountFormatter.number(from: enteredBolusString)?.doubleValue, enteredBolusAmount > 0 {
145177
bolus = HKQuantity(unit: .internationalUnit(), doubleValue: enteredBolusAmount)
146178
} else {
147179
bolus = nil
148180
}
181+
updateNotice()
149182
}
150183
}
151184

@@ -172,12 +205,12 @@ class SimpleBolusViewModel: ObservableObject {
172205

173206
private var recommendation: Double? = nil {
174207
didSet {
175-
if let recommendation = recommendation, let recommendationString = Self.doseAmountFormatter.string(from: recommendation) {
176-
recommendedBolus = recommendationString
177-
enteredBolusAmount = recommendationString
208+
if let recommendation = recommendation {
209+
recommendedBolus = Self.doseAmountFormatter.string(from: recommendation)!
210+
enteredBolusString = Self.doseAmountFormatter.string(from: min(recommendation, delegate.maximumBolus))!
178211
} else {
179212
recommendedBolus = NSLocalizedString("", comment: "String denoting lack of a recommended bolus amount in the simple bolus calculator")
180-
enteredBolusAmount = Self.doseAmountFormatter.string(from: 0.0)!
213+
enteredBolusString = Self.doseAmountFormatter.string(from: 0.0)!
181214
}
182215
}
183216
}
@@ -223,6 +256,15 @@ class SimpleBolusViewModel: ObservableObject {
223256
}
224257
}
225258

259+
var actionButtonDisabled: Bool {
260+
switch activeNotice {
261+
case .glucoseOutOfAllowedInputRange, .maxBolusExceeded, .carbohydrateEntryTooLarge:
262+
return true
263+
default:
264+
return false
265+
}
266+
}
267+
226268
var carbPlaceholder: String {
227269
Self.carbAmountFormatter.string(from: 0.0)!
228270
}
@@ -233,8 +275,9 @@ class SimpleBolusViewModel: ObservableObject {
233275

234276
private lazy var bolusVolumeFormatter = QuantityFormatter(for: .internationalUnit())
235277

236-
var maximumBolusAmountString: String? {
237-
return bolusVolumeFormatter.numberFormatter.string(from: delegate.maximumBolus) ?? String(delegate.maximumBolus)
278+
var maximumBolusAmountString: String {
279+
let maxBolusQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: delegate.maximumBolus)
280+
return bolusVolumeFormatter.string(from: maxBolusQuantity, for: .internationalUnit())!
238281
}
239282

240283
init(delegate: SimpleBolusViewModelDelegate, displayMealEntry: Bool) {
@@ -243,13 +286,28 @@ class SimpleBolusViewModel: ObservableObject {
243286
let glucoseQuantityFormatter = QuantityFormatter()
244287
glucoseQuantityFormatter.setPreferredNumberFormatter(for: delegate.displayGlucoseUnitObservable.displayGlucoseUnit)
245288
cachedDisplayGlucoseUnit = delegate.displayGlucoseUnitObservable.displayGlucoseUnit
246-
enteredBolusAmount = Self.doseAmountFormatter.string(from: 0.0)!
289+
enteredBolusString = Self.doseAmountFormatter.string(from: 0.0)!
247290
updateRecommendation()
248291
dosingDecision = BolusDosingDecision()
249292
}
250293

251294
func updateRecommendation() {
252295
let recommendationDate = Date()
296+
297+
if let carbs = self.carbQuantity {
298+
guard carbs <= LoopConstants.maxCarbEntryQuantity else {
299+
recommendation = nil
300+
return
301+
}
302+
}
303+
304+
if let glucose = manualGlucoseQuantity {
305+
guard LoopConstants.validManualGlucoseEntryRange.contains(glucose) else {
306+
recommendation = nil
307+
return
308+
}
309+
}
310+
253311
if carbQuantity != nil || manualGlucoseQuantity != nil {
254312
dosingDecision = delegate.computeSimpleBolusRecommendation(at: recommendationDate, mealCarbs: carbQuantity, manualGlucose: manualGlucoseQuantity)
255313
if let decision = dosingDecision, let bolusRecommendation = decision.recommendedBolus {
@@ -273,36 +331,13 @@ class SimpleBolusViewModel: ObservableObject {
273331
}
274332

275333
func saveAndDeliver(completion: @escaping (Bool) -> Void) {
276-
if let bolus = bolus {
277-
guard bolus.doubleValue(for: .internationalUnit()) <= delegate.maximumBolus else {
278-
presentAlert(.maxBolusExceeded)
279-
completion(false)
280-
return
281-
}
282-
}
283-
284-
if let manualGlucoseQuantity = manualGlucoseQuantity {
285-
guard LoopConstants.validManualGlucoseEntryRange.contains(manualGlucoseQuantity) else {
286-
presentAlert(.manualGlucoseEntryOutOfAcceptableRange)
287-
completion(false)
288-
return
289-
}
290-
}
291-
292-
if let carbs = carbQuantity {
293-
guard carbs <= LoopConstants.maxCarbEntryQuantity else {
294-
presentAlert(.carbEntrySizeTooLarge)
295-
completion(false)
296-
return
297-
}
298-
}
299334

300335
let saveDate = Date()
301336

302337
// Authenticate the bolus before saving anything
303338
func authenticateIfNeeded(_ completion: @escaping (Bool) -> Void) {
304339
if let bolus = bolus, bolus.doubleValue(for: .internationalUnit()) > 0 {
305-
let message = String(format: NSLocalizedString("Authenticate to Bolus %@ Units", comment: "The message displayed during a device authentication prompt for bolus specification"), enteredBolusAmount)
340+
let message = String(format: NSLocalizedString("Authenticate to Bolus %@ Units", comment: "The message displayed during a device authentication prompt for bolus specification"), enteredBolusString)
306341
authenticate(message) {
307342
switch $0 {
308343
case .success:

Loop/Views/SimpleBolusView.swift

Lines changed: 32 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ struct SimpleBolusView: View {
7474
}
7575
}
7676

77+
let glucoseFormatter = QuantityFormatter()
78+
79+
private func formatGlucose(_ quantity: HKQuantity) -> String {
80+
return glucoseFormatter.string(from: quantity, for: displayGlucoseUnitObservable.displayGlucoseUnit)!
81+
}
82+
7783
private func shouldAutoScroll(basedOn geometry: GeometryProxy) -> Bool {
7884
// Taking a guess of 640 to cover iPhone SE, iPod Touch, and other smaller devices.
7985
// Devices such as the iPhone 11 Pro Max do not need to auto-scroll.
@@ -197,7 +203,7 @@ struct SimpleBolusView: View {
197203
Spacer()
198204
HStack(alignment: .firstTextBaseline) {
199205
DismissibleKeyboardTextField(
200-
text: $viewModel.enteredBolusAmount,
206+
text: $viewModel.enteredBolusString,
201207
placeholder: "",
202208
font: .preferredFont(forTextStyle: .title1),
203209
textColor: .loopAccent,
@@ -269,42 +275,18 @@ struct SimpleBolusView: View {
269275
}
270276
}
271277
)
278+
.disabled(viewModel.actionButtonDisabled)
272279
.buttonStyle(ActionButtonStyle(.primary))
273280
.padding()
274281
}
275282

276283
private func alert(for alert: SimpleBolusViewModel.Alert) -> SwiftUI.Alert {
277284
switch alert {
278-
case .maxBolusExceeded:
279-
guard let maximumBolusAmountString = viewModel.maximumBolusAmountString else {
280-
fatalError("Impossible to exceed max bolus without a configured max bolus")
281-
}
282-
return SwiftUI.Alert(
283-
title: Text("Exceeds Maximum Bolus", comment: "Alert title for a maximum bolus validation error"),
284-
message: Text(String(format: NSLocalizedString("The maximum bolus amount is %1$@ U.", comment: "Format string for maximum bolus exceeded alert (1: maximumBolusAmount)"), maximumBolusAmountString))
285-
)
286285
case .carbEntryPersistenceFailure:
287286
return SwiftUI.Alert(
288287
title: Text("Unable to Save Carb Entry", comment: "Alert title for a carb entry persistence error"),
289288
message: Text("An error occurred while trying to save your carb entry.", comment: "Alert message for a carb entry persistence error")
290289
)
291-
case .carbEntrySizeTooLarge:
292-
let message = String(
293-
format: NSLocalizedString("The maximum allowed amount is %1$@ grams", comment: "Alert body displayed for quantity greater than max (1: maximum quantity in grams)"),
294-
NumberFormatter.localizedString(from: NSNumber(value: LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram())), number: .none)
295-
)
296-
return SwiftUI.Alert(
297-
title: Text("Carb Entry Too Large", comment: "Alert title for a carb entry too large error"),
298-
message: Text(message)
299-
)
300-
case .manualGlucoseEntryOutOfAcceptableRange:
301-
let formatter = QuantityFormatter(for: displayGlucoseUnitObservable.displayGlucoseUnit)
302-
let acceptableLowerBound = formatter.string(from: LoopConstants.validManualGlucoseEntryRange.lowerBound, for: displayGlucoseUnitObservable.displayGlucoseUnit) ?? String(describing: LoopConstants.validManualGlucoseEntryRange.lowerBound)
303-
let acceptableUpperBound = formatter.string(from: LoopConstants.validManualGlucoseEntryRange.upperBound, for: displayGlucoseUnitObservable.displayGlucoseUnit) ?? String(describing: LoopConstants.validManualGlucoseEntryRange.upperBound)
304-
return SwiftUI.Alert(
305-
title: Text("Glucose Entry Out of Range", comment: "Alert title for a manual glucose entry out of range error"),
306-
message: Text(String(format: NSLocalizedString("A manual glucose entry must be between %1$@ and %2$@", comment: "Alert message for a manual glucose entry out of range error. (1: acceptable lower bound) (2: acceptable upper bound)"), acceptableLowerBound, acceptableUpperBound))
307-
)
308290
case .manualGlucoseEntryPersistenceFailure:
309291
return SwiftUI.Alert(
310292
title: Text("Unable to Save Manual Glucose Entry", comment: "Alert title for a manual glucose entry persistence error"),
@@ -315,7 +297,7 @@ struct SimpleBolusView: View {
315297
}
316298

317299
}
318-
300+
319301
private func warning(for notice: SimpleBolusViewModel.Notice) -> some View {
320302

321303
switch notice {
@@ -326,13 +308,13 @@ struct SimpleBolusView: View {
326308
} else {
327309
title = Text("No Bolus Recommended", comment: "Title for bolus screen warning when glucose is below suspend threshold, and a bolus is not recommended")
328310
}
329-
let suspendThresholdString = QuantityFormatter().string(from: viewModel.suspendThreshold, for: displayGlucoseUnitObservable.displayGlucoseUnit) ?? String(describing: viewModel.suspendThreshold)
311+
let suspendThresholdString = formatGlucose(viewModel.suspendThreshold)
330312
return WarningView(
331313
title: title,
332314
caption: Text(String(format: NSLocalizedString("Your glucose is below your glucose safety limit, %1$@.", comment: "Format string for bolus screen warning when no bolus is recommended due input value below glucose safety limit. (1: suspendThreshold)"), suspendThresholdString))
333315
)
334316
case .glucoseWarning:
335-
let warningThresholdString = QuantityFormatter().string(from: LoopConstants.simpleBolusCalculatorGlucoseWarningLimit, for: displayGlucoseUnitObservable.displayGlucoseUnit)!
317+
let warningThresholdString = formatGlucose(LoopConstants.simpleBolusCalculatorGlucoseWarningLimit)
336318
return WarningView(
337319
title: Text("Low Glucose", comment: "Title for bolus screen warning when glucose is below glucose warning limit."),
338320
caption: Text(String(format: NSLocalizedString("Your glucose is below %1$@. Are you sure you want to bolus?", comment: "Format string for simple bolus screen warning when glucose is below glucose warning limit."), warningThresholdString))
@@ -342,12 +324,31 @@ struct SimpleBolusView: View {
342324
if viewModel.displayMealEntry {
343325
caption = NSLocalizedString("Your glucose is low. Eat carbs and consider waiting to bolus until your glucose is in a safe range.", comment: "Format string for meal bolus screen warning when no bolus is recommended due to glucose input value below recommendation threshold")
344326
} else {
345-
caption = NSLocalizedString("Your glucose is low. Eat carbs and monitor closely.", comment: "Format string for bolus screen warning when no bolus is recommended due to glucose input value below recommendation threshold for meal bolus")
327+
caption = NSLocalizedString("Your glucose is low. Eat carbs and monitor closely.", comment: "Bolus screen warning when no bolus is recommended due to glucose input value below recommendation threshold for meal bolus")
346328
}
347329
return WarningView(
348-
title: Text("No Bolus Recommended", comment: "Title for bolus screen notice when no bolus is recommended"),
330+
title: Text("No Bolus Recommended", comment: "Title for bolus screen warning when no bolus is recommended"),
349331
caption: Text(caption)
350332
)
333+
case .glucoseOutOfAllowedInputRange:
334+
let glucoseMinString = formatGlucose(LoopConstants.validManualGlucoseEntryRange.lowerBound)
335+
let glucoseMaxString = formatGlucose(LoopConstants.validManualGlucoseEntryRange.upperBound)
336+
return WarningView(
337+
title: Text("Glucose Entry Out of Range", comment: "Title for bolus screen warning when glucose entry is out of range"),
338+
caption: Text(String(format: NSLocalizedString("A manual glucose entry must be between %1$@ and %2$@.", comment: "Warning for simple bolus when glucose entry is out of range. (1: upper bound) (2: lower bound)"), glucoseMinString, glucoseMaxString)))
339+
case .maxBolusExceeded:
340+
return WarningView(
341+
title: Text("Maximum Bolus Exceeded", comment: "Title for bolus screen warning when max bolus is exceeded"),
342+
caption: Text(String(format: NSLocalizedString("Your maximum bolus amount is %1$@.", comment: "Warning for simple bolus when max bolus is exceeded. (1: maximum bolus)"), viewModel.maximumBolusAmountString )))
343+
case .recommendationExceedsMaxBolus:
344+
return WarningView(
345+
title: Text("Recommended Bolus Exceeds Maximum Bolus", comment: "Title for bolus screen warning when recommended bolus exceeds max bolus"),
346+
caption: Text(String(format: NSLocalizedString("Your recommended bolus exceeds your maximum bolus amount of %1$@.", comment: "Warning for simple bolus when recommended bolus exceeds max bolus. (1: maximum bolus)"), viewModel.maximumBolusAmountString )))
347+
case .carbohydrateEntryTooLarge:
348+
let maximumCarbohydrateString = QuantityFormatter().string(from: LoopConstants.maxCarbEntryQuantity, for: .gram())!
349+
return WarningView(
350+
title: Text("Carbohydrate Entry Too Large", comment: "Title for bolus screen warning when carbohydrate entry is too large"),
351+
caption: Text(String(format: NSLocalizedString("The maximum amount allowed is %1$@.", comment: "Warning for simple bolus when carbohydrate entry is too large. (1: maximum carbohydrate entry)"), maximumCarbohydrateString)))
351352
}
352353
}
353354

0 commit comments

Comments
 (0)