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/AppDelegate.swift b/Loop/AppDelegate.swift index 401ae1c467..f2f0e4a612 100644 --- a/Loop/AppDelegate.swift +++ b/Loop/AppDelegate.swift @@ -50,11 +50,11 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { NotificationManager.authorize(delegate: self) - let mainStatusViewControllers = UIStoryboard(name: "Main", bundle: Bundle(for: AppDelegate.self)).instantiateViewController(withIdentifier: "MainStatusViewController") as! StatusTableViewController + let mainStatusViewController = UIStoryboard(name: "Main", bundle: Bundle(for: AppDelegate.self)).instantiateViewController(withIdentifier: "MainStatusViewController") as! StatusTableViewController - rootViewController.pushViewController(mainStatusViewControllers, animated: false) - - rootViewController.rootViewController.deviceManager = deviceManager + mainStatusViewController.deviceManager = deviceManager + + rootViewController.pushViewController(mainStatusViewController, animated: false) } diff --git a/Loop/Base.lproj/Main.storyboard b/Loop/Base.lproj/Main.storyboard index b59845e58e..a322adb532 100644 --- a/Loop/Base.lproj/Main.storyboard +++ b/Loop/Base.lproj/Main.storyboard @@ -1,9 +1,9 @@ - + - + @@ -514,7 +514,7 @@ - + @@ -526,7 +526,7 @@ - + @@ -664,7 +664,7 @@ - + @@ -700,7 +700,7 @@ - + @@ -708,67 +708,109 @@ - - + + - - + + - + + + + + + + - - - - + + + + - + + + + - - + + - - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + - - - - - + @@ -814,7 +856,7 @@ - + @@ -860,34 +902,6 @@ - - - - - - - - - - - - - - - - - - - - - @@ -903,8 +917,6 @@ - - @@ -933,17 +945,7 @@ - - - - - - - - - - - + @@ -974,7 +976,7 @@ - + @@ -1113,8 +1115,206 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1126,8 +1326,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 0000000000..a67042ecbf Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Oval Selection Dark.pdf differ 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 0000000000..13056f028e Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Oval Selection.pdf differ diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 78ddaee77d..96ce36bcf0 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -101,6 +101,7 @@ final class LoopDataManager { self.carbEffect = nil self.carbsOnBoard = nil + self.recentCarbEntries = nil self.notify(forChange: .carbs) } }, @@ -169,36 +170,55 @@ final class LoopDataManager { retrospectiveGlucoseDiscrepancies = nil } } + private var insulinEffect: [GlucoseEffect]? { didSet { + insulinEffectIncludingPendingInsulin = nil predictedGlucose = nil } } + + private var insulinEffectIncludingPendingInsulin: [GlucoseEffect]? { + didSet { + predictedGlucoseIncludingPendingInsulin = nil + } + } + private var glucoseMomentumEffect: [GlucoseEffect]? { didSet { predictedGlucose = nil } } + private var retrospectiveGlucoseEffect: [GlucoseEffect] = [] { didSet { predictedGlucose = nil } } + /// When combining retrospective glucose discrepancies, extend the window slightly as a buffer. + private let retrospectiveCorrectionGroupingIntervalMultiplier = 1.01 + private var retrospectiveGlucoseDiscrepancies: [GlucoseEffect]? { didSet { - retrospectiveGlucoseDiscrepanciesSummed = retrospectiveGlucoseDiscrepancies?.combinedSums(of: settings.retrospectiveCorrectionGroupingInterval * 1.01) + retrospectiveGlucoseDiscrepanciesSummed = retrospectiveGlucoseDiscrepancies?.combinedSums(of: settings.retrospectiveCorrectionGroupingInterval * retrospectiveCorrectionGroupingIntervalMultiplier) } } + private var retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange]? fileprivate var predictedGlucose: [PredictedGlucoseValue]? { didSet { recommendedTempBasal = nil recommendedBolus = nil + predictedGlucoseIncludingPendingInsulin = nil } } + fileprivate var predictedGlucoseIncludingPendingInsulin: [PredictedGlucoseValue]? + + private var recentCarbEntries: [StoredCarbEntry]? + fileprivate var recommendedTempBasal: (recommendation: TempBasalRecommendation, date: Date)? fileprivate var recommendedBolus: (recommendation: BolusRecommendation, date: Date)? @@ -725,6 +745,21 @@ extension LoopDataManager { } } + if insulinEffectIncludingPendingInsulin == nil { + updateGroup.enter() + doseStore.getGlucoseEffects(start: nextEffectDate, basalDosingEnd: nil) { (result) -> Void in + switch result { + case .failure(let error): + self.logger.error("Could not fetch insulin effects: \(error)") + self.insulinEffectIncludingPendingInsulin = nil + case .success(let effects): + self.insulinEffectIncludingPendingInsulin = effects + } + + updateGroup.leave() + } + } + _ = updateGroup.wait(timeout: .distantFuture) if nextEffectDate < lastGlucoseDate, let insulinEffect = insulinEffect { @@ -750,8 +785,10 @@ extension LoopDataManager { case .failure(let error): self.logger.error(error) self.carbEffect = nil - case .success(let (_, effects)): + self.recentCarbEntries = nil + case .success(let (samples, effects)): self.carbEffect = effects + self.recentCarbEntries = samples } updateGroup.leave() @@ -834,8 +871,18 @@ extension LoopDataManager { return pendingTempBasalInsulin + pendingBolusAmount } - /// - Throws: LoopError.missingDataError - fileprivate func predictGlucose(using inputs: PredictionInputEffect) throws -> [PredictedGlucoseValue] { + /// - Throws: + /// - LoopError.missingDataError + /// - LoopError.configurationError + /// - LoopError.glucoseTooOld + /// - LoopError.pumpDataTooOld + fileprivate func predictGlucose( + using inputs: PredictionInputEffect, + potentialBolus: DoseEntry? = nil, + potentialCarbEntry: NewCarbEntry? = nil, + replacingCarbEntry replacedCarbEntry: StoredCarbEntry? = nil, + includingPendingInsulin: Bool = false + ) throws -> [PredictedGlucoseValue] { dispatchPrecondition(condition: .onQueue(dataAccessQueue)) guard let model = insulinModelSettings?.model else { @@ -846,15 +893,76 @@ extension LoopDataManager { throw LoopError.missingDataError(.glucose) } + let pumpStatusDate = doseStore.lastAddedPumpData + let lastGlucoseDate = glucose.startDate + let now = Date() + + guard now.timeIntervalSince(lastGlucoseDate) <= settings.recencyInterval else { + throw LoopError.glucoseTooOld(date: glucose.startDate) + } + + guard now.timeIntervalSince(pumpStatusDate) <= settings.recencyInterval else { + throw LoopError.pumpDataTooOld(date: pumpStatusDate) + } + var momentum: [GlucoseEffect] = [] + var retrospectiveGlucoseEffect = self.retrospectiveGlucoseEffect 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 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 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 }) + + let potentialCarbEffect = try carbStore.glucoseEffects( + of: entries, + startingAt: retrospectiveStart, + effectVelocities: settings.dynamicCarbAbsorptionEnabled ? insulinCounteractionEffects : nil + ) + + effects.append(potentialCarbEffect) + + retrospectiveGlucoseEffect = computeRetrospectiveGlucoseEffect(startingAt: glucose, carbEffects: potentialCarbEffect) + } + } else { + effects.append(carbEffect) + } } - if inputs.contains(.insulin), let insulinEffect = self.insulinEffect { + if inputs.contains(.insulin), let insulinEffect = includingPendingInsulin ? self.insulinEffectIncludingPendingInsulin : 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 { @@ -862,7 +970,7 @@ extension LoopDataManager { } if inputs.contains(.retrospection) { - effects.append(self.retrospectiveGlucoseEffect) + effects.append(retrospectiveGlucoseEffect) } var prediction = LoopMath.predictGlucose(startingAt: glucose, momentum: momentum, effects: effects) @@ -877,6 +985,87 @@ 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 lastGlucoseDate = glucose.startDate + let now = Date() + + guard now.timeIntervalSince(lastGlucoseDate) <= settings.recencyInterval else { + throw LoopError.glucoseTooOld(date: glucose.startDate) + } + + guard now.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 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: 0, // Pending insulin is already reflected in the prediction + 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 @@ -911,6 +1100,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 * retrospectiveCorrectionGroupingIntervalMultiplier) + 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: @@ -952,13 +1155,15 @@ extension LoopDataManager { throw LoopError.missingDataError(.carbEffect) } - guard insulinEffect != nil else { + guard insulinEffect != nil, insulinEffectIncludingPendingInsulin != nil else { self.predictedGlucose = nil throw LoopError.missingDataError(.insulinEffect) } let predictedGlucose = try predictGlucose(using: settings.enabledEffects) self.predictedGlucose = predictedGlucose + let predictedGlucoseIncludingPendingInsulin = try predictGlucose(using: settings.enabledEffects, includingPendingInsulin: true) + self.predictedGlucoseIncludingPendingInsulin = predictedGlucoseIncludingPendingInsulin guard let maxBasal = settings.maximumBasalRatePerHour, @@ -1013,19 +1218,17 @@ extension LoopDataManager { recommendedTempBasal = nil } - let pendingInsulin = try self.getPendingInsulin() - let volumeRounder = { (_ units: Double) in return self.delegate?.loopDataManager(self, roundBolusVolume: units) ?? units } - let recommendation = predictedGlucose.recommendedBolus( + let recommendation = predictedGlucoseIncludingPendingInsulin.recommendedBolus( to: glucoseTargetRange, at: predictedGlucose[0].startDate, suspendThreshold: settings.suspendThreshold?.quantity, sensitivity: insulinSensitivity, model: model, - pendingInsulin: pendingInsulin, + pendingInsulin: 0, // Pending insulin is already reflected in the prediction maxBolus: maxBolus, volumeRounder: volumeRounder ) @@ -1076,6 +1279,9 @@ protocol LoopState { /// The calculated timeline of predicted glucose values var predictedGlucose: [PredictedGlucoseValue]? { get } + /// The calculated timeline of predicted glucose values, including the effects of pending insulin + var predictedGlucoseIncludingPendingInsulin: [PredictedGlucoseValue]? { get } + /// The recommended temp basal based on predicted glucose var recommendedTempBasal: (recommendation: TempBasalRecommendation, date: Date)? { get } @@ -1092,9 +1298,39 @@ protocol LoopState { /// 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` + /// - Parameter includingPendingInsulin: If `true`, the returned prediction will include the effects of scheduled but not yet delivered insulin + /// - 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?, includingPendingInsulin: Bool) 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. + /// + /// - Parameter inputs: The effect inputs to include + /// - Parameter includingPendingInsulin: If `true`, the returned prediction will include the effects of scheduled but not yet delivered insulin /// - 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, includingPendingInsulin: Bool = false) throws -> [GlucoseValue] { + try predictGlucose(using: inputs, potentialBolus: nil, potentialCarbEntry: nil, replacingCarbEntry: nil, includingPendingInsulin: includingPendingInsulin) + } } @@ -1128,6 +1364,11 @@ extension LoopDataManager { return loopDataManager.predictedGlucose } + var predictedGlucoseIncludingPendingInsulin: [PredictedGlucoseValue]? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return loopDataManager.predictedGlucoseIncludingPendingInsulin + } + var recommendedTempBasal: (recommendation: TempBasalRecommendation, date: Date)? { dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) guard loopDataManager.lastRequestedBolus == nil else { @@ -1154,8 +1395,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?, includingPendingInsulin: Bool) throws -> [PredictedGlucoseValue] { + return try loopDataManager.predictGlucose(using: inputs, potentialBolus: potentialBolus, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: includingPendingInsulin) + } + + 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..1c8882842f 100644 --- a/Loop/View Controllers/BolusViewController.swift +++ b/Loop/View Controllers/BolusViewController.swift @@ -9,24 +9,49 @@ 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 - case active +final class BolusViewController: ChartsTableViewController, IdentifiableClass, UITextFieldDelegate { + private enum Row: Int { + case carbEntry = 0 + case chart + case notice 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) + + notificationObservers += [ + NotificationCenter.default.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,87 +64,276 @@ 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() - - AnalyticsManager.shared.didDisplayBolusScreen() } - func generateActiveInsulinDescription(activeInsulin: Double?, pendingInsulin: Double?) -> String - { - let iobStr: String - if let iob = activeInsulin, let valueStr = insulinFormatter.string(from: iob) - { - iobStr = valueStr + " U" - } else { - iobStr = "-" + 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 } + } - var rval = String(format: NSLocalizedString("Active Insulin: %@", comment: "The string format describing active insulin. (1: localized insulin value description)"), iobStr) + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() - if let pending = pendingInsulin, pending > 0, let pendingStr = insulinFormatter.string(from: pending) - { - rval += String(format: NSLocalizedString(" (pending: %@)", comment: "The string format appended to active insulin that describes pending insulin. (1: pending insulin)"), pendingStr + " U") + if !visible { + refreshContext = RefreshContext.all } - return rval + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + refreshContext.update(with: .size(size)) + + super.viewWillTransition(to: size, with: coordinator) } // MARK: - State + enum Configuration { + case manualCorrection + case newCarbEntry(NewCarbEntry) + case updatedCarbEntry(from: StoredCarbEntry, to: NewCarbEntry) + } + + var configuration: Configuration = .manualCorrection { + didSet { + switch configuration { + case .manualCorrection: + title = NSLocalizedString("Bolus", comment: "Title text for bolus screen (manual correction)") + case .newCarbEntry, .updatedCarbEntry: + title = NSLocalizedString("Meal Bolus", comment: "Title text for bolus screen following a carb entry") + } + } + } + + 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 + private var computedInitialBolusRecommendation = false + var bolusRecommendation: BolusRecommendation? = nil { didSet { let amount = bolusRecommendation?.amount ?? 0 recommendedBolusAmountLabel?.text = bolusUnitsFormatter.string(from: amount) + updateNotice() - if let pendingInsulin = bolusRecommendation?.pendingInsulin { - self.pendingInsulin = pendingInsulin + 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 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) } } } - var activeCarbohydratesDescription: String? = nil { - didSet { - activeCarbohydratesLabel?.text = activeCarbohydratesDescription + var maxBolus: Double = 25 + + 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 } } - var activeCarbohydrates: Double? = nil { - didSet { + private var eventualGlucoseDescription: String? - let cobStr: String - if let cob = activeCarbohydrates, let str = integerFormatter.string(from: cob) { - cobStr = str + " g" - } else { - cobStr = "-" + 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() } - activeCarbohydratesDescription = String(format: NSLocalizedString("Active Carbohydrates: %@", comment: "The string format describing active carbohydrates. (1: localized glucose value description)"), cobStr) } - } - var activeInsulinDescription: String? = nil { - didSet { - activeInsulinLabel?.text = activeInsulinDescription + _ = self.refreshContext.remove(.status) + reloadGroup.enter() + self.deviceManager.loopManager.getLoopState { (manager, state) in + let enteredBolus = DispatchQueue.main.sync { self.enteredBolus } + + let predictedGlucoseIncludingPendingInsulin: [PredictedGlucoseValue] + do { + predictedGlucoseIncludingPendingInsulin = try state.predictGlucose(using: .all, potentialBolus: enteredBolus, potentialCarbEntry: self.potentialCarbEntry, replacingCarbEntry: self.originalCarbEntry, includingPendingInsulin: true) + } catch { + self.refreshContext.update(with: .status) + predictedGlucoseIncludingPendingInsulin = [] + } + + self.glucoseChart.setPredictedGlucoseValues(predictedGlucoseIncludingPendingInsulin) + + 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 bolusRecommendation = try? state.recommendBolus(forPrediction: predictedGlucoseIncludingPendingInsulin) + + DispatchQueue.main.async { + if let maxBolus = maximumBolus { + self.maxBolus = maxBolus + } + + self.bolusRecommendation = bolusRecommendation + self.computedInitialBolusRecommendation = true + } + + reloadGroup.leave() } - } - var activeInsulin: Double? = nil { - didSet { - activeInsulinDescription = generateActiveInsulinDescription(activeInsulin: activeInsulin, pendingInsulin: pendingInsulin) + reloadGroup.notify(queue: .main) { + self.updateDeliverButtonState() + self.redrawChart() } } - var pendingInsulin: Double? = nil { - didSet { - activeInsulinDescription = generateActiveInsulinDescription(activeInsulin: activeInsulin, pendingInsulin: pendingInsulin) + 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() - var maxBolus: Double = 25 + tableView.beginUpdates() + for case let cell as ChartTableViewCell in tableView.visibleCells { + cell.reloadChart() - private(set) var bolus: Double? + 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 @@ -136,49 +350,164 @@ final class BolusViewController: UITableViewController, IdentifiableClass, UITex } } - @IBOutlet weak var activeCarbohydratesLabel: UILabel? { - didSet { - activeCarbohydratesLabel?.text = activeCarbohydratesDescription - } - } + // MARK: - TableView Delegate - @IBOutlet weak var activeInsulinLabel: UILabel? { - didSet { - activeInsulinLabel?.text = activeInsulinDescription + 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) } } - // MARK: - TableView Delegate - 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?.removeAll() + 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 + } + } + + 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?.removeAll() } } - @objc - func acceptRecommendedBolus() { + @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, includingPendingInsulin: true) { + 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,36 +543,43 @@ final class BolusViewController: UITableViewController, IdentifiableClass, UITex } private func setBolusAndClose(_ bolus: Double) { + self.updatedCarbEntry = potentialCarbEntry self.bolus = bolus self.performSegue(withIdentifier: "close", sender: nil) } - private lazy var bolusUnitsFormatter: NumberFormatter = { - let numberFormatter = NumberFormatter() - - numberFormatter.maximumSignificantDigits = 3 - numberFormatter.minimumFractionDigits = 1 + @objc private func cancel() { + dismiss(animated: true) + } - return numberFormatter + 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 insulinFormatter: NumberFormatter = { - let numberFormatter = NumberFormatter() - - numberFormatter.numberStyle = .decimal - numberFormatter.minimumFractionDigits = 2 - numberFormatter.maximumFractionDigits = 2 - - return numberFormatter + private lazy var timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter }() - private lazy var integerFormatter: NumberFormatter = { + private lazy var bolusUnitsFormatter: NumberFormatter = { let numberFormatter = NumberFormatter() - numberFormatter.numberStyle = .none - numberFormatter.maximumFractionDigits = 0 + numberFormatter.maximumSignificantDigits = 3 + numberFormatter.minimumFractionDigits = 1 return numberFormatter }() @@ -270,3 +606,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 b7138c4b63..b65e8fcaf6 100644 --- a/Loop/View Controllers/CarbAbsorptionViewController.swift +++ b/Loop/View Controllers/CarbAbsorptionViewController.swift @@ -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..7103b4be05 --- /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: preferredUnit) > 0 + return footerView + }() + + private var lastContentHeight: CGFloat = 0 + + override func createChartsManager() -> ChartsManager { + // 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() { + // Consider including a chart on this screen to demonstrate how absorption time affects prediction + } + + 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: preferredUnit) > 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: preferredUnit) > 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/PredictionTableViewController.swift b/Loop/View Controllers/PredictionTableViewController.swift index 62b8183e6e..ddfa4eac92 100644 --- a/Loop/View Controllers/PredictionTableViewController.swift +++ b/Loop/View Controllers/PredictionTableViewController.swift @@ -123,10 +123,10 @@ class PredictionTableViewController: ChartsTableViewController, IdentifiableClas self.deviceManager.loopManager.getLoopState { (manager, state) in self.retrospectiveGlucoseDiscrepancies = state.retrospectiveGlucoseDiscrepancies totalRetrospectiveCorrection = state.totalRetrospectiveCorrection - self.glucoseChart.setPredictedGlucoseValues(state.predictedGlucose ?? []) + self.glucoseChart.setPredictedGlucoseValues(state.predictedGlucoseIncludingPendingInsulin ?? []) do { - let glucose = try state.predictGlucose(using: self.selectedInputs) + let glucose = try state.predictGlucose(using: self.selectedInputs, includingPendingInsulin: true) self.glucoseChart.setAlternatePredictedGlucoseValues(glucose) } catch { self.refreshContext.update(with: .status) 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..0eb5becb43 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -313,7 +313,7 @@ final class StatusTableViewController: ChartsTableViewController { // TODO: Don't always assume currentContext.contains(.status) reloadGroup.enter() self.deviceManager.loopManager.getLoopState { (manager, state) -> Void in - predictedGlucoseValues = state.predictedGlucose ?? [] + predictedGlucoseValues = state.predictedGlucoseIncludingPendingInsulin ?? [] // Retry this refresh again if predicted glucose isn't available if state.predictedGlucose == nil { @@ -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 + AnalyticsManager.shared.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 + } +}