From 8eae264e4ff19b14f447a1c9d4e90a6d26a7209a Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 7 Jan 2020 20:59:29 -0600 Subject: [PATCH 01/44] Bump Loop version to 2.1 for dev --- Loop.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop.xcconfig b/Loop.xcconfig index 1d0542afda..a87dc4ee33 100644 --- a/Loop.xcconfig +++ b/Loop.xcconfig @@ -11,7 +11,7 @@ MAIN_APP_BUNDLE_IDENTIFIER = com.${DEVELOPMENT_TEAM}.loopkit MAIN_APP_DISPLAY_NAME = Loop -LOOP_MARKETING_VERSION = 1.10.3 +LOOP_MARKETING_VERSION = 2.1.0 APPICON_NAME = AppIcon From 71b4f4140d3fb6aa7729b23b6319f7662fbe644f Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sun, 12 Jan 2020 09:39:40 -0600 Subject: [PATCH 02/44] Increase history interval for carb cache --- Loop/Managers/LoopDataManager.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 5bbc7611c6..fe94c2a13e 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -65,6 +65,7 @@ final class LoopDataManager { carbStore = CarbStore( healthStore: healthStore, cacheStore: cacheStore, + cacheLength: .hours(24), defaultAbsorptionTimes: LoopSettings.defaultCarbAbsorptionTimes, carbRatioSchedule: carbRatioSchedule, insulinSensitivitySchedule: insulinSensitivitySchedule, From 0a19eff8fbdc522825794a43ebe14fd8270e6e1b Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sun, 12 Jan 2020 09:48:55 -0600 Subject: [PATCH 03/44] Include current bg when considering suspend threshold and dosing threshold --- Loop/Managers/LoopDataManager.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 5bbc7611c6..dd384c2683 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -993,6 +993,7 @@ extension LoopDataManager { let tempBasal = predictedGlucose.recommendedTempBasal( to: glucoseTargetRange, + at: predictedGlucose[0].startDate, suspendThreshold: settings.suspendThreshold?.quantity, sensitivity: insulinSensitivity, model: model, @@ -1019,6 +1020,7 @@ extension LoopDataManager { let recommendation = predictedGlucose.recommendedBolus( to: glucoseTargetRange, + at: predictedGlucose[0].startDate, suspendThreshold: settings.suspendThreshold?.quantity, sensitivity: insulinSensitivity, model: model, From 012aa23fdbf1eeba41aaf7da374b841baff0f9f0 Mon Sep 17 00:00:00 2001 From: "@dm61" Date: Tue, 14 Jan 2020 13:41:43 -0700 Subject: [PATCH 04/44] Include insulin model delay in computation of dose --- Loop/Managers/DoseMath.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Loop/Managers/DoseMath.swift b/Loop/Managers/DoseMath.swift index 5502084671..c0e82b1597 100644 --- a/Loop/Managers/DoseMath.swift +++ b/Loop/Managers/DoseMath.swift @@ -236,6 +236,7 @@ extension Collection where Element: GlucoseValue { let unit = correctionRange.unit let sensitivityValue = sensitivity.doubleValue(for: unit) let suspendThresholdValue = suspendThreshold.doubleValue(for: unit) + let delay = TimeInterval(minutes: 10) // For each prediction above target, determine the amount of insulin necessary to correct glucose based on the modeled effectiveness of the insulin at that time for prediction in self { @@ -267,7 +268,8 @@ extension Collection where Element: GlucoseValue { // Compute the dose required to bring this prediction to target: // dose = (Glucose Δ) / (% effect × sensitivity) - let percentEffected = 1 - model.percentEffectRemaining(at: time) + // For 0 <= time <= delay, assume a small amount effected. This will result in large unit recommendation rather than no recommendation at all. + let percentEffected = Swift.max(.ulpOfOne, 1 - model.percentEffectRemaining(at: time - delay)) let effectedSensitivity = percentEffected * sensitivityValue guard let correctionUnits = insulinCorrectionUnits( fromValue: predictedGlucoseValue, @@ -299,8 +301,8 @@ extension Collection where Element: GlucoseValue { eventual.quantity < eventualGlucoseTargets.lowerBound { let time = min.startDate.timeIntervalSince(date) - // For time = 0, assume a small amount effected. This will result in large (negative) unit recommendation rather than no recommendation at all. - let percentEffected = Swift.max(.ulpOfOne, 1 - model.percentEffectRemaining(at: time)) + // For 0 <= time <= delay, assume a small amount effected. This will result in large (negative) unit recommendation rather than no recommendation at all. + let percentEffected = Swift.max(.ulpOfOne, 1 - model.percentEffectRemaining(at: time - delay)) guard let units = insulinCorrectionUnits( fromValue: min.quantity.doubleValue(for: unit), From c45caaa22a5921c93e169cd88754d1bc80949efa Mon Sep 17 00:00:00 2001 From: "@dm61" Date: Tue, 14 Jan 2020 14:14:59 -0700 Subject: [PATCH 05/44] Fix dose math tests --- DoseMathTests/DoseMathTests.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/DoseMathTests/DoseMathTests.swift b/DoseMathTests/DoseMathTests.swift index f2c1b495cf..1cf10a89a3 100644 --- a/DoseMathTests/DoseMathTests.swift +++ b/DoseMathTests/DoseMathTests.swift @@ -347,7 +347,7 @@ class RecommendTempBasalTests: XCTestCase { lastTempBasal: nil ) - XCTAssertEqual(1.425, dose!.unitsPerHour, accuracy: 1.0 / 40.0) + XCTAssertEqual(1.450, dose!.unitsPerHour, accuracy: 1.0 / 40.0) XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) } @@ -588,7 +588,7 @@ class RecommendBolusTests: XCTestCase { volumeRounder: fortyIncrementsPerUnitRounder ) - XCTAssertEqual(1.575, dose.amount) + XCTAssertEqual(1.625, dose.amount) if case BolusRecommendationNotice.currentGlucoseBelowTarget(let glucose) = dose.notice! { XCTAssertEqual(glucose.quantity.doubleValue(for: .milligramsPerDeciliter), 60) @@ -657,7 +657,7 @@ class RecommendBolusTests: XCTestCase { volumeRounder: fortyIncrementsPerUnitRounder ) - XCTAssertEqual(1.4, dose.amount) + XCTAssertEqual(1.575, dose.amount) XCTAssertEqual(BolusRecommendationNotice.predictedGlucoseBelowTarget(minGlucose: glucose[1]), dose.notice!) } @@ -676,7 +676,7 @@ class RecommendBolusTests: XCTestCase { volumeRounder: fortyIncrementsPerUnitRounder ) - XCTAssertEqual(0.575, dose.amount) + XCTAssertEqual(0.625, dose.amount) } func testStartVeryLowEndHigh() { @@ -708,7 +708,7 @@ class RecommendBolusTests: XCTestCase { maxBolus: maxBolus ) - XCTAssertEqual(1.575, dose.amount, accuracy: 1.0 / 40.0) + XCTAssertEqual(1.625, dose.amount, accuracy: 1.0 / 40.0) } func testHighAndFalling() { @@ -787,7 +787,7 @@ class RecommendBolusTests: XCTestCase { maxBolus: maxBolus ) - XCTAssertEqual(1.25, dose.amount) + XCTAssertEqual(1.30, dose.amount, accuracy: 1.0 / 40.0) // Use mmol sensitivity value let insulinSensitivitySchedule = InsulinSensitivitySchedule(unit: HKUnit.millimolesPerLiter, dailyItems: [RepeatingScheduleValue(startTime: 0.0, value: 10.0 / 3)])! @@ -802,7 +802,7 @@ class RecommendBolusTests: XCTestCase { maxBolus: maxBolus ) - XCTAssertEqual(1.25, dose.amount, accuracy: 1.0 / 40.0) + XCTAssertEqual(1.30, dose.amount, accuracy: 1.0 / 40.0) } func testRiseAfterDIA() { From 40ae183e4b65c7a3e249181b759580493d2cfa99 Mon Sep 17 00:00:00 2001 From: "@dm61" Date: Fri, 17 Jan 2020 22:01:48 -0700 Subject: [PATCH 06/44] add delay to insulin model presets --- DoseMathTests/DoseMathTests.swift | 20 +++++++++---------- Loop/Managers/DoseMath.swift | 9 ++++----- .../ExponentialInsulinModelPreset.swift | 17 +++++++++++++++- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/DoseMathTests/DoseMathTests.swift b/DoseMathTests/DoseMathTests.swift index 1cf10a89a3..6637784f2b 100644 --- a/DoseMathTests/DoseMathTests.swift +++ b/DoseMathTests/DoseMathTests.swift @@ -347,7 +347,7 @@ class RecommendTempBasalTests: XCTestCase { lastTempBasal: nil ) - XCTAssertEqual(1.450, dose!.unitsPerHour, accuracy: 1.0 / 40.0) + XCTAssertEqual(1.60, dose!.unitsPerHour, accuracy: 1.0 / 40.0) XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) } @@ -365,7 +365,7 @@ class RecommendTempBasalTests: XCTestCase { lastTempBasal: nil ) - XCTAssertEqual(1.475, dose!.unitsPerHour, accuracy: 1.0 / 40.0) + XCTAssertEqual(1.60, dose!.unitsPerHour, accuracy: 1.0 / 40.0) XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) } @@ -588,7 +588,7 @@ class RecommendBolusTests: XCTestCase { volumeRounder: fortyIncrementsPerUnitRounder ) - XCTAssertEqual(1.625, dose.amount) + XCTAssertEqual(1.7, dose.amount) if case BolusRecommendationNotice.currentGlucoseBelowTarget(let glucose) = dose.notice! { XCTAssertEqual(glucose.quantity.doubleValue(for: .milligramsPerDeciliter), 60) @@ -676,7 +676,7 @@ class RecommendBolusTests: XCTestCase { volumeRounder: fortyIncrementsPerUnitRounder ) - XCTAssertEqual(0.625, dose.amount) + XCTAssertEqual(0.7, dose.amount) } func testStartVeryLowEndHigh() { @@ -708,7 +708,7 @@ class RecommendBolusTests: XCTestCase { maxBolus: maxBolus ) - XCTAssertEqual(1.625, dose.amount, accuracy: 1.0 / 40.0) + XCTAssertEqual(1.7, dose.amount, accuracy: 1.0 / 40.0) } func testHighAndFalling() { @@ -724,7 +724,7 @@ class RecommendBolusTests: XCTestCase { maxBolus: maxBolus ) - XCTAssertEqual(0.325, dose.amount, accuracy: 1.0 / 40.0) + XCTAssertEqual(0.4, dose.amount, accuracy: 1.0 / 40.0) } func testInRangeAndRising() { @@ -740,7 +740,7 @@ class RecommendBolusTests: XCTestCase { maxBolus: maxBolus ) - XCTAssertEqual(0.325, dose.amount, accuracy: 1.0 / 40.0) + XCTAssertEqual(0.4, dose.amount, accuracy: 1.0 / 40.0) // Less existing temp @@ -771,7 +771,7 @@ class RecommendBolusTests: XCTestCase { volumeRounder: fortyIncrementsPerUnitRounder ) - XCTAssertEqual(0.275, dose.amount) + XCTAssertEqual(0.375, dose.amount) } func testHighAndRising() { @@ -787,7 +787,7 @@ class RecommendBolusTests: XCTestCase { maxBolus: maxBolus ) - XCTAssertEqual(1.30, dose.amount, accuracy: 1.0 / 40.0) + XCTAssertEqual(1.35, dose.amount, accuracy: 1.0 / 40.0) // Use mmol sensitivity value let insulinSensitivitySchedule = InsulinSensitivitySchedule(unit: HKUnit.millimolesPerLiter, dailyItems: [RepeatingScheduleValue(startTime: 0.0, value: 10.0 / 3)])! @@ -802,7 +802,7 @@ class RecommendBolusTests: XCTestCase { maxBolus: maxBolus ) - XCTAssertEqual(1.30, dose.amount, accuracy: 1.0 / 40.0) + XCTAssertEqual(1.35, dose.amount, accuracy: 1.0 / 40.0) } func testRiseAfterDIA() { diff --git a/Loop/Managers/DoseMath.swift b/Loop/Managers/DoseMath.swift index c0e82b1597..b10e4450c0 100644 --- a/Loop/Managers/DoseMath.swift +++ b/Loop/Managers/DoseMath.swift @@ -236,7 +236,6 @@ extension Collection where Element: GlucoseValue { let unit = correctionRange.unit let sensitivityValue = sensitivity.doubleValue(for: unit) let suspendThresholdValue = suspendThreshold.doubleValue(for: unit) - let delay = TimeInterval(minutes: 10) // For each prediction above target, determine the amount of insulin necessary to correct glucose based on the modeled effectiveness of the insulin at that time for prediction in self { @@ -268,8 +267,8 @@ extension Collection where Element: GlucoseValue { // Compute the dose required to bring this prediction to target: // dose = (Glucose Δ) / (% effect × sensitivity) - // For 0 <= time <= delay, assume a small amount effected. This will result in large unit recommendation rather than no recommendation at all. - let percentEffected = Swift.max(.ulpOfOne, 1 - model.percentEffectRemaining(at: time - delay)) + // For 0 <= time <= effectDelay, assume a small amount effected. This will result in large unit recommendation rather than no recommendation at all. + let percentEffected = Swift.max(.ulpOfOne, 1 - model.percentEffectRemaining(at: time)) let effectedSensitivity = percentEffected * sensitivityValue guard let correctionUnits = insulinCorrectionUnits( fromValue: predictedGlucoseValue, @@ -301,8 +300,8 @@ extension Collection where Element: GlucoseValue { eventual.quantity < eventualGlucoseTargets.lowerBound { let time = min.startDate.timeIntervalSince(date) - // For 0 <= time <= delay, assume a small amount effected. This will result in large (negative) unit recommendation rather than no recommendation at all. - let percentEffected = Swift.max(.ulpOfOne, 1 - model.percentEffectRemaining(at: time - delay)) + // For 0 <= time <= effectDelay, assume a small amount effected. This will result in large (negative) unit recommendation rather than no recommendation at all. + let percentEffected = Swift.max(.ulpOfOne, 1 - model.percentEffectRemaining(at: time)) guard let units = insulinCorrectionUnits( fromValue: min.quantity.doubleValue(for: unit), diff --git a/LoopCore/Insulin/ExponentialInsulinModelPreset.swift b/LoopCore/Insulin/ExponentialInsulinModelPreset.swift index e2f0877ed8..e65cdd1fb7 100644 --- a/LoopCore/Insulin/ExponentialInsulinModelPreset.swift +++ b/LoopCore/Insulin/ExponentialInsulinModelPreset.swift @@ -38,9 +38,20 @@ extension ExponentialInsulinModelPreset { return .minutes(55) } } + + var effectDelay: TimeInterval { + switch self { + case .humalogNovologAdult: + return .minutes(10) + case .humalogNovologChild: + return .minutes(10) + case .fiasp: + return .minutes(10) + } + } var model: InsulinModel { - return ExponentialInsulinModel(actionDuration: actionDuration, peakActivityTime: peakActivity) + return ExponentialInsulinModel(actionDuration: actionDuration, peakActivityTime: peakActivity, delay: effectDelay) } } @@ -49,6 +60,10 @@ extension ExponentialInsulinModelPreset: InsulinModel { public var effectDuration: TimeInterval { return model.effectDuration } + + public var delay: TimeInterval { + return model.delay + } public func percentEffectRemaining(at time: TimeInterval) -> Double { return model.percentEffectRemaining(at: time) From 08a6f35a49ec408530a6ae3baf1469efb0e708ef Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sun, 19 Jan 2020 15:31:26 -0600 Subject: [PATCH 07/44] Bump carthage revs --- Cartfile | 6 +++--- Cartfile.resolved | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cartfile b/Cartfile index 439e66e555..66a46975b2 100644 --- a/Cartfile +++ b/Cartfile @@ -1,7 +1,7 @@ -github "LoopKit/LoopKit" ~> 3.0 -github "LoopKit/CGMBLEKit" ~> 3.2 +github "LoopKit/LoopKit" "dev" +github "LoopKit/CGMBLEKit" "dev" github "i-schuetz/SwiftCharts" == 0.6.5 github "LoopKit/dexcom-share-client-swift" ~> 1.2 github "LoopKit/G4ShareSpy" ~> 1.1 -github "ps2/rileylink_ios" ~> 3.0 +github "ps2/rileylink_ios" "dev" github "LoopKit/Amplitude-iOS" "decreepify" diff --git a/Cartfile.resolved b/Cartfile.resolved index 1e048677ca..3f30f97700 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -1,8 +1,8 @@ github "LoopKit/Amplitude-iOS" "2137d5fd44bf630ed33e1e72d7af6d8f8612f270" -github "LoopKit/CGMBLEKit" "v3.2" +github "LoopKit/CGMBLEKit" "aaa67861eeddfd6dd8b4b59e8d71cfa065fb07f8" github "LoopKit/G4ShareSpy" "v1.1" -github "LoopKit/LoopKit" "v3.0" +github "LoopKit/LoopKit" "bd7c0566be8cbbeb25296dc065baaf4643c5c0ae" github "LoopKit/MKRingProgressView" "f548a5c64832be2d37d7c91b5800e284887a2a0a" github "LoopKit/dexcom-share-client-swift" "v1.2" github "i-schuetz/SwiftCharts" "0.6.5" -github "ps2/rileylink_ios" "v3.0" +github "ps2/rileylink_ios" "8b762dbd3c72132141cab2061e9d15375ea46b7e" From a1fc11547ecc33f60f39d507d158553f1d2ffceb Mon Sep 17 00:00:00 2001 From: mpangburn Date: Fri, 27 Dec 2019 19:04:03 -0800 Subject: [PATCH 08/44] Unify carb entry + bolus flow --- Loop.xcodeproj/project.pbxproj | 12 +- Loop/Base.lproj/Main.storyboard | 356 +++++++++++-- .../Oval Selection.imageset/Contents.json | 53 ++ .../Oval Selection Dark.pdf | Bin 0 -> 3915 bytes .../Oval Selection.pdf | Bin 0 -> 3916 bytes Loop/Managers/LoopDataManager.swift | 183 ++++++- .../BolusViewController+LoopDataManager.swift | 51 -- .../BolusViewController.swift | 471 ++++++++++++++++- .../CarbAbsorptionViewController.swift | 75 ++- .../CarbEntryEditTableViewController.swift | 14 - .../CarbEntryViewController.swift | 488 ++++++++++++++++++ .../RootNavigationController.swift | 2 +- .../StatusTableViewController.swift | 78 ++- .../PotentialCarbEntryTableViewCell.swift | 39 ++ 14 files changed, 1593 insertions(+), 229 deletions(-) create mode 100644 Loop/DefaultAssets.xcassets/Oval Selection.imageset/Contents.json create mode 100644 Loop/DefaultAssets.xcassets/Oval Selection.imageset/Oval Selection Dark.pdf create mode 100644 Loop/DefaultAssets.xcassets/Oval Selection.imageset/Oval Selection.pdf delete mode 100644 Loop/View Controllers/BolusViewController+LoopDataManager.swift delete mode 100644 Loop/View Controllers/CarbEntryEditTableViewController.swift create mode 100644 Loop/View Controllers/CarbEntryViewController.swift create mode 100644 Loop/Views/PotentialCarbEntryTableViewCell.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 2be8f19cba..0003951137 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -341,6 +341,7 @@ 892A5D59222F0A27008961AB /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D58222F0A27008961AB /* Debug.swift */; }; 892A5D5B222F0D7C008961AB /* LoopTestingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 892A5D5A222F0D7C008961AB /* LoopTestingKit.framework */; }; 892A5D692230C41D008961AB /* RangeReplaceableCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D682230C41D008961AB /* RangeReplaceableCollection.swift */; }; + 892D7C5123B54A15008A9656 /* CarbEntryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892D7C5023B54A14008A9656 /* CarbEntryViewController.swift */; }; 892FB4CD22040104005293EC /* OverridePresetRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892FB4CC22040104005293EC /* OverridePresetRow.swift */; }; 892FB4CF220402C0005293EC /* OverrideSelectionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892FB4CE220402C0005293EC /* OverrideSelectionController.swift */; }; 895FE0952201234000FCF18A /* OverrideSelectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895FE0942201234000FCF18A /* OverrideSelectionViewController.swift */; }; @@ -353,6 +354,7 @@ 89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CA2B2F226C0161004D9350 /* DirectoryObserver.swift */; }; 89CA2B32226C18B8004D9350 /* TestingScenariosTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CA2B31226C18B8004D9350 /* TestingScenariosTableViewController.swift */; }; 89CA2B3D226E6B13004D9350 /* LocalTestingScenariosManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CA2B3C226E6B13004D9350 /* LocalTestingScenariosManager.swift */; }; + 89D6953E23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89D6953D23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift */; }; 89E267FC2292456700A3F2AF /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E267FB2292456700A3F2AF /* FeatureFlags.swift */; }; 89E267FD2292456700A3F2AF /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E267FB2292456700A3F2AF /* FeatureFlags.swift */; }; 89E267FF229267DF00A3F2AF /* Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E267FE229267DF00A3F2AF /* Optional.swift */; }; @@ -1045,6 +1047,7 @@ 892A5D58222F0A27008961AB /* Debug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debug.swift; sourceTree = ""; }; 892A5D5A222F0D7C008961AB /* LoopTestingKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; name = LoopTestingKit.framework; path = Carthage/Build/iOS/LoopTestingKit.framework; sourceTree = SOURCE_ROOT; }; 892A5D682230C41D008961AB /* RangeReplaceableCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RangeReplaceableCollection.swift; sourceTree = ""; }; + 892D7C5023B54A14008A9656 /* CarbEntryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbEntryViewController.swift; sourceTree = ""; }; 892FB4CC22040104005293EC /* OverridePresetRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetRow.swift; sourceTree = ""; }; 892FB4CE220402C0005293EC /* OverrideSelectionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideSelectionController.swift; sourceTree = ""; }; 894F71E11FFEC4D8007D365C /* DefaultAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DefaultAssets.xcassets; sourceTree = ""; }; @@ -1060,6 +1063,7 @@ 89CA2B2F226C0161004D9350 /* DirectoryObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectoryObserver.swift; sourceTree = ""; }; 89CA2B31226C18B8004D9350 /* TestingScenariosTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestingScenariosTableViewController.swift; sourceTree = ""; }; 89CA2B3C226E6B13004D9350 /* LocalTestingScenariosManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalTestingScenariosManager.swift; sourceTree = ""; }; + 89D6953D23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PotentialCarbEntryTableViewCell.swift; sourceTree = ""; }; 89E267FB2292456700A3F2AF /* FeatureFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; 89E267FE229267DF00A3F2AF /* Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Optional.swift; sourceTree = ""; }; C10428961D17BAD400DD539A /* NightscoutUploadKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = NightscoutUploadKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1592,9 +1596,8 @@ isa = PBXGroup; children = ( 43DBF04B1C93B8D700B3C386 /* BolusViewController.swift */, - 43D2E8221F00425400AE5CBF /* BolusViewController+LoopDataManager.swift */, 43A51E1E1EB6D62A000736CC /* CarbAbsorptionViewController.swift */, - 4315D2861CA5CC3B00589052 /* CarbEntryEditTableViewController.swift */, + 892D7C5023B54A14008A9656 /* CarbEntryViewController.swift */, 43DBF0581C93F73800B3C386 /* CarbEntryTableViewController.swift */, 43A51E201EB6DBDD000736CC /* ChartsTableViewController.swift */, 433EA4C31D9F71C800CD78FB /* CommandResponseViewController.swift */, @@ -1625,6 +1628,7 @@ 43C3B6EB20B650A80026CAFA /* SettingsImageTableViewCell.swift */, 43F64DD81D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift */, 4311FB9A1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift */, + 89D6953D23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift */, 439706E522D2E84900C81566 /* PredictionSettingTableViewCell.swift */, ); path = Views; @@ -2627,6 +2631,7 @@ 4341F4EB1EDB92AC001C936B /* LogglyService.swift in Sources */, 43CE7CDE1CA8B63E003CC1B0 /* Data.swift in Sources */, C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */, + 89D6953E23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift in Sources */, 89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */, 439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */, 4F11D3C020DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */, @@ -2646,6 +2651,7 @@ 4372E487213C86240068E043 /* SampleValue.swift in Sources */, 4346D1E71C77F5FE00ABAFE3 /* ChartTableViewCell.swift in Sources */, 437CEEE41CDE5C0A003C8C80 /* UIImage.swift in Sources */, + 892D7C5123B54A15008A9656 /* CarbEntryViewController.swift in Sources */, 43DBF0591C93F73800B3C386 /* CarbEntryTableViewController.swift in Sources */, 89CA2B32226C18B8004D9350 /* TestingScenariosTableViewController.swift in Sources */, 43E93FB71E469A5100EAB8DB /* HKUnit.swift in Sources */, @@ -2660,7 +2666,6 @@ 438849EA1D297CB6003B3F23 /* NightscoutService.swift in Sources */, 438172D91F4E9E37003C3328 /* NewPumpEvent.swift in Sources */, 4389916B1E91B689000EEF90 /* ChartSettings+Loop.swift in Sources */, - 4315D2871CA5CC3B00589052 /* CarbEntryEditTableViewController.swift in Sources */, C178249A1E1999FA00D9D25C /* CaseCountable.swift in Sources */, 43DBF04C1C93B8D700B3C386 /* BolusViewController.swift in Sources */, 4FB76FBB1E8C42CF00B39636 /* UIColor.swift in Sources */, @@ -2685,7 +2690,6 @@ 43F41C371D3BF32400C11ED6 /* UIAlertController.swift in Sources */, 433EA4C41D9F71C800CD78FB /* CommandResponseViewController.swift in Sources */, C16DA84222E8E112008624C2 /* LoopPlugins.swift in Sources */, - 43D2E8231F00425400AE5CBF /* BolusViewController+LoopDataManager.swift in Sources */, 430B29952041F5CB00BA9F93 /* LoopSettings+Loop.swift in Sources */, 43785E932120A01B0057DED1 /* NewCarbEntryIntent+Loop.swift in Sources */, 43CEE6E61E56AFD400CB9116 /* NightscoutUploader.swift in Sources */, diff --git a/Loop/Base.lproj/Main.storyboard b/Loop/Base.lproj/Main.storyboard index b59845e58e..5316f564c1 100644 --- a/Loop/Base.lproj/Main.storyboard +++ b/Loop/Base.lproj/Main.storyboard @@ -514,7 +514,7 @@ - + @@ -526,7 +526,7 @@ - + @@ -541,7 +541,7 @@ - + @@ -664,7 +664,7 @@ - + @@ -700,7 +700,7 @@ - + @@ -708,8 +708,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -723,7 +810,7 @@ - + @@ -732,7 +819,7 @@ - + @@ -768,7 +855,7 @@ - + @@ -814,7 +901,7 @@ - + @@ -860,34 +947,6 @@ - - - - - - - - - - - - - - - - - - - - - @@ -930,20 +989,13 @@ + + + - - - - - - - - - - - + @@ -974,7 +1026,7 @@ - + @@ -1113,8 +1165,206 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1126,8 +1376,8 @@ - - - + + + diff --git a/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Contents.json b/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Contents.json new file mode 100644 index 0000000000..7bff2dbe3a --- /dev/null +++ b/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Contents.json @@ -0,0 +1,53 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "Oval Selection.pdf", + "resizing" : { + "mode" : "9-part", + "center" : { + "mode" : "tile", + "width" : 1, + "height" : 1 + }, + "cap-insets" : { + "bottom" : 10, + "top" : 10, + "right" : 10, + "left" : 10 + } + } + }, + { + "idiom" : "universal", + "filename" : "Oval Selection Dark.pdf", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "resizing" : { + "mode" : "9-part", + "center" : { + "mode" : "tile", + "width" : 1, + "height" : 1 + }, + "cap-insets" : { + "bottom" : 10, + "top" : 10, + "right" : 10, + "left" : 10 + } + } + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "preserves-vector-representation" : true + } +} \ No newline at end of file diff --git a/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Oval Selection Dark.pdf b/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Oval Selection Dark.pdf new file mode 100644 index 0000000000000000000000000000000000000000..a67042ecbf12742d028513a25a139913e538c5f8 GIT binary patch literal 3915 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 LFa)BF*8%?zRrsBC literal 0 HcmV?d00001 diff --git a/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Oval Selection.pdf b/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Oval Selection.pdf new file mode 100644 index 0000000000000000000000000000000000000000..13056f028e4be0660ffa4d91f6892f47727249ad GIT binary patch literal 3916 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 [PredictedGlucoseValue] { + fileprivate func predictGlucose(using inputs: PredictionInputEffect, potentialBolus: DoseEntry? = nil, potentialCarbEntry: NewCarbEntry? = nil, replacingCarbEntry replacedCarbEntry: StoredCarbEntry? = nil) throws -> [PredictedGlucoseValue] { dispatchPrecondition(condition: .onQueue(dataAccessQueue)) guard let model = insulinModelSettings?.model else { @@ -850,11 +855,58 @@ extension LoopDataManager { var effects: [[GlucoseEffect]] = [] if inputs.contains(.carbs), let carbEffect = self.carbEffect { - effects.append(carbEffect) + if let potentialCarbEntry = potentialCarbEntry, var recentEntries = recentCarbEntries { + if let replacedCarbEntry = replacedCarbEntry, let index = recentEntries.firstIndex(of: replacedCarbEntry) { + recentEntries.remove(at: index) + } + + let lastGlucoseDate = glucose.startDate + let retrospectiveStart = lastGlucoseDate.addingTimeInterval(-retrospectiveCorrection.retrospectionInterval) + + if potentialCarbEntry.startDate > lastGlucoseDate, replacedCarbEntry == nil { + // The potential carb effect is independent and can be summed with the existing effect + effects.append(carbEffect) + let potentialCarbEffect = try carbStore.glucoseEffects( + of: [potentialCarbEntry], + startingAt: retrospectiveStart, + effectVelocities: nil // ICE is irrelevant for future entries + ) + + effects.append(potentialCarbEffect) + } else { + // If the entry is in the past or an entry is replaced, DCA effects must be recomputed + var entries = recentEntries.map { NewCarbEntry(quantity: $0.quantity, startDate: $0.startDate, foodType: nil, absorptionTime: $0.absorptionTime) } + entries.append(potentialCarbEntry) + entries.sort(by: { $0.startDate > $1.startDate }) + + let potentialCarbEffect = try carbStore.glucoseEffects( + of: entries, + startingAt: retrospectiveStart, + effectVelocities: settings.dynamicCarbAbsorptionEnabled ? insulinCounteractionEffects : nil + ) + + effects.append(potentialCarbEffect) + } + } else { + effects.append(carbEffect) + } } if inputs.contains(.insulin), let insulinEffect = self.insulinEffect { effects.append(insulinEffect) + + if let potentialBolus = potentialBolus { + guard let sensitivity = insulinSensitivityScheduleApplyingOverrideHistory else { + throw LoopError.configurationError(.generalSettings) + } + + let earliestEffectDate = Date(timeIntervalSinceNow: .hours(-24)) + let nextEffectDate = insulinCounteractionEffects.last?.endDate ?? earliestEffectDate + let bolusEffect = [potentialBolus] + .glucoseEffects(insulinModel: model, insulinSensitivity: sensitivity) + .filterDateRange(nextEffectDate, nil) + effects.append(bolusEffect) + } } if inputs.contains(.momentum), let momentumEffect = self.glucoseMomentumEffect { @@ -877,6 +929,89 @@ extension LoopDataManager { return prediction } + /// - Throws: LoopError.missingDataError + fileprivate func recommendBolus(forPrediction predictedGlucose: [Sample]) throws -> BolusRecommendation? { + guard let glucose = glucoseStore.latestGlucose else { + throw LoopError.missingDataError(.glucose) + } + + let pumpStatusDate = doseStore.lastAddedPumpData + + let startDate = Date() + + guard startDate.timeIntervalSince(glucose.startDate) <= settings.recencyInterval else { + throw LoopError.glucoseTooOld(date: glucose.startDate) + } + + guard startDate.timeIntervalSince(pumpStatusDate) <= settings.recencyInterval else { + throw LoopError.pumpDataTooOld(date: pumpStatusDate) + } + + guard glucoseMomentumEffect != nil else { + throw LoopError.missingDataError(.momentumEffect) + } + + guard carbEffect != nil else { + throw LoopError.missingDataError(.carbEffect) + } + + guard insulinEffect != nil else { + throw LoopError.missingDataError(.insulinEffect) + } + + guard + let glucoseTargetRange = settings.glucoseTargetRangeScheduleApplyingOverrideIfActive, + let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory, + let maxBolus = settings.maximumBolus, + let model = insulinModelSettings?.model + else { + throw LoopError.configurationError(.generalSettings) + } + + guard lastRequestedBolus == nil + else { + // Don't recommend changes if a bolus was just requested. + // Sending additional pump commands is not going to be + // successful in any case. + return nil + } + + let pendingInsulin = try self.getPendingInsulin() + + let volumeRounder = { (_ units: Double) in + return self.delegate?.loopDataManager(self, roundBolusVolume: units) ?? units + } + + return predictedGlucose.recommendedBolus( + to: glucoseTargetRange, + suspendThreshold: settings.suspendThreshold?.quantity, + sensitivity: insulinSensitivity, + model: model, + pendingInsulin: pendingInsulin, + maxBolus: maxBolus, + volumeRounder: volumeRounder + ) + } + + fileprivate func computeCarbsOnBoard(potentialCarbEntry: NewCarbEntry?, replacing replacedCarbEntry: StoredCarbEntry?) -> CarbValue? { + var recentEntries = recentCarbEntries ?? [] + if let replacedCarbEntry = replacedCarbEntry, let index = recentEntries.firstIndex(of: replacedCarbEntry) { + recentEntries.remove(at: index) + } + + var entries = recentEntries.map { NewCarbEntry(quantity: $0.quantity, startDate: $0.startDate, foodType: nil, absorptionTime: $0.absorptionTime) } + if let potentialCarbEntry = potentialCarbEntry { + entries.append(potentialCarbEntry) + entries.sort(by: { $0.startDate > $1.startDate }) + } + + return try? carbStore.carbsOnBoard( + from: entries, + at: Date(), + effectVelocities: settings.dynamicCarbAbsorptionEnabled ? insulinCounteractionEffects : nil + ) + } + /// Generates a correction effect based on how large the discrepancy is between the current glucose and its model predicted value. /// /// - Throws: LoopError.missingDataError @@ -1087,6 +1222,32 @@ protocol LoopState { /// The total corrective glucose effect from retrospective correction var totalRetrospectiveCorrection: HKQuantity? { get } + /// Calculates a new prediction from the current data using the specified effect inputs + /// + /// This method is intended for visualization purposes only, not dosing calculation. No validation of input data is done. + /// + /// - Parameter inputs: The effect inputs to include + /// - Parameter potentialBolus: A bolus under consideration for which to include effects in the prediction + /// - Parameter potentialCarbEntry: A carb entry under consideration for which to include effects in the prediction + /// - Parameter replacedCarbEntry: An existing carb entry replaced by `potentialCarbEntry` + /// - Returns: An timeline of predicted glucose values + /// - Throws: LoopError.missingDataError if prediction cannot be computed + func predictGlucose(using inputs: PredictionInputEffect, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?) throws -> [PredictedGlucoseValue] + + /// Computes the recommended bolus for correcting a glucose prediction + /// - Parameter predictedGlucose: A timeline of predicted glucose values + /// - Returns: A bolus recommendation, or `nil` if not applicable + /// - Throws: LoopError.missingDataError if recommendation cannot be computed + func recommendBolus(forPrediction predictedGlucose: [Sample]) throws -> BolusRecommendation? + + /// Computes the carbs on board, taking into account an unstored carb entry + /// - Parameters: + /// - potentialCarbEntry: An unstored carb entry under consideration + /// - replacedCarbEntry: An existing carb entry replaced by `potentialCarbEntry` + func computeCarbsOnBoard(potentialCarbEntry: NewCarbEntry?, replacing replacedCarbEntry: StoredCarbEntry?) -> CarbValue? +} + +extension LoopState { /// Calculates a new prediction from the current data using the specified effect inputs /// /// This method is intended for visualization purposes only, not dosing calculation. No validation of input data is done. @@ -1094,7 +1255,9 @@ protocol LoopState { /// - Parameter inputs: The effect inputs to include /// - Returns: An timeline of predicted glucose values /// - Throws: LoopError.missingDataError if prediction cannot be computed - func predictGlucose(using inputs: PredictionInputEffect) throws -> [GlucoseValue] + func predictGlucose(using inputs: PredictionInputEffect) throws -> [GlucoseValue] { + try predictGlucose(using: inputs, potentialBolus: nil, potentialCarbEntry: nil, replacingCarbEntry: nil) + } } @@ -1154,8 +1317,16 @@ extension LoopDataManager { return loopDataManager.retrospectiveCorrection.totalGlucoseCorrectionEffect } - func predictGlucose(using inputs: PredictionInputEffect) throws -> [GlucoseValue] { - return try loopDataManager.predictGlucose(using: inputs) + func predictGlucose(using inputs: PredictionInputEffect, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?) throws -> [PredictedGlucoseValue] { + return try loopDataManager.predictGlucose(using: inputs, potentialBolus: potentialBolus, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry) + } + + func recommendBolus(forPrediction predictedGlucose: [Sample]) throws -> BolusRecommendation? { + return try loopDataManager.recommendBolus(forPrediction: predictedGlucose) + } + + func computeCarbsOnBoard(potentialCarbEntry: NewCarbEntry?, replacing replacedCarbEntry: StoredCarbEntry?) -> CarbValue? { + return loopDataManager.computeCarbsOnBoard(potentialCarbEntry: potentialCarbEntry, replacing: replacedCarbEntry) } } diff --git a/Loop/View Controllers/BolusViewController+LoopDataManager.swift b/Loop/View Controllers/BolusViewController+LoopDataManager.swift deleted file mode 100644 index a032bc439c..0000000000 --- a/Loop/View Controllers/BolusViewController+LoopDataManager.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// BolusViewController+LoopDataManager.swift -// Loop -// -// Copyright © 2017 LoopKit Authors. All rights reserved. -// - -import UIKit -import HealthKit - - -extension BolusViewController { - func configureWithLoopManager(_ manager: LoopDataManager, recommendation: BolusRecommendation?, glucoseUnit: HKUnit) { - manager.getLoopState { (manager, state) in - let maximumBolus = manager.settings.maximumBolus - - let activeCarbohydrates = state.carbsOnBoard?.quantity.doubleValue(for: .gram()) - let bolusRecommendation: BolusRecommendation? - - if let recommendation = recommendation { - bolusRecommendation = recommendation - } else { - bolusRecommendation = state.recommendedBolus?.recommendation - } - - print("BolusViewController: recommendation = \(String(describing: bolusRecommendation))") - - manager.doseStore.insulinOnBoard(at: Date()) { (result) in - let activeInsulin: Double? - - switch result { - case .success(let value): - activeInsulin = value.value - case .failure: - activeInsulin = nil - } - - DispatchQueue.main.async { - if let maxBolus = maximumBolus { - self.maxBolus = maxBolus - } - - self.glucoseUnit = glucoseUnit - self.activeInsulin = activeInsulin - self.activeCarbohydrates = activeCarbohydrates - self.bolusRecommendation = bolusRecommendation - } - } - } - } -} diff --git a/Loop/View Controllers/BolusViewController.swift b/Loop/View Controllers/BolusViewController.swift index c2648778b9..e89bb3839f 100644 --- a/Loop/View Controllers/BolusViewController.swift +++ b/Loop/View Controllers/BolusViewController.swift @@ -9,24 +9,52 @@ import UIKit import LocalAuthentication import LoopKit +import LoopKitUI import HealthKit import LoopCore +import LoopUI -final class BolusViewController: UITableViewController, IdentifiableClass, UITextFieldDelegate { +private extension RefreshContext { + static let all: Set = [.glucose, .targets] +} - fileprivate enum Rows: Int, CaseCountable { - case notice = 0 +final class BolusViewController: ChartsTableViewController, IdentifiableClass, UITextFieldDelegate { + private enum Row: Int { + case carbEntry = 0 + case chart + case notice case active case recommended case entry - case deliver } override func viewDidLoad() { super.viewDidLoad() // This gets rid of the empty space at the top. tableView.tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: tableView.bounds.size.width, height: 0.01)) + + glucoseChart.glucoseDisplayRange = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 60)...HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 200) + + let notificationCenter = NotificationCenter.default + + notificationObservers += [ + notificationCenter.addObserver(forName: .LoopDataUpdated, object: deviceManager.loopManager, queue: nil) { [weak self] note in + let context = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopDataManager.LoopUpdateContext.RawValue + DispatchQueue.main.async { + switch LoopDataManager.LoopUpdateContext(rawValue: context) { + case .preferences?: + self?.refreshContext.formUnion([.status, .targets]) + case .glucose?: + self?.refreshContext.update(with: .glucose) + default: + break + } + + self?.reloadData(animated: true) + } + } + ] } override func viewDidAppear(_ animated: Bool) { @@ -39,12 +67,37 @@ final class BolusViewController: UITableViewController, IdentifiableClass, UITex bolusAmountTextField.accessibilityHint = String(format: NSLocalizedString("Recommended Bolus: %@ Units", comment: "Accessibility hint describing recommended bolus units"), spellOutFormatter.string(from: amount) ?? "0") bolusAmountTextField.becomeFirstResponder() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + // Reposition footer view if necessary + if tableView.contentSize.height != lastContentHeight { + lastContentHeight = tableView.contentSize.height + tableView.tableFooterView = nil + + let footerSize = footerView.systemLayoutSizeFitting(CGSize(width: tableView.frame.size.width, height: UIView.layoutFittingCompressedSize.height)) + footerView.frame.size = footerSize + tableView.tableFooterView = footerView + } + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + + if !visible { + refreshContext = RefreshContext.all + } + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + refreshContext.update(with: .size(size)) - AnalyticsManager.shared.didDisplayBolusScreen() + super.viewWillTransition(to: size, with: coordinator) } - func generateActiveInsulinDescription(activeInsulin: Double?, pendingInsulin: Double?) -> String - { + func generateActiveInsulinDescription(activeInsulin: Double?, pendingInsulin: Double?) -> String { let iobStr: String if let iob = activeInsulin, let valueStr = insulinFormatter.string(from: iob) { @@ -64,6 +117,38 @@ final class BolusViewController: UITableViewController, IdentifiableClass, UITex // MARK: - State + enum Configuration { + case manualCorrection + case newCarbEntry(NewCarbEntry) + case updatedCarbEntry(from: StoredCarbEntry, to: NewCarbEntry) + } + + var configuration: Configuration = .manualCorrection + + var originalCarbEntry: StoredCarbEntry? { + switch configuration { + case .manualCorrection: + return nil + case .newCarbEntry: + return nil + case .updatedCarbEntry(from: let entry, to: _): + return entry + } + } + + private var potentialCarbEntry: NewCarbEntry? { + switch configuration { + case .manualCorrection: + return nil + case .newCarbEntry(let entry): + return entry + case .updatedCarbEntry(from: _, to: let entry): + return entry + } + } + + var selectedDefaultAbsorptionTimeEmoji: String? + var glucoseUnit: HKUnit = .milligramsPerDeciliter var bolusRecommendation: BolusRecommendation? = nil { @@ -71,6 +156,11 @@ final class BolusViewController: UITableViewController, IdentifiableClass, UITex let amount = bolusRecommendation?.amount ?? 0 recommendedBolusAmountLabel?.text = bolusUnitsFormatter.string(from: amount) updateNotice() + let wasNoticeRowHidden = oldValue?.notice == nil + let isNoticeRowHidden = bolusRecommendation?.notice == nil + if wasNoticeRowHidden != isNoticeRowHidden { + tableView.reloadRows(at: [IndexPath(row: Row.notice.rawValue, section: 0)], with: .automatic) + } if let pendingInsulin = bolusRecommendation?.pendingInsulin { self.pendingInsulin = pendingInsulin } @@ -85,7 +175,6 @@ final class BolusViewController: UITableViewController, IdentifiableClass, UITex var activeCarbohydrates: Double? = nil { didSet { - let cobStr: String if let cob = activeCarbohydrates, let str = integerFormatter.string(from: cob) { cobStr = str + " g" @@ -120,6 +209,178 @@ final class BolusViewController: UITableViewController, IdentifiableClass, UITex private(set) var bolus: Double? + private(set) var updatedCarbEntry: NewCarbEntry? + + private var refreshContext = RefreshContext.all + + private let glucoseChart = PredictedGlucoseChart() + + private var chartStartDate: Date { + get { charts.startDate } + set { + if newValue != chartStartDate { + refreshContext = RefreshContext.all + } + + charts.startDate = newValue + } + } + + private var eventualGlucoseDescription: String? + + private(set) lazy var footerView: SetupTableFooterView = { + let footerView = SetupTableFooterView(frame: .zero) + footerView.primaryButton.addTarget(self, action: #selector(confirmCarbEntryAndBolus(_:)), for: .touchUpInside) + return footerView + }() + + private var lastContentHeight: CGFloat = 0 + + override func createChartsManager() -> ChartsManager { + ChartsManager(colors: .default, settings: .default, charts: [glucoseChart], traitCollection: traitCollection) + } + + override func glucoseUnitDidChange() { + refreshContext = RefreshContext.all + } + + override func reloadData(animated: Bool = false) { + updateChartDateRange() + redrawChart() + + guard active && visible && !refreshContext.isEmpty else { + return + } + + let reloadGroup = DispatchGroup() + if self.refreshContext.remove(.glucose) != nil { + reloadGroup.enter() + self.deviceManager.loopManager.glucoseStore.getCachedGlucoseSamples(start: self.chartStartDate) { (values) -> Void in + self.glucoseChart.setGlucoseValues(values) + reloadGroup.leave() + } + } + + // For now, do this every time + _ = self.refreshContext.remove(.status) + reloadGroup.enter() + self.deviceManager.loopManager.getLoopState { (manager, state) in + let predictedGlucose: [PredictedGlucoseValue] + do { + let enteredBolus = DispatchQueue.main.sync { self.enteredBolus } + predictedGlucose = try state.predictGlucose(using: .all, potentialBolus: enteredBolus, potentialCarbEntry: self.potentialCarbEntry, replacingCarbEntry: self.originalCarbEntry) + } catch { + self.refreshContext.update(with: .status) + predictedGlucose = [] + } + self.glucoseChart.setPredictedGlucoseValues(predictedGlucose) + + if let lastPoint = self.glucoseChart.predictedGlucosePoints.last?.y { + self.eventualGlucoseDescription = String(describing: lastPoint) + } else { + self.eventualGlucoseDescription = nil + } + + if self.refreshContext.remove(.targets) != nil { + self.glucoseChart.targetGlucoseSchedule = manager.settings.glucoseTargetRangeSchedule + self.glucoseChart.scheduleOverride = manager.settings.scheduleOverride + } + + if self.glucoseChart.scheduleOverride?.hasFinished() == true { + self.glucoseChart.scheduleOverride = nil + } + + let maximumBolus = manager.settings.maximumBolus + let activeCarbohydrates = state.computeCarbsOnBoard(potentialCarbEntry: self.potentialCarbEntry, replacing: self.originalCarbEntry)?.quantity.doubleValue(for: .gram()) + let bolusRecommendation = try? state.recommendBolus(forPrediction: predictedGlucose) + + reloadGroup.enter() + manager.doseStore.insulinOnBoard(at: Date()) { (result) in + let activeInsulin: Double? + + switch result { + case .success(let value): + activeInsulin = value.value + case .failure: + activeInsulin = nil + } + + DispatchQueue.main.async { + if let maxBolus = maximumBolus { + self.maxBolus = maxBolus + } + + self.activeInsulin = activeInsulin + self.activeCarbohydrates = activeCarbohydrates + self.bolusRecommendation = bolusRecommendation + } + + reloadGroup.leave() + } + + reloadGroup.leave() + } + + reloadGroup.notify(queue: .main) { + self.updateDeliverButtonState() + self.redrawChart() + } + } + + private func updateChartDateRange() { + let settings = deviceManager.loopManager.settings + + // How far back should we show data? Use the screen size as a guide. + let availableWidth = (refreshContext.newSize ?? self.tableView.bounds.size).width - self.charts.fixedHorizontalMargin + + let totalHours = floor(Double(availableWidth / settings.minimumChartWidthPerHour)) + let futureHours = ceil((deviceManager.loopManager.insulinModelSettings?.model.effectDuration ?? .hours(4)).hours) + let historyHours = max(settings.statusChartMinimumHistoryDisplay.hours, totalHours - futureHours) + + let date = Date(timeIntervalSinceNow: -TimeInterval(hours: historyHours)) + let chartStartDate = Calendar.current.nextDate(after: date, matching: DateComponents(minute: 0), matchingPolicy: .strict, direction: .backward) ?? date + if charts.startDate != chartStartDate { + refreshContext.formUnion(RefreshContext.all) + } + charts.startDate = chartStartDate + charts.maxEndDate = chartStartDate.addingTimeInterval(.hours(totalHours)) + charts.updateEndDate(charts.maxEndDate) + } + + private func redrawChart() { + charts.invalidateChart(atIndex: 0) + charts.prerender() + + tableView.beginUpdates() + for case let cell as ChartTableViewCell in tableView.visibleCells { + cell.reloadChart() + + if let indexPath = tableView.indexPath(for: cell) { + self.tableView(tableView, updateSubtitleFor: cell, at: indexPath) + } + } + tableView.endUpdates() + } + + private var isBolusRecommended: Bool { + bolusRecommendation != nil && bolusRecommendation!.amount > 0 + } + + private func updateDeliverButtonState() { + let deliverText = NSLocalizedString("Deliver", comment: "The button text to initiate a bolus") + if potentialCarbEntry == nil { + footerView.primaryButton.setTitle(deliverText, for: .normal) + footerView.primaryButton.isEnabled = enteredBolusAmount != nil && enteredBolusAmount! > 0 + } else { + if enteredBolusAmount == nil || enteredBolusAmount! == 0 { + footerView.primaryButton.setTitle(NSLocalizedString("Save without Bolusing", comment: "The button text to save a carb entry without bolusing"), for: .normal) + footerView.primaryButton.tintColor = isBolusRecommended ? .alternateBlue : .systemBlue + } else { + footerView.primaryButton.setTitle(deliverText, for: .normal) + footerView.primaryButton.tintColor = .systemBlue + } + } + } // MARK: - IBOutlets @@ -150,35 +411,162 @@ final class BolusViewController: UITableViewController, IdentifiableClass, UITex // MARK: - TableView Delegate + override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + switch Row(rawValue: indexPath.row)! { + case .carbEntry where potentialCarbEntry == nil: + return 0 + case .notice where bolusRecommendation?.notice == nil: + return 0 + default: + return super.tableView(tableView, heightForRowAt: indexPath) + } + } + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if case .recommended? = Rows(rawValue: indexPath.row) { + switch Row(rawValue: indexPath.row)! { + case .carbEntry where potentialCarbEntry != nil: + navigationController?.popViewController(animated: true) + case .recommended: acceptRecommendedBolus() + default: + break } } override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - if case .recommended? = Rows(rawValue: indexPath.row) { + let row = Row(rawValue: indexPath.row) + switch row { + case .carbEntry: + guard let entry = potentialCarbEntry else { + return + } + + let cell = cell as! PotentialCarbEntryTableViewCell + let unit = HKUnit.gram() + let carbText = carbFormatter.string(from: entry.quantity.doubleValue(for: unit), unit: unit.unitString) + + if let carbText = carbText, let foodType = entry.foodType ?? selectedDefaultAbsorptionTimeEmoji { + cell.valueLabel?.text = String( + format: NSLocalizedString("%1$@: %2$@", comment: "Formats (1: carb value) and (2: food type)"), + carbText, foodType + ) + } else { + cell.valueLabel?.text = carbText + } + + let startTime = timeFormatter.string(from: entry.startDate) + if let absorptionTime = entry.absorptionTime, + let duration = absorptionFormatter.string(from: absorptionTime) + { + cell.dateLabel?.text = String( + format: NSLocalizedString("%1$@ + %2$@", comment: "Formats (1: carb start time) and (2: carb absorption duration)"), + startTime, duration + ) + } else { + cell.dateLabel?.text = startTime + } + case .chart: + let cell = cell as! ChartTableViewCell + cell.contentView.layoutMargins.left = tableView.separatorInset.left + cell.chartContentView.chartGenerator = { [weak self] (frame) in + return self?.charts.chart(atIndex: 0, frame: frame)?.view + } + + cell.titleLabel?.text = NSLocalizedString("Carb & Bolus Forecast", comment: "Title text for glucose prediction chart on bolus screen") + cell.subtitleLabel?.textColor = UIColor.secondaryLabelColor + self.tableView(tableView, updateSubtitleFor: cell, at: indexPath) + cell.selectionStyle = .none + + cell.addGestureRecognizer(charts.gestureRecognizer!) + case .recommended: cell.accessibilityCustomActions = [ UIAccessibilityCustomAction(name: NSLocalizedString("AcceptRecommendedBolus", comment: "Action to copy the recommended Bolus value to the actual Bolus Field"), target: self, selector: #selector(BolusViewController.acceptRecommendedBolus)) ] + default: + break } } - @objc - func acceptRecommendedBolus() { + private func tableView(_ tableView: UITableView, updateSubtitleFor cell: ChartTableViewCell, at indexPath: IndexPath) { + assert(Row(rawValue: indexPath.row) == .chart) + + if let eventualGlucose = eventualGlucoseDescription { + cell.subtitleLabel?.text = String(format: NSLocalizedString("Eventually %@", comment: "The subtitle format describing eventual glucose. (1: localized glucose value description)"), eventualGlucose) + } else { + cell.subtitleLabel?.text = SettingsTableViewCell.NoValueString + } + } + + @objc func acceptRecommendedBolus() { bolusAmountTextField?.text = recommendedBolusAmountLabel?.text + bolusAmountTextField?.resignFirstResponder() + + updateDeliverButtonState() + predictionRecomputation?.cancel() + recomputePrediction() } - - @IBOutlet weak var bolusAmountTextField: UITextField! + @IBOutlet weak var bolusAmountTextField: UITextField! { + didSet { + bolusAmountTextField.addTarget(self, action: #selector(bolusAmountChanged), for: .editingChanged) + } + } + + private var enteredBolusAmount: Double? { + guard let text = bolusAmountTextField?.text, let amount = bolusUnitsFormatter.number(from: text)?.doubleValue else { + return nil + } + + return amount >= 0 ? amount : nil + } + + private var enteredBolus: DoseEntry? { + guard let amount = enteredBolusAmount else { + return nil + } + + return DoseEntry(type: .bolus, startDate: Date(), value: amount, unit: .units) + } + + private var predictionRecomputation: DispatchWorkItem? + + @objc private func bolusAmountChanged() { + updateDeliverButtonState() + + predictionRecomputation?.cancel() + let predictionRecomputation = DispatchWorkItem(block: recomputePrediction) + self.predictionRecomputation = predictionRecomputation + let recomputeDelayMS = 300 + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(recomputeDelayMS), execute: predictionRecomputation) + } + + private func recomputePrediction() { + deviceManager.loopManager.getLoopState { [weak self] manager, state in + guard let self = self else { return } + let enteredBolus = DispatchQueue.main.sync { self.enteredBolus } + if let prediction = try? state.predictGlucose(using: .all, potentialBolus: enteredBolus, potentialCarbEntry: self.potentialCarbEntry, replacingCarbEntry: self.originalCarbEntry) { + self.glucoseChart.setPredictedGlucoseValues(prediction) + + if let lastPoint = self.glucoseChart.predictedGlucosePoints.last?.y { + self.eventualGlucoseDescription = String(describing: lastPoint) + } else { + self.eventualGlucoseDescription = nil + } + + DispatchQueue.main.async { + self.redrawChart() + } + } + } + } // MARK: - Actions - @IBAction func authenticateBolus(_ sender: Any) { + @objc private func confirmCarbEntryAndBolus(_ sender: Any) { bolusAmountTextField.resignFirstResponder() - guard let text = bolusAmountTextField?.text, let bolus = bolusUnitsFormatter.number(from: text)?.doubleValue, - let amountString = bolusUnitsFormatter.string(from: bolus) else { + guard let bolus = enteredBolusAmount, let amountString = bolusUnitsFormatter.string(from: bolus) else { + setBolusAndClose(0) return } @@ -214,11 +602,38 @@ final class BolusViewController: UITableViewController, IdentifiableClass, UITex } private func setBolusAndClose(_ bolus: Double) { + self.updatedCarbEntry = potentialCarbEntry self.bolus = bolus self.performSegue(withIdentifier: "close", sender: nil) } + @objc private func cancel() { + dismiss(animated: true) + } + + private lazy var carbFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .none + return formatter + }() + + private lazy var absorptionFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.collapsesLargestUnit = true + formatter.unitsStyle = .abbreviated + formatter.allowsFractionalUnits = true + formatter.allowedUnits = [.hour, .minute] + return formatter + }() + + private lazy var timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter + }() + private lazy var bolusUnitsFormatter: NumberFormatter = { let numberFormatter = NumberFormatter() @@ -270,3 +685,25 @@ final class BolusViewController: UITableViewController, IdentifiableClass, UITex return true } } + +extension BolusViewController { + static func instance() -> BolusViewController { + let storyboard = UIStoryboard(name: "Main", bundle: nil) + return storyboard.instantiateViewController(withIdentifier: className) as! BolusViewController + } +} + + +extension UIColor { + static var alternateBlue: UIColor { + if #available(iOS 13.0, *) { + return UIColor(dynamicProvider: { traitCollection in + traitCollection.userInterfaceStyle == .dark + ? UIColor(red: 50 / 255, green: 148 / 255, blue: 255 / 255, alpha: 1.0) + : UIColor(red: 0 / 255, green: 97 / 255, blue: 204 / 255, alpha: 1.0) + }) + } else { + return UIColor(red: 50 / 255, green: 148 / 255, blue: 255 / 255, alpha: 1.0) + } + } +} diff --git a/Loop/View Controllers/CarbAbsorptionViewController.swift b/Loop/View Controllers/CarbAbsorptionViewController.swift index 9a1a67556e..b65e8fcaf6 100644 --- a/Loop/View Controllers/CarbAbsorptionViewController.swift +++ b/Loop/View Controllers/CarbAbsorptionViewController.swift @@ -159,7 +159,7 @@ final class CarbAbsorptionViewController: ChartsTableViewController, Identifiabl reloadGroup.enter() manager.carbStore.getGlucoseEffects(start: chartStartDate, effectVelocities: manager.settings.dynamicCarbAbsorptionEnabled ? insulinCounteractionEffects : nil) { (result) in switch result { - case .success(let effects): + case .success(let (_, effects)): carbEffects = effects case .failure(let error): carbEffects = [] @@ -479,7 +479,7 @@ final class CarbAbsorptionViewController: ChartsTableViewController, Identifiabl override func restoreUserActivityState(_ activity: NSUserActivity) { switch activity.activityType { case NSUserActivity.newCarbEntryActivityType: - performSegue(withIdentifier: CarbEntryEditViewController.className, sender: activity) + performSegue(withIdentifier: CarbEntryViewController.className, sender: activity) default: break } @@ -495,18 +495,16 @@ final class CarbAbsorptionViewController: ChartsTableViewController, Identifiabl } switch targetViewController { - case let vc as BolusViewController: - vc.configureWithLoopManager(self.deviceManager.loopManager, - recommendation: sender as? BolusRecommendation, - glucoseUnit: self.carbEffectChart.glucoseUnit - ) - case let vc as CarbEntryEditViewController: + case is BolusViewController: + assertionFailure() + case let vc as CarbEntryViewController: if let selectedCell = sender as? UITableViewCell, let indexPath = tableView.indexPath(for: selectedCell), indexPath.row < carbStatuses.count { vc.originalCarbEntry = carbStatuses[indexPath.row].entry } else if let activity = sender as? NSUserActivity { vc.restoreUserActivityState(activity) } + vc.deviceManager = deviceManager vc.defaultAbsorptionTimes = deviceManager.loopManager.carbStore.defaultAbsorptionTimes vc.preferredUnit = deviceManager.loopManager.carbStore.preferredUnit default: @@ -514,48 +512,41 @@ final class CarbAbsorptionViewController: ChartsTableViewController, Identifiabl } } - /// Unwind segue action from the CarbEntryEditViewController - /// - /// - parameter segue: The unwind segue - @IBAction func unwindFromEditing(_ segue: UIStoryboardSegue) { - guard let editVC = segue.source as? CarbEntryEditViewController, - let updatedEntry = editVC.updatedCarbEntry - else { + @IBAction func unwindFromEditing(_ segue: UIStoryboardSegue) {} + + @IBAction func unwindFromBolusViewController(_ segue: UIStoryboardSegue) { + guard let bolusViewController = segue.source as? BolusViewController else { return } - if #available(iOS 12.0, *), editVC.originalCarbEntry == nil { - let interaction = INInteraction(intent: NewCarbEntryIntent(), response: nil) - interaction.donate { (error) in - if let error = error { - os_log(.error, "Failed to donate intent: %{public}@", String(describing: error)) - } - } - } - deviceManager.loopManager.addCarbEntryAndRecommendBolus(updatedEntry, replacing: editVC.originalCarbEntry) { (result) in - DispatchQueue.main.async { - switch result { - case .success(let recommendation): - if self.active && self.visible, let bolus = recommendation?.amount, bolus > 0 { - self.performSegue(withIdentifier: BolusViewController.className, sender: recommendation) - } - case .failure(let error): - // Ignore bolus wizard errors - if error is CarbStore.CarbStoreError { - self.present(UIAlertController(with: error), animated: true) + if let updatedEntry = bolusViewController.updatedCarbEntry { + if #available(iOS 12.0, *), bolusViewController.originalCarbEntry == nil { + let interaction = INInteraction(intent: NewCarbEntryIntent(), response: nil) + interaction.donate { (error) in + if let error = error { + os_log(.error, "Failed to donate intent: %{public}@", String(describing: error)) } } } - } - } - - @IBAction func unwindFromBolusViewController(_ segue: UIStoryboardSegue) { - if let bolusViewController = segue.source as? BolusViewController { - if let bolus = bolusViewController.bolus, bolus > 0 { - deviceManager.enactBolus(units: bolus) { (_) in + + deviceManager.loopManager.addCarbEntryAndRecommendBolus(updatedEntry, replacing: bolusViewController.originalCarbEntry) { (result) in + DispatchQueue.main.async { + switch result { + case .success: + // Enact the user-entered bolus + if let bolus = bolusViewController.bolus, bolus > 0 { + self.deviceManager.enactBolus(units: bolus) { _ in } + } + case .failure(let error): + // Ignore bolus wizard errors + if error is CarbStore.CarbStoreError { + self.present(UIAlertController(with: error), animated: true) + } + } } } + } else if let bolus = bolusViewController.bolus, bolus > 0 { + deviceManager.enactBolus(units: bolus) { _ in } } } - } diff --git a/Loop/View Controllers/CarbEntryEditTableViewController.swift b/Loop/View Controllers/CarbEntryEditTableViewController.swift deleted file mode 100644 index 99f73c6102..0000000000 --- a/Loop/View Controllers/CarbEntryEditTableViewController.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// CarbEntryEditTableViewController.swift -// Naterade -// -// Created by Nathan Racklyeft on 3/25/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import LoopKitUI -import LoopCore - - -extension CarbEntryEditViewController: IdentifiableClass { -} diff --git a/Loop/View Controllers/CarbEntryViewController.swift b/Loop/View Controllers/CarbEntryViewController.swift new file mode 100644 index 0000000000..9690c79a55 --- /dev/null +++ b/Loop/View Controllers/CarbEntryViewController.swift @@ -0,0 +1,488 @@ +// +// CarbEntryViewController.swift +// CarbKit +// +// Created by Nathan Racklyeft on 1/15/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import UIKit +import HealthKit +import LoopKit +import LoopKitUI +import LoopCore +import LoopUI + + +final class CarbEntryViewController: ChartsTableViewController, IdentifiableClass { + + var navigationDelegate = CarbEntryNavigationDelegate() + + var defaultAbsorptionTimes: CarbStore.DefaultAbsorptionTimes? { + didSet { + if let times = defaultAbsorptionTimes { + orderedAbsorptionTimes = [times.fast, times.medium, times.slow] + } + } + } + + fileprivate var orderedAbsorptionTimes = [TimeInterval]() + + var preferredUnit = HKUnit.gram() + + var maxQuantity = HKQuantity(unit: .gram(), doubleValue: 250) + + /// Entry configuration values. Must be set before presenting. + var absorptionTimePickerInterval = TimeInterval(minutes: 30) + + var maxAbsorptionTime = TimeInterval(hours: 8) + + var maximumDateFutureInterval = TimeInterval(hours: 4) + + var glucoseUnit: HKUnit = .milligramsPerDeciliter + + var originalCarbEntry: StoredCarbEntry? { + didSet { + if let entry = originalCarbEntry { + quantity = entry.quantity + date = entry.startDate + foodType = entry.foodType + absorptionTime = entry.absorptionTime + + absorptionTimeWasEdited = true + usesCustomFoodType = true + + shouldBeginEditingQuantity = false + } + } + } + + fileprivate var quantity: HKQuantity? { + didSet { + updateContinueButtonEnabled() + } + } + + fileprivate var date = Date() { + didSet { + updateContinueButtonEnabled() + } + } + + fileprivate var foodType: String? { + didSet { + updateContinueButtonEnabled() + } + } + + fileprivate var absorptionTime: TimeInterval? { + didSet { + updateContinueButtonEnabled() + } + } + + private var selectedDefaultAbsorptionTimeEmoji: String? + + fileprivate var absorptionTimeWasEdited = false + + fileprivate var usesCustomFoodType = false + + private var shouldBeginEditingQuantity = true + + private var shouldBeginEditingFoodType = false + + var updatedCarbEntry: NewCarbEntry? { + if let quantity = quantity, + let absorptionTime = absorptionTime ?? defaultAbsorptionTimes?.medium + { + if let o = originalCarbEntry, o.quantity == quantity && o.startDate == date && o.foodType == foodType && o.absorptionTime == absorptionTime { + return nil // No changes were made + } + + return NewCarbEntry( + quantity: quantity, + startDate: date, + foodType: foodType, + absorptionTime: absorptionTime, + externalID: originalCarbEntry?.externalID + ) + } else { + return nil + } + } + + private var isSampleEditable: Bool { + return originalCarbEntry?.createdByCurrentApp != false + } + + private(set) lazy var footerView: SetupTableFooterView = { + let footerView = SetupTableFooterView(frame: .zero) + footerView.primaryButton.addTarget(self, action: #selector(continueButtonPressed), for: .touchUpInside) + footerView.primaryButton.isEnabled = quantity != nil && quantity!.doubleValue(for: .gram()) > 0 + return footerView + }() + + private var lastContentHeight: CGFloat = 0 + + override func createChartsManager() -> ChartsManager { + // TODO: implement chart in this controller + ChartsManager(colors: .default, settings: .default, charts: [], traitCollection: traitCollection) + } + + override func glucoseUnitDidChange() { + // TODO: implement chart in this controller + } + + override func viewDidLoad() { + super.viewDidLoad() + + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 44 + tableView.register(DateAndDurationTableViewCell.nib(), forCellReuseIdentifier: DateAndDurationTableViewCell.className) + + if originalCarbEntry != nil { + title = NSLocalizedString("carb-entry-title-edit", value: "Edit Carb Entry", comment: "The title of the view controller to edit an existing carb entry") + } else { + title = NSLocalizedString("carb-entry-title-add", value: "Add Carb Entry", comment: "The title of the view controller to create a new carb entry") + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if shouldBeginEditingQuantity, let cell = tableView.cellForRow(at: IndexPath(row: Row.value.rawValue, section: 0)) as? DecimalTextFieldTableViewCell { + shouldBeginEditingQuantity = false + cell.textField.becomeFirstResponder() + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + // Reposition footer view if necessary + if tableView.contentSize.height != lastContentHeight { + lastContentHeight = tableView.contentSize.height + tableView.tableFooterView = nil + + let footerSize = footerView.systemLayoutSizeFitting(CGSize(width: tableView.frame.size.width, height: UIView.layoutFittingCompressedSize.height)) + footerView.frame.size = footerSize + tableView.tableFooterView = footerView + } + } + + private var foodKeyboard: EmojiInputController! + + // MARK: - Table view data source + + fileprivate enum Row: Int { + case value + case date + case foodType + case absorptionTime + + static let count = 4 + } + + override func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return Row.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + switch Row(rawValue: indexPath.row)! { + case .value: + let cell = tableView.dequeueReusableCell(withIdentifier: DecimalTextFieldTableViewCell.className) as! DecimalTextFieldTableViewCell + + if let quantity = quantity { + cell.number = NSNumber(value: quantity.doubleValue(for: preferredUnit)) + } + cell.textField.isEnabled = isSampleEditable + cell.unitLabel?.text = String(describing: preferredUnit) + cell.delegate = self + + return cell + case .date: + let cell = tableView.dequeueReusableCell(withIdentifier: DateAndDurationTableViewCell.className) as! DateAndDurationTableViewCell + + cell.titleLabel.text = NSLocalizedString("Date", comment: "Title of the carb entry date picker cell") + cell.datePicker.isEnabled = isSampleEditable + cell.datePicker.datePickerMode = .dateAndTime + cell.datePicker.maximumDate = Date(timeIntervalSinceNow: maximumDateFutureInterval) + cell.datePicker.minuteInterval = 1 + cell.date = date + cell.delegate = self + + return cell + case .foodType: + if usesCustomFoodType { + let cell = tableView.dequeueReusableCell(withIdentifier: TextFieldTableViewCell.className, for: indexPath) as! TextFieldTableViewCell + + cell.textField.text = foodType + cell.delegate = self + + if let textField = cell.textField as? CustomInputTextField { + if foodKeyboard == nil { + foodKeyboard = CarbAbsorptionInputController() + foodKeyboard.delegate = self + } + + textField.customInput = foodKeyboard + } + + return cell + } else { + let cell = tableView.dequeueReusableCell(withIdentifier: FoodTypeShortcutCell.className, for: indexPath) as! FoodTypeShortcutCell + + if absorptionTime == nil { + cell.selectionState = .medium + } + + selectedDefaultAbsorptionTimeEmoji = cell.selectedEmoji + cell.delegate = self + + return cell + } + case .absorptionTime: + let cell = tableView.dequeueReusableCell(withIdentifier: DateAndDurationTableViewCell.className) as! DateAndDurationTableViewCell + + cell.titleLabel.text = NSLocalizedString("Absorption Time", comment: "Title of the carb entry absorption time cell") + cell.datePicker.isEnabled = isSampleEditable + cell.datePicker.datePickerMode = .countDownTimer + cell.datePicker.minuteInterval = Int(absorptionTimePickerInterval.minutes) + + if let duration = absorptionTime ?? defaultAbsorptionTimes?.medium { + cell.duration = duration + } + + cell.maximumDuration = maxAbsorptionTime + cell.delegate = self + + return cell + } + } + + override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + switch Row(rawValue: indexPath.row)! { + case .value, .date: + break + case .foodType: + if usesCustomFoodType, shouldBeginEditingFoodType, let cell = cell as? TextFieldTableViewCell { + shouldBeginEditingFoodType = false + cell.textField.becomeFirstResponder() + } + case .absorptionTime: + break + } + } + + override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + return NSLocalizedString("Choose a longer absorption time for larger meals, or those containing fats and proteins. This is only guidance to the algorithm and need not be exact.", comment: "Carb entry section footer text explaining absorption time") + } + + // MARK: - UITableViewDelegate + + override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + tableView.endEditing(false) + tableView.beginUpdates() + hideDatePickerCells(excluding: indexPath) + return indexPath + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + switch tableView.cellForRow(at: indexPath) { + case is FoodTypeShortcutCell: + usesCustomFoodType = true + shouldBeginEditingFoodType = true + tableView.reloadRows(at: [IndexPath(row: Row.foodType.rawValue, section: 0)], with: .none) + default: + break + } + + tableView.endUpdates() + tableView.deselectRow(at: indexPath, animated: true) + } + + // MARK: - Navigation + + override func restoreUserActivityState(_ activity: NSUserActivity) { + if let entry = activity.newCarbEntry { + quantity = entry.quantity + date = entry.startDate + + if let foodType = entry.foodType { + self.foodType = foodType + usesCustomFoodType = true + } + + if let absorptionTime = entry.absorptionTime { + self.absorptionTime = absorptionTime + absorptionTimeWasEdited = true + } + } + } + + @objc private func continueButtonPressed() { + tableView.endEditing(true) + guard validateInput(), let updatedEntry = updatedCarbEntry else { + return + } + + let bolusVC = BolusViewController.instance() + bolusVC.deviceManager = deviceManager + bolusVC.glucoseUnit = glucoseUnit + if let originalEntry = originalCarbEntry { + bolusVC.configuration = .updatedCarbEntry(from: originalEntry, to: updatedEntry) + } else { + bolusVC.configuration = .newCarbEntry(updatedEntry) + } + bolusVC.selectedDefaultAbsorptionTimeEmoji = selectedDefaultAbsorptionTimeEmoji + + show(bolusVC, sender: footerView.primaryButton) + } + + private func validateInput() -> Bool { + guard let absorptionTime = absorptionTime ?? defaultAbsorptionTimes?.medium else { + return false + } + guard absorptionTime <= maxAbsorptionTime else { + navigationDelegate.showAbsorptionTimeValidationWarning(for: self, maxAbsorptionTime: maxAbsorptionTime) + return false + } + + guard let quantity = quantity, quantity.doubleValue(for: HKUnit.gram()) > 0 else { return false } + guard quantity.compare(maxQuantity) != .orderedDescending else { + navigationDelegate.showMaxQuantityValidationWarning(for: self, maxQuantityGrams: maxQuantity.doubleValue(for: .gram())) + return false + } + + return true + } + + private func updateContinueButtonEnabled() { + let hasValidQuantity = quantity != nil && quantity!.doubleValue(for: .gram()) > 0 + let haveChangesBeenMade = updatedCarbEntry != nil + footerView.primaryButton.isEnabled = hasValidQuantity && haveChangesBeenMade + } +} + + +extension CarbEntryViewController: TextFieldTableViewCellDelegate { + func textFieldTableViewCellDidBeginEditing(_ cell: TextFieldTableViewCell) { + // Collapse any date picker cells to save space + tableView.beginUpdates() + hideDatePickerCells() + tableView.endUpdates() + } + + func textFieldTableViewCellDidEndEditing(_ cell: TextFieldTableViewCell) { + guard let row = tableView.indexPath(for: cell)?.row else { return } + + switch Row(rawValue: row) { + case .value?: + if let cell = cell as? DecimalTextFieldTableViewCell, let number = cell.number { + quantity = HKQuantity(unit: preferredUnit, doubleValue: number.doubleValue) + } else { + quantity = nil + } + case .foodType?: + foodType = cell.textField.text + default: + break + } + } + + func textFieldTableViewCellDidChangeEditing(_ cell: TextFieldTableViewCell) { + guard let row = tableView.indexPath(for: cell)?.row else { return } + + switch Row(rawValue: row) { + case .value?: + if let cell = cell as? DecimalTextFieldTableViewCell, let number = cell.number { + quantity = HKQuantity(unit: preferredUnit, doubleValue: number.doubleValue) + } else { + quantity = nil + } + default: + break + } + } +} + + +extension CarbEntryViewController: DatePickerTableViewCellDelegate { + func datePickerTableViewCellDidUpdateDate(_ cell: DatePickerTableViewCell) { + guard let row = tableView.indexPath(for: cell)?.row else { return } + + switch Row(rawValue: row) { + case .date?: + date = cell.date + case .absorptionTime?: + absorptionTime = cell.duration + absorptionTimeWasEdited = true + default: + break + } + } +} + + +extension CarbEntryViewController: FoodTypeShortcutCellDelegate { + func foodTypeShortcutCellDidUpdateSelection(_ cell: FoodTypeShortcutCell) { + var absorptionTime: TimeInterval? + + switch cell.selectionState { + case .fast: + absorptionTime = defaultAbsorptionTimes?.fast + case .medium: + absorptionTime = defaultAbsorptionTimes?.medium + case .slow: + absorptionTime = defaultAbsorptionTimes?.slow + case .custom: + tableView.beginUpdates() + usesCustomFoodType = true + shouldBeginEditingFoodType = true + tableView.reloadRows(at: [IndexPath(row: Row.foodType.rawValue, section: 0)], with: .fade) + tableView.endUpdates() + } + + if let absorptionTime = absorptionTime { + self.absorptionTime = absorptionTime + + if let cell = tableView.cellForRow(at: IndexPath(row: Row.absorptionTime.rawValue, section: 0)) as? DateAndDurationTableViewCell { + cell.duration = absorptionTime + } + } + + selectedDefaultAbsorptionTimeEmoji = cell.selectedEmoji + } +} + + +extension CarbEntryViewController: EmojiInputControllerDelegate { + func emojiInputControllerDidAdvanceToStandardInputMode(_ controller: EmojiInputController) { + if let cell = tableView.cellForRow(at: IndexPath(row: Row.foodType.rawValue, section: 0)) as? TextFieldTableViewCell, let textField = cell.textField as? CustomInputTextField, textField.customInput != nil { + let customInput = textField.customInput + textField.customInput = nil + textField.resignFirstResponder() + textField.becomeFirstResponder() + textField.customInput = customInput + } + } + + func emojiInputControllerDidSelectItemInSection(_ section: Int) { + guard !absorptionTimeWasEdited, section < orderedAbsorptionTimes.count else { + return + } + + let lastAbsorptionTime = self.absorptionTime + self.absorptionTime = orderedAbsorptionTimes[section] + + if let cell = tableView.cellForRow(at: IndexPath(row: Row.absorptionTime.rawValue, section: 0)) as? DateAndDurationTableViewCell { + cell.duration = max(lastAbsorptionTime ?? 0, orderedAbsorptionTimes[section]) + } + } +} + +extension DateAndDurationTableViewCell: NibLoadable {} diff --git a/Loop/View Controllers/RootNavigationController.swift b/Loop/View Controllers/RootNavigationController.swift index 4529831074..c6f763e25a 100644 --- a/Loop/View Controllers/RootNavigationController.swift +++ b/Loop/View Controllers/RootNavigationController.swift @@ -29,7 +29,7 @@ class RootNavigationController: UINavigationController { } case NSUserActivity.newCarbEntryActivityType: if let navVC = presentedViewController as? UINavigationController { - if let carbVC = navVC.topViewController as? CarbEntryEditViewController { + if let carbVC = navVC.topViewController as? CarbEntryViewController { carbVC.restoreUserActivityState(activity) return } else { diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 9e7f37c6f2..6e51020f8a 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1045,7 +1045,7 @@ final class StatusTableViewController: ChartsTableViewController { override func restoreUserActivityState(_ activity: NSUserActivity) { switch activity.activityType { case NSUserActivity.newCarbEntryActivityType: - performSegue(withIdentifier: CarbEntryEditViewController.className, sender: activity) + performSegue(withIdentifier: CarbEntryViewController.className, sender: activity) default: break } @@ -1064,10 +1064,9 @@ final class StatusTableViewController: ChartsTableViewController { case let vc as CarbAbsorptionViewController: vc.deviceManager = deviceManager vc.hidesBottomBarWhenPushed = true - case let vc as CarbEntryTableViewController: - vc.carbStore = deviceManager.loopManager.carbStore - vc.hidesBottomBarWhenPushed = true - case let vc as CarbEntryEditViewController: + case let vc as CarbEntryViewController: + vc.deviceManager = deviceManager + vc.glucoseUnit = statusCharts.glucose.glucoseUnit vc.defaultAbsorptionTimes = deviceManager.loopManager.carbStore.defaultAbsorptionTimes vc.preferredUnit = deviceManager.loopManager.carbStore.preferredUnit @@ -1078,10 +1077,10 @@ final class StatusTableViewController: ChartsTableViewController { vc.doseStore = deviceManager.loopManager.doseStore vc.hidesBottomBarWhenPushed = true case let vc as BolusViewController: - vc.configureWithLoopManager(self.deviceManager.loopManager, - recommendation: sender as? BolusRecommendation, - glucoseUnit: self.statusCharts.glucose.glucoseUnit - ) + vc.deviceManager = deviceManager + vc.glucoseUnit = statusCharts.glucose.glucoseUnit + vc.configuration = .manualCorrection + deviceManager.analyticsServicesManager.didDisplayBolusScreen() case let vc as OverrideSelectionViewController: if deviceManager.loopManager.settings.futureOverrideEnabled() { vc.scheduledOverride = deviceManager.loopManager.settings.scheduleOverride @@ -1098,46 +1097,43 @@ final class StatusTableViewController: ChartsTableViewController { } } - /// Unwind segue action from the CarbEntryEditViewController - /// - /// - parameter segue: The unwind segue - @IBAction func unwindFromEditing(_ segue: UIStoryboardSegue) { - guard let carbVC = segue.source as? CarbEntryEditViewController, let updatedEntry = carbVC.updatedCarbEntry else { + @IBAction func unwindFromEditing(_ segue: UIStoryboardSegue) {} + + @IBAction func unwindFromBolusViewController(_ segue: UIStoryboardSegue) { + guard let bolusViewController = segue.source as? BolusViewController else { return } - if #available(iOS 12.0, *) { - let interaction = INInteraction(intent: NewCarbEntryIntent(), response: nil) - interaction.donate { [weak self] (error) in - if let error = error { - self?.log.error("Failed to donate intent: %{public}@", String(describing: error)) - } - } - } - deviceManager.loopManager.addCarbEntryAndRecommendBolus(updatedEntry) { (result) -> Void in - DispatchQueue.main.async { - switch result { - case .success(let recommendation): - if self.active && self.visible, let bolus = recommendation?.amount, bolus > 0 { - self.performSegue(withIdentifier: BolusViewController.className, sender: recommendation) - } - case .failure(let error): - // Ignore bolus wizard errors - if error is CarbStore.CarbStoreError { - self.present(UIAlertController(with: error), animated: true) - } else { - self.log.error("Failed to add carb entry: %{public}@", String(describing: error)) + if let carbEntry = bolusViewController.updatedCarbEntry { + if #available(iOS 12.0, *) { + let interaction = INInteraction(intent: NewCarbEntryIntent(), response: nil) + interaction.donate { [weak self] (error) in + if let error = error { + self?.log.error("Failed to donate intent: %{public}@", String(describing: error)) } } } - } - } - @IBAction func unwindFromBolusViewController(_ segue: UIStoryboardSegue) { - if let bolusViewController = segue.source as? BolusViewController { - if let bolus = bolusViewController.bolus, bolus > 0 { - deviceManager.enactBolus(units: bolus) { (_) in } + deviceManager.loopManager.addCarbEntryAndRecommendBolus(carbEntry) { result in + DispatchQueue.main.async { + switch result { + case .success: + // Enact the user-entered bolus + if let bolus = bolusViewController.bolus, bolus > 0 { + self.deviceManager.enactBolus(units: bolus) { _ in } + } + case .failure(let error): + // Ignore bolus wizard errors + if error is CarbStore.CarbStoreError { + self.present(UIAlertController(with: error), animated: true) + } else { + self.log.error("Failed to add carb entry: %{public}@", String(describing: error)) + } + } + } } + } else if let bolus = bolusViewController.bolus, bolus > 0 { + self.deviceManager.enactBolus(units: bolus) { _ in } } } diff --git a/Loop/Views/PotentialCarbEntryTableViewCell.swift b/Loop/Views/PotentialCarbEntryTableViewCell.swift new file mode 100644 index 0000000000..fac1d8060b --- /dev/null +++ b/Loop/Views/PotentialCarbEntryTableViewCell.swift @@ -0,0 +1,39 @@ +// +// PotentialCarbEntryTableViewCell.swift +// Loop +// +// Created by Michael Pangburn on 12/27/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import UIKit + + +class PotentialCarbEntryTableViewCell: UITableViewCell { + @IBOutlet weak var valueLabel: UILabel! + @IBOutlet weak var dateLabel: UILabel! + + override func layoutSubviews() { + super.layoutSubviews() + + contentView.layoutMargins.left = separatorInset.left + contentView.layoutMargins.right = separatorInset.left + } + + override func awakeFromNib() { + super.awakeFromNib() + + resetViews() + } + + override func prepareForReuse() { + super.prepareForReuse() + + resetViews() + } + + private func resetViews() { + valueLabel.text = nil + dateLabel.text = nil + } +} From 50562ca41f48d3ea1b2758df6418f3bbc96eaaf2 Mon Sep 17 00:00:00 2001 From: mpangburn Date: Sat, 28 Dec 2019 21:01:48 -0800 Subject: [PATCH 09/44] Alert on bolus recommendation change --- .../BolusViewController.swift | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Loop/View Controllers/BolusViewController.swift b/Loop/View Controllers/BolusViewController.swift index e89bb3839f..ca189527df 100644 --- a/Loop/View Controllers/BolusViewController.swift +++ b/Loop/View Controllers/BolusViewController.swift @@ -151,19 +151,41 @@ final class BolusViewController: ChartsTableViewController, IdentifiableClass, U var glucoseUnit: HKUnit = .milligramsPerDeciliter + private var computedInitialBolusRecommendation = false + var bolusRecommendation: BolusRecommendation? = nil { didSet { let amount = bolusRecommendation?.amount ?? 0 recommendedBolusAmountLabel?.text = bolusUnitsFormatter.string(from: amount) + updateNotice() let wasNoticeRowHidden = oldValue?.notice == nil let isNoticeRowHidden = bolusRecommendation?.notice == nil if wasNoticeRowHidden != isNoticeRowHidden { tableView.reloadRows(at: [IndexPath(row: Row.notice.rawValue, section: 0)], with: .automatic) } + if let pendingInsulin = bolusRecommendation?.pendingInsulin { self.pendingInsulin = pendingInsulin } + + if computedInitialBolusRecommendation, + bolusRecommendation?.amount != oldValue?.amount, + bolusAmountTextField.text?.isEmpty == false + { + bolusAmountTextField.text?.removeAll() + + let alert = UIAlertController( + title: NSLocalizedString("Bolus Recommendation Updated", comment: "Alert title for an updated bolus recommendation"), + message: NSLocalizedString("The bolus recommendation has updated. Please reconfirm the bolus amount.", comment: "Alert message for an updated bolus recommendation"), + preferredStyle: .alert + ) + + let acknowledgeChange = UIAlertAction(title: NSLocalizedString("OK", comment: "Button text to acknowledge an updated bolus recommendation alert"), style: .default) { _ in } + alert.addAction(acknowledgeChange) + + present(alert, animated: true) + } } } @@ -313,6 +335,7 @@ final class BolusViewController: ChartsTableViewController, IdentifiableClass, U self.activeInsulin = activeInsulin self.activeCarbohydrates = activeCarbohydrates self.bolusRecommendation = bolusRecommendation + self.computedInitialBolusRecommendation = true } reloadGroup.leave() From ca8d2fa65151a25371ae0f36e66f94671caf9bde Mon Sep 17 00:00:00 2001 From: mpangburn Date: Wed, 8 Jan 2020 12:54:38 -0800 Subject: [PATCH 10/44] Recompute RC when considering past carb entry --- Loop/Managers/LoopDataManager.swift | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 98cd674c14..87635e507e 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -852,6 +852,7 @@ extension LoopDataManager { } var momentum: [GlucoseEffect] = [] + var retrospectiveGlucoseEffect = self.retrospectiveGlucoseEffect var effects: [[GlucoseEffect]] = [] if inputs.contains(.carbs), let carbEffect = self.carbEffect { @@ -874,7 +875,7 @@ extension LoopDataManager { effects.append(potentialCarbEffect) } else { - // If the entry is in the past or an entry is replaced, DCA effects must be recomputed + // If the entry is in the past or an entry is replaced, DCA and RC effects must be recomputed var entries = recentEntries.map { NewCarbEntry(quantity: $0.quantity, startDate: $0.startDate, foodType: nil, absorptionTime: $0.absorptionTime) } entries.append(potentialCarbEntry) entries.sort(by: { $0.startDate > $1.startDate }) @@ -886,6 +887,8 @@ extension LoopDataManager { ) effects.append(potentialCarbEffect) + + retrospectiveGlucoseEffect = computeRetrospectiveGlucoseEffect(startingAt: glucose, carbEffects: potentialCarbEffect) } } else { effects.append(carbEffect) @@ -914,7 +917,7 @@ extension LoopDataManager { } if inputs.contains(.retrospection) { - effects.append(self.retrospectiveGlucoseEffect) + effects.append(retrospectiveGlucoseEffect) } var prediction = LoopMath.predictGlucose(startingAt: glucose, momentum: momentum, effects: effects) @@ -1046,6 +1049,20 @@ extension LoopDataManager { ) } + private func computeRetrospectiveGlucoseEffect(startingAt glucose: GlucoseValue, carbEffects: [GlucoseEffect]) -> [GlucoseEffect] { + let retrospectiveGlucoseDiscrepancies = insulinCounteractionEffects.subtracting(carbEffects, withUniformInterval: carbStore.delta) + let retrospectiveGlucoseDiscrepanciesSummed = retrospectiveGlucoseDiscrepancies.combinedSums(of: settings.retrospectiveCorrectionGroupingInterval * 1.01) + return retrospectiveCorrection.computeEffect( + startingAt: glucose, + retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed, + recencyInterval: settings.recencyInterval, + insulinSensitivitySchedule: insulinSensitivitySchedule, + basalRateSchedule: basalRateSchedule, + glucoseCorrectionRangeSchedule: settings.glucoseTargetRangeSchedule, + retrospectiveCorrectionGroupingInterval: settings.retrospectiveCorrectionGroupingInterval + ) + } + /// Runs the glucose prediction on the latest effect data. /// /// - Throws: From 4e6eef5bb3b12ef0f193777a5a015acb7f4df649 Mon Sep 17 00:00:00 2001 From: mpangburn Date: Wed, 8 Jan 2020 13:00:15 -0800 Subject: [PATCH 11/44] Update comments re carb entry chart --- Loop/View Controllers/CarbEntryViewController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Loop/View Controllers/CarbEntryViewController.swift b/Loop/View Controllers/CarbEntryViewController.swift index 9690c79a55..10e25b2e29 100644 --- a/Loop/View Controllers/CarbEntryViewController.swift +++ b/Loop/View Controllers/CarbEntryViewController.swift @@ -125,12 +125,12 @@ final class CarbEntryViewController: ChartsTableViewController, IdentifiableClas private var lastContentHeight: CGFloat = 0 override func createChartsManager() -> ChartsManager { - // TODO: implement chart in this controller + // Consider including a chart on this screen to demonstrate how absorption time affects prediction ChartsManager(colors: .default, settings: .default, charts: [], traitCollection: traitCollection) } override func glucoseUnitDidChange() { - // TODO: implement chart in this controller + // Consider including a chart on this screen to demonstrate how absorption time affects prediction } override func viewDidLoad() { From 25f87d140aa91d16517960155fad0e9f5f456e13 Mon Sep 17 00:00:00 2001 From: mpangburn Date: Wed, 15 Jan 2020 14:40:25 -0800 Subject: [PATCH 12/44] Visual updates per review --- Loop/Base.lproj/Main.storyboard | 52 +------ .../BolusViewController.swift | 131 ++---------------- 2 files changed, 22 insertions(+), 161 deletions(-) diff --git a/Loop/Base.lproj/Main.storyboard b/Loop/Base.lproj/Main.storyboard index 5316f564c1..b9c87528b9 100644 --- a/Loop/Base.lproj/Main.storyboard +++ b/Loop/Base.lproj/Main.storyboard @@ -752,8 +752,8 @@ -