diff --git a/Common/Extensions/NSTimeInterval.swift b/Common/Extensions/NSTimeInterval.swift index 400a5e01cb..d40ded0035 100644 --- a/Common/Extensions/NSTimeInterval.swift +++ b/Common/Extensions/NSTimeInterval.swift @@ -10,6 +10,14 @@ import Foundation extension TimeInterval { + static func minutes(_ minutes: Double) -> TimeInterval { + return TimeInterval(minutes: minutes) + } + + static func hours(_ hours: Double) -> TimeInterval { + return TimeInterval(hours: hours) + } + init(minutes: Double) { self.init(minutes * 60) } diff --git a/Common/Extensions/NumberFormatter.swift b/Common/Extensions/NumberFormatter.swift index e7e3cf35a4..b3f41c0fca 100644 --- a/Common/Extensions/NumberFormatter.swift +++ b/Common/Extensions/NumberFormatter.swift @@ -20,21 +20,26 @@ extension NumberFormatter { return numberFormatter } - func describingGlucose(_ value: Double, for unit: HKUnit) -> String? { - guard let stringValue = string(from: NSNumber(value: value)) else { + func string(from number: Double, unit: String) -> String? { + guard let stringValue = string(from: NSNumber(value: number)) else { return nil } return String( - format: NSLocalizedString("GLUCOSE_VALUE_AND_UNIT", - value: "%1$@ %2$@", - comment: "Format string for combining localized glucose value and unit. (1: glucose value)(2: unit)" + format: NSLocalizedString( + "QUANTITY_VALUE_AND_UNIT", + value: "%1$@ %2$@", + comment: "Format string for combining localized numeric value and unit. (1: numeric value)(2: unit)" ), stringValue, - unit.glucoseUnitDisplayString + unit ) } + func describingGlucose(_ value: Double, for unit: HKUnit) -> String? { + return string(from: value, unit: unit.glucoseUnitDisplayString) + } + @nonobjc func describingGlucose(_ value: HKQuantity, for unit: HKUnit) -> String? { return describingGlucose(value.doubleValue(for: unit), for: unit) } diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 29a0bae981..5cdc5b1ffd 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -75,6 +75,7 @@ 439BED2A1E76093C00B0AED5 /* CGMManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439BED291E76093C00B0AED5 /* CGMManager.swift */; }; 439BED2C1E760A7A00B0AED5 /* DexCGMManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439BED2B1E760A7A00B0AED5 /* DexCGMManager.swift */; }; 439BED2E1E760BC600B0AED5 /* EnliteCGMManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439BED2D1E760BC600B0AED5 /* EnliteCGMManager.swift */; }; + 43A51E211EB6DBDD000736CC /* ChartsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A51E201EB6DBDD000736CC /* ChartsTableViewController.swift */; }; 43A567691C94880B00334FAC /* LoopDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A567681C94880B00334FAC /* LoopDataManager.swift */; }; 43A5676B1C96155700334FAC /* SwitchTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A5676A1C96155700334FAC /* SwitchTableViewCell.swift */; }; 43A943761B926B7B0051FA24 /* Interface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 43A943741B926B7B0051FA24 /* Interface.storyboard */; }; @@ -429,6 +430,7 @@ 439BED291E76093C00B0AED5 /* CGMManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGMManager.swift; sourceTree = ""; }; 439BED2B1E760A7A00B0AED5 /* DexCGMManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DexCGMManager.swift; sourceTree = ""; }; 439BED2D1E760BC600B0AED5 /* EnliteCGMManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnliteCGMManager.swift; sourceTree = ""; }; + 43A51E201EB6DBDD000736CC /* ChartsTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartsTableViewController.swift; sourceTree = ""; }; 43A567681C94880B00334FAC /* LoopDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = LoopDataManager.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 43A5676A1C96155700334FAC /* SwitchTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwitchTableViewCell.swift; sourceTree = ""; }; 43A943721B926B7B0051FA24 /* WatchApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WatchApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -853,6 +855,7 @@ 43DBF04B1C93B8D700B3C386 /* BolusViewController.swift */, 4315D2861CA5CC3B00589052 /* CarbEntryEditTableViewController.swift */, 43DBF0581C93F73800B3C386 /* CarbEntryTableViewController.swift */, + 43A51E201EB6DBDD000736CC /* ChartsTableViewController.swift */, 433EA4C31D9F71C800CD78FB /* CommandResponseViewController.swift */, C178249F1E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift */, 4302F4E21D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift */, @@ -1508,6 +1511,7 @@ 4F70C2101DE8FAC5006380B7 /* StatusExtensionDataManager.swift in Sources */, 436FACEE1D0BA636004E2427 /* InsulinDataSource.swift in Sources */, 439897371CD2F80600223065 /* AnalyticsManager.swift in Sources */, + 43A51E211EB6DBDD000736CC /* ChartsTableViewController.swift in Sources */, 4346D1F61C78501000ABAFE3 /* ChartPoint+Loop.swift in Sources */, 438849EE1D2A1EBB003B3F23 /* MLabService.swift in Sources */, 43D848B21E7DF42500DADCBC /* LoopSettings.swift in Sources */, diff --git a/Loop/AppDelegate.swift b/Loop/AppDelegate.swift index 994f8d987f..d35f243872 100644 --- a/Loop/AppDelegate.swift +++ b/Loop/AppDelegate.swift @@ -27,7 +27,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { if let navVC = window?.rootViewController as? UINavigationController, let statusVC = navVC.viewControllers.first as? StatusTableViewController { - statusVC.dataManager = deviceManager + statusVC.deviceManager = deviceManager } return true diff --git a/Loop/Extensions/NSUserDefaults.swift b/Loop/Extensions/NSUserDefaults.swift index 3950f69a06..8e84f5019b 100644 --- a/Loop/Extensions/NSUserDefaults.swift +++ b/Loop/Extensions/NSUserDefaults.swift @@ -14,44 +14,45 @@ import HealthKit extension UserDefaults { private enum Key: String { - case BasalRateSchedule = "com.loudnate.Naterade.BasalRateSchedule" + case basalRateSchedule = "com.loudnate.Naterade.BasalRateSchedule" + case batteryChemistry = "com.loopkit.Loop.BatteryChemistry" case cgmSettings = "com.loopkit.Loop.cgmSettings" - case CarbRatioSchedule = "com.loudnate.Naterade.CarbRatioSchedule" - case ConnectedPeripheralIDs = "com.loudnate.Naterade.ConnectedPeripheralIDs" + case carbRatioSchedule = "com.loudnate.Naterade.CarbRatioSchedule" + case connectedPeripheralIDs = "com.loudnate.Naterade.ConnectedPeripheralIDs" case loopSettings = "com.loopkit.Loop.loopSettings" - case InsulinActionDuration = "com.loudnate.Naterade.InsulinActionDuration" - case InsulinSensitivitySchedule = "com.loudnate.Naterade.InsulinSensitivitySchedule" - case PreferredInsulinDataSource = "com.loudnate.Loop.PreferredInsulinDataSource" - case PumpID = "com.loudnate.Naterade.PumpID" - case PumpModelNumber = "com.loudnate.Naterade.PumpModelNumber" - case PumpRegion = "com.loopkit.Loop.PumpRegion" - case PumpTimeZone = "com.loudnate.Naterade.PumpTimeZone" - case BatteryChemistry = "com.loopkit.Loop.BatteryChemistry" + case insulinActionDuration = "com.loudnate.Naterade.InsulinActionDuration" + case insulinCounteractionEffects = "com.loopkit.Loop.insulinCounteractionEffects" + case insulinSensitivitySchedule = "com.loudnate.Naterade.InsulinSensitivitySchedule" + case preferredInsulinDataSource = "com.loudnate.Loop.PreferredInsulinDataSource" + case pumpID = "com.loudnate.Naterade.PumpID" + case pumpModelNumber = "com.loudnate.Naterade.PumpModelNumber" + case pumpRegion = "com.loopkit.Loop.PumpRegion" + case pumpTimeZone = "com.loudnate.Naterade.PumpTimeZone" } var basalRateSchedule: BasalRateSchedule? { get { - if let rawValue = dictionary(forKey: Key.BasalRateSchedule.rawValue) { + if let rawValue = dictionary(forKey: Key.basalRateSchedule.rawValue) { return BasalRateSchedule(rawValue: rawValue) } else { return nil } } set { - set(newValue?.rawValue, forKey: Key.BasalRateSchedule.rawValue) + set(newValue?.rawValue, forKey: Key.basalRateSchedule.rawValue) } } var carbRatioSchedule: CarbRatioSchedule? { get { - if let rawValue = dictionary(forKey: Key.CarbRatioSchedule.rawValue) { + if let rawValue = dictionary(forKey: Key.carbRatioSchedule.rawValue) { return CarbRatioSchedule(rawValue: rawValue) } else { return nil } } set { - set(newValue?.rawValue, forKey: Key.CarbRatioSchedule.rawValue) + set(newValue?.rawValue, forKey: Key.carbRatioSchedule.rawValue) } } @@ -93,10 +94,10 @@ extension UserDefaults { var connectedPeripheralIDs: [String] { get { - return array(forKey: Key.ConnectedPeripheralIDs.rawValue) as? [String] ?? [] + return array(forKey: Key.connectedPeripheralIDs.rawValue) as? [String] ?? [] } set { - set(newValue, forKey: Key.ConnectedPeripheralIDs.rawValue) + set(newValue, forKey: Key.connectedPeripheralIDs.rawValue) } } @@ -159,98 +160,98 @@ extension UserDefaults { var insulinActionDuration: TimeInterval? { get { - let value = double(forKey: Key.InsulinActionDuration.rawValue) + let value = double(forKey: Key.insulinActionDuration.rawValue) return value > 0 ? value : nil } set { if let insulinActionDuration = newValue { - set(insulinActionDuration, forKey: Key.InsulinActionDuration.rawValue) + set(insulinActionDuration, forKey: Key.insulinActionDuration.rawValue) } else { - removeObject(forKey: Key.InsulinActionDuration.rawValue) + removeObject(forKey: Key.insulinActionDuration.rawValue) } } } var insulinSensitivitySchedule: InsulinSensitivitySchedule? { get { - if let rawValue = dictionary(forKey: Key.InsulinSensitivitySchedule.rawValue) { + if let rawValue = dictionary(forKey: Key.insulinSensitivitySchedule.rawValue) { return InsulinSensitivitySchedule(rawValue: rawValue) } else { return nil } } set { - set(newValue?.rawValue, forKey: Key.InsulinSensitivitySchedule.rawValue) + set(newValue?.rawValue, forKey: Key.insulinSensitivitySchedule.rawValue) } } var preferredInsulinDataSource: InsulinDataSource? { get { - return InsulinDataSource(rawValue: integer(forKey: Key.PreferredInsulinDataSource.rawValue)) + return InsulinDataSource(rawValue: integer(forKey: Key.preferredInsulinDataSource.rawValue)) } set { if let preferredInsulinDataSource = newValue { - set(preferredInsulinDataSource.rawValue, forKey: Key.PreferredInsulinDataSource.rawValue) + set(preferredInsulinDataSource.rawValue, forKey: Key.preferredInsulinDataSource.rawValue) } else { - removeObject(forKey: Key.PreferredInsulinDataSource.rawValue) + removeObject(forKey: Key.preferredInsulinDataSource.rawValue) } } } var pumpID: String? { get { - return string(forKey: Key.PumpID.rawValue) + return string(forKey: Key.pumpID.rawValue) } set { - set(newValue, forKey: Key.PumpID.rawValue) + set(newValue, forKey: Key.pumpID.rawValue) } } var pumpModelNumber: String? { get { - return string(forKey: Key.PumpModelNumber.rawValue) + return string(forKey: Key.pumpModelNumber.rawValue) } set { - set(newValue, forKey: Key.PumpModelNumber.rawValue) + set(newValue, forKey: Key.pumpModelNumber.rawValue) } } var pumpRegion: PumpRegion? { get { // Defaults to 0 / northAmerica - return PumpRegion(rawValue: integer(forKey: Key.PumpRegion.rawValue)) + return PumpRegion(rawValue: integer(forKey: Key.pumpRegion.rawValue)) } set { - set(newValue?.rawValue, forKey: Key.PumpRegion.rawValue) + set(newValue?.rawValue, forKey: Key.pumpRegion.rawValue) } } var pumpTimeZone: TimeZone? { get { - if let offset = object(forKey: Key.PumpTimeZone.rawValue) as? NSNumber { + if let offset = object(forKey: Key.pumpTimeZone.rawValue) as? NSNumber { return TimeZone(secondsFromGMT: offset.intValue) } else { return nil } } set { if let value = newValue { - set(NSNumber(value: value.secondsFromGMT() as Int), forKey: Key.PumpTimeZone.rawValue) + set(NSNumber(value: value.secondsFromGMT() as Int), forKey: Key.pumpTimeZone.rawValue) } else { - removeObject(forKey: Key.PumpTimeZone.rawValue) + removeObject(forKey: Key.pumpTimeZone.rawValue) } } } var batteryChemistry: BatteryChemistryType? { get { - return BatteryChemistryType(rawValue: integer(forKey: Key.BatteryChemistry.rawValue)) + return BatteryChemistryType(rawValue: integer(forKey: Key.batteryChemistry.rawValue)) } set { if let batteryChemistry = newValue { - set(batteryChemistry.rawValue, forKey: Key.BatteryChemistry.rawValue) + set(batteryChemistry.rawValue, forKey: Key.batteryChemistry.rawValue) } else { - removeObject(forKey: Key.BatteryChemistry.rawValue) + removeObject(forKey: Key.batteryChemistry.rawValue) } } } diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index f95b0f59ee..79a3a84f41 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -223,7 +223,7 @@ final class LoopDataManager { if success { self.dataAccessQueue.async { self.glucoseMomentumEffect = nil - self.glucoseChange = nil + self.retrospectiveGlucoseChange = nil self.notify(forChange: .glucose) } } @@ -373,27 +373,6 @@ final class LoopDataManager { } } - func getPredictedGlucose(using inputs: PredictionInputEffect, completion: @escaping (_ prediction: Result<[GlucoseValue]>) -> Void) { - dataAccessQueue.async { - do { - completion(.success(try self.predictGlucoseFromCurrentData(using: inputs))) - } catch let error { - completion(.failure(error)) - } - } - } - - func getRecommendedBolus(_ resultsHandler: @escaping (_ units: BolusRecommendation?, _ error: Error?) -> Void) { - dataAccessQueue.async { - do { - let recommendation = try self.recommendBolus() - resultsHandler(recommendation, nil) - } catch let error { - resultsHandler(nil, error) - } - } - } - // References to registered notification center observers private var carbUpdateObserver: Any? @@ -403,23 +382,29 @@ final class LoopDataManager { } } - private func update() throws { + /// - Throws: + /// - LoopError.configurationError + /// - LoopError.glucoseTooOld + /// - LoopError.missingDataError + /// - LoopError.pumpDataTooOld + fileprivate func update() throws { dispatchPrecondition(condition: .onQueue(dataAccessQueue)) let updateGroup = DispatchGroup() // Fetch glucose effects as far back as we want to make retroactive analysis - guard let effectStartDate = glucoseStore.latestGlucose?.startDate.addingTimeInterval(-glucoseStore.reflectionDataInterval) else { + guard let lastGlucoseDate = glucoseStore.latestGlucose?.startDate else { throw LoopError.missingDataError(details: "Glucose data not available", recovery: "Check your CGM data source") } - if glucoseChange == nil { + let retrospectiveStart = lastGlucoseDate.addingTimeInterval(-glucoseStore.reflectionDataInterval) + + if retrospectiveGlucoseChange == nil { updateGroup.enter() - glucoseStore.getRecentGlucoseChange { (values, error) in + glucoseStore.getRecentGlucoseChange { (change, error) in if let error = error { self.logger.addError(error, fromSource: "GlucoseStore") } - - self.glucoseChange = values + self.retrospectiveGlucoseChange = change updateGroup.leave() } } @@ -440,8 +425,7 @@ final class LoopDataManager { if carbEffect == nil { updateGroup.enter() - - carbStore.getGlucoseEffects(startDate: effectStartDate) { (effects, error) -> Void in + carbStore.getGlucoseEffects(startDate: retrospectiveStart) { (effects, error) -> Void in if let error = error { self.logger.addError(error, fromSource: "CarbStore") self.carbEffect = nil @@ -455,7 +439,7 @@ final class LoopDataManager { if carbsOnBoardSeries == nil { updateGroup.enter() - carbStore.getCarbsOnBoardValues(startDate: effectStartDate) { (values, error) in + carbStore.getCarbsOnBoardValues(startDate: retrospectiveStart) { (values, error) in if let error = error { self.logger.addError(error, fromSource: "CarbStore") } @@ -467,7 +451,7 @@ final class LoopDataManager { if insulinEffect == nil { updateGroup.enter() - doseStore.getGlucoseEffects(start: effectStartDate) { (result) -> Void in + doseStore.getGlucoseEffects(start: retrospectiveStart) { (result) -> Void in switch result { case .failure(let error): self.logger.addError(error, fromSource: "DoseStore") @@ -494,21 +478,21 @@ final class LoopDataManager { } } - _ = updateGroup.wait(timeout: DispatchTime.distantFuture) + _ = updateGroup.wait(timeout: .distantFuture) - if self.retrospectivePredictedGlucose == nil { + if retrospectivePredictedGlucose == nil { do { - try self.updateRetrospectiveGlucoseEffect() + try updateRetrospectiveGlucoseEffect() } catch let error { - self.logger.addError(error, fromSource: "RetrospectiveGlucose") + logger.addError(error, fromSource: "RetrospectiveGlucose") } } - if self.predictedGlucose == nil { + if predictedGlucose == nil { do { - try self.updatePredictedGlucoseAndRecommendedBasal() + try updatePredictedGlucoseAndRecommendedBasal() } catch let error { - self.logger.addError(error, fromSource: "PredictGlucose") + logger.addError(error, fromSource: "PredictGlucose") throw error } @@ -522,45 +506,12 @@ final class LoopDataManager { ) } - /** - Retrieves the current state of the loop, calculating - - This operation is performed asynchronously and the completion will be executed on an arbitrary background queue. - - - parameter resultsHandler: A closure called once the values have been retrieved. The closure takes the following arguments: - - predictedGlucose: The calculated timeline of predicted glucose values - - retrospectivePredictedGlucose: The retrospective prediction over a recent period of glucose samples - - recommendedTempBasal: The recommended temp basal based on predicted glucose - - lastTempBasal: The last set temp basal - - lastLoopCompleted: The last date at which a loop completed, from prediction to dose (if dosing is enabled) - - insulinOnBoard Current insulin on board - - carbsOnBoard Current carbs on board - - error: An error in the current state of the loop, or one that happened during the last attempt to loop. - */ - func getLoopStatus(_ resultsHandler: @escaping (_ predictedGlucose: [GlucoseValue]?, _ retrospectivePredictedGlucose: [GlucoseValue]?, _ recommendedTempBasal: TempBasalRecommendation?, _ lastTempBasal: DoseEntry?, _ lastLoopCompleted: Date?, _ insulinOnBoard: InsulinValue?, _ carbsOnBoard: CarbValue?, _ error: Error?) -> Void) { - dataAccessQueue.async { - var error: Error? - - do { - try self.update() - } catch let updateError { - error = updateError - } - - let currentCOB = self.carbsOnBoardSeries?.closestPriorToDate(Date()) - - resultsHandler(self.predictedGlucose, self.retrospectivePredictedGlucose, self.recommendedTempBasal, self.lastTempBasal, self.lastLoopCompleted, self.insulinOnBoard, currentCOB, error ?? self.lastLoopError) - } - } - - /** - Computes amount of insulin from boluses that have been issued and not confirmed, and - remaining insulin delivery from temporary basal rate adjustments above scheduled rate - that are still in progress. - - *This method should only be called from the `dataAccessQueue`* - - **/ + /// Computes amount of insulin from boluses that have been issued and not confirmed, and + /// remaining insulin delivery from temporary basal rate adjustments above scheduled rate + /// that are still in progress. + /// + /// - Returns: The amount of pending insulin, in units + /// - Throws: LoopError.configurationError private func getPendingInsulin() throws -> Double { dispatchPrecondition(condition: .onQueue(dataAccessQueue)) @@ -587,7 +538,8 @@ final class LoopDataManager { return pendingTempBasalInsulin + pendingBolusAmount } - private func predictGlucoseFromCurrentData(using inputs: PredictionInputEffect) throws -> [GlucoseValue] { + /// - Throws: LoopError.missingDataError + fileprivate func predictGlucose(using inputs: PredictionInputEffect) throws -> [GlucoseValue] { dispatchPrecondition(condition: .onQueue(dataAccessQueue)) guard let glucose = self.glucoseStore.latestGlucose else { @@ -616,9 +568,9 @@ final class LoopDataManager { return LoopMath.predictGlucose(glucose, momentum: momentum, effects: effects) } - // Calculation + // MARK: - Calculation state - private let dataAccessQueue: DispatchQueue = DispatchQueue(label: "com.loudnate.Naterade.LoopDataManager.dataAccessQueue", qos: .utility) + fileprivate let dataAccessQueue: DispatchQueue = DispatchQueue(label: "com.loudnate.Naterade.LoopDataManager.dataAccessQueue", qos: .utility) private var carbEffect: [GlucoseEffect]? { didSet { @@ -628,7 +580,6 @@ final class LoopDataManager { retrospectivePredictedGlucose = nil } } - private var carbsOnBoardSeries: [CarbValue]? private var insulinEffect: [GlucoseEffect]? { didSet { if let bolusDate = lastBolus?.date, bolusDate.timeIntervalSinceNow < TimeInterval(minutes: -5) { @@ -638,50 +589,55 @@ final class LoopDataManager { predictedGlucose = nil } } - private var insulinOnBoard: InsulinValue? private var glucoseMomentumEffect: [GlucoseEffect]? { didSet { predictedGlucose = nil } } - private var glucoseChange: GlucoseChange? { + private var retrospectiveGlucoseEffect: [GlucoseEffect] = [] { + didSet { + predictedGlucose = nil + } + } + + /// The change in glucose over the reflection time interval (default is 30 min) + private var retrospectiveGlucoseChange: GlucoseChange? { didSet { retrospectivePredictedGlucose = nil } } - private var predictedGlucose: [GlucoseValue]? { + + fileprivate var predictedGlucose: [GlucoseValue]? { didSet { recommendedTempBasal = nil } } - private var retrospectivePredictedGlucose: [GlucoseValue]? { + fileprivate var retrospectivePredictedGlucose: [GlucoseValue]? { didSet { retrospectiveGlucoseEffect = [] } } - private var retrospectiveGlucoseEffect: [GlucoseEffect] = [] { + fileprivate var recommendedTempBasal: TempBasalRecommendation? + + fileprivate var carbsOnBoardSeries: [CarbValue]? + fileprivate var insulinOnBoard: InsulinValue? + + fileprivate var lastTempBasal: DoseEntry? + fileprivate var lastBolus: (units: Double, date: Date)? + fileprivate var lastLoopCompleted: Date? { didSet { - predictedGlucose = nil + NotificationManager.scheduleLoopNotRunningNotifications() + + AnalyticsManager.sharedManager.loopDidSucceed() } } - private var recommendedTempBasal: TempBasalRecommendation? - - private var lastTempBasal: DoseEntry? - private var lastBolus: (units: Double, date: Date)? - private var lastLoopError: Error? { + fileprivate var lastLoopError: Error? { didSet { if lastLoopError != nil { AnalyticsManager.sharedManager.loopDidError() } } } - private var lastLoopCompleted: Date? { - didSet { - NotificationManager.scheduleLoopNotRunningNotifications() - - AnalyticsManager.sharedManager.loopDidSucceed() - } - } /** Runs the glucose retrospective analysis using the latest effect data. @@ -699,7 +655,7 @@ final class LoopDataManager { throw LoopError.missingDataError(details: "Cannot retrospect glucose due to missing input data", recovery: nil) } - guard let change = glucoseChange else { + guard let change = retrospectiveGlucoseChange else { self.retrospectivePredictedGlucose = nil return // Expected case for calibrations } @@ -726,11 +682,14 @@ final class LoopDataManager { self.retrospectiveGlucoseEffect = LoopMath.decayEffect(from: glucose, atRate: velocity, for: effectDuration) } - /** - Runs the glucose prediction on the latest effect data. - - *This method should only be called from the `dataAccessQueue`* - */ + + /// Runs the glucose prediction on the latest effect data. + /// + /// - Throws: + /// - LoopError.configurationError + /// - LoopError.glucoseTooOld + /// - LoopError.missingDataError + /// - LoopError.pumpDataTooOld private func updatePredictedGlucoseAndRecommendedBasal() throws { dispatchPrecondition(condition: .onQueue(dataAccessQueue)) @@ -761,7 +720,7 @@ final class LoopDataManager { throw LoopError.missingDataError(details: "Glucose effects", recovery: nil) } - let predictedGlucose = try predictGlucoseFromCurrentData(using: settings.enabledEffects) + let predictedGlucose = try predictGlucose(using: settings.enabledEffects) self.predictedGlucose = predictedGlucose guard let minimumBGGuard = settings.minimumBGGuard else { @@ -797,8 +756,12 @@ final class LoopDataManager { recommendedTempBasal = (recommendedDate: Date(), rate: tempBasal.rate, duration: tempBasal.duration) } - /// *This method should only be called from the `dataAccessQueue`* - private func recommendBolus() throws -> BolusRecommendation { + /// - Returns: A bolus recommendation from the current data + /// - Throws: + /// - LoopError.configurationError + /// - LoopError.glucoseTooOld + /// - LoopError.missingDataError + fileprivate func recommendBolus() throws -> BolusRecommendation { dispatchPrecondition(condition: .onQueue(dataAccessQueue)) guard let minimumBGGuard = settings.minimumBGGuard else { @@ -869,25 +832,157 @@ final class LoopDataManager { } } + +/// Describes a view into the loop state +protocol LoopState { + /// An error in the current state of the loop, or one that happened during the last attempt to loop. + var error: Error? { get } + + /// The last date at which a loop completed, from prediction to dose (if dosing is enabled) + var lastLoopCompleted: Date? { get } + + /// The last set temp basal + var lastTempBasal: DoseEntry? { get } + + /// The calculated timeline of predicted glucose values + var predictedGlucose: [GlucoseValue]? { get } + + /// The recommended temp basal based on predicted glucose + var recommendedTempBasal: LoopDataManager.TempBasalRecommendation? { get } + + /// The retrospective prediction over a recent period of glucose samples + var retrospectivePredictedGlucose: [GlucoseValue]? { get } + + /// Calculates a new prediction from the current data using the specified effect inputs + /// + /// This method is intended for visualization purposes only, not dosing calculation. No validation of input data is done. + /// + /// - Parameter inputs: The effect inputs to include + /// - Returns: An timeline of predicted glucose values + /// - Throws: LoopError.missingDataError if prediction cannot be computed + func predictGlucose(using inputs: PredictionInputEffect) throws -> [GlucoseValue] + + /// Calculates a recommended bolus based on predicted glucose + /// + /// - Returns: A bolus recommendation + /// - Throws: An error describing why a bolus couldn't be computed + /// - LoopError.configurationError + /// - LoopError.glucoseTooOld + /// - LoopError.missingDataError + func recommendBolus() throws -> BolusRecommendation + + // TODO: These values are duplicative and don't need to be cached in LoopDataManager + + /// Current carbs on board + var carbsOnBoard: CarbValue? { get } + + /// Current insulin on board + var insulinOnBoard: InsulinValue? { get } +} + + +extension LoopDataManager { + private struct LoopStateView: LoopState { + private let loopDataManager: LoopDataManager + private let updateError: Error? + + init(loopDataManager: LoopDataManager, updateError: Error?) { + self.loopDataManager = loopDataManager + self.updateError = updateError + } + + var error: Error? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return updateError ?? loopDataManager.lastLoopError + } + + var lastLoopCompleted: Date? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return loopDataManager.lastLoopCompleted + } + + var lastTempBasal: DoseEntry? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return loopDataManager.lastTempBasal + } + + var predictedGlucose: [GlucoseValue]? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return loopDataManager.predictedGlucose + } + + var recommendedTempBasal: LoopDataManager.TempBasalRecommendation? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return loopDataManager.recommendedTempBasal + } + + var retrospectivePredictedGlucose: [GlucoseValue]? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return loopDataManager.retrospectivePredictedGlucose + } + + var carbsOnBoard: CarbValue? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return loopDataManager.carbsOnBoardSeries?.closestPriorToDate(Date()) + } + + var insulinOnBoard: InsulinValue? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return loopDataManager.insulinOnBoard + } + + func predictGlucose(using inputs: PredictionInputEffect) throws -> [GlucoseValue] { + return try loopDataManager.predictGlucose(using: inputs) + } + + func recommendBolus() throws -> BolusRecommendation { + return try loopDataManager.recommendBolus() + } + } + + /// Executes a closure with access to the current state of the loop. + /// + /// This operation is performed asynchronously and the closure will be executed on an arbitrary background queue. + /// + /// - Parameter handler: A closure called when the state is ready + /// - Parameter manager: The loop manager + /// - Parameter state: The current state of the manager. This is invalid to access outside of the closure. + func getLoopState(_ handler: @escaping (_ manager: LoopDataManager, _ state: LoopState) -> Void) { + dataAccessQueue.async { + var updateError: Error? + + do { + try self.update() + } catch let error { + updateError = error + } + + handler(self, LoopStateView(loopDataManager: self, updateError: updateError)) + } + } +} + + extension LoopDataManager { /// Generates a diagnostic report about the current state /// /// This operation is performed asynchronously and the completion will be executed on an arbitrary background queue. /// /// - parameter completion: A closure called once the report has been generated. The closure takes a single argument of the report string. - func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) { - getLoopStatus { (predictedGlucose, retrospectivePredictedGlucose, recommendedTempBasal, lastTempBasal, lastLoopCompleted, insulinOnBoard, carbsOnBoard, error) in + func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) { + getLoopState { (manager, state) in var entries = [ "## LoopDataManager", - "settings: \(String(reflecting: self.settings))", - "predictedGlucose: \(predictedGlucose ?? [])", - "retrospectivePredictedGlucose: \(retrospectivePredictedGlucose ?? [])", - "recommendedTempBasal: \(String(describing: recommendedTempBasal))", - "lastTempBasal: \(String(describing: lastTempBasal))", - "lastLoopCompleted: \(lastLoopCompleted ?? .distantPast)", - "insulinOnBoard: \(String(describing: insulinOnBoard))", - "carbsOnBoard: \(String(describing: carbsOnBoard))", - "error: \(String(describing: error))" + "settings: \(String(reflecting: manager.settings))", + "predictedGlucose: \(state.predictedGlucose ?? [])", + "retrospectivePredictedGlucose: \(state.retrospectivePredictedGlucose ?? [])", + "recommendedTempBasal: \(String(describing: state.recommendedTempBasal))", + "lastTempBasal: \(String(describing: state.lastTempBasal))", + "lastBolus: \(String(describing: manager.lastBolus))", + "lastLoopCompleted: \(String(describing: state.lastLoopCompleted))", + "insulinOnBoard: \(String(describing: state.insulinOnBoard))", + "carbsOnBoard: \(String(describing: state.carbsOnBoard))", + "error: \(String(describing: state.error))" ] self.glucoseStore.generateDiagnosticReport { (report) in diff --git a/Loop/Managers/NightscoutDataManager.swift b/Loop/Managers/NightscoutDataManager.swift index 9a2404819c..24f3f62041 100644 --- a/Loop/Managers/NightscoutDataManager.swift +++ b/Loop/Managers/NightscoutDataManager.swift @@ -34,23 +34,38 @@ final class NightscoutDataManager { let context = LoopDataManager.LoopUpdateContext(rawValue: rawContext), case .tempBasal = context else { - return + return } - deviceDataManager.loopManager.getLoopStatus { (predictedGlucose, _, recommendedTempBasal, lastTempBasal, _, insulinOnBoard, carbsOnBoard, loopError) in - - self.deviceDataManager.loopManager.getRecommendedBolus { (recommendation, getBolusError) in - if let getBolusError = getBolusError { - self.deviceDataManager.logger.addError(getBolusError, fromSource: "NightscoutDataManager") + deviceDataManager.loopManager.getLoopState { (_, state) in + var loopError = state.error + let recommendation: Double? + + do { + recommendation = try state.recommendBolus().amount + } catch let error { + recommendation = nil + + if loopError == nil { + loopError = error } - self.uploadLoopStatus(insulinOnBoard, carbsOnBoard: carbsOnBoard, predictedGlucose: predictedGlucose, recommendedTempBasal: recommendedTempBasal, recommendedBolus: recommendation?.amount, lastTempBasal: lastTempBasal, loopError: loopError ?? getBolusError) } + + self.uploadLoopStatus( + insulinOnBoard: state.insulinOnBoard, + carbsOnBoard: state.carbsOnBoard, + predictedGlucose: state.predictedGlucose, + recommendedTempBasal: state.recommendedTempBasal, + recommendedBolus: recommendation, + lastTempBasal: state.lastTempBasal, + loopError: loopError + ) } } private var lastTempBasalUploaded: DoseEntry? - func uploadLoopStatus(_ insulinOnBoard: InsulinValue? = nil, carbsOnBoard: CarbValue? = nil, predictedGlucose: [GlucoseValue]? = nil, recommendedTempBasal: LoopDataManager.TempBasalRecommendation? = nil, recommendedBolus: Double? = nil, lastTempBasal: DoseEntry? = nil, loopError: Error? = nil) { + func uploadLoopStatus(insulinOnBoard: InsulinValue? = nil, carbsOnBoard: CarbValue? = nil, predictedGlucose: [GlucoseValue]? = nil, recommendedTempBasal: LoopDataManager.TempBasalRecommendation? = nil, recommendedBolus: Double? = nil, lastTempBasal: DoseEntry? = nil, loopError: Error? = nil) { guard deviceDataManager.remoteDataManager.nightscoutService.uploader != nil else { return diff --git a/Loop/Managers/StatusExtensionDataManager.swift b/Loop/Managers/StatusExtensionDataManager.swift index eedc85d64e..137c4be6fb 100644 --- a/Loop/Managers/StatusExtensionDataManager.swift +++ b/Loop/Managers/StatusExtensionDataManager.swift @@ -42,9 +42,7 @@ final class StatusExtensionDataManager { } private func createContext(glucoseUnit: HKUnit, _ completionHandler: @escaping (_ context: StatusExtensionContext?) -> Void) { - dataManager.loopManager.getLoopStatus { - (predictedGlucose, _, recommendedTempBasal, lastTempBasal, lastLoopCompleted, _, _, error) in - + dataManager.loopManager.getLoopState { (manager, state) in let dataManager = self.dataManager var context = StatusExtensionContext() @@ -68,16 +66,18 @@ final class StatusExtensionDataManager { let lastLoopCompleted = Date(timeIntervalSinceNow: -TimeInterval(minutes: 0)) #else - guard error == nil else { + guard state.error == nil else { // TODO: unclear how to handle the error here properly. completionHandler(nil) return } + let lastLoopCompleted = state.lastLoopCompleted #endif context.loop = LoopContext( - dosingEnabled: dataManager.loopManager.settings.dosingEnabled, - lastCompleted: lastLoopCompleted) + dosingEnabled: manager.settings.dosingEnabled, + lastCompleted: lastLoopCompleted + ) let updateGroup = DispatchGroup() @@ -87,7 +87,7 @@ final class StatusExtensionDataManager { let chartEndDate = Date().addingTimeInterval(TimeInterval(hours: 3)) updateGroup.enter() - self.dataManager.loopManager.glucoseStore.getCachedGlucoseValues(start: chartStartDate, end: Date()) { + manager.glucoseStore.getCachedGlucoseValues(start: chartStartDate, end: Date()) { (values) in context.glucose = values.map({ return GlucoseContext( @@ -96,35 +96,34 @@ final class StatusExtensionDataManager { startDate: $0.startDate ) }) - - // Only tranfer the predicted glucose if we have glucose history - // Drop the first element in predictedGlucose because it is the currentGlucose - // and will have a different interval to the next element - if let predictedGlucose = predictedGlucose?.dropFirst(), - predictedGlucose.count > 1 { - let first = predictedGlucose[predictedGlucose.startIndex] - let second = predictedGlucose[predictedGlucose.startIndex.advanced(by: 1)] - context.predictedGlucose = PredictedGlucoseContext( - values: predictedGlucose.map { $0.quantity.doubleValue(for: glucoseUnit) }, - unit: glucoseUnit, - startDate: first.startDate, - interval: second.startDate.timeIntervalSince(first.startDate)) - } updateGroup.leave() } - let date = lastTempBasal?.startDate ?? Date() - if let scheduledBasal = dataManager.loopManager.basalRateSchedule?.between(start: date, end: date).first { + // Drop the first element in predictedGlucose because it is the currentGlucose + // and will have a different interval to the next element + if let predictedGlucose = state.predictedGlucose?.dropFirst(), + predictedGlucose.count > 1 { + let first = predictedGlucose[predictedGlucose.startIndex] + let second = predictedGlucose[predictedGlucose.startIndex.advanced(by: 1)] + context.predictedGlucose = PredictedGlucoseContext( + values: predictedGlucose.map { $0.quantity.doubleValue(for: glucoseUnit) }, + unit: glucoseUnit, + startDate: first.startDate, + interval: second.startDate.timeIntervalSince(first.startDate)) + } + + let date = state.lastTempBasal?.startDate ?? Date() + if let scheduledBasal = manager.basalRateSchedule?.between(start: date, end: date).first { let netBasal = NetBasal( - lastTempBasal: lastTempBasal, - maxBasal: dataManager.loopManager.settings.maximumBasalRatePerHour, + lastTempBasal: state.lastTempBasal, + maxBasal: manager.settings.maximumBasalRatePerHour, scheduledBasal: scheduledBasal ) context.netBasal = NetBasalContext(rate: netBasal.rate, percentage: netBasal.percent, startDate: netBasal.startDate) } - if let reservoir = dataManager.loopManager.doseStore.lastReservoirValue, + if let reservoir = manager.doseStore.lastReservoirValue, let capacity = dataManager.pumpState?.pumpModel?.reservoirCapacity { context.reservoir = ReservoirContext( startDate: reservoir.startDate, @@ -137,15 +136,16 @@ final class StatusExtensionDataManager { context.batteryPercentage = batteryPercentage } - if let targetRanges = self.dataManager.loopManager.settings.glucoseTargetRangeSchedule { + if let targetRanges = manager.settings.glucoseTargetRangeSchedule { context.targetRanges = targetRanges.between(start: chartStartDate, end: chartEndDate) - .map({ + .map { return DatedRangeContext( startDate: $0.startDate, endDate: $0.endDate, minValue: $0.value.minValue, - maxValue: $0.value.maxValue) - }) + maxValue: $0.value.maxValue + ) + } if let override = targetRanges.temporaryOverride { context.temporaryOverride = DatedRangeContext( @@ -164,9 +164,9 @@ final class StatusExtensionDataManager { isLocal: sensorInfo.isLocal) } - updateGroup.notify(queue: DispatchQueue.global(qos: .background), execute: { + updateGroup.notify(queue: DispatchQueue.global(qos: .background)) { completionHandler(context) - }) + } } } } diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index adb765fa6a..1834842d8a 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -93,29 +93,27 @@ final class WatchDataManager: NSObject, WCSessionDelegate { } } - private func createWatchContext(_ completionHandler: @escaping (_ context: WatchContext?) -> Void) { - let glucose = deviceDataManager.loopManager.glucoseStore.latestGlucose - let reservoir = deviceDataManager.loopManager.doseStore.lastReservoirValue - let maxBolus = deviceDataManager.loopManager.settings.maximumBolus + private func createWatchContext(_ completion: @escaping (_ context: WatchContext?) -> Void) { + let loopManager = deviceDataManager.loopManager! - deviceDataManager.loopManager.getLoopStatus { (predictedGlucose, _, recommendedTempBasal, lastTempBasal, lastLoopCompleted, _, _, error) in - let eventualGlucose = predictedGlucose?.last + let glucose = loopManager.glucoseStore.latestGlucose + let reservoir = loopManager.doseStore.lastReservoirValue - self.deviceDataManager.loopManager.getRecommendedBolus { (recommendation, error) in - self.deviceDataManager.loopManager.glucoseStore.preferredUnit { (unit, error) in - let context = WatchContext(glucose: glucose, eventualGlucose: eventualGlucose, glucoseUnit: unit) - context.reservoir = reservoir?.unitVolume + loopManager.glucoseStore.preferredUnit { (unit, error) in + loopManager.getLoopState { (manager, state) in + let eventualGlucose = state.predictedGlucose?.last + let context = WatchContext(glucose: glucose, eventualGlucose: eventualGlucose, glucoseUnit: unit) + context.reservoir = reservoir?.unitVolume - context.loopLastRunDate = lastLoopCompleted - context.recommendedBolusDose = recommendation?.amount - context.maxBolus = maxBolus + context.loopLastRunDate = state.lastLoopCompleted + context.recommendedBolusDose = try? state.recommendBolus().amount + context.maxBolus = manager.settings.maximumBolus - if let trend = self.deviceDataManager.sensorInfo?.trendType { - context.glucoseTrendRawValue = trend.rawValue - } - - completionHandler(context) + if let trend = self.deviceDataManager.sensorInfo?.trendType { + context.glucoseTrendRawValue = trend.rawValue } + + completion(context) } } } diff --git a/Loop/View Controllers/ChartsTableViewController.swift b/Loop/View Controllers/ChartsTableViewController.swift new file mode 100644 index 0000000000..13b5588731 --- /dev/null +++ b/Loop/View Controllers/ChartsTableViewController.swift @@ -0,0 +1,148 @@ +// +// ChartsTableViewController.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import UIKit +import LoopUI + + +struct RefreshContext: OptionSet { + let rawValue: Int + + /// Catch-all for lastLoopCompleted, recommendedTempBasal, lastTempBasal, preferences + static let status = RefreshContext(rawValue: 1 << 0) + + static let glucose = RefreshContext(rawValue: 1 << 1) + static let insulin = RefreshContext(rawValue: 1 << 2) + static let carbs = RefreshContext(rawValue: 1 << 3) + static let targets = RefreshContext(rawValue: 1 << 4) +} + + +/// Abstract class providing boilerplate setup for chart-based table view controllers +class ChartsTableViewController: UITableViewController, UIGestureRecognizerDelegate { + + override func viewDidLoad() { + super.viewDidLoad() + + let notificationCenter = NotificationCenter.default + notificationObservers += [ + notificationCenter.addObserver(forName: .UIApplicationWillResignActive, object: UIApplication.shared, queue: .main) { _ in + self.active = false + }, + notificationCenter.addObserver(forName: .UIApplicationDidBecomeActive, object: UIApplication.shared, queue: .main) { _ in + self.active = true + } + ] + + let gestureRecognizer = UILongPressGestureRecognizer() + gestureRecognizer.delegate = self + gestureRecognizer.minimumPressDuration = 0.1 + gestureRecognizer.addTarget(self, action: #selector(handlePan(_:))) + charts.gestureRecognizer = gestureRecognizer + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + + if !visible { + charts.didReceiveMemoryWarning() + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + visible = true + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + visible = false + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + if visible { + reloadData(animated: false, to: size) + } + } + + deinit { + for observer in notificationObservers { + NotificationCenter.default.removeObserver(observer) + } + } + + // MARK: - State + + weak var deviceManager: DeviceDataManager! + + var charts = StatusChartsManager(colors: .default, settings: .default) + + // References to registered notification center observers + var notificationObservers: [Any] = [] + + var active = true { + didSet { + reloadData() + } + } + + var visible = false { + didSet { + reloadData() + } + } + + // MARK: - Data loading + + /// Refetches all data and updates the views. Must be called on the main queue. + /// + /// - Parameters: + /// - animated: Whether the updating should be animated if possible + /// - size: The size to render into. Defaults to the table view bounds + func reloadData(animated: Bool = false, to size: CGSize? = nil) { + + } + + // MARK: - UIGestureRecognizer + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + /// Only start the long-press recognition when it starts in a chart cell + let point = gestureRecognizer.location(in: tableView) + if let indexPath = tableView.indexPathForRow(at: point) { + if let cell = tableView.cellForRow(at: indexPath), cell is ChartTableViewCell { + return true + } + } + + return false + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return true + } + + @objc func handlePan(_ gestureRecognizer: UIGestureRecognizer) { + switch gestureRecognizer.state { + case .possible, .changed: + // Follow your dreams! + break + case .began, .cancelled, .ended, .failed: + for case let row as ChartTableViewCell in self.tableView.visibleCells { + let forwards = gestureRecognizer.state == .began + UIView.animate(withDuration: forwards ? 0.2 : 0.5, delay: forwards ? 0 : 1, animations: { + let alpha: CGFloat = forwards ? 0 : 1 + row.titleLabel?.alpha = alpha + row.subtitleLabel?.alpha = alpha + }) + } + } + } +} diff --git a/Loop/View Controllers/PredictionTableViewController.swift b/Loop/View Controllers/PredictionTableViewController.swift index 67cb1faa74..72dd55d39e 100644 --- a/Loop/View Controllers/PredictionTableViewController.swift +++ b/Loop/View Controllers/PredictionTableViewController.swift @@ -12,138 +12,101 @@ import LoopKit import LoopUI -class PredictionTableViewController: UITableViewController, IdentifiableClass, UIGestureRecognizerDelegate { +private extension RefreshContext { + static let all: RefreshContext = [.glucose, .targets] +} + + +class PredictionTableViewController: ChartsTableViewController, IdentifiableClass { override func viewDidLoad() { super.viewDidLoad() tableView.cellLayoutMarginsFollowReadableWidth = true + charts.glucoseDisplayRange = ( + min: HKQuantity(unit: HKUnit.milligramsPerDeciliter(), doubleValue: 60), + max: HKQuantity(unit: HKUnit.milligramsPerDeciliter(), doubleValue: 200) + ) + let notificationCenter = NotificationCenter.default - let mainQueue = OperationQueue.main - let application = UIApplication.shared notificationObservers += [ - notificationCenter.addObserver(forName: .LoopDataUpdated, object: dataManager.loopManager, queue: nil) { note in - guard let rawContext = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as? Int, LoopDataManager.LoopUpdateContext(rawValue: rawContext) != .preferences else { - return - } - + notificationCenter.addObserver(forName: .LoopDataUpdated, object: deviceManager.loopManager, queue: nil) { note in + let context = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopDataManager.LoopUpdateContext.RawValue DispatchQueue.main.async { - self.needsRefresh = true + switch LoopDataManager.LoopUpdateContext(rawValue: context) { + case .preferences?: + self.refreshContext.update(with: [.status, .targets]) + case .glucose?: + self.refreshContext.update(with: .glucose) + default: + break + } + self.reloadData(animated: true) } - }, - notificationCenter.addObserver(forName: .UIApplicationWillResignActive, object: application, queue: mainQueue) { _ in - self.active = false - }, - notificationCenter.addObserver(forName: .UIApplicationDidBecomeActive, object: application, queue: mainQueue) { _ in - self.active = true } ] - - let gestureRecognizer = UILongPressGestureRecognizer() - gestureRecognizer.delegate = self - gestureRecognizer.minimumPressDuration = 0.1 - gestureRecognizer.addTarget(self, action: #selector(handlePan(_:))) - charts.gestureRecognizer = gestureRecognizer - } - - deinit { - for observer in notificationObservers { - NotificationCenter.default.removeObserver(observer) - } - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - visible = true - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - AnalyticsManager.sharedManager.didDisplayStatusScreen() } - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() - visible = false + if !visible { + refreshContext = .all + } } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - super.viewWillTransition(to: size, with: coordinator) + refreshContext.update(with: .status) - needsRefresh = true - if visible { - reloadData(animated: false) - } + super.viewWillTransition(to: size, with: coordinator) } // MARK: - State - // References to registered notification center observers - private var notificationObservers: [Any] = [] - - var dataManager: DeviceDataManager! - - private lazy var charts: StatusChartsManager = { - let charts = StatusChartsManager(colors: .default, settings: .default) - - charts.glucoseDisplayRange = ( - min: HKQuantity(unit: HKUnit.milligramsPerDeciliter(), doubleValue: 60), - max: HKQuantity(unit: HKUnit.milligramsPerDeciliter(), doubleValue: 200) - ) - - return charts - }() - private var retrospectivePredictedGlucose: [GlucoseValue]? - private var active = true { - didSet { - reloadData() - } - } + private var refreshContext = RefreshContext.all - private var needsRefresh = true + private var chartStartDate: Date { + get { + return charts.startDate + } + set { + if newValue != chartStartDate { + refreshContext = .all + } - private var visible = false { - didSet { - reloadData() + charts.startDate = newValue } } - private var reloading = false + override func reloadData(animated: Bool = false, to size: CGSize? = nil) { + guard active && visible && !refreshContext.isEmpty else { return } - private func reloadData(animated: Bool = false) { - if active && visible && needsRefresh { - needsRefresh = false - reloading = true + let calendar = Calendar.current + var components = DateComponents() + components.minute = 0 + let date = Date(timeIntervalSinceNow: -TimeInterval(hours: 1)) + chartStartDate = calendar.nextDate(after: date, matching: components, matchingPolicy: .strict, direction: .backward) ?? date - let calendar = Calendar.current - var components = DateComponents() - components.minute = 0 - let date = Date(timeIntervalSinceNow: -TimeInterval(hours: 1)) - charts.startDate = calendar.nextDate(after: date, matching: components, matchingPolicy: .strict, direction: .backward) ?? date + let reloadGroup = DispatchGroup() - let reloadGroup = DispatchGroup() - - reloadGroup.enter() - - dataManager.loopManager.glucoseStore.preferredUnit { (unit, error) in - if let unit = unit { - self.charts.glucoseUnit = unit - } + reloadGroup.enter() + deviceManager.loopManager.glucoseStore.preferredUnit { (unit, error) in + if let unit = unit { + self.charts.glucoseUnit = unit + } + if self.refreshContext.remove(.glucose) != nil { reloadGroup.enter() - self.dataManager.loopManager.glucoseStore.getGlucoseValues(start: self.charts.startDate) { (result) -> Void in + self.deviceManager.loopManager.glucoseStore.getGlucoseValues(start: self.chartStartDate) { (result) -> Void in switch result { case .failure(let error): - self.dataManager.logger.addError(error, fromSource: "GlucoseStore") - self.needsRefresh = true + self.deviceManager.logger.addError(error, fromSource: "GlucoseStore") + self.refreshContext.update(with: .glucose) self.charts.setGlucoseValues([]) case .success(let values): self.charts.setGlucoseValues(values) @@ -151,55 +114,52 @@ class PredictionTableViewController: UITableViewController, IdentifiableClass, U reloadGroup.leave() } + } - reloadGroup.enter() - self.dataManager.loopManager.getLoopStatus { (predictedGlucose, retrospectivePredictedGlucose, _, _, _, _, _, error) in - if error != nil { - self.needsRefresh = true - } - - self.retrospectivePredictedGlucose = retrospectivePredictedGlucose - self.charts.setPredictedGlucoseValues(predictedGlucose ?? []) - - reloadGroup.leave() + // For now, do this every time + _ = self.refreshContext.remove(.status) + reloadGroup.enter() + self.deviceManager.loopManager.getLoopState { (manager, state) in + self.retrospectivePredictedGlucose = state.retrospectivePredictedGlucose + self.charts.setPredictedGlucoseValues(state.predictedGlucose ?? []) + + do { + let glucose = try state.predictGlucose(using: self.selectedInputs) + self.charts.setAlternatePredictedGlucoseValues(glucose) + } catch { + self.refreshContext.update(with: .status) + self.charts.setAlternatePredictedGlucoseValues([]) } - reloadGroup.enter() - self.dataManager.loopManager.getPredictedGlucose(using: self.selectedInputs) { (result) in - switch result { - case .failure: - self.needsRefresh = true - self.charts.setAlternatePredictedGlucoseValues([]) - case .success(let predictedGlucose): - self.charts.setAlternatePredictedGlucoseValues(predictedGlucose) - } + if let lastPoint = self.charts.alternatePredictedGlucosePoints?.last?.y { + self.eventualGlucoseDescription = String(describing: lastPoint) + } else { + self.eventualGlucoseDescription = nil + } - if let lastPoint = self.charts.alternatePredictedGlucosePoints?.last?.y { - self.eventualGlucoseDescription = String(describing: lastPoint) + if self.refreshContext.remove(.targets) != nil { + if let schedule = manager.settings.glucoseTargetRangeSchedule { + self.charts.targetPointsCalculator = GlucoseRangeScheduleCalculator(schedule) } else { - self.eventualGlucoseDescription = nil + self.charts.targetPointsCalculator = nil } - - reloadGroup.leave() } reloadGroup.leave() } - charts.targetPointsCalculator = GlucoseRangeScheduleCalculator(dataManager.loopManager.settings.glucoseTargetRangeSchedule) + reloadGroup.leave() + } - reloadGroup.notify(queue: DispatchQueue.main) { - self.charts.prerender() + reloadGroup.notify(queue: .main) { + self.charts.prerender() - for case let cell as ChartTableViewCell in self.tableView.visibleCells { - cell.reloadChart() + for case let cell as ChartTableViewCell in self.tableView.visibleCells { + cell.reloadChart() - if let indexPath = self.tableView.indexPath(for: cell) { - self.tableView(self.tableView, updateTitleFor: cell, at: indexPath) - } + if let indexPath = self.tableView.indexPath(for: cell) { + self.tableView(self.tableView, updateTitleFor: cell, at: indexPath) } - - self.reloading = false } } } @@ -240,7 +200,6 @@ class PredictionTableViewController: UITableViewController, IdentifiableClass, U case .charts: let cell = tableView.dequeueReusableCell(withIdentifier: ChartTableViewCell.className, for: indexPath) as! ChartTableViewCell cell.titleLabel?.textColor = UIColor.secondaryLabelColor - cell.subtitleLabel?.textColor = UIColor.secondaryLabelColor cell.contentView.layoutMargins.left = tableView.separatorInset.left cell.chartContentView.chartGenerator = { [weak self] (frame) in return self?.charts.glucoseChartWithFrame(frame)?.view @@ -260,14 +219,14 @@ class PredictionTableViewController: UITableViewController, IdentifiableClass, U cell.titleLabel?.text = input.localizedTitle cell.accessoryType = selectedInputs.contains(input) ? .checkmark : .none - cell.enabled = input != .retrospection || dataManager.loopManager.settings.retrospectiveCorrectionEnabled + cell.enabled = input != .retrospection || deviceManager.loopManager.settings.retrospectiveCorrectionEnabled var subtitleText = input.localizedDescription(forGlucoseUnit: charts.glucoseUnit) ?? "" if input == .retrospection, let startGlucose = retrospectivePredictedGlucose?.first, let endGlucose = retrospectivePredictedGlucose?.last, - let currentGlucose = self.dataManager.loopManager.glucoseStore.latestGlucose + let currentGlucose = self.deviceManager.loopManager.glucoseStore.latestGlucose { let formatter = NumberFormatter.glucoseFormatter(for: charts.glucoseUnit) let values = [startGlucose, endGlucose, currentGlucose].map { formatter.string(from: NSNumber(value: $0.quantity.doubleValue(for: charts.glucoseUnit))) ?? "?" } @@ -290,7 +249,7 @@ class PredictionTableViewController: UITableViewController, IdentifiableClass, U cell.titleLabel?.text = NSLocalizedString("Enable Retrospective Correction", comment: "Title of the switch which toggles retrospective correction effects") cell.subtitleLabel?.text = NSLocalizedString("This will more aggresively increase or decrease basal delivery when glucose movement doesn't match the carbohydrate and insulin-based model.", comment: "The description of the switch which toggles retrospective correction effects") - cell.`switch`?.isOn = dataManager.loopManager.settings.retrospectiveCorrectionEnabled + cell.`switch`?.isOn = deviceManager.loopManager.settings.retrospectiveCorrectionEnabled cell.`switch`?.addTarget(self, action: #selector(retrospectiveCorrectionSwitchChanged(_:)), for: .valueChanged) cell.contentView.layoutMargins.left = tableView.separatorInset.left @@ -346,41 +305,19 @@ class PredictionTableViewController: UITableViewController, IdentifiableClass, U tableView.deselectRow(at: indexPath, animated: true) - needsRefresh = true + refreshContext.update(with: .status) reloadData() } - // MARK: - UIGestureRecognizer - - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { - return true - } - - @objc func handlePan(_ gestureRecognizer: UIGestureRecognizer) { - switch gestureRecognizer.state { - case .possible, .changed: - // Follow your dreams! - break - case .began, .cancelled, .ended, .failed: - for case let row as ChartTableViewCell in self.tableView.visibleCells { - let forwards = gestureRecognizer.state == .began - UIView.animate(withDuration: forwards ? 0.2 : 0.5, delay: forwards ? 0 : 1, animations: { - let alpha: CGFloat = forwards ? 0 : 1 - row.titleLabel?.alpha = alpha - }) - } - } - } - // MARK: - Actions @objc private func retrospectiveCorrectionSwitchChanged(_ sender: UISwitch) { - dataManager.loopManager.settings.retrospectiveCorrectionEnabled = sender.isOn + deviceManager.loopManager.settings.retrospectiveCorrectionEnabled = sender.isOn if let row = availableInputs.index(where: { $0 == .retrospection }), let cell = tableView.cellForRow(at: IndexPath(row: row, section: Section.inputs.rawValue)) as? PredictionInputEffectTableViewCell { - cell.enabled = self.dataManager.loopManager.settings.retrospectiveCorrectionEnabled + cell.enabled = self.deviceManager.loopManager.settings.retrospectiveCorrectionEnabled } } } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index ae1ee22906..a531e521e8 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -16,21 +16,6 @@ import LoopUI import SwiftCharts -private struct RefreshContext: OptionSet { - let rawValue: Int - - /// Catch-all for lastLoopCompleted, recommendedTempBasal, lastTempBasal, preferences - static let status = RefreshContext(rawValue: 1 << 0) - - static let glucose = RefreshContext(rawValue: 1 << 1) - static let insulin = RefreshContext(rawValue: 1 << 2) - static let carbs = RefreshContext(rawValue: 1 << 3) - static let targets = RefreshContext(rawValue: 1 << 4) - - static let all: RefreshContext = [.status, .glucose, .insulin, .carbs, .targets] -} - - /// Describes the state within the bolus setting flow /// /// - recommended: A bolus recommendation was discovered and the bolus view controller is presenting/presented @@ -41,17 +26,25 @@ private enum BolusState { } -final class StatusTableViewController: UITableViewController, UIGestureRecognizerDelegate { +private extension RefreshContext { + static let all: RefreshContext = [.status, .glucose, .insulin, .carbs, .targets] +} + + +final class StatusTableViewController: ChartsTableViewController { override func viewDidLoad() { super.viewDidLoad() + charts.glucoseDisplayRange = ( + min: HKQuantity(unit: HKUnit.milligramsPerDeciliter(), doubleValue: 100), + max: HKQuantity(unit: HKUnit.milligramsPerDeciliter(), doubleValue: 175) + ) + let notificationCenter = NotificationCenter.default - let mainQueue = OperationQueue.main - let application = UIApplication.shared notificationObservers += [ - notificationCenter.addObserver(forName: .LoopDataUpdated, object: dataManager.loopManager, queue: nil) { note in + notificationCenter.addObserver(forName: .LoopDataUpdated, object: deviceManager.loopManager, queue: nil) { note in let context = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopDataManager.LoopUpdateContext.RawValue DispatchQueue.main.async { switch LoopDataManager.LoopUpdateContext(rawValue: context) { @@ -71,25 +64,16 @@ final class StatusTableViewController: UITableViewController, UIGestureRecognize self.reloadData(animated: true) } }, - notificationCenter.addObserver(forName: .LoopRunning, object: dataManager.loopManager, queue: nil) { _ in + notificationCenter.addObserver(forName: .LoopRunning, object: deviceManager.loopManager, queue: nil) { _ in DispatchQueue.main.async { self.hudView.loopCompletionHUD.loopInProgress = true } - }, - notificationCenter.addObserver(forName: .UIApplicationWillResignActive, object: application, queue: mainQueue) { _ in - self.active = false - }, - notificationCenter.addObserver(forName: .UIApplicationDidBecomeActive, object: application, queue: mainQueue) { _ in - self.active = true } ] - let gestureRecognizer = UILongPressGestureRecognizer() - gestureRecognizer.delegate = self - gestureRecognizer.minimumPressDuration = 0.1 - gestureRecognizer.addTarget(self, action: #selector(handlePan(_:))) - tableView.addGestureRecognizer(gestureRecognizer) - charts.gestureRecognizer = gestureRecognizer + if let gestureRecognizer = charts.gestureRecognizer { + tableView.addGestureRecognizer(gestureRecognizer) + } // Toolbar toolbarItems![0].accessibilityLabel = NSLocalizedString("Add Meal", comment: "The label of the carb entry button") @@ -100,17 +84,10 @@ final class StatusTableViewController: UITableViewController, UIGestureRecognize toolbarItems![6].tintColor = UIColor.secondaryLabelColor } - deinit { - for observer in notificationObservers { - NotificationCenter.default.removeObserver(observer) - } - } - override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() if !visible { - charts.didReceiveMemoryWarning() refreshContext = .all } } @@ -119,7 +96,6 @@ final class StatusTableViewController: UITableViewController, UIGestureRecognize super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(true, animated: animated) - visible = true } override func viewDidAppear(_ animated: Bool) { @@ -134,28 +110,18 @@ final class StatusTableViewController: UITableViewController, UIGestureRecognize if presentedViewController == nil { navigationController?.setNavigationBarHidden(false, animated: animated) } - visible = false } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - super.viewWillTransition(to: size, with: coordinator) - refreshContext.update(with: .status) - if visible { - reloadData(animated: false, to: size) - } + + super.viewWillTransition(to: size, with: coordinator) } // MARK: - State - // References to registered notification center observers - private var notificationObservers: [Any] = [] - - weak var dataManager: DeviceDataManager! - - private var active = true { + override var active: Bool { didSet { - reloadData() hudView.loopCompletionHUD.assertTimer(active) } } @@ -181,235 +147,220 @@ final class StatusTableViewController: UITableViewController, UIGestureRecognize } } - private var visible = false { - didSet { - reloadData() - } - } + override func reloadData(animated: Bool = false, to size: CGSize? = nil) { + guard active && visible && !refreshContext.isEmpty else { return } - private var reloading = false + // How far back should we show data? Use the screen size as a guide. + let minimumSegmentWidth: CGFloat = 50 + let availableWidth = (size ?? self.tableView.bounds.size).width - self.charts.fixedHorizontalMargin + let totalHours = floor(Double(availableWidth / minimumSegmentWidth)) + let historyHours = totalHours - (deviceManager.loopManager.insulinActionDuration ?? TimeInterval(hours: 4)).hours - /// Refetches all data and updates the views. Must be called on the main queue. - /// - /// - parameter animated: Whether the updating should be animated if possible - private func reloadData(animated: Bool = false, to size: CGSize? = nil) { - if active && visible && !refreshContext.isEmpty { - reloading = true - - // How far back should we show data? Use the screen size as a guide. - let minimumSegmentWidth: CGFloat = 50 - let availableWidth = (size ?? self.tableView.bounds.size).width - self.charts.fixedHorizontalMargin - let totalHours = floor(Double(availableWidth / minimumSegmentWidth)) - let historyHours = totalHours - (dataManager.loopManager.insulinActionDuration ?? TimeInterval(hours: 4)).hours - - var components = DateComponents() - components.minute = 0 - let date = Date(timeIntervalSinceNow: -TimeInterval(hours: max(1, historyHours))) - chartStartDate = Calendar.current.nextDate(after: date, matching: components, matchingPolicy: .strict, direction: .backward) ?? date - - let reloadGroup = DispatchGroup() - var newLastLoopCompleted: Date? - var newLastTempBasal: DoseEntry? - var newRecommendedTempBasal: LoopDataManager.TempBasalRecommendation? + var components = DateComponents() + components.minute = 0 + let date = Date(timeIntervalSinceNow: -TimeInterval(hours: max(1, historyHours))) + chartStartDate = Calendar.current.nextDate(after: date, matching: components, matchingPolicy: .strict, direction: .backward) ?? date - reloadGroup.enter() - dataManager.loopManager.glucoseStore.preferredUnit { (unit, error) in - if let unit = unit { - self.charts.glucoseUnit = unit - } + let reloadGroup = DispatchGroup() + var newLastLoopCompleted: Date? + var newLastTempBasal: DoseEntry? + var newRecommendedTempBasal: LoopDataManager.TempBasalRecommendation? - if self.refreshContext.remove(.glucose) != nil { - reloadGroup.enter() - self.dataManager.loopManager.glucoseStore.getGlucoseValues(start: self.chartStartDate) { (result) -> Void in - switch result { - case .failure(let error): - self.dataManager.logger.addError(error, fromSource: "GlucoseStore") - self.refreshContext.update(with: .glucose) - self.charts.setGlucoseValues([]) - case .success(let values): - self.charts.setGlucoseValues(values) - } + reloadGroup.enter() + deviceManager.loopManager.glucoseStore.preferredUnit { (unit, error) in + if let unit = unit { + self.charts.glucoseUnit = unit + } - reloadGroup.leave() + if self.refreshContext.remove(.glucose) != nil { + reloadGroup.enter() + self.deviceManager.loopManager.glucoseStore.getGlucoseValues(start: self.chartStartDate) { (result) -> Void in + switch result { + case .failure(let error): + self.deviceManager.logger.addError(error, fromSource: "GlucoseStore") + self.refreshContext.update(with: .glucose) + self.charts.setGlucoseValues([]) + case .success(let values): + self.charts.setGlucoseValues(values) } + + reloadGroup.leave() } + } - // For now, do this every time - _ = self.refreshContext.remove(.status) - reloadGroup.enter() - self.dataManager.loopManager.getLoopStatus { (predictedGlucose, _, recommendedTempBasal, lastTempBasal, lastLoopCompleted, _, _, lastError) -> Void in - self.charts.setPredictedGlucoseValues(predictedGlucose ?? []) + // For now, do this every time + _ = self.refreshContext.remove(.status) + reloadGroup.enter() + self.deviceManager.loopManager.getLoopState { (manager, state) -> Void in + self.charts.setPredictedGlucoseValues(state.predictedGlucose ?? []) - // Retry this refresh again if predicted glucose isn't available - if predictedGlucose == nil { - self.refreshContext.update(with: .status) - } + // Retry this refresh again if predicted glucose isn't available + if state.predictedGlucose == nil { + self.refreshContext.update(with: .status) + } - switch self.bolusState { - case .recommended?, .enacting?: - newRecommendedTempBasal = nil - case .none: - newRecommendedTempBasal = recommendedTempBasal - } + switch self.bolusState { + case .recommended?, .enacting?: + newRecommendedTempBasal = nil + case .none: + newRecommendedTempBasal = state.recommendedTempBasal + } + + newLastTempBasal = state.lastTempBasal + newLastLoopCompleted = state.lastLoopCompleted - newLastTempBasal = lastTempBasal - newLastLoopCompleted = lastLoopCompleted + if let lastPoint = self.charts.predictedGlucosePoints.last?.y { + self.eventualGlucoseDescription = String(describing: lastPoint) + } else { + self.eventualGlucoseDescription = nil + } - if let lastPoint = self.charts.predictedGlucosePoints.last?.y { - self.eventualGlucoseDescription = String(describing: lastPoint) + if self.refreshContext.remove(.targets) != nil { + if let schedule = manager.settings.glucoseTargetRangeSchedule { + self.charts.targetPointsCalculator = GlucoseRangeScheduleCalculator(schedule) } else { - self.eventualGlucoseDescription = nil + self.charts.targetPointsCalculator = nil } - - reloadGroup.leave() } reloadGroup.leave() } - if refreshContext.remove(.insulin) != nil { - reloadGroup.enter() - dataManager.loopManager.doseStore.getInsulinOnBoardValues(start: chartStartDate) { (result) -> Void in - switch result { - case .failure(let error): - self.dataManager.logger.addError(error, fromSource: "DoseStore") - self.refreshContext.update(with: .insulin) - self.charts.setIOBValues([]) - case .success(let values): - self.charts.setIOBValues(values) - } - reloadGroup.leave() - } + reloadGroup.leave() + } - reloadGroup.enter() - dataManager.loopManager.doseStore.getNormalizedDoseEntries(start: chartStartDate) { (result) -> Void in - switch result { - case .failure(let error): - self.dataManager.logger.addError(error, fromSource: "DoseStore") - self.refreshContext.update(with: .insulin) - self.charts.setDoseEntries([]) - case .success(let doses): - self.charts.setDoseEntries(doses) - } - reloadGroup.leave() + if refreshContext.remove(.insulin) != nil { + reloadGroup.enter() + deviceManager.loopManager.doseStore.getInsulinOnBoardValues(start: chartStartDate) { (result) -> Void in + switch result { + case .failure(let error): + self.deviceManager.logger.addError(error, fromSource: "DoseStore") + self.refreshContext.update(with: .insulin) + self.charts.setIOBValues([]) + case .success(let values): + self.charts.setIOBValues(values) } + reloadGroup.leave() + } - reloadGroup.enter() - dataManager.loopManager.doseStore.getTotalUnitsDelivered(since: Calendar.current.startOfDay(for: Date())) { (result) in - switch result { - case .failure: - self.refreshContext.update(with: .insulin) - self.totalDelivery = nil - case .success(let total): - self.totalDelivery = total.value - } - - reloadGroup.leave() + reloadGroup.enter() + deviceManager.loopManager.doseStore.getNormalizedDoseEntries(start: chartStartDate) { (result) -> Void in + switch result { + case .failure(let error): + self.deviceManager.logger.addError(error, fromSource: "DoseStore") + self.refreshContext.update(with: .insulin) + self.charts.setDoseEntries([]) + case .success(let doses): + self.charts.setDoseEntries(doses) } + reloadGroup.leave() } - if refreshContext.remove(.carbs) != nil { - reloadGroup.enter() - dataManager.loopManager.carbStore.getCarbsOnBoardValues(startDate: chartStartDate) { (values, error) -> Void in - if let error = error { - self.dataManager.logger.addError(error, fromSource: "CarbStore") - self.refreshContext.update(with: .carbs) - } - - self.charts.setCOBValues(values) - - reloadGroup.leave() + reloadGroup.enter() + deviceManager.loopManager.doseStore.getTotalUnitsDelivered(since: Calendar.current.startOfDay(for: Date())) { (result) in + switch result { + case .failure: + self.refreshContext.update(with: .insulin) + self.totalDelivery = nil + case .success(let total): + self.totalDelivery = total.value } + + reloadGroup.leave() } + } - if let reservoir = dataManager.loopManager.doseStore.lastReservoirValue { - if let capacity = dataManager.pumpState?.pumpModel?.reservoirCapacity { - hudView.reservoirVolumeHUD.reservoirLevel = min(1, max(0, Double(reservoir.unitVolume / Double(capacity)))) + if refreshContext.remove(.carbs) != nil { + reloadGroup.enter() + deviceManager.loopManager.carbStore.getCarbsOnBoardValues(startDate: chartStartDate) { (values, error) -> Void in + if let error = error { + self.deviceManager.logger.addError(error, fromSource: "CarbStore") + self.refreshContext.update(with: .carbs) } - - hudView.reservoirVolumeHUD.setReservoirVolume(volume: reservoir.unitVolume, at: reservoir.startDate) + + self.charts.setCOBValues(values) + + reloadGroup.leave() } + } - if let level = dataManager.pumpBatteryChargeRemaining { - hudView.batteryHUD.batteryLevel = level + if let reservoir = deviceManager.loopManager.doseStore.lastReservoirValue { + if let capacity = deviceManager.pumpState?.pumpModel?.reservoirCapacity { + hudView.reservoirVolumeHUD.reservoirLevel = min(1, max(0, Double(reservoir.unitVolume / Double(capacity)))) } + + hudView.reservoirVolumeHUD.setReservoirVolume(volume: reservoir.unitVolume, at: reservoir.startDate) + } - hudView.loopCompletionHUD.dosingEnabled = dataManager.loopManager.settings.dosingEnabled + if let level = deviceManager.pumpBatteryChargeRemaining { + hudView.batteryHUD.batteryLevel = level + } - if refreshContext.remove(.targets) != nil { - if let schedule = dataManager.loopManager.settings.glucoseTargetRangeSchedule { - charts.targetPointsCalculator = GlucoseRangeScheduleCalculator(schedule) - } else { - charts.targetPointsCalculator = nil - } - } + hudView.loopCompletionHUD.dosingEnabled = deviceManager.loopManager.settings.dosingEnabled - workoutMode = dataManager.loopManager.settings.glucoseTargetRangeSchedule?.workoutModeEnabled + workoutMode = deviceManager.loopManager.settings.glucoseTargetRangeSchedule?.workoutModeEnabled - reloadGroup.notify(queue: DispatchQueue.main) { - if let glucose = self.dataManager.loopManager.glucoseStore.latestGlucose { - self.hudView.glucoseHUD.setGlucoseQuantity(glucose.quantity.doubleValue(for: self.charts.glucoseUnit), - at: glucose.startDate, - unit: self.charts.glucoseUnit, - sensor: self.dataManager.sensorInfo - ) - } + reloadGroup.notify(queue: .main) { + if let glucose = self.deviceManager.loopManager.glucoseStore.latestGlucose { + self.hudView.glucoseHUD.setGlucoseQuantity(glucose.quantity.doubleValue(for: self.charts.glucoseUnit), + at: glucose.startDate, + unit: self.charts.glucoseUnit, + sensor: self.deviceManager.sensorInfo + ) + } - // Loop completion HUD - self.hudView.loopCompletionHUD.lastLoopCompleted = newLastLoopCompleted - - // Net basal rate HUD - let date = newLastTempBasal?.startDate ?? Date() - if let scheduledBasal = self.dataManager.loopManager.basalRateSchedule?.between(start: date, - end: date).first - { - let netBasal = NetBasal( - lastTempBasal: newLastTempBasal, - maxBasal: self.dataManager.loopManager.settings.maximumBasalRatePerHour, - scheduledBasal: scheduledBasal - ) - - self.hudView.basalRateHUD.setNetBasalRate(netBasal.rate, percent: netBasal.percent, at: netBasal.startDate) - } + // Loop completion HUD + self.hudView.loopCompletionHUD.lastLoopCompleted = newLastLoopCompleted + + // Net basal rate HUD + let date = newLastTempBasal?.startDate ?? Date() + if let scheduledBasal = self.deviceManager.loopManager.basalRateSchedule?.between(start: date, + end: date).first + { + let netBasal = NetBasal( + lastTempBasal: newLastTempBasal, + maxBasal: self.deviceManager.loopManager.settings.maximumBasalRatePerHour, + scheduledBasal: scheduledBasal + ) + + self.hudView.basalRateHUD.setNetBasalRate(netBasal.rate, percent: netBasal.percent, at: netBasal.startDate) + } - // Fetch the current IOB subtitle - if let index = self.charts.iobPoints.closestIndexPriorToDate(Date()) { - self.currentIOBDescription = String(describing: self.charts.iobPoints[index].y) - } else { - self.currentIOBDescription = nil - } - // Fetch the current COB subtitle - if let index = self.charts.cobPoints.closestIndexPriorToDate(Date()) { - self.currentCOBDescription = String(describing: self.charts.cobPoints[index].y) - } else { - self.currentCOBDescription = nil - } + // Fetch the current IOB subtitle + if let index = self.charts.iobPoints.closestIndexPriorToDate(Date()) { + self.currentIOBDescription = String(describing: self.charts.iobPoints[index].y) + } else { + self.currentIOBDescription = nil + } + // Fetch the current COB subtitle + if let index = self.charts.cobPoints.closestIndexPriorToDate(Date()) { + self.currentCOBDescription = String(describing: self.charts.cobPoints[index].y) + } else { + self.currentCOBDescription = nil + } - self.charts.prerender() - - // Show/hide the recommended temp basal row - let oldRecommendedTempBasal = self.recommendedTempBasal - self.recommendedTempBasal = newRecommendedTempBasal - switch (oldRecommendedTempBasal, newRecommendedTempBasal) { - case (let old?, let new?) where old != new: - self.tableView.reloadRows(at: [IndexPath(row: 0, section: Section.status.rawValue)], with: animated ? .top : .none) - case (.none, .some): - self.tableView.insertRows(at: [IndexPath(row: 0, section: Section.status.rawValue)], with: animated ? .top : .none) - case (.some, .none): - self.tableView.deleteRows(at: [IndexPath(row: 0, section: Section.status.rawValue)], with: animated ? .top : .none) - default: - break - } + self.charts.prerender() + + // Show/hide the recommended temp basal row + let oldRecommendedTempBasal = self.recommendedTempBasal + self.recommendedTempBasal = newRecommendedTempBasal + switch (oldRecommendedTempBasal, newRecommendedTempBasal) { + case (let old?, let new?) where old != new: + self.tableView.reloadRows(at: [IndexPath(row: 0, section: Section.status.rawValue)], with: animated ? .top : .none) + case (.none, .some): + self.tableView.insertRows(at: [IndexPath(row: 0, section: Section.status.rawValue)], with: animated ? .top : .none) + case (.some, .none): + self.tableView.deleteRows(at: [IndexPath(row: 0, section: Section.status.rawValue)], with: animated ? .top : .none) + default: + break + } - for case let cell as ChartTableViewCell in self.tableView.visibleCells { - cell.reloadChart() + for case let cell as ChartTableViewCell in self.tableView.visibleCells { + cell.reloadChart() - if let indexPath = self.tableView.indexPath(for: cell) { - self.tableView(self.tableView, updateSubtitleFor: cell, at: indexPath) - } + if let indexPath = self.tableView.indexPath(for: cell) { + self.tableView(self.tableView, updateSubtitleFor: cell, at: indexPath) } - - self.reloading = false } } } @@ -432,17 +383,6 @@ final class StatusTableViewController: UITableViewController, UIGestureRecognize static let count = 4 } - private lazy var charts: StatusChartsManager = { - let charts = StatusChartsManager(colors: .default, settings: .default) - - charts.glucoseDisplayRange = ( - min: HKQuantity(unit: HKUnit.milligramsPerDeciliter(), doubleValue: 100), - max: HKQuantity(unit: HKUnit.milligramsPerDeciliter(), doubleValue: 175) - ) - - return charts - }() - // MARK: Glucose private var eventualGlucoseDescription: String? @@ -657,12 +597,12 @@ final class StatusTableViewController: UITableViewController, UIGestureRecognize if recommendedTempBasal != nil && !settingTempBasal { settingTempBasal = true - self.dataManager.loopManager.enactRecommendedTempBasal { (error) in + self.deviceManager.loopManager.enactRecommendedTempBasal { (error) in DispatchQueue.main.async { self.settingTempBasal = false if let error = error { - self.dataManager.logger.addError(error, fromSource: "TempBasal") + self.deviceManager.logger.addError(error, fromSource: "TempBasal") self.presentAlertController(with: error) } else { self.refreshContext.update(with: .status) @@ -675,35 +615,12 @@ final class StatusTableViewController: UITableViewController, UIGestureRecognize } } - // MARK: - UIGestureRecognizer - - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { - return true - } - - @objc func handlePan(_ gestureRecognizer: UIGestureRecognizer) { - switch gestureRecognizer.state { - case .possible, .changed: - // Follow your dreams! - break - case .began, .cancelled, .ended, .failed: - for case let row as ChartTableViewCell in self.tableView.visibleCells { - let forwards = gestureRecognizer.state == .began - UIView.animate(withDuration: forwards ? 0.2 : 0.5, delay: forwards ? 0 : 1, animations: { - let alpha: CGFloat = forwards ? 0 : 1 - row.titleLabel?.alpha = alpha - row.subtitleLabel?.alpha = alpha - }) - } - } - } - // MARK: - Actions override func shouldPerformSegue(withIdentifier identifier: String, sender: Any?) -> Bool { if identifier == CarbEntryEditViewController.className { - if dataManager.loopManager.carbStore.authorizationRequired { - dataManager.loopManager.carbStore.authorize { (success, error) in + if deviceManager.loopManager.carbStore.authorizationRequired { + deviceManager.loopManager.carbStore.authorize { (success, error) in if success { self.performSegue(withIdentifier: CarbEntryEditViewController.className, sender: sender) } @@ -726,43 +643,48 @@ final class StatusTableViewController: UITableViewController, UIGestureRecognize switch targetViewController { case let vc as CarbEntryTableViewController: - vc.carbStore = dataManager.loopManager.carbStore + vc.carbStore = deviceManager.loopManager.carbStore vc.hidesBottomBarWhenPushed = true case let vc as CarbEntryEditViewController: - vc.defaultAbsorptionTimes = dataManager.loopManager.carbStore.defaultAbsorptionTimes - vc.preferredUnit = dataManager.loopManager.carbStore.preferredUnit + vc.defaultAbsorptionTimes = deviceManager.loopManager.carbStore.defaultAbsorptionTimes + vc.preferredUnit = deviceManager.loopManager.carbStore.preferredUnit case let vc as InsulinDeliveryTableViewController: - vc.doseStore = dataManager.loopManager.doseStore + vc.doseStore = deviceManager.loopManager.doseStore vc.hidesBottomBarWhenPushed = true case let vc as BolusViewController: - if let maxBolus = self.dataManager.loopManager.settings.maximumBolus { - vc.maxBolus = maxBolus - } + self.deviceManager.loopManager.getLoopState { (manager, state) in + let maximumBolus = manager.settings.maximumBolus - if let recommendation = sender as? BolusRecommendation { - vc.bolusRecommendation = recommendation - } else { - self.dataManager.loopManager.getRecommendedBolus { (recommendation, error) -> Void in - if let error = error { - self.dataManager.logger.addError(error, fromSource: "Bolus") - } else if let recommendation = recommendation { - DispatchQueue.main.async { - vc.bolusRecommendation = recommendation - } + let activeInsulin = state.insulinOnBoard?.value + let activeCarbohydrates = state.carbsOnBoard?.quantity.doubleValue(for: HKUnit.gram()) + let bolusRecommendation: BolusRecommendation? + + if let recommendation = sender as? BolusRecommendation { + bolusRecommendation = recommendation + } else { + do { + bolusRecommendation = try state.recommendBolus() + } catch let error { + bolusRecommendation = nil + self.deviceManager.logger.addError(error, fromSource: "Bolus") } } - } - self.dataManager.loopManager.getLoopStatus { (_, _, _, _, _, iob, cob, _) in + DispatchQueue.main.async { + if let maxBolus = maximumBolus { + vc.maxBolus = maxBolus + } + vc.glucoseUnit = self.charts.glucoseUnit - vc.activeInsulin = iob?.value - vc.activeCarbohydrates = cob?.quantity.doubleValue(for: HKUnit.gram()) + vc.activeInsulin = activeInsulin + vc.activeCarbohydrates = activeCarbohydrates + vc.bolusRecommendation = bolusRecommendation } } case let vc as PredictionTableViewController: - vc.dataManager = dataManager + vc.deviceManager = deviceManager case let vc as SettingsTableViewController: - vc.dataManager = dataManager + vc.dataManager = deviceManager default: break } @@ -773,7 +695,7 @@ final class StatusTableViewController: UITableViewController, UIGestureRecognize /// - parameter segue: The unwind segue @IBAction func unwindFromEditing(_ segue: UIStoryboardSegue) { if let carbVC = segue.source as? CarbEntryEditViewController, let updatedEntry = carbVC.updatedCarbEntry { - dataManager.loopManager.addCarbEntryAndRecommendBolus(updatedEntry) { (result) -> Void in + deviceManager.loopManager.addCarbEntryAndRecommendBolus(updatedEntry) { (result) -> Void in DispatchQueue.main.async { switch result { case .success(let recommendation): @@ -786,7 +708,7 @@ final class StatusTableViewController: UITableViewController, UIGestureRecognize if error is CarbStore.CarbStoreError { self.presentAlertController(with: error) } else { - self.dataManager.logger.addError(error, fromSource: "Bolus") + self.deviceManager.logger.addError(error, fromSource: "Bolus") } } } @@ -798,7 +720,7 @@ final class StatusTableViewController: UITableViewController, UIGestureRecognize if let bolusViewController = segue.source as? BolusViewController { if let bolus = bolusViewController.bolus, bolus > 0 { self.bolusState = .enacting - dataManager.enactBolus(units: bolus) { (_) in + deviceManager.enactBolus(units: bolus) { (_) in self.bolusState = nil } } else { @@ -828,10 +750,10 @@ final class StatusTableViewController: UITableViewController, UIGestureRecognize @IBAction func toggleWorkoutMode(_ sender: UIBarButtonItem) { if let workoutModeEnabled = workoutMode, workoutModeEnabled { - dataManager.loopManager.disableWorkoutMode() + deviceManager.loopManager.disableWorkoutMode() } else { let vc = UIAlertController(workoutDurationSelectionHandler: { (endDate) in - self.dataManager.loopManager.enableWorkoutMode(until: endDate) + self.deviceManager.loopManager.enableWorkoutMode(until: endDate) }) present(vc, animated: true, completion: nil) @@ -849,7 +771,7 @@ final class StatusTableViewController: UITableViewController, UIGestureRecognize let glucoseTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openCGMApp(_:))) hudView.glucoseHUD.addGestureRecognizer(glucoseTapGestureRecognizer) - if dataManager.cgm?.appURL != nil { + if deviceManager.cgm?.appURL != nil { hudView.glucoseHUD.accessibilityHint = NSLocalizedString("Launches CGM app", comment: "Glucose HUD accessibility hint") } @@ -863,15 +785,15 @@ final class StatusTableViewController: UITableViewController, UIGestureRecognize } @objc private func showLastError(_: Any) { - self.dataManager.loopManager.getLoopStatus { (_, _, _, _, _, _, _, error) -> Void in - if let error = error { + self.deviceManager.loopManager.getLoopState { (_, state) in + if let error = state.error { self.presentAlertController(with: error) } } } @objc private func openCGMApp(_: Any) { - if let url = dataManager.cgm?.appURL, UIApplication.shared.canOpenURL(url) { + if let url = deviceManager.cgm?.appURL, UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } } diff --git a/LoopUI/Managers/StatusChartsManager.swift b/LoopUI/Managers/StatusChartsManager.swift index 658ebb17f0..3f2325b9c6 100644 --- a/LoopUI/Managers/StatusChartsManager.swift +++ b/LoopUI/Managers/StatusChartsManager.swift @@ -338,7 +338,8 @@ public final class StatusChartsManager { targetOverrideDurationLayer = ChartPointsAreaLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, chartPoints: targetOverrideDurationPoints, areaColor: colors.glucoseTint.withAlphaComponent(0.3), animDuration: 0, animDelay: 0, addContainerPoints: false) } - let gridLayer = ChartGuideLinesForValuesLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, settings: guideLinesLayerSettings, axisValuesX: Array(xAxisValues.dropLast(1)), axisValuesY: yAxisValues) + // Grid lines + let gridLayer = ChartGuideLinesForValuesLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, settings: guideLinesLayerSettings, axisValuesX: Array(xAxisValues.dropFirst().dropLast()), axisValuesY: yAxisValues) let circles = ChartPointsScatterCirclesLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, chartPoints: glucosePoints, displayDelay: 0, itemSize: CGSize(width: 4, height: 4), itemFillColor: colors.glucoseTint, optimized: true) @@ -445,7 +446,7 @@ public final class StatusChartsManager { } // Grid lines - let gridLayer = ChartGuideLinesForValuesLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, settings: guideLinesLayerSettings, axisValuesX: Array(xAxisValues.dropLast(1)), axisValuesY: yAxisValues) + let gridLayer = ChartGuideLinesForValuesLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, settings: guideLinesLayerSettings, axisValuesX: Array(xAxisValues.dropFirst().dropLast()), axisValuesY: yAxisValues) // 0-line let dummyZeroChartPoint = ChartPoint(x: ChartAxisValueDouble(0), y: ChartAxisValueDouble(0)) @@ -531,7 +532,7 @@ public final class StatusChartsManager { } // Grid lines - let gridLayer = ChartGuideLinesForValuesLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, settings: guideLinesLayerSettings, axisValuesX: Array(xAxisValues.dropLast(1)), axisValuesY: yAxisValues) + let gridLayer = ChartGuideLinesForValuesLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, settings: guideLinesLayerSettings, axisValuesX: Array(xAxisValues.dropFirst().dropLast()), axisValuesY: yAxisValues) if gestureRecognizer != nil { cobChartCache = ChartPointsTouchHighlightLayerViewCache( @@ -604,7 +605,7 @@ public final class StatusChartsManager { } // Grid lines - let gridLayer = ChartGuideLinesForValuesLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, settings: guideLinesLayerSettings, axisValuesX: Array(xAxisValues.dropLast(1)), axisValuesY: yAxisValues) + let gridLayer = ChartGuideLinesForValuesLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, settings: guideLinesLayerSettings, axisValuesX: Array(xAxisValues.dropFirst().dropLast()), axisValuesY: yAxisValues) // 0-line let dummyZeroChartPoint = ChartPoint(x: ChartAxisValueDouble(0), y: ChartAxisValueDouble(0)) @@ -657,9 +658,11 @@ public final class StatusChartsManager { ) ] + let segments = ceil(endDate.timeIntervalSince(startDate).hours) + let xAxisValues = ChartAxisValuesStaticGenerator.generateXAxisValuesWithChartPoints(points, - minSegmentCount: 4, - maxSegmentCount: 10, + minSegmentCount: segments - 1, + maxSegmentCount: segments + 1, multiple: TimeInterval(hours: 1), axisValueGenerator: { ChartAxisValueDate( diff --git a/LoopUI/Models/ChartAxisValueDoubleUnit.swift b/LoopUI/Models/ChartAxisValueDoubleUnit.swift index cb612d87ee..1369391618 100644 --- a/LoopUI/Models/ChartAxisValueDoubleUnit.swift +++ b/LoopUI/Models/ChartAxisValueDoubleUnit.swift @@ -26,6 +26,6 @@ public final class ChartAxisValueDoubleUnit: ChartAxisValueDouble { } override public var description: String { - return "\(super.description) \(unitString)" + return formatter.string(from: scalar, unit: unitString) ?? "" } }