Skip to content

Commit 64996c1

Browse files
authored
Add missed meal notifications to Loop (#1825)
* Add unannounced meal func to CarbStoreProtocol * V1 of UAM algo * Add debug logs * Handle unannounced meal notification & open up controller * Add mealstart to notification userinfo * Improve notification description * Add direct entry to carb flow from notification on watch * Add auto-setting of carb entry on watch * carbThreshold -> mealCarbThreshold * Retract UAM notifications after the carbs have expired * Improve function naming * Move notification logic from LoopKit to Loop * Make current date configurable * Make current date configurable during unit testing * Update status enum naming for point-of-use clarity * Make 'now' time configurable * Extract loop data manager testing logic into base class * Extract loop data manager dosing tests into their own file * Add unannounced meal tests * Fix concurrency issue * Pull UAM constants into separate struct * Fix notifications not being retracted after their expiration date * Expire using start of meal instead of current time * Add unannounced meal notifications permission * Add AlertPermissionsViewModel * Remove unneeded `AnyView`s * Removed unused completion block * Use the counteraction effects passed into the function * Schedule missed meal notifications to avoid notification during an microbolus (#3) * Delay missed meal notification if possible to avoid delivering it during an autobolus * Add tests for notification delay * Update `estimatedDuration` function headers in response to PR feedback * Add UAM banner to carb entry screen (#5) * Add warning banner to remind the user to edit the meal estimate * Update warning text * UAM algo updates: only use directly observed carb absorption (#6) * Add ability to calculate the number of carbs in a missed meal (#4) * Plumb a customizable carb amount through UAM notification architecture * Fix merge conflict * Update tests for changes from the merge * Make UAM notifications unit testable * Add carb autofill clamping based on the user's carb threshold & max bolus * Update target order to group extensions together * Improve issue report description * Create `MealDetectionManager` from old UAM functions in `CarbStore` * Move notification logic into `MealDetectionManager` * Updates based on feedback for UAM PR (#7) * Create `MealDetectionManager` from old UAM functions in `CarbStore` * Move notification logic into `MealDetectionManager` * Move UAM test fixtures from LoopKit to Loop * Remove old TODO * Revert change to Loop signing team * Fix merge issues * Make tests runnable * Lower meal carb threshold to 15 g to reduce false-negatives * Fix carb entry controller merge issues * Unannounced meal / UAM -> missed meal * UAM test fixture -> missed meal test fixture * Variable naming improvements * Remove `AlertPermissionsViewModel` * Move call to check for missed meals to `loop()`-level * Remove `CarbStore` dependency from `MealDetectionManager` These changes also remove the requirement that there be no carbs entered after the missed meal was detected - since we're now observing direct absorption, I didn't think this requirement made sense anymore * Reduce `minRecency` required to detect a meal * Update counteraction effect math in `MealDetectionManager` to skew towards an earlier meal time instead of a later one * Fix conflict + move missed meal toggle 1 level higher
1 parent 26b62c1 commit 64996c1

27 files changed

+5697
-582
lines changed

Loop.xcodeproj/project.pbxproj

Lines changed: 75 additions & 1 deletion
Large diffs are not rendered by default.

Loop/Managers/DeviceDataManager.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1307,6 +1307,10 @@ extension DeviceDataManager: LoopDataManagerDelegate {
13071307

13081308
return rounded
13091309
}
1310+
1311+
func loopDataManager(_ manager: LoopDataManager, estimateBolusDuration units: Double) -> TimeInterval? {
1312+
pumpManager?.estimatedDuration(toBolus: units)
1313+
}
13101314

13111315
func loopDataManager(
13121316
_ manager: LoopDataManager,

Loop/Managers/LoopAppManager.swift

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -484,7 +484,8 @@ extension LoopAppManager: UNUserNotificationCenterDelegate {
484484
LoopNotificationCategory.remoteBolus.rawValue,
485485
LoopNotificationCategory.remoteBolusFailure.rawValue,
486486
LoopNotificationCategory.remoteCarbs.rawValue,
487-
LoopNotificationCategory.remoteCarbsFailure.rawValue:
487+
LoopNotificationCategory.remoteCarbsFailure.rawValue,
488+
LoopNotificationCategory.missedMeal.rawValue:
488489
completionHandler([.badge, .sound, .list, .banner])
489490
default:
490491
// For all others, banners are not to be displayed while in the foreground
@@ -516,6 +517,28 @@ extension LoopAppManager: UNUserNotificationCenterDelegate {
516517
let managerIdentifier = userInfo[LoopNotificationUserInfoKey.managerIDForAlert.rawValue] as? String {
517518
alertManager?.acknowledgeAlert(identifier: Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: alertIdentifier))
518519
}
520+
case UNNotificationDefaultActionIdentifier:
521+
guard response.notification.request.identifier == LoopNotificationCategory.missedMeal.rawValue else {
522+
break
523+
}
524+
525+
let carbActivity = NSUserActivity.forNewCarbEntry()
526+
let userInfo = response.notification.request.content.userInfo
527+
528+
if
529+
let mealTime = userInfo[LoopNotificationUserInfoKey.missedMealTime.rawValue] as? Date,
530+
let carbAmount = userInfo[LoopNotificationUserInfoKey.missedMealCarbAmount.rawValue] as? Double
531+
{
532+
let missedEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(),
533+
doubleValue: carbAmount),
534+
startDate: mealTime,
535+
foodType: nil,
536+
absorptionTime: nil)
537+
carbActivity.update(from: missedEntry, isMissedMeal: true)
538+
}
539+
540+
rootViewController?.restoreUserActivityState(carbActivity)
541+
519542
default:
520543
break
521544
}

Loop/Managers/LoopDataManager.swift

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ final class LoopDataManager {
3030
static let LoopUpdateContextKey = "com.loudnate.Loop.LoopDataManager.LoopUpdateContext"
3131

3232
private let carbStore: CarbStoreProtocol
33+
34+
private let mealDetectionManager: MealDetectionManager
3335

3436
private let doseStore: DoseStoreProtocol
3537

@@ -109,6 +111,11 @@ final class LoopDataManager {
109111
self.now = now
110112

111113
self.latestStoredSettingsProvider = latestStoredSettingsProvider
114+
self.mealDetectionManager = MealDetectionManager(
115+
carbRatioScheduleApplyingOverrideHistory: carbStore.carbRatioScheduleApplyingOverrideHistory,
116+
insulinSensitivityScheduleApplyingOverrideHistory: carbStore.insulinSensitivityScheduleApplyingOverrideHistory,
117+
maximumBolus: settings.maximumBolus
118+
)
112119

113120
self.lockedPumpInsulinType = Locked(pumpInsulinType)
114121

@@ -259,11 +266,16 @@ final class LoopDataManager {
259266

260267
// Invalidate cached effects affected by the override
261268
invalidateCachedEffects = true
269+
270+
// Update the affected schedules
271+
mealDetectionManager.carbRatioScheduleApplyingOverrideHistory = carbRatioScheduleApplyingOverrideHistory
272+
mealDetectionManager.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivityScheduleApplyingOverrideHistory
262273
}
263274

264275
if newValue.insulinSensitivitySchedule != oldValue.insulinSensitivitySchedule {
265276
carbStore.insulinSensitivitySchedule = newValue.insulinSensitivitySchedule
266277
doseStore.insulinSensitivitySchedule = newValue.insulinSensitivitySchedule
278+
mealDetectionManager.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivityScheduleApplyingOverrideHistory
267279
invalidateCachedEffects = true
268280
analyticsServicesManager.didChangeInsulinSensitivitySchedule()
269281
}
@@ -278,6 +290,7 @@ final class LoopDataManager {
278290

279291
if newValue.carbRatioSchedule != oldValue.carbRatioSchedule {
280292
carbStore.carbRatioSchedule = newValue.carbRatioSchedule
293+
mealDetectionManager.carbRatioScheduleApplyingOverrideHistory = carbRatioScheduleApplyingOverrideHistory
281294
invalidateCachedEffects = true
282295
analyticsServicesManager.didChangeCarbRatioSchedule()
283296
}
@@ -292,6 +305,10 @@ final class LoopDataManager {
292305
analyticsServicesManager.didChangeInsulinModel()
293306
}
294307

308+
if newValue.maximumBolus != oldValue.maximumBolus {
309+
mealDetectionManager.maximumBolus = newValue.maximumBolus
310+
}
311+
295312
if invalidateCachedEffects {
296313
dataAccessQueue.async {
297314
// Invalidate cached effects based on this schedule
@@ -857,6 +874,28 @@ extension LoopDataManager {
857874
logger.default("Loop ended")
858875
notify(forChange: .loopFinished)
859876

877+
let carbEffectStart = now().addingTimeInterval(-MissedMealSettings.maxRecency)
878+
carbStore.getGlucoseEffects(start: carbEffectStart, end: now(), effectVelocities: insulinCounteractionEffects) {[weak self] result in
879+
guard
880+
let self = self,
881+
case .success((_, let carbEffects)) = result
882+
else {
883+
if case .failure(let error) = result {
884+
self?.logger.error("Failed to fetch glucose effects to check for missed meal: %{public}@", String(describing: error))
885+
}
886+
return
887+
}
888+
889+
self.mealDetectionManager.generateMissedMealNotificationIfNeeded(
890+
insulinCounteractionEffects: self.insulinCounteractionEffects,
891+
carbEffects: carbEffects,
892+
pendingAutobolusUnits: self.recommendedAutomaticDose?.recommendation.bolusUnits,
893+
bolusDurationEstimator: { [unowned self] bolusAmount in
894+
return self.delegate?.loopDataManager(self, estimateBolusDuration: bolusAmount)
895+
}
896+
)
897+
}
898+
860899
// 5 second delay to allow stores to cache data before it is read by widget
861900
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
862901
self.widgetLog.default("Refreshing widget. Reason: Loop completed")
@@ -1764,7 +1803,6 @@ extension LoopDataManager {
17641803
}
17651804
}
17661805
}
1767-
17681806
}
17691807

17701808
/// Describes a view into the loop state
@@ -2104,16 +2142,21 @@ extension LoopDataManager {
21042142
self.doseStore.generateDiagnosticReport { (report) in
21052143
entries.append(report)
21062144
entries.append("")
2107-
2108-
UNUserNotificationCenter.current().generateDiagnosticReport { (report) in
2145+
2146+
self.mealDetectionManager.generateDiagnosticReport { report in
21092147
entries.append(report)
21102148
entries.append("")
2111-
2112-
UIDevice.current.generateDiagnosticReport { (report) in
2149+
2150+
UNUserNotificationCenter.current().generateDiagnosticReport { (report) in
21132151
entries.append(report)
21142152
entries.append("")
21152153

2116-
completion(entries.joined(separator: "\n"))
2154+
UIDevice.current.generateDiagnosticReport { (report) in
2155+
entries.append(report)
2156+
entries.append("")
2157+
2158+
completion(entries.joined(separator: "\n"))
2159+
}
21172160
}
21182161
}
21192162
}
@@ -2147,7 +2190,14 @@ protocol LoopDataManagerDelegate: AnyObject {
21472190
/// - rate: The recommended rate in U/hr
21482191
/// - Returns: a supported rate of delivery in Units/hr. The rate returned should not be larger than the passed in rate.
21492192
func roundBasalRate(unitsPerHour: Double) -> Double
2150-
2193+
2194+
/// Asks the delegate to estimate the duration to deliver the bolus.
2195+
///
2196+
/// - Parameters:
2197+
/// - bolusUnits: size of the bolus in U
2198+
/// - Returns: the estimated time it will take to deliver bolus
2199+
func loopDataManager(_ manager: LoopDataManager, estimateBolusDuration bolusUnits: Double) -> TimeInterval?
2200+
21512201
/// Asks the delegate to round a recommended bolus volume to a supported volume
21522202
///
21532203
/// - Parameters:

0 commit comments

Comments
 (0)