diff --git a/Common/Models/WatchContext.swift b/Common/Models/WatchContext.swift index 67f6c603d4..9a2d36f47d 100644 --- a/Common/Models/WatchContext.swift +++ b/Common/Models/WatchContext.swift @@ -31,6 +31,7 @@ final class WatchContext: RawRepresentable { var lastNetTempBasalDose: Double? var lastNetTempBasalDate: Date? var recommendedBolusDose: Double? + var doNotOpenBolusScreenWithMicroboluses: Bool? var cob: Double? var iob: Double? @@ -68,6 +69,7 @@ final class WatchContext: RawRepresentable { lastNetTempBasalDate = rawValue["bad"] as? Date recommendedBolusDose = rawValue["rbo"] as? Double cob = rawValue["cob"] as? Double + doNotOpenBolusScreenWithMicroboluses = rawValue["mb"] as? Bool cgmManagerState = rawValue["cgmManagerState"] as? CGMManager.RawStateValue @@ -100,6 +102,7 @@ final class WatchContext: RawRepresentable { raw["r"] = reservoir raw["rbo"] = recommendedBolusDose raw["rp"] = reservoirPercentage + raw["mb"] = doNotOpenBolusScreenWithMicroboluses raw["pg"] = predictedGlucose?.rawValue diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 8680204eae..b098a38dc9 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -22,6 +22,11 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + 3804343D23747354004BAB52 /* Microbolus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3804343C23747354004BAB52 /* Microbolus.swift */; }; + 3804343E237474AE004BAB52 /* Microbolus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3804343C23747354004BAB52 /* Microbolus.swift */; }; + 38BE6D8D236C8A110074CF11 /* MicrobolusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38BE6D8C236C8A110074CF11 /* MicrobolusViewController.swift */; }; + 38CE2218236B134F00DFE990 /* MicrobolusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38CE2217236B134F00DFE990 /* MicrobolusView.swift */; }; + 38DAF6F223D7203500A3E9E3 /* MicrobolusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DAF6F123D7203500A3E9E3 /* MicrobolusViewModel.swift */; }; 43027F0F1DFE0EC900C51989 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F526D5E1DF2459000A04910 /* HKUnit.swift */; }; 4302F4E11D4E9C8900F0FCAF /* TextFieldTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4302F4E01D4E9C8900F0FCAF /* TextFieldTableViewController.swift */; }; 4302F4E31D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4302F4E21D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift */; }; @@ -236,7 +241,6 @@ 43E2D8F41D20C0DB004DA55F /* recommend_temp_basal_start_high_end_low.json in Resources */ = {isa = PBXBuildFile; fileRef = 43E2D8E91D20C0DB004DA55F /* recommend_temp_basal_start_high_end_low.json */; }; 43E2D8F51D20C0DB004DA55F /* recommend_temp_basal_start_low_end_high.json in Resources */ = {isa = PBXBuildFile; fileRef = 43E2D8EA1D20C0DB004DA55F /* recommend_temp_basal_start_low_end_high.json */; }; 43E2D8F61D20C0DB004DA55F /* recommend_temp_basal_start_low_end_in_range.json in Resources */ = {isa = PBXBuildFile; fileRef = 43E2D8EB1D20C0DB004DA55F /* recommend_temp_basal_start_low_end_in_range.json */; }; - 43E2D9151D20C5A2004DA55F /* KeychainManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43E2D8C91D20B9E7004DA55F /* KeychainManagerTests.swift */; }; 43E2D9191D222759004DA55F /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43F78D4B1C914197002152D1 /* LoopKit.framework */; }; 43E3449F1B9D68E900C85C07 /* StatusTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43E3449E1B9D68E900C85C07 /* StatusTableViewController.swift */; }; 43E93FB51E4675E800EAB8DB /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0B31E45C1BE00FF19A9 /* NumberFormatter.swift */; }; @@ -567,6 +571,12 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 3804343C23747354004BAB52 /* Microbolus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Microbolus.swift; sourceTree = ""; }; + 38589861235E0A4200919FD4 /* NightscoutAPIClient.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = NightscoutAPIClient.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 38589864235E0A4C00919FD4 /* NightscoutAPIClientUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = NightscoutAPIClientUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 38BE6D8C236C8A110074CF11 /* MicrobolusViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicrobolusViewController.swift; sourceTree = ""; }; + 38CE2217236B134F00DFE990 /* MicrobolusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicrobolusView.swift; sourceTree = ""; }; + 38DAF6F123D7203500A3E9E3 /* MicrobolusViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicrobolusViewModel.swift; sourceTree = ""; }; 4302F4E01D4E9C8900F0FCAF /* TextFieldTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextFieldTableViewController.swift; sourceTree = ""; }; 4302F4E21D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryTableViewController.swift; sourceTree = ""; }; 430B29892041F54A00BA9F93 /* NSUserDefaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSUserDefaults.swift; sourceTree = ""; }; @@ -1218,6 +1228,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 38DAF6F023D71FFE00A3E9E3 /* Microboluses */ = { + isa = PBXGroup; + children = ( + 38CE2217236B134F00DFE990 /* MicrobolusView.swift */, + 38DAF6F123D7203500A3E9E3 /* MicrobolusViewModel.swift */, + ); + path = Microboluses; + sourceTree = ""; + }; 4328E0121CFBE1B700E199AA /* Controllers */ = { isa = PBXGroup; children = ( @@ -1520,6 +1539,7 @@ 43D848AF1E7DCBE100DADCBC /* Result.swift */, 43D9FFD121EAE05D00AF44BF /* LoopCore.h */, 43D9FFD221EAE05D00AF44BF /* Info.plist */, + 3804343C23747354004BAB52 /* Microbolus.swift */, ); path = LoopCore; sourceTree = ""; @@ -1606,6 +1626,7 @@ 43E3449E1B9D68E900C85C07 /* StatusTableViewController.swift */, 4302F4E01D4E9C8900F0FCAF /* TextFieldTableViewController.swift */, 89CA2B31226C18B8004D9350 /* TestingScenariosTableViewController.swift */, + 38BE6D8C236C8A110074CF11 /* MicrobolusViewController.swift */, ); path = "View Controllers"; sourceTree = ""; @@ -1626,6 +1647,7 @@ 4311FB9A1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift */, 89D6953D23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift */, 439706E522D2E84900C81566 /* PredictionSettingTableViewCell.swift */, + 38DAF6F023D71FFE00A3E9E3 /* Microboluses */, ); path = Views; sourceTree = ""; @@ -1841,6 +1863,8 @@ 968DCD53F724DE56FFE51920 /* Frameworks */ = { isa = PBXGroup; children = ( + 38589864235E0A4C00919FD4 /* NightscoutAPIClientUI.framework */, + 38589861235E0A4200919FD4 /* NightscoutAPIClient.framework */, 434FB6451D68F1CD007B9C70 /* Amplitude.framework */, 4344628420A7A3BE00C4BE6F /* CGMBLEKit.framework */, 43A8EC6E210E622600A81379 /* CGMBLEKitUI.framework */, @@ -2637,6 +2661,7 @@ 43B260491ED248FB008CAA77 /* CarbEntryTableViewCell.swift in Sources */, 4302F4E11D4E9C8900F0FCAF /* TextFieldTableViewController.swift in Sources */, 43F64DD91D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift in Sources */, + 38CE2218236B134F00DFE990 /* MicrobolusView.swift in Sources */, C15713821DAC6983005BC4D2 /* MealBolusNightscoutTreatment.swift in Sources */, 43FCEEA9221A615B0013DD30 /* StatusChartsManager.swift in Sources */, 43511CE321FD80E400566C63 /* StandardRetrospectiveCorrection.swift in Sources */, @@ -2657,6 +2682,7 @@ 43D9003321EB258C00AF44BF /* InsulinModelSettings+Loop.swift in Sources */, 434FF1EE1CF27EEF000DB779 /* UITableViewCell.swift in Sources */, 439BED2A1E76093C00B0AED5 /* CGMManager.swift in Sources */, + 38DAF6F223D7203500A3E9E3 /* MicrobolusViewModel.swift in Sources */, C18C8C511D5A351900E043FB /* NightscoutDataManager.swift in Sources */, C165B8CE23302C5D0004112E /* RemoteCommand.swift in Sources */, 438849EA1D297CB6003B3F23 /* NightscoutService.swift in Sources */, @@ -2696,6 +2722,7 @@ 89ADE13B226BFA0F0067222B /* TestingScenariosManager.swift in Sources */, 4F7E8ACB20E2ACB500AEA65E /* WatchPredictedGlucose.swift in Sources */, 436A0DA51D236A2A00104B24 /* LoopError.swift in Sources */, + 38BE6D8D236C8A110074CF11 /* MicrobolusViewController.swift in Sources */, 4F11D3C220DD80B3006E072C /* WatchHistoricalGlucose.swift in Sources */, 435CB6231F37967800C320C7 /* InsulinModelSettingsViewController.swift in Sources */, 4372E490213CFCE70068E043 /* LoopSettingsUserInfo.swift in Sources */, @@ -2784,6 +2811,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3804343E237474AE004BAB52 /* Microbolus.swift in Sources */, 43C05CB821EBEA54006FB252 /* HKUnit.swift in Sources */, 4345E3F421F036FC009E00E5 /* Result.swift in Sources */, 43D9002021EB209400AF44BF /* NSTimeInterval.swift in Sources */, @@ -2836,6 +2864,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3804343D23747354004BAB52 /* Microbolus.swift in Sources */, 43C05CB921EBEA54006FB252 /* HKUnit.swift in Sources */, 4345E3F521F036FC009E00E5 /* Result.swift in Sources */, 43D9FFFB21EAF3D300AF44BF /* NSTimeInterval.swift in Sources */, @@ -2870,7 +2899,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 43E2D9151D20C5A2004DA55F /* KeychainManagerTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3530,6 +3558,7 @@ DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = YES; INFOPLIST_FILE = Loop/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; "OTHER_SWIFT_FLAGS[arch=*]" = "-DDEBUG"; "OTHER_SWIFT_FLAGS[sdk=iphonesimulator*]" = "-D IOS_SIMULATOR"; @@ -3549,6 +3578,7 @@ DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = YES; INFOPLIST_FILE = Loop/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -3576,6 +3606,7 @@ SWIFT_OBJC_BRIDGING_HEADER = "WatchApp Extension/Extensions/WatchApp Extension-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 6.0; }; name = Debug; }; @@ -3598,6 +3629,7 @@ SKIP_INSTALL = YES; SWIFT_OBJC_BRIDGING_HEADER = "WatchApp Extension/Extensions/WatchApp Extension-Bridging-Header.h"; TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 6.0; }; name = Release; }; @@ -3619,6 +3651,7 @@ SDKROOT = watchos; SKIP_INSTALL = YES; TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 6.0; }; name = Debug; }; @@ -3640,6 +3673,7 @@ SDKROOT = watchos; SKIP_INSTALL = YES; TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 6.0; }; name = Release; }; @@ -3912,6 +3946,7 @@ DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; INFOPLIST_FILE = "Loop Status Extension/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).statuswidget"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -3934,6 +3969,7 @@ DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; INFOPLIST_FILE = "Loop Status Extension/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).statuswidget"; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 194382ec2b..8605b8632a 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -618,6 +618,15 @@ extension DeviceDataManager: LoopDataManagerDelegate { } ) } + + func loopDataManager(_ manager: LoopDataManager, didRecommendMicroBolus bolus: (amount: Double, date: Date), completion: @escaping (_ error: Error?) -> Void) -> Void { + enactBolus( + units: bolus.amount, + at: bolus.date, + completion: completion) + } + + var bolusState: PumpManagerStatus.BolusState? { pumpManager?.status.bolusState } } diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 96ce36bcf0..ddf9ee56dc 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -10,6 +10,7 @@ import Foundation import HealthKit import LoopKit import LoopCore +import Combine final class LoopDataManager { @@ -33,6 +34,10 @@ final class LoopDataManager { private let logger: CategoryLogger + private var loopSubscription: AnyCancellable? + + let lastMicrobolusEvent = CurrentValueSubject(nil) + // References to registered notification center observers private var notificationObservers: [Any] = [] @@ -657,41 +662,83 @@ extension LoopDataManager { /// Executes an analysis of the current data, and recommends an adjustment to the current /// temporary basal rate. func loop() { - self.dataAccessQueue.async { - self.logger.default("Loop running") - NotificationCenter.default.post(name: .LoopRunning, object: self) + self.logger.default("Loop running") + NotificationCenter.default.post(name: .LoopRunning, object: self) + + let updatePublisher = Deferred { + Future<(), Error> { promise in + do { + try self.update() + promise(.success(())) + } catch let error { + promise(.failure(error)) + } + } + } + .subscribe(on: dataAccessQueue) + .eraseToAnyPublisher() - self.lastLoopError = nil - let startDate = Date() + let enactBolusPublisher = Deferred { + Future { promise in + self.calculateAndEnactMicroBolusIfNeeded { event, error in + if let error = error { + promise(.failure(error)) + } + promise(.success(event)) + } + } + } + .subscribe(on: dataAccessQueue) + .eraseToAnyPublisher() - do { - try self.update() + let setBasalPublisher = Deferred { + Future<(), Error> { promise in + guard self.settings.dosingEnabled else { + return promise(.success(())) + } - if self.settings.dosingEnabled { - self.setRecommendedTempBasal { (error) -> Void in - self.lastLoopError = error - - if let error = error { - self.logger.error(error) - } else { - self.loopDidComplete(date: Date(), duration: -startDate.timeIntervalSinceNow) - } - self.logger.default("Loop ended") - self.notify(forChange: .tempBasal) + self.setRecommendedTempBasal { error in + if let error = error { + promise(.failure(error)) } + promise(.success(())) + } - // Delay the notification until we know the result of the temp basal - return - } else { + } + } + .subscribe(on: dataAccessQueue) + .eraseToAnyPublisher() + + logger.default("Loop running") + NotificationCenter.default.post(name: .LoopRunning, object: self) + + loopSubscription?.cancel() + + let startDate = Date() + loopSubscription = updatePublisher + .flatMap { _ in setBasalPublisher } + .flatMap { _ in enactBolusPublisher } + .receive(on: dataAccessQueue) + .sink( + receiveCompletion: { [weak self] completion in + guard let self = self else { return } + switch completion { + case .finished: + self.lastLoopError = nil self.loopDidComplete(date: Date(), duration: -startDate.timeIntervalSinceNow) + self.notify(forChange: .bolus) + case let .failure(error): + self.lastLoopError = error + self.logger.error(error) } - } catch let error { - self.lastLoopError = error + self.logger.default("Loop ended") + }, + receiveValue: { [weak self] event in + guard let event = event, let self = self else { return } + self.lastMicrobolusEvent.send(event) + self.logger.debug("Microbolus event. \(event.description)") } - - self.logger.default("Loop ended") - self.notify(forChange: .tempBasal) - } + ) } /// - Throws: @@ -1165,8 +1212,13 @@ extension LoopDataManager { let predictedGlucoseIncludingPendingInsulin = try predictGlucose(using: settings.enabledEffects, includingPendingInsulin: true) self.predictedGlucoseIncludingPendingInsulin = predictedGlucoseIncludingPendingInsulin + let useCurrentBasalRateMultiplier = checkCOBforMicrobolus() && checkTempOverrideForMicrobolus() + let selectedMaxBasal = useCurrentBasalRateMultiplier + ? settings.currentMaximumBasalRatePerHour(date: startDate, basalRates: basalRateScheduleApplyingOverrideHistory) + : settings.maximumBasalRatePerHour + guard - let maxBasal = settings.maximumBasalRatePerHour, + let maxBasal = selectedMaxBasal, let glucoseTargetRange = settings.glucoseTargetRangeScheduleApplyingOverrideIfActive, let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory, let basalRates = basalRateScheduleApplyingOverrideHistory, @@ -1236,6 +1288,123 @@ extension LoopDataManager { self.logger.debug("Recommending bolus: \(String(describing: recommendedBolus))") } + /// *This method should only be called from the `dataAccessQueue`* + private func calculateAndEnactMicroBolusIfNeeded(_ completion: @escaping (_ event: Microbolus.Event?, _ error: Error?) -> Void) { + dispatchPrecondition(condition: .onQueue(dataAccessQueue)) + + let startDate = Date() + + guard settings.dosingEnabled else { + logger.debug("Closed loop is disabled. Cancel microbolus calculation.") + completion(nil, nil) + return + } + + guard let recommendedBolus = recommendedBolus else { + logger.debug("No recommended bolus. Cancel microbolus calculation.") + completion(nil, nil) + return + } + + let insulinReq = recommendedBolus.recommendation.amount + + guard insulinReq > 0 else { + logger.debug("No microbolus needed.") + completion(nil, nil) + return + } + + guard abs(recommendedBolus.date.timeIntervalSinceNow) < TimeInterval(minutes: 5) else { + completion(.canceled(date: startDate, recommended: insulinReq, reason: "Bolus recommendation expired."), nil) + return + } + + guard let glucose = self.glucoseStore.latestGlucose, + let predictedGlucose = predictedGlucose, + let unit = glucoseStore.preferredUnit, + let glucoseTargetRange = settings.glucoseTargetRangeScheduleApplyingOverrideIfActive else { + completion(.canceled(date: startDate, recommended: insulinReq, reason: "Glucose data not found."), nil) + return + } + + let glucoseBelowRange = predictedGlucose.first { $0.quantity.doubleValue(for: unit) < glucoseTargetRange.value(at: $0.startDate).minValue } + + guard glucoseBelowRange == nil else { + completion(.canceled(date: startDate, recommended: insulinReq, reason: "Glucose is below target at \(glucoseBelowRange!.startDate)"), nil) + return + } + + guard checkCOBforMicrobolus() else { + completion(.canceled(date: startDate, recommended: insulinReq, reason: "Microboluses disabled."), nil) + return + } + + guard checkTempOverrideForMicrobolus() else { + completion(.canceled(date: startDate, recommended: insulinReq, reason: "Canceled by temporary override."), nil) + return + } + + guard let bolusState = delegate?.bolusState, case .none = bolusState else { + completion(.canceled(date: startDate, recommended: insulinReq, reason: "Already bolusing."), nil) + return + } + + guard let threshold = settings.suspendThreshold, glucose.quantity > threshold.quantity else { + completion(.canceled(date: startDate, recommended: insulinReq, reason: "Current glucose is below the suspend threshold."), nil) + return + } + + switch recommendedBolus.recommendation.notice { + case let .some(notice): + let notice = "Microbolus canceled by recommendation notice: \(notice.description(using: unit))" + completion(.canceled(date: startDate, recommended: insulinReq, reason: notice), nil) + return + case .none: break + } + + let volumeRounder = { (_ units: Double) in + self.delegate?.loopDataManager(self, roundBolusVolume: units) ?? units + } + + let microBolus = volumeRounder(insulinReq * settings.microbolusSettings.partialApplication) + guard microBolus > 0 else { + completion(.canceled(date: startDate, recommended: insulinReq, reason: "Microbolus < then supported volume."), nil) + return + } + guard microBolus >= settings.microbolusSettings.minimumBolusSize else { + completion(.canceled(date: startDate, recommended: insulinReq, reason: "Microbolus < then minimum bolus size."), nil) + return + } + + let recommendation = (amount: microBolus, date: startDate) + logger.debug("Enact microbolus: \(String(describing: microBolus))") + + self.delegate?.loopDataManager(self, didRecommendMicroBolus: recommendation) { error in + if let error = error { + completion(.failed(date: startDate, recommended: insulinReq, error: error), error) + } else { + completion(.succeeded(date: startDate, recommended: insulinReq, amount: microBolus), nil) + } + } + } + + private func checkCOBforMicrobolus() -> Bool { + let cob = carbsOnBoard?.quantity.doubleValue(for: .gram()) ?? 0 + return (cob > 0 && settings.microbolusSettings.enabled) || (cob == 0 && settings.microbolusSettings.enabledWithoutCarbs) + } + + private func checkTempOverrideForMicrobolus() -> Bool { + if settings.microbolusSettings.disableByOverride, + let unit = glucoseStore.preferredUnit, + let override = settings.scheduleOverride, + !override.hasFinished(), + let overrideLowerBound = override.settings.targetRange?.lowerBound, + overrideLowerBound >= HKQuantity(unit: unit, doubleValue: settings.microbolusSettings.overrideLowerBound) { + return false + } + return true + } + /// *This method should only be called from the `dataAccessQueue`* private func setRecommendedTempBasal(_ completion: @escaping (_ error: Error?) -> Void) { dispatchPrecondition(condition: .onQueue(dataAccessQueue)) @@ -1554,6 +1723,17 @@ protocol LoopDataManagerDelegate: class { /// - units: The recommended bolus in U /// - Returns: a supported bolus volume in U. The volume returned should not be larger than the passed in rate. func loopDataManager(_ manager: LoopDataManager, roundBolusVolume units: Double) -> Double + + /// Informs the delegate that a micro bolus is recommended + /// + /// - Parameters: + /// - manager: The manager + /// - bolus: The new recommended micro bolus + /// - completion: A closure called once on completion + func loopDataManager(_ manager: LoopDataManager, didRecommendMicroBolus bolus: (amount: Double, date: Date), completion: @escaping (_ error: Error?) -> Void) -> Void + + /// Current bolus state + var bolusState: PumpManagerStatus.BolusState? { get } } private extension TemporaryScheduleOverride { diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index 34f563fa57..2aac71f530 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -155,6 +155,9 @@ final class WatchDataManager: NSObject { context.recommendedBolusDose = state.recommendedBolus?.recommendation.amount context.cob = state.carbsOnBoard?.quantity.doubleValue(for: HKUnit.gram()) context.glucoseTrendRawValue = self.deviceManager.sensorState?.trendType?.rawValue + context.doNotOpenBolusScreenWithMicroboluses = loopManager.settings.dosingEnabled + && loopManager.settings.microbolusSettings.enabled + && !loopManager.settings.microbolusSettings.shouldOpenBolusScreenOnWatch context.cgmManagerState = self.deviceManager.cgmManager?.rawValue diff --git a/Loop/View Controllers/MicrobolusViewController.swift b/Loop/View Controllers/MicrobolusViewController.swift new file mode 100644 index 0000000000..209c931fe5 --- /dev/null +++ b/Loop/View Controllers/MicrobolusViewController.swift @@ -0,0 +1,25 @@ +// +// MicrobolusViewController.swift +// Loop +// +// Created by Ivan Valkou on 01.11.2019. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +final class MicrobolusViewController: UIHostingController { + init(viewModel: MicrobolusView.ViewModel) { + super.init(rootView: MicrobolusView(viewModel: viewModel)) + } + + @objc required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + var onDeinit: (() -> Void)? + + deinit { + onDeinit?() + } +} diff --git a/Loop/View Controllers/SettingsTableViewController.swift b/Loop/View Controllers/SettingsTableViewController.swift index e806e7776b..981c028cb4 100644 --- a/Loop/View Controllers/SettingsTableViewController.swift +++ b/Loop/View Controllers/SettingsTableViewController.swift @@ -12,7 +12,7 @@ import LoopKit import LoopKitUI import LoopCore import LoopTestingKit - +import Combine final class SettingsTableViewController: UITableViewController { @@ -53,6 +53,7 @@ final class SettingsTableViewController: UITableViewController { fileprivate enum LoopRow: Int, CaseCountable { case dosing = 0 + case microbolus case diagnostic } @@ -90,6 +91,8 @@ final class SettingsTableViewController: UITableViewController { return formatter }() + private var microbolusCancellable: AnyCancellable? + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { switch segue.destination { case let vc as InsulinModelSettingsViewController: @@ -164,6 +167,26 @@ final class SettingsTableViewController: UITableViewController { switchCell.switch?.addTarget(self, action: #selector(dosingEnabledChanged(_:)), for: .valueChanged) return switchCell + case .microbolus: + let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath) + + cell.textLabel?.text = NSLocalizedString("Microboluses", comment: "The title text for the Microboluses cell") + let settings = dataManager.loopManager.settings + cell.detailTextLabel?.text = { + guard settings.dosingEnabled else { + return "Disabled" + } + switch (settings.microbolusSettings.enabledWithoutCarbs, settings.microbolusSettings.enabled) { + case (true, true): return "Always" + case (false, true): return "With Carbs" + case (true, false): return "Without Carbs" + default: return "Disabled" + } + }() + cell.accessoryType = .disclosureIndicator + + return cell + case .diagnostic: let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath) @@ -545,6 +568,32 @@ final class SettingsTableViewController: UITableViewController { let vc = CommandResponseViewController.generateDiagnosticReport(deviceManager: dataManager) vc.title = sender?.textLabel?.text + show(vc, sender: sender) + case .microbolus: + var settings = dataManager.loopManager.settings + guard settings.dosingEnabled, + let unit = dataManager.loopManager.glucoseStore.preferredUnit + else { break } + + let viewModel = MicrobolusView.ViewModel( + settings: settings.microbolusSettings, + glucoseUnit: unit, + eventPublisher: dataManager.loopManager.lastMicrobolusEvent.eraseToAnyPublisher() + ) + + microbolusCancellable = viewModel.changes() + .sink { [weak self] result in + guard let self = self else { return } + settings.microbolusSettings = result + self.dataManager.loopManager.settings = settings + self.tableView.reloadRows(at: [indexPath], with: .none) + } + + let vc = MicrobolusViewController(viewModel: viewModel) + vc.onDeinit = { + self.microbolusCancellable?.cancel() + } + show(vc, sender: sender) case .dosing: break @@ -597,6 +646,13 @@ final class SettingsTableViewController: UITableViewController { @objc private func dosingEnabledChanged(_ sender: UISwitch) { dataManager.loopManager.settings.dosingEnabled = sender.isOn + + tableView.reloadRows( + at: [ + IndexPath(row: LoopRow.microbolus.rawValue, section: Section.loop.rawValue) + ], + with: .none + ) } } diff --git a/Loop/Views/Microboluses/MicrobolusView.swift b/Loop/Views/Microboluses/MicrobolusView.swift new file mode 100644 index 0000000000..5133c2f8d4 --- /dev/null +++ b/Loop/Views/Microboluses/MicrobolusView.swift @@ -0,0 +1,175 @@ +// +// MicrobolusView.swift +// Loop +// +// Created by Ivan Valkou on 31.10.2019. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopCore +import HealthKit +import Combine + +struct MicrobolusView: View { + @ObservedObject var viewModel: ViewModel + + init(viewModel: ViewModel) { + self.viewModel = viewModel + } + + var body: some View { + Form { + switchSection + partialApplicationSection + basalRateSection + temporaryOverridesSection + otherOptionsSection + if viewModel.event != nil { + lastEventSection + } + } + .navigationBarTitle("Microboluses") + .modifier(AdaptsToSoftwareKeyboard()) + } + + private var topSection: some View { + Section { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .padding(.trailing) + + Text("Caution! Microboluses have potential to reduce the safety effects of other mitigations like max temp basal rate. Please be careful!\nThe actual size of a microbolus is always limited to the partial application of recommended bolus.") + .font(.caption) + } + + } + } + + private var switchSection: some View { + Section { + Toggle (isOn: $viewModel.microbolusesWithCOB) { + Text("Enable With Carbs") + } + + Toggle (isOn: $viewModel.microbolusesWithoutCOB) { + Text("Enable Without Carbs") + } + } + } + + private var partialApplicationSection: some View { + Section(footer: + Text("What part of the recommended bolus will be applied automatically.") + ) { + Picker(selection: $viewModel.partialApplicationIndex, label: Text("Partial Bolus Application")) { + ForEach(0 ..< viewModel.partialApplicationValues.count) { index in + Text(String(format: "%.0f %%", self.viewModel.partialApplicationValues[index] * 100)).tag(index) + } + } + } + } + + private var temporaryOverridesSection: some View { + Section(header: Text("Temporary overrides").font(.headline)) { + Toggle (isOn: $viewModel.disableByOverride) { + Text("Disable MB by enabling temporary override") + } + + VStack(alignment: .leading) { + Text("If the override's target range starts at the given value or more").font(.caption) + HStack { + TextField("0", text: $viewModel.lowerBound) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(height: 38) + + Text(viewModel.unit.localizedShortUnitString) + } + } + } + } + + private var otherOptionsSection: some View { + Section(header: Text("Other Options").font(.headline), footer: + Text("This is the minimum microbolus size in units that will be delivered. Only if the microbolus calculated is equal to or greater than this number of units will a bolus be delivered.") + ) { + Toggle (isOn: $viewModel.openBolusScreen) { + Text("Open bolus screen after carbs on watch") + } + + Picker(selection: $viewModel.pickerMinimumBolusSizeIndex, label: Text("Minimum Bolus Size")) { + ForEach(0 ..< viewModel.minimumBolusSizeValues.count) { index in Text(String(format: "%.2f U", self.viewModel.minimumBolusSizeValues[index])).tag(index) + } + } + } + } + + private var basalRateSection: some View { + Section(footer: + Text("Limits the maximum basal rate to a multiple of the scheduled basal rate in loop. The value cannot exceed your maximum basal rate setting.\nThis setting is ignored if microboluses are disabled.") + ) { + Picker(selection: $viewModel.basalRateMultiplierIndex, label: Text("Basal Rate Multiplier")) { + ForEach(0 ..< viewModel.basalRateMultiplierValues.count) { index in + if self.viewModel.basalRateMultiplierValues[index] > 0 { + Text("× " + self.viewModel.formatter.string(from: self.viewModel.basalRateMultiplierValues[index])!).tag(index) + } else { + Text("Max basal limit").tag(index) + } + } + } + } + } + + private var lastEventSection: some View { + Section(header: Text("Last Event").font(.headline)) { + Text(viewModel.event ?? "No event") + } + } +} + +struct MicrobolusView_Previews: PreviewProvider { + static var previews: some View { + MicrobolusView(viewModel: .init( + settings: Microbolus.Settings(), + glucoseUnit: HKUnit(from: "mmol/L") + ) + ) + .environment(\.colorScheme, .dark) + .previewLayout(.fixed(width: 375, height: 1000)) + } +} + +// MARK: - Helpers + +struct AdaptsToSoftwareKeyboard: ViewModifier { + @State var currentHeight: CGFloat = 0 + + func body(content: Content) -> some View { + content + .padding(.bottom, currentHeight).animation(.easeOut(duration: 0.25)) + .edgesIgnoringSafeArea(currentHeight == 0 ? Edge.Set() : .bottom) + .onAppear(perform: subscribeToKeyboardChanges) + } + + private let keyboardHeightOnOpening = NotificationCenter.default + .publisher(for: UIResponder.keyboardWillShowNotification) + .map { $0.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect } + .map { $0.height } + + + private let keyboardHeightOnHiding = NotificationCenter.default + .publisher(for: UIResponder.keyboardWillHideNotification) + .map {_ in return CGFloat(0) } + + private func subscribeToKeyboardChanges() { + _ = Publishers.Merge(keyboardHeightOnOpening, keyboardHeightOnHiding) + .subscribe(on: DispatchQueue.main) + .sink { height in + if self.currentHeight == 0 || height == 0 { + self.currentHeight = height + } + } + } +} diff --git a/Loop/Views/Microboluses/MicrobolusViewModel.swift b/Loop/Views/Microboluses/MicrobolusViewModel.swift new file mode 100644 index 0000000000..d07d035526 --- /dev/null +++ b/Loop/Views/Microboluses/MicrobolusViewModel.swift @@ -0,0 +1,118 @@ +// +// MicrobolusViewModel.swift +// Loop +// +// Created by Ivan Valkou on 21.01.2020. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Combine +import LoopCore +import LoopKit +import HealthKit + +extension MicrobolusView { + final class ViewModel: ObservableObject { + @Published var microbolusesWithCOB: Bool + @Published var microbolusesWithoutCOB: Bool + @Published var partialApplication: Double + @Published var microbolusesMinimumBolusSize: Double + @Published var openBolusScreen: Bool + @Published var disableByOverride: Bool + @Published var lowerBound: String + @Published var pickerMinimumBolusSizeIndex: Int + @Published var partialApplicationIndex: Int + @Published var basalRateMultiplier: Double + @Published var basalRateMultiplierIndex: Int + @Published var event: String? = nil + + // @ToDo: Should be able to get the to limit from the settings but for now defult to a low value + let minimumBolusSizeValues = stride(from: 0.0, to: 0.51, by: 0.05).map { $0 } + let partialApplicationValues = stride(from: 0.1, to: 1.01, by: 0.05).map { $0 } + let basalRateMultiplierValues = stride(from: 1, to: 5.01, by: 0.1).map { $0 } + [0] + + private var cancellable: AnyCancellable! + let formatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 1 + return formatter + }() + + let unit: HKUnit + + init(settings: Microbolus.Settings, glucoseUnit: HKUnit, eventPublisher: AnyPublisher? = nil) { + self.microbolusesWithCOB = settings.enabled + self.microbolusesWithoutCOB = settings.enabledWithoutCarbs + self.partialApplication = settings.partialApplication + self.microbolusesMinimumBolusSize = settings.minimumBolusSize + self.openBolusScreen = settings.shouldOpenBolusScreenOnWatch + self.disableByOverride = settings.disableByOverride + self.basalRateMultiplier = settings.basalRateMultiplier + self.lowerBound = formatter.string(from: settings.overrideLowerBound) ?? "" + self.unit = glucoseUnit + + pickerMinimumBolusSizeIndex = minimumBolusSizeValues.firstIndex(of: settings.minimumBolusSize) ?? 0 + partialApplicationIndex = partialApplicationValues.firstIndex(of: settings.partialApplication) ?? 0 + basalRateMultiplierIndex = basalRateMultiplierValues.firstIndex(of: settings.basalRateMultiplier) ?? 0 + + let microbolusesMinimumBolusSizeCancellable = $pickerMinimumBolusSizeIndex + .map { Double(self.minimumBolusSizeValues[$0]) } + .sink { self.microbolusesMinimumBolusSize = $0 } + + let partialApplicationCancellable = $partialApplicationIndex + .map { Double(self.partialApplicationValues[$0]) } + .sink { self.partialApplication = $0 } + + let basalRateMultiplierCancellable = $basalRateMultiplierIndex + .map { Double(self.basalRateMultiplierValues[$0]) } + .sink { self.basalRateMultiplier = $0 } + + let lastEventCancellable = eventPublisher? + .map { $0?.description } + .receive(on: DispatchQueue.main) + .sink { self.event = $0 } + + + cancellable = AnyCancellable { + microbolusesMinimumBolusSizeCancellable.cancel() + partialApplicationCancellable.cancel() + basalRateMultiplierCancellable.cancel() + lastEventCancellable?.cancel() + } + } + + func changes() -> AnyPublisher { + let lowerBoundPublisher = $lowerBound + .map { value -> Double in self.formatter.number(from: value)?.doubleValue ?? 0 } + + return Publishers.CombineLatest( + Publishers.CombineLatest4( + $microbolusesWithCOB, + $microbolusesWithoutCOB, + $partialApplication, + $microbolusesMinimumBolusSize + ), + Publishers.CombineLatest4( + $openBolusScreen, + $disableByOverride, + lowerBoundPublisher, + $basalRateMultiplier + ) + ) + .map { + Microbolus.Settings( + enabled: $0.0.0, + enabledWithoutCarbs: $0.0.1, + partialApplication: $0.0.2, + minimumBolusSize: $0.0.3, + shouldOpenBolusScreenOnWatch: $0.1.0, + disableByOverride: $0.1.1, + overrideLowerBound: $0.1.2, + basalRateMultiplier: $0.1.3 + ) + } + .eraseToAnyPublisher() + } + } +} diff --git a/Loop/ru.lproj/Localizable.strings b/Loop/ru.lproj/Localizable.strings index 2e84a5fe16..90c41f0489 100644 --- a/Loop/ru.lproj/Localizable.strings +++ b/Loop/ru.lproj/Localizable.strings @@ -316,7 +316,7 @@ "Pump Suspended" = "Помпа приостановлена"; /* Title of insulin model preset */ -"Rapid-Acting – Adults" = "Боыстродействующий - взрослые"; +"Rapid-Acting – Adults" = "Быстродействующий - взрослые"; /* Title of insulin model preset */ "Rapid-Acting – Children" = "Быстродействующий - дети"; diff --git a/LoopCore/LoopSettings.swift b/LoopCore/LoopSettings.swift index b524e3495c..295c56fe67 100644 --- a/LoopCore/LoopSettings.swift +++ b/LoopCore/LoopSettings.swift @@ -11,6 +11,8 @@ import HealthKit public struct LoopSettings: Equatable { public var dosingEnabled = false + public var microbolusSettings = Microbolus.Settings() + public let dynamicCarbAbsorptionEnabled = true public static let defaultCarbAbsorptionTimes: CarbStore.DefaultAbsorptionTimes = (fast: .hours(2), medium: .hours(3), slow: .hours(4)) @@ -54,7 +56,7 @@ public struct LoopSettings: Equatable { public var glucoseUnit: HKUnit? { return glucoseTargetRangeSchedule?.unit } - + // MARK - Push Notifications public var deviceToken: Data? @@ -174,6 +176,15 @@ extension LoopSettings { scheduleOverride = nil } } + + public func currentMaximumBasalRatePerHour(date: Date, basalRates: BasalRateSchedule?) -> Double? { + guard let maximumBasalRatePerHour = maximumBasalRatePerHour else { return nil } + guard dosingEnabled, microbolusSettings.basalRateMultiplier > 0, + let currentBasalRate = basalRates?.value(at: date) + else { return maximumBasalRatePerHour } + + return min(maximumBasalRatePerHour, currentBasalRate * microbolusSettings.basalRateMultiplier) + } } extension LoopSettings: RawRepresentable { @@ -192,6 +203,11 @@ extension LoopSettings: RawRepresentable { self.dosingEnabled = dosingEnabled } + if let microbolusSettingsRaw = rawValue["microbolusSettings"] as? Microbolus.Settings.RawValue, + let microbolusSettings = Microbolus.Settings(rawValue: microbolusSettingsRaw) { + self.microbolusSettings = microbolusSettings + } + if let glucoseRangeScheduleRawValue = rawValue["glucoseTargetRangeSchedule"] as? GlucoseRangeSchedule.RawValue { self.glucoseTargetRangeSchedule = GlucoseRangeSchedule(rawValue: glucoseRangeScheduleRawValue) @@ -235,7 +251,8 @@ extension LoopSettings: RawRepresentable { var raw: RawValue = [ "version": LoopSettings.version, "dosingEnabled": dosingEnabled, - "overridePresets": overridePresets.map { $0.rawValue } + "overridePresets": overridePresets.map { $0.rawValue }, + "microbolusSettings": microbolusSettings.rawValue ] raw["glucoseTargetRangeSchedule"] = glucoseTargetRangeSchedule?.rawValue diff --git a/LoopCore/Microbolus.swift b/LoopCore/Microbolus.swift new file mode 100644 index 0000000000..2be8f88a12 --- /dev/null +++ b/LoopCore/Microbolus.swift @@ -0,0 +1,126 @@ +// +// Microbolus.swift +// LoopCore +// +// Created by Ivan Valkou on 07.11.2019. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation + +public enum Microbolus { + public struct Settings: Equatable, RawRepresentable { + public typealias RawValue = [String: Any] + + public var enabled: Bool + public var enabledWithoutCarbs: Bool + public var partialApplication: Double + public var minimumBolusSize: Double + public var shouldOpenBolusScreenOnWatch: Bool + public var disableByOverride: Bool + public var overrideLowerBound: Double + public var basalRateMultiplier: Double + + public init( + enabled: Bool = false, + enabledWithoutCarbs: Bool = false, + partialApplication: Double = 0.3, + minimumBolusSize: Double = 0, + shouldOpenBolusScreenOnWatch: Bool = false, + disableByOverride: Bool = false, + overrideLowerBound: Double = 0, + basalRateMultiplier: Double = 0 // 0 means MaximumBasalRatePerHour + ) { + self.enabled = enabled + self.enabledWithoutCarbs = enabledWithoutCarbs + self.partialApplication = partialApplication + self.minimumBolusSize = minimumBolusSize + self.shouldOpenBolusScreenOnWatch = shouldOpenBolusScreenOnWatch + self.disableByOverride = disableByOverride + self.overrideLowerBound = overrideLowerBound + self.basalRateMultiplier = basalRateMultiplier + } + + public init?(rawValue: [String : Any]) { + self = Settings() + + if let enabled = rawValue["enabled"] as? Bool { + self.enabled = enabled + } + + if let enabledWithoutCarbs = rawValue["enabledWithoutCarbs"] as? Bool { + self.enabledWithoutCarbs = enabledWithoutCarbs + } + + if let partialApplication = rawValue["partialApplication"] as? Double { + self.partialApplication = partialApplication + } + + if let minimumBolusSize = rawValue["minimumBolusSize"] as? Double { + self.minimumBolusSize = minimumBolusSize + } + + if let shouldOpenBolusScreenOnWatch = rawValue["shouldOpenBolusScreenOnWatch"] as? Bool { + self.shouldOpenBolusScreenOnWatch = shouldOpenBolusScreenOnWatch + } + + if let disableByOverride = rawValue["disableByOverride"] as? Bool { + self.disableByOverride = disableByOverride + } + + if let overrideLowerBound = rawValue["overrideLowerBound"] as? Double { + self.overrideLowerBound = overrideLowerBound + } + + if let basalRateMultiplier = rawValue["basalRateMultiplier"] as? Double { + self.basalRateMultiplier = basalRateMultiplier + } + } + + public var rawValue: [String : Any] { + [ + "enabled": enabled, + "enabledWithoutCarbs": enabledWithoutCarbs, + "partialApplication": partialApplication, + "minimumBolusSize": minimumBolusSize, + "shouldOpenBolusScreenOnWatch": shouldOpenBolusScreenOnWatch, + "disableByOverride": disableByOverride, + "overrideLowerBound": overrideLowerBound, + "basalRateMultiplier": basalRateMultiplier + ] + } + } +} + +public extension Microbolus { + struct Event { + public let date: Date + public let recommendedAmount: Double + public let amount: Double + public let reason: String? + + public static func canceled(date: Date, recommended: Double, reason: String) -> Event { + Event(date: date, recommendedAmount: recommended, amount: 0, reason: reason) + } + + public static func failed(date: Date, recommended: Double, error: Error) -> Event { + Event(date: date, recommendedAmount: recommended, amount: 0, reason: "Failed with error: \(error.localizedDescription)") + } + + public static func succeeded(date: Date, recommended: Double, amount: Double) -> Event { + Event(date: date, recommendedAmount: recommended, amount: amount, reason: nil) + } + + public var description: String { + let timeFormatter = DateFormatter() + timeFormatter.timeStyle = .short + let percentFormatter = NumberFormatter() + percentFormatter.maximumFractionDigits = 2 + percentFormatter.numberStyle = .percent + let percent = amount/recommendedAmount + return "At \(timeFormatter.string(from: date)): enacted \(amount) (\(percentFormatter.string(for: percent) ?? "0 %")) of recommended \(recommendedAmount). " + + (reason ?? "") + } + + } +} diff --git a/WatchApp Extension/Controllers/AddCarbsInterfaceController.swift b/WatchApp Extension/Controllers/AddCarbsInterfaceController.swift index aadebc0ff3..04072ce36e 100644 --- a/WatchApp Extension/Controllers/AddCarbsInterfaceController.swift +++ b/WatchApp Extension/Controllers/AddCarbsInterfaceController.swift @@ -216,7 +216,8 @@ final class AddCarbsInterfaceController: WKInterfaceController, IdentifiableClas loopManager.addConfirmedCarbEntry(entry.carbEntry) loopManager.updateContext(context) - if let units = context.recommendedBolusDose, units > 0.0 { + let doNotOpenBolusScreenWithMicroboluses = context.doNotOpenBolusScreenWithMicroboluses ?? false + if let units = context.recommendedBolusDose, units > 0.0, !doNotOpenBolusScreenWithMicroboluses { WKExtension.shared().rootInterfaceController?.presentController(withName: BolusInterfaceController.className, context: context) } }