diff --git a/WatchApp Extension/Extensions/Comparable.swift b/Common/Extensions/Comparable.swift similarity index 95% rename from WatchApp Extension/Extensions/Comparable.swift rename to Common/Extensions/Comparable.swift index aae6846520..84c1642424 100644 --- a/WatchApp Extension/Extensions/Comparable.swift +++ b/Common/Extensions/Comparable.swift @@ -1,6 +1,6 @@ // // Comparable.swift -// WatchApp Extension +// Loop // // Created by Michael Pangburn on 3/27/20. // Copyright © 2020 LoopKit Authors. All rights reserved. diff --git a/Common/Extensions/NSBundle.swift b/Common/Extensions/NSBundle.swift index 57b7d6ad88..0e6dd493b7 100644 --- a/Common/Extensions/NSBundle.swift +++ b/Common/Extensions/NSBundle.swift @@ -60,5 +60,27 @@ extension Bundle { } return .days(localCacheDurationDays) } + + var hostIdentifier: String { + var identifier = bundleIdentifier ?? "com.loopkit.Loop" + let components = identifier.components(separatedBy: ".") + // DIY Loop has bundle identifiers like com.UY653SP37Q.loopkit.Loop + if components[2] == "loopkit" && components[3] == "Loop" { + identifier = "com.loopkit.Loop" + } + return identifier + } + + var hostVersion: String { + var semanticVersion = shortVersionString + + while semanticVersion.split(separator: ".").count < 3 { + semanticVersion += ".0" + } + + semanticVersion += "+\(Bundle.main.version)" + + return semanticVersion + } } diff --git a/Common/Extensions/SampleValue.swift b/Common/Extensions/SampleValue.swift index 39dcb16e9c..dd9c901ecf 100644 --- a/Common/Extensions/SampleValue.swift +++ b/Common/Extensions/SampleValue.swift @@ -7,7 +7,7 @@ import HealthKit import LoopKit - +import LoopAlgorithm extension Collection where Element == SampleValue { /// O(n) diff --git a/Common/FeatureFlags.swift b/Common/FeatureFlags.swift index 0c6440b564..44d8a84e4b 100644 --- a/Common/FeatureFlags.swift +++ b/Common/FeatureFlags.swift @@ -39,7 +39,7 @@ struct FeatureFlagConfiguration: Decodable { let profileExpirationSettingsViewEnabled: Bool let missedMealNotifications: Bool let allowAlgorithmExperiments: Bool - + let isInvestigationalDevice: Bool fileprivate init() { // Swift compiler config is inverse, since the default state is enabled. @@ -232,6 +232,12 @@ struct FeatureFlagConfiguration: Decodable { #else self.allowAlgorithmExperiments = false #endif + + #if INVESTIGATIONAL_DEVICE + self.isInvestigationalDevice = true + #else + self.isInvestigationalDevice = false + #endif } } @@ -267,7 +273,8 @@ extension FeatureFlagConfiguration : CustomDebugStringConvertible { "* profileExpirationSettingsViewEnabled: \(profileExpirationSettingsViewEnabled)", "* missedMealNotifications: \(missedMealNotifications)", "* allowAlgorithmExperiments: \(allowAlgorithmExperiments)", - "* allowExperimentalFeatures: \(allowExperimentalFeatures)" + "* allowExperimentalFeatures: \(allowExperimentalFeatures)", + "* isInvestigationalDevice: \(isInvestigationalDevice)" ].joined(separator: "\n") } } diff --git a/Common/Models/LoopSettingsUserInfo.swift b/Common/Models/LoopSettingsUserInfo.swift index a6123825d8..bf95b076b4 100644 --- a/Common/Models/LoopSettingsUserInfo.swift +++ b/Common/Models/LoopSettingsUserInfo.swift @@ -6,10 +6,67 @@ // import LoopCore +import LoopKit +struct LoopSettingsUserInfo: Equatable { + var loopSettings: LoopSettings + var scheduleOverride: TemporaryScheduleOverride? + var preMealOverride: TemporaryScheduleOverride? + + public mutating func enablePreMealOverride(at date: Date = Date(), for duration: TimeInterval) { + preMealOverride = makePreMealOverride(beginningAt: date, for: duration) + } + + private func makePreMealOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { + guard let preMealTargetRange = loopSettings.preMealTargetRange else { + return nil + } + return TemporaryScheduleOverride( + context: .preMeal, + settings: TemporaryScheduleOverrideSettings(targetRange: preMealTargetRange), + startDate: date, + duration: .finite(duration), + enactTrigger: .local, + syncIdentifier: UUID() + ) + } + + public mutating func clearOverride(matching context: TemporaryScheduleOverride.Context? = nil) { + if context == .preMeal { + preMealOverride = nil + return + } + + guard let scheduleOverride = scheduleOverride else { return } + + if let context = context { + if scheduleOverride.context == context { + self.scheduleOverride = nil + } + } else { + self.scheduleOverride = nil + } + } + + public func nonPreMealOverrideEnabled(at date: Date = Date()) -> Bool { + return scheduleOverride?.isActive(at: date) == true + } + + public mutating func legacyWorkoutOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { + guard let legacyWorkoutTargetRange = loopSettings.legacyWorkoutTargetRange else { + return nil + } + + return TemporaryScheduleOverride( + context: .legacyWorkout, + settings: TemporaryScheduleOverrideSettings(targetRange: legacyWorkoutTargetRange), + startDate: date, + duration: duration.isInfinite ? .indefinite : .finite(duration), + enactTrigger: .local, + syncIdentifier: UUID() + ) + } -struct LoopSettingsUserInfo { - let settings: LoopSettings } @@ -23,19 +80,36 @@ extension LoopSettingsUserInfo: RawRepresentable { guard rawValue["v"] as? Int == LoopSettingsUserInfo.version, rawValue["name"] as? String == LoopSettingsUserInfo.name, let settingsRaw = rawValue["s"] as? LoopSettings.RawValue, - let settings = LoopSettings(rawValue: settingsRaw) + let loopSettings = LoopSettings(rawValue: settingsRaw) else { return nil } - self.settings = settings + self.loopSettings = loopSettings + + if let rawScheduleOverride = rawValue["o"] as? TemporaryScheduleOverride.RawValue { + self.scheduleOverride = TemporaryScheduleOverride(rawValue: rawScheduleOverride) + } else { + self.scheduleOverride = nil + } + + if let rawPreMealOverride = rawValue["p"] as? TemporaryScheduleOverride.RawValue { + self.preMealOverride = TemporaryScheduleOverride(rawValue: rawPreMealOverride) + } else { + self.preMealOverride = nil + } } var rawValue: RawValue { - return [ + var raw: RawValue = [ "v": LoopSettingsUserInfo.version, "name": LoopSettingsUserInfo.name, - "s": settings.rawValue + "s": loopSettings.rawValue ] + + raw["o"] = scheduleOverride?.rawValue + raw["p"] = preMealOverride?.rawValue + + return raw } } diff --git a/Common/Models/PumpManager.swift b/Common/Models/PumpManager.swift index 5ec574366c..d1a82fa2f4 100644 --- a/Common/Models/PumpManager.swift +++ b/Common/Models/PumpManager.swift @@ -12,13 +12,13 @@ import MockKit import MockKitUI let staticPumpManagersByIdentifier: [String: PumpManagerUI.Type] = [ - MockPumpManager.pluginIdentifier : MockPumpManager.self + MockPumpManager.managerIdentifier : MockPumpManager.self ] var availableStaticPumpManagers: [PumpManagerDescriptor] { if FeatureFlags.allowSimulators { return [ - PumpManagerDescriptor(identifier: MockPumpManager.pluginIdentifier, localizedTitle: MockPumpManager.localizedTitle) + PumpManagerDescriptor(identifier: MockPumpManager.managerIdentifier, localizedTitle: MockPumpManager.localizedTitle) ] } else { return [] diff --git a/Common/Models/StatusExtensionContext.swift b/Common/Models/StatusExtensionContext.swift index bee1f32894..cf486fd1a8 100644 --- a/Common/Models/StatusExtensionContext.swift +++ b/Common/Models/StatusExtensionContext.swift @@ -11,6 +11,7 @@ import Foundation import HealthKit import LoopKit import LoopKitUI +import LoopAlgorithm struct NetBasalContext { @@ -295,6 +296,8 @@ struct StatusExtensionContext: RawRepresentable { var predictedGlucose: PredictedGlucoseContext? var lastLoopCompleted: Date? + var mostRecentGlucoseDataDate: Date? + var mostRecentPumpDataDate: Date? var createdAt: Date? var isClosedLoop: Bool? var preMealPresetAllowed: Bool? @@ -327,6 +330,8 @@ struct StatusExtensionContext: RawRepresentable { } lastLoopCompleted = rawValue["lastLoopCompleted"] as? Date + mostRecentGlucoseDataDate = rawValue["mostRecentGlucoseDataDate"] as? Date + mostRecentPumpDataDate = rawValue["mostRecentPumpDataDate"] as? Date createdAt = rawValue["createdAt"] as? Date isClosedLoop = rawValue["isClosedLoop"] as? Bool preMealPresetAllowed = rawValue["preMealPresetAllowed"] as? Bool @@ -368,6 +373,8 @@ struct StatusExtensionContext: RawRepresentable { raw["predictedGlucose"] = predictedGlucose?.rawValue raw["lastLoopCompleted"] = lastLoopCompleted + raw["mostRecentGlucoseDataDate"] = mostRecentGlucoseDataDate + raw["mostRecentPumpDataDate"] = mostRecentPumpDataDate raw["createdAt"] = createdAt raw["isClosedLoop"] = isClosedLoop raw["preMealPresetAllowed"] = preMealPresetAllowed diff --git a/Common/Models/WatchContext.swift b/Common/Models/WatchContext.swift index 3ce3adebf1..6d4e7a23a0 100644 --- a/Common/Models/WatchContext.swift +++ b/Common/Models/WatchContext.swift @@ -9,6 +9,7 @@ import Foundation import HealthKit import LoopKit +import LoopAlgorithm final class WatchContext: RawRepresentable { diff --git a/Common/Models/WatchHistoricalGlucose.swift b/Common/Models/WatchHistoricalGlucose.swift index 13fda34816..3b166170a9 100644 --- a/Common/Models/WatchHistoricalGlucose.swift +++ b/Common/Models/WatchHistoricalGlucose.swift @@ -9,6 +9,7 @@ import Foundation import HealthKit import LoopKit +import LoopAlgorithm struct WatchHistoricalGlucose { let samples: [StoredGlucoseSample] diff --git a/Common/Models/WatchPredictedGlucose.swift b/Common/Models/WatchPredictedGlucose.swift index 080a824074..d5978eb6ed 100644 --- a/Common/Models/WatchPredictedGlucose.swift +++ b/Common/Models/WatchPredictedGlucose.swift @@ -9,6 +9,7 @@ import Foundation import LoopKit import HealthKit +import LoopAlgorithm struct WatchPredictedGlucose: Equatable { @@ -29,7 +30,7 @@ extension WatchPredictedGlucose: RawRepresentable { var rawValue: RawValue { return [ - "v": values.map { Int16($0.quantity.doubleValue(for: .milligramsPerDeciliter)) }, + "v": values.map { Int16($0.quantity.doubleValue(for: .milligramsPerDeciliter).clamped(to: Double(Int16.min)...Double(Int16.max))) }, "d": values[0].startDate, "i": values[1].startDate.timeIntervalSince(values[0].startDate) ] diff --git a/Learn/Managers/DataManager.swift b/Learn/Managers/DataManager.swift index 80e958a02f..3929c42bac 100644 --- a/Learn/Managers/DataManager.swift +++ b/Learn/Managers/DataManager.swift @@ -47,7 +47,6 @@ final class DataManager { healthStore: healthStore, cacheStore: cacheStore, observationEnabled: false, - insulinModelProvider: PresetInsulinModelProvider(defaultRapidActingModel: defaultRapidActingModel), longestEffectDuration: ExponentialInsulinModelPreset.rapidActingAdult.effectDuration, basalProfile: basalRateSchedule, insulinSensitivitySchedule: insulinSensitivitySchedule, diff --git a/Loop Status Extension/Base.lproj/InfoPlist.strings b/Loop Status Extension/Base.lproj/InfoPlist.strings deleted file mode 100644 index f8e9a2b43f..0000000000 --- a/Loop Status Extension/Base.lproj/InfoPlist.strings +++ /dev/null @@ -1,3 +0,0 @@ -/* (No Comment) */ -"CFBundleName" = "$(PRODUCT_NAME)"; - diff --git a/Loop Status Extension/Base.lproj/Localizable.strings b/Loop Status Extension/Base.lproj/Localizable.strings deleted file mode 100644 index d21551845d..0000000000 --- a/Loop Status Extension/Base.lproj/Localizable.strings +++ /dev/null @@ -1,5 +0,0 @@ -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Eventually %1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ U"; diff --git a/Loop Status Extension/Base.lproj/MainInterface.storyboard b/Loop Status Extension/Base.lproj/MainInterface.storyboard deleted file mode 100644 index 78d5e1c465..0000000000 --- a/Loop Status Extension/Base.lproj/MainInterface.storyboard +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Loop Status Extension/Info.plist b/Loop Status Extension/Info.plist deleted file mode 100644 index 98c5c3e989..0000000000 --- a/Loop Status Extension/Info.plist +++ /dev/null @@ -1,35 +0,0 @@ - - - - - AppGroupIdentifier - $(APP_GROUP_IDENTIFIER) - CFBundleDevelopmentRegion - en - CFBundleDisplayName - $(MAIN_APP_DISPLAY_NAME) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - XPC! - CFBundleShortVersionString - $(LOOP_MARKETING_VERSION) - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - MainAppBundleIdentifier - $(MAIN_APP_BUNDLE_IDENTIFIER) - NSExtension - - NSExtensionMainStoryboard - MainInterface - NSExtensionPointIdentifier - com.apple.widget-extension - - - diff --git a/Loop Status Extension/Loop Status Extension.entitlements b/Loop Status Extension/Loop Status Extension.entitlements deleted file mode 100644 index d9849a816d..0000000000 --- a/Loop Status Extension/Loop Status Extension.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.application-groups - - $(APP_GROUP_IDENTIFIER) - - - diff --git a/Loop Status Extension/StateColorPalette.swift b/Loop Status Extension/StateColorPalette.swift deleted file mode 100644 index e6f18b436a..0000000000 --- a/Loop Status Extension/StateColorPalette.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// StateColorPalette.swift -// Loop -// -// Copyright © 2017 LoopKit Authors. All rights reserved. -// - -import LoopUI -import LoopKitUI - -extension StateColorPalette { - static let loopStatus = StateColorPalette(unknown: .unknownColor, normal: .freshColor, warning: .agingColor, error: .staleColor) - - static let cgmStatus = loopStatus - - static let pumpStatus = StateColorPalette(unknown: .unknownColor, normal: .pumpStatusNormal, warning: .agingColor, error: .staleColor) -} diff --git a/Loop Status Extension/StatusChartsManager.swift b/Loop Status Extension/StatusChartsManager.swift deleted file mode 100644 index c75041e52f..0000000000 --- a/Loop Status Extension/StatusChartsManager.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// StatusChartsManager.swift -// Loop Status Extension -// -// Copyright © 2019 LoopKit Authors. All rights reserved. -// - -import Foundation -import LoopUI -import LoopKitUI -import SwiftCharts -import UIKit - -class StatusChartsManager: ChartsManager { - let predictedGlucose = PredictedGlucoseChart(predictedGlucoseBounds: FeatureFlags.predictedGlucoseChartClampEnabled ? .default : nil, - yAxisStepSizeMGDLOverride: FeatureFlags.predictedGlucoseChartClampEnabled ? 40 : nil) - - init(colors: ChartColorPalette, settings: ChartSettings, traitCollection: UITraitCollection) { - super.init(colors: colors, settings: settings, charts: [predictedGlucose], traitCollection: traitCollection) - } -} diff --git a/Loop Status Extension/StatusViewController.swift b/Loop Status Extension/StatusViewController.swift deleted file mode 100644 index e3c57a98d9..0000000000 --- a/Loop Status Extension/StatusViewController.swift +++ /dev/null @@ -1,330 +0,0 @@ -// -// StatusViewController.swift -// Loop Status Extension -// -// Created by Bharat Mediratta on 11/25/16. -// Copyright © 2016 LoopKit Authors. All rights reserved. -// - -import CoreData -import HealthKit -import LoopKit -import LoopKitUI -import LoopCore -import LoopUI -import NotificationCenter -import UIKit -import SwiftCharts - -class StatusViewController: UIViewController, NCWidgetProviding { - - @IBOutlet weak var hudView: StatusBarHUDView! { - didSet { - hudView.loopCompletionHUD.stateColors = .loopStatus - hudView.cgmStatusHUD.stateColors = .cgmStatus - hudView.cgmStatusHUD.tintColor = .label - hudView.pumpStatusHUD.tintColor = .insulinTintColor - hudView.backgroundColor = .clear - - // given the reduced width of the widget, allow for tighter spacing - hudView.containerView.spacing = 6.0 - } - } - @IBOutlet weak var activeCarbsTitleLabel: UILabel! - @IBOutlet weak var activeCarbsAmountLabel: UILabel! - @IBOutlet weak var activeInsulinTitleLabel: UILabel! - @IBOutlet weak var activeInsulinAmountLabel: UILabel! - @IBOutlet weak var glucoseChartContentView: LoopKitUI.ChartContainerView! - - private lazy var charts: StatusChartsManager = { - let charts = StatusChartsManager( - colors: ChartColorPalette( - axisLine: .axisLineColor, - axisLabel: .axisLabelColor, - grid: .gridColor, - glucoseTint: .glucoseTintColor, - insulinTint: .insulinTintColor, - carbTint: .carbTintColor - ), - settings: { - var settings = ChartSettings() - settings.top = 8 - settings.bottom = 8 - settings.trailing = 8 - settings.axisTitleLabelsToLabelsSpacing = 0 - settings.labelsToAxisSpacingX = 6 - settings.clipInnerFrame = false - return settings - }(), - traitCollection: traitCollection - ) - - if FeatureFlags.predictedGlucoseChartClampEnabled { - charts.predictedGlucose.glucoseDisplayRange = ChartConstants.glucoseChartDefaultDisplayBoundClamped - } else { - charts.predictedGlucose.glucoseDisplayRange = ChartConstants.glucoseChartDefaultDisplayBound - } - - return charts - }() - - var statusExtensionContext: StatusExtensionContext? - - lazy var defaults = UserDefaults.appGroup - - private var observers: [Any] = [] - - lazy var healthStore = HKHealthStore() - - lazy var cacheStore = PersistenceController.controllerInAppGroupDirectory() - - lazy var localCacheDuration = Bundle.main.localCacheDuration - - lazy var settingsStore: SettingsStore = SettingsStore( - store: cacheStore, - expireAfter: localCacheDuration) - - lazy var glucoseStore = GlucoseStore( - cacheStore: cacheStore, - provenanceIdentifier: HKSource.default().bundleIdentifier - ) - - lazy var doseStore = DoseStore( - cacheStore: cacheStore, - insulinModelProvider: PresetInsulinModelProvider(defaultRapidActingModel: settingsStore.latestSettings?.defaultRapidActingModel?.presetForRapidActingInsulin), - longestEffectDuration: ExponentialInsulinModelPreset.rapidActingAdult.effectDuration, - basalProfile: settingsStore.latestSettings?.basalRateSchedule, - insulinSensitivitySchedule: settingsStore.latestSettings?.insulinSensitivitySchedule, - provenanceIdentifier: HKSource.default().bundleIdentifier - ) - - private var pluginManager: PluginManager = { - let containingAppFrameworksURL = Bundle.main.privateFrameworksURL?.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Frameworks") - return PluginManager(pluginsURL: containingAppFrameworksURL) - }() - - override func viewDidLoad() { - super.viewDidLoad() - - activeCarbsTitleLabel.text = NSLocalizedString("Active Carbs", comment: "Widget label title describing the active carbs") - activeInsulinTitleLabel.text = NSLocalizedString("Active Insulin", comment: "Widget label title describing the active insulin") - activeCarbsTitleLabel.textColor = .secondaryLabel - activeCarbsAmountLabel.textColor = .label - activeInsulinTitleLabel.textColor = .secondaryLabel - activeInsulinAmountLabel.textColor = .label - - let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openLoopApp(_:))) - view.addGestureRecognizer(tapGestureRecognizer) - - self.charts.prerender() - glucoseChartContentView.chartGenerator = { [weak self] (frame) in - return self?.charts.chart(atIndex: 0, frame: frame)?.view - } - - extensionContext?.widgetLargestAvailableDisplayMode = .expanded - - switch extensionContext?.widgetActiveDisplayMode ?? .compact { - case .expanded: - glucoseChartContentView.isHidden = false - case .compact: - fallthrough - @unknown default: - glucoseChartContentView.isHidden = true - } - - observers = [ - // TODO: Observe cross-process notifications of Loop status updating - ] - } - - deinit { - for observer in observers { - NotificationCenter.default.removeObserver(observer) - } - } - - func widgetActiveDisplayModeDidChange(_ activeDisplayMode: NCWidgetDisplayMode, withMaximumSize maxSize: CGSize) { - let compactHeight = hudView.systemLayoutSizeFitting(maxSize).height + activeCarbsTitleLabel.systemLayoutSizeFitting(maxSize).height - - switch activeDisplayMode { - case .expanded: - preferredContentSize = CGSize(width: maxSize.width, height: compactHeight + 135) - case .compact: - fallthrough - @unknown default: - preferredContentSize = CGSize(width: maxSize.width, height: compactHeight) - } - } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - super.viewWillTransition(to: size, with: coordinator) - - coordinator.animate(alongsideTransition: { - (UIViewControllerTransitionCoordinatorContext) -> Void in - self.glucoseChartContentView.isHidden = self.extensionContext?.widgetActiveDisplayMode != .expanded - }) - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - charts.traitCollection = traitCollection - } - - @objc private func openLoopApp(_: Any) { - if let url = Bundle.main.mainAppUrl { - self.extensionContext?.open(url) - } - } - - func widgetPerformUpdate(completionHandler: (@escaping (NCUpdateResult) -> Void)) { - let result = update() - completionHandler(result) - } - - @discardableResult - func update() -> NCUpdateResult { - let group = DispatchGroup() - - var activeInsulin: Double? - let carbUnit = HKUnit.gram() - var glucose: [StoredGlucoseSample] = [] - - group.enter() - doseStore.insulinOnBoard(at: Date()) { (result) in - switch result { - case .success(let iobValue): - activeInsulin = iobValue.value - case .failure: - activeInsulin = nil - } - group.leave() - } - - charts.startDate = Calendar.current.nextDate(after: Date(timeIntervalSinceNow: .minutes(-5)), matching: DateComponents(minute: 0), matchingPolicy: .strict, direction: .backward) ?? Date() - - // Showing the whole history plus full prediction in the glucose plot - // is a little crowded, so limit it to three hours in the future: - charts.maxEndDate = charts.startDate.addingTimeInterval(TimeInterval(hours: 3)) - - group.enter() - glucoseStore.getGlucoseSamples(start: charts.startDate) { (result) in - switch result { - case .failure: - glucose = [] - case .success(let samples): - glucose = samples - } - group.leave() - } - - group.notify(queue: .main) { - guard let defaults = self.defaults, let context = defaults.statusExtensionContext else { - return - } - - // Pump Status - let pumpManagerHUDView: BaseHUDView - if let hudViewContext = context.pumpManagerHUDViewContext, - let contextHUDView = PumpManagerHUDViewFromRawValue(hudViewContext.pumpManagerHUDViewRawValue, pluginManager: self.pluginManager) - { - pumpManagerHUDView = contextHUDView - } else { - pumpManagerHUDView = ReservoirVolumeHUDView.instantiate() - } - pumpManagerHUDView.stateColors = .pumpStatus - self.hudView.removePumpManagerProvidedView() - self.hudView.addPumpManagerProvidedHUDView(pumpManagerHUDView) - - if let netBasal = context.netBasal { - self.hudView.pumpStatusHUD.basalRateHUD.setNetBasalRate(netBasal.rate, percent: netBasal.percentage, at: netBasal.start) - } - - if let lastCompleted = context.lastLoopCompleted { - self.hudView.loopCompletionHUD.lastLoopCompleted = lastCompleted - } - - if let isClosedLoop = context.isClosedLoop { - self.hudView.loopCompletionHUD.loopIconClosed = isClosedLoop - } - - let insulinFormatter: NumberFormatter = { - let numberFormatter = NumberFormatter() - - numberFormatter.numberStyle = .decimal - numberFormatter.minimumFractionDigits = 2 - numberFormatter.maximumFractionDigits = 2 - - return numberFormatter - }() - - if let activeInsulin = activeInsulin, - let valueStr = insulinFormatter.string(from: activeInsulin) - { - self.activeInsulinAmountLabel.text = String(format: NSLocalizedString("%1$@ U", comment: "The subtitle format describing units of active insulin. (1: localized insulin value description)"), valueStr) - } else { - self.activeInsulinAmountLabel.text = NSLocalizedString("? U", comment: "Displayed in the widget when the amount of active insulin cannot be determined.") - } - - self.hudView.pumpStatusHUD.presentStatusHighlight(context.pumpStatusHighlightContext) - self.hudView.pumpStatusHUD.lifecycleProgress = context.pumpLifecycleProgressContext - - // Active carbs - let carbsFormatter = QuantityFormatter(for: carbUnit) - - if let carbsOnBoard = context.carbsOnBoard, - let activeCarbsNumberString = carbsFormatter.string(from: HKQuantity(unit: carbUnit, doubleValue: carbsOnBoard)) - { - self.activeCarbsAmountLabel.text = String(format: NSLocalizedString("%1$@", comment: "The subtitle format describing the grams of active carbs. (1: localized carb value description)"), activeCarbsNumberString) - } else { - self.activeCarbsAmountLabel.text = NSLocalizedString("? g", comment: "Displayed in the widget when the amount of active carbs cannot be determined.") - } - - // CGM Status - self.hudView.cgmStatusHUD.presentStatusHighlight(context.cgmStatusHighlightContext) - self.hudView.cgmStatusHUD.lifecycleProgress = context.cgmLifecycleProgressContext - - guard let unit = context.predictedGlucose?.unit else { - return - } - - if let lastGlucose = glucose.last { - self.hudView.cgmStatusHUD.setGlucoseQuantity( - lastGlucose.quantity.doubleValue(for: unit), - at: lastGlucose.startDate, - unit: unit, - staleGlucoseAge: LoopCoreConstants.inputDataRecencyInterval, - glucoseDisplay: context.glucoseDisplay, - wasUserEntered: lastGlucose.wasUserEntered, - isDisplayOnly: lastGlucose.isDisplayOnly - ) - } - - // Charts - self.charts.predictedGlucose.glucoseUnit = unit - self.charts.predictedGlucose.setGlucoseValues(glucose) - - if let predictedGlucose = context.predictedGlucose?.samples, context.isClosedLoop == true { - self.charts.predictedGlucose.setPredictedGlucoseValues(predictedGlucose) - } else { - self.charts.predictedGlucose.setPredictedGlucoseValues([]) - } - - self.charts.predictedGlucose.targetGlucoseSchedule = self.settingsStore.latestSettings?.glucoseTargetRangeSchedule - self.charts.invalidateChart(atIndex: 0) - self.charts.prerender() - self.glucoseChartContentView.reloadChart() - } - - switch extensionContext?.widgetActiveDisplayMode ?? .compact { - case .expanded: - glucoseChartContentView.isHidden = false - case .compact: - fallthrough - @unknown default: - glucoseChartContentView.isHidden = true - } - - // Right now we always act as if there's new data. - // TODO: keep track of data changes and return .noData if necessary - return NCUpdateResult.newData - } -} diff --git a/Loop Status Extension/ar.lproj/InfoPlist.strings b/Loop Status Extension/ar.lproj/InfoPlist.strings deleted file mode 100644 index 034a1e1f6a..0000000000 --- a/Loop Status Extension/ar.lproj/InfoPlist.strings +++ /dev/null @@ -1,3 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - diff --git a/Loop Status Extension/ar.lproj/Localizable.strings b/Loop Status Extension/ar.lproj/Localizable.strings deleted file mode 100644 index 5935bf3282..0000000000 --- a/Loop Status Extension/ar.lproj/Localizable.strings +++ /dev/null @@ -1,33 +0,0 @@ -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "كارب النشط"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "أنسولين نشط"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "متوقع %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "أنسولين نشط %1$@ وحدة"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "وحدة"; - diff --git a/Loop Status Extension/ar.lproj/MainInterface.strings b/Loop Status Extension/ar.lproj/MainInterface.strings deleted file mode 100644 index 23ec628122..0000000000 --- a/Loop Status Extension/ar.lproj/MainInterface.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "كارب النشط"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "أنسولين نشط"; - diff --git a/Loop Status Extension/da.lproj/InfoPlist.strings b/Loop Status Extension/da.lproj/InfoPlist.strings deleted file mode 100644 index ffe563a634..0000000000 --- a/Loop Status Extension/da.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Loop-statusudvidelse"; - diff --git a/Loop Status Extension/da.lproj/Localizable.strings b/Loop Status Extension/da.lproj/Localizable.strings deleted file mode 100644 index 4388492489..0000000000 --- a/Loop Status Extension/da.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? g"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? E"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ E"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Aktive kulhydrater"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Aktivt insulin"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Med tiden %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ E"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "E"; - diff --git a/Loop Status Extension/da.lproj/MainInterface.strings b/Loop Status Extension/da.lproj/MainInterface.strings deleted file mode 100644 index ca088fa3ce..0000000000 --- a/Loop Status Extension/da.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Aktive kulhydrater"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 g"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Aktivt insulin"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 E"; - diff --git a/Loop Status Extension/de.lproj/InfoPlist.strings b/Loop Status Extension/de.lproj/InfoPlist.strings deleted file mode 100644 index 8a7abf7ee4..0000000000 --- a/Loop Status Extension/de.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Loop Status-Erweiterung"; - diff --git a/Loop Status Extension/de.lproj/Localizable.strings b/Loop Status Extension/de.lproj/Localizable.strings deleted file mode 100644 index 196ef74140..0000000000 --- a/Loop Status Extension/de.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? g"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? IE"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ IE"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Aktive KH"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Aktives Insulin"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Voraussichtlich %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ IE"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "IE"; - diff --git a/Loop Status Extension/de.lproj/MainInterface.strings b/Loop Status Extension/de.lproj/MainInterface.strings deleted file mode 100644 index fb0ae387e6..0000000000 --- a/Loop Status Extension/de.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Aktive KH"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 g"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Aktives Insulin"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 IE"; - diff --git a/Loop Status Extension/en.lproj/Localizable.strings b/Loop Status Extension/en.lproj/Localizable.strings deleted file mode 100644 index d21551845d..0000000000 --- a/Loop Status Extension/en.lproj/Localizable.strings +++ /dev/null @@ -1,5 +0,0 @@ -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Eventually %1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ U"; diff --git a/Loop Status Extension/en.lproj/MainInterface.strings b/Loop Status Extension/en.lproj/MainInterface.strings deleted file mode 100644 index 3a52b2e5e2..0000000000 --- a/Loop Status Extension/en.lproj/MainInterface.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Eventually 92 mg/dL"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "IOB 1.0 U"; - diff --git a/Loop Status Extension/es.lproj/InfoPlist.strings b/Loop Status Extension/es.lproj/InfoPlist.strings deleted file mode 100644 index 029eaa2d2a..0000000000 --- a/Loop Status Extension/es.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Extensión de Estado de Loop"; - diff --git a/Loop Status Extension/es.lproj/Localizable.strings b/Loop Status Extension/es.lproj/Localizable.strings deleted file mode 100644 index a893db7399..0000000000 --- a/Loop Status Extension/es.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? gr"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? U"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ U"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Carbohidratos Activos"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Insulina activa"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Eventualmente %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ U"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "U"; - diff --git a/Loop Status Extension/es.lproj/MainInterface.strings b/Loop Status Extension/es.lproj/MainInterface.strings deleted file mode 100644 index 5354b0e9c3..0000000000 --- a/Loop Status Extension/es.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Carbohidratos Activos"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 gr"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Insulina activa"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 U"; - diff --git a/Loop Status Extension/fi.lproj/InfoPlist.strings b/Loop Status Extension/fi.lproj/InfoPlist.strings deleted file mode 100644 index 1565e025fa..0000000000 --- a/Loop Status Extension/fi.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Loop Status Extension"; - diff --git a/Loop Status Extension/fi.lproj/Localizable.strings b/Loop Status Extension/fi.lproj/Localizable.strings deleted file mode 100644 index af5d51baf2..0000000000 --- a/Loop Status Extension/fi.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? g"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? U"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ U"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Akt. hiilari"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Akt. insuliini"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Ennuste %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ U"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "U"; - diff --git a/Loop Status Extension/fi.lproj/MainInterface.strings b/Loop Status Extension/fi.lproj/MainInterface.strings deleted file mode 100644 index a1e847d468..0000000000 --- a/Loop Status Extension/fi.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Akt. hiilari"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 g"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Akt. insuliini"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 U"; - diff --git a/Loop Status Extension/fr.lproj/InfoPlist.strings b/Loop Status Extension/fr.lproj/InfoPlist.strings deleted file mode 100644 index 1565e025fa..0000000000 --- a/Loop Status Extension/fr.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Loop Status Extension"; - diff --git a/Loop Status Extension/fr.lproj/Localizable.strings b/Loop Status Extension/fr.lproj/Localizable.strings deleted file mode 100644 index 1c6e8dfb18..0000000000 --- a/Loop Status Extension/fr.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? g"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? U"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ U"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Glucides actifs"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Insuline active"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Finalement %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ U"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "U"; - diff --git a/Loop Status Extension/fr.lproj/MainInterface.strings b/Loop Status Extension/fr.lproj/MainInterface.strings deleted file mode 100644 index 4d13ebda2d..0000000000 --- a/Loop Status Extension/fr.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Glucides actifs"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 g"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Insuline active"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 U"; - diff --git a/Loop Status Extension/he.lproj/InfoPlist.strings b/Loop Status Extension/he.lproj/InfoPlist.strings deleted file mode 100644 index 1565e025fa..0000000000 --- a/Loop Status Extension/he.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Loop Status Extension"; - diff --git a/Loop Status Extension/he.lproj/Localizable.strings b/Loop Status Extension/he.lproj/Localizable.strings deleted file mode 100644 index 27db2c87a8..0000000000 --- a/Loop Status Extension/he.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? g"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? U"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "U %1$@"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "פחמימות פעילות"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "אינסולין פעיל"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "בדרך ל-%1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ U"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "U"; - diff --git a/Loop Status Extension/he.lproj/MainInterface.strings b/Loop Status Extension/he.lproj/MainInterface.strings deleted file mode 100644 index 7bb2ea5747..0000000000 --- a/Loop Status Extension/he.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "פחמימות פעילות"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 g"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "אינסולין פעיל"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "U 0"; - diff --git a/Loop Status Extension/it.lproj/InfoPlist.strings b/Loop Status Extension/it.lproj/InfoPlist.strings deleted file mode 100644 index da11eb5a77..0000000000 --- a/Loop Status Extension/it.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Estensione dello stato di funzionamento di Loop"; - diff --git a/Loop Status Extension/it.lproj/Localizable.strings b/Loop Status Extension/it.lproj/Localizable.strings deleted file mode 100644 index 9404086e35..0000000000 --- a/Loop Status Extension/it.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? g"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? U"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ U"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ contro %2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Carboidrati Attivi"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Insulina attiva"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Probabile Glic. %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ U"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "U"; - diff --git a/Loop Status Extension/it.lproj/MainInterface.strings b/Loop Status Extension/it.lproj/MainInterface.strings deleted file mode 100644 index ab9c005998..0000000000 --- a/Loop Status Extension/it.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Carb Attivi"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 g"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Insulina attiva"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 U"; - diff --git a/Loop Status Extension/ja.lproj/InfoPlist.strings b/Loop Status Extension/ja.lproj/InfoPlist.strings deleted file mode 100644 index bb232bb4cc..0000000000 --- a/Loop Status Extension/ja.lproj/InfoPlist.strings +++ /dev/null @@ -1,3 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "ループ"; - diff --git a/Loop Status Extension/ja.lproj/Localizable.strings b/Loop Status Extension/ja.lproj/Localizable.strings deleted file mode 100644 index d328a81f35..0000000000 --- a/Loop Status Extension/ja.lproj/Localizable.strings +++ /dev/null @@ -1,33 +0,0 @@ -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "残存糖質"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "残存インスリン"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "予想 %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ U"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "U"; - diff --git a/Loop Status Extension/ja.lproj/MainInterface.strings b/Loop Status Extension/ja.lproj/MainInterface.strings deleted file mode 100644 index 2407f97e64..0000000000 --- a/Loop Status Extension/ja.lproj/MainInterface.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "残存糖質"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "残存インスリン"; - diff --git a/Loop Status Extension/nb.lproj/InfoPlist.strings b/Loop Status Extension/nb.lproj/InfoPlist.strings deleted file mode 100644 index 24d50f5390..0000000000 --- a/Loop Status Extension/nb.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Utvidelse av Loop status"; - diff --git a/Loop Status Extension/nb.lproj/Localizable.strings b/Loop Status Extension/nb.lproj/Localizable.strings deleted file mode 100644 index 2e4a88ce5f..0000000000 --- a/Loop Status Extension/nb.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? g"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? E"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ E"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v %2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Aktive karbohydrater"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Aktivt insulin"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Omsider %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ E"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "E"; - diff --git a/Loop Status Extension/nb.lproj/MainInterface.strings b/Loop Status Extension/nb.lproj/MainInterface.strings deleted file mode 100644 index 7942de07be..0000000000 --- a/Loop Status Extension/nb.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Aktive karbohydrater"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 g"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Aktivt insulin"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 E"; - diff --git a/Loop Status Extension/nl.lproj/InfoPlist.strings b/Loop Status Extension/nl.lproj/InfoPlist.strings deleted file mode 100644 index 62e5156f17..0000000000 --- a/Loop Status Extension/nl.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Loop Status Extensie"; - diff --git a/Loop Status Extension/nl.lproj/Localizable.strings b/Loop Status Extension/nl.lproj/Localizable.strings deleted file mode 100644 index b5f9439380..0000000000 --- a/Loop Status Extension/nl.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? g"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? E"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ E"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Actieve Koolhydraten"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Actieve Insuline"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Uiteindelijk %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ E"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "E"; - diff --git a/Loop Status Extension/nl.lproj/MainInterface.strings b/Loop Status Extension/nl.lproj/MainInterface.strings deleted file mode 100644 index 3300ee0aec..0000000000 --- a/Loop Status Extension/nl.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Actieve Koolhydraten"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 g"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Actieve Insuline"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 E"; - diff --git a/Loop Status Extension/pl.lproj/InfoPlist.strings b/Loop Status Extension/pl.lproj/InfoPlist.strings deleted file mode 100644 index 1565e025fa..0000000000 --- a/Loop Status Extension/pl.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Loop Status Extension"; - diff --git a/Loop Status Extension/pl.lproj/Localizable.strings b/Loop Status Extension/pl.lproj/Localizable.strings deleted file mode 100644 index 9f9cab187f..0000000000 --- a/Loop Status Extension/pl.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? g"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? J"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ J"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Aktywne węglowodany"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Aktywna insulina"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Docelowo %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ J"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dl"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "J"; - diff --git a/Loop Status Extension/pl.lproj/MainInterface.strings b/Loop Status Extension/pl.lproj/MainInterface.strings deleted file mode 100644 index 137aac2c3c..0000000000 --- a/Loop Status Extension/pl.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Aktywne węglowodany"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 g"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Aktywna insulina"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 J"; - diff --git a/Loop Status Extension/pt-BR.lproj/InfoPlist.strings b/Loop Status Extension/pt-BR.lproj/InfoPlist.strings deleted file mode 100644 index 034a1e1f6a..0000000000 --- a/Loop Status Extension/pt-BR.lproj/InfoPlist.strings +++ /dev/null @@ -1,3 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - diff --git a/Loop Status Extension/pt-BR.lproj/Localizable.strings b/Loop Status Extension/pt-BR.lproj/Localizable.strings deleted file mode 100644 index ed1ddc8056..0000000000 --- a/Loop Status Extension/pt-BR.lproj/Localizable.strings +++ /dev/null @@ -1,33 +0,0 @@ -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Carboidratos Ativos"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Insulina Ativa"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Eventualmente %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ U"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "U"; - diff --git a/Loop Status Extension/pt-BR.lproj/MainInterface.strings b/Loop Status Extension/pt-BR.lproj/MainInterface.strings deleted file mode 100644 index 09c2331507..0000000000 --- a/Loop Status Extension/pt-BR.lproj/MainInterface.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Carboidratos Ativos"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Insulina Ativa"; - diff --git a/Loop Status Extension/ro.lproj/InfoPlist.strings b/Loop Status Extension/ro.lproj/InfoPlist.strings deleted file mode 100644 index 811f60ffd2..0000000000 --- a/Loop Status Extension/ro.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Extensie stare Loop"; - diff --git a/Loop Status Extension/ro.lproj/Localizable.strings b/Loop Status Extension/ro.lproj/Localizable.strings deleted file mode 100644 index e749a36e8e..0000000000 --- a/Loop Status Extension/ro.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? g"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? U"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ U"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Carbohidrați activi"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Insulină activă"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Eventually %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ U"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@%2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "U"; - diff --git a/Loop Status Extension/ro.lproj/MainInterface.strings b/Loop Status Extension/ro.lproj/MainInterface.strings deleted file mode 100644 index 52df0e4c8c..0000000000 --- a/Loop Status Extension/ro.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Carbohidrați activi"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 g"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Insulină activă"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 U"; - diff --git a/Loop Status Extension/ru.lproj/InfoPlist.strings b/Loop Status Extension/ru.lproj/InfoPlist.strings deleted file mode 100644 index 1565e025fa..0000000000 --- a/Loop Status Extension/ru.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Loop Status Extension"; - diff --git a/Loop Status Extension/ru.lproj/Localizable.strings b/Loop Status Extension/ru.lproj/Localizable.strings deleted file mode 100644 index 590b1893da..0000000000 --- a/Loop Status Extension/ru.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? г"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? ед."; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ Ед"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ версии %2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Активные углеводы"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Активный инсулин"; - -/* The short unit display string for decibles */ -"dB" = "дБ"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "В конечном итоге %1$@"; - -/* The short unit display string for grams */ -"g" = "г"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ ед"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "мг/дл"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "ммоль/л"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "ед"; - diff --git a/Loop Status Extension/ru.lproj/MainInterface.strings b/Loop Status Extension/ru.lproj/MainInterface.strings deleted file mode 100644 index 7a44069cbe..0000000000 --- a/Loop Status Extension/ru.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Активные углеводы"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 г"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Активный инсулин"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 ед."; - diff --git a/Loop Status Extension/sk.lproj/InfoPlist.strings b/Loop Status Extension/sk.lproj/InfoPlist.strings deleted file mode 100644 index 034a1e1f6a..0000000000 --- a/Loop Status Extension/sk.lproj/InfoPlist.strings +++ /dev/null @@ -1,3 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - diff --git a/Loop Status Extension/sk.lproj/Localizable.strings b/Loop Status Extension/sk.lproj/Localizable.strings deleted file mode 100644 index f7fe0850f1..0000000000 --- a/Loop Status Extension/sk.lproj/Localizable.strings +++ /dev/null @@ -1,42 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? g"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? j"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%@ j"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v %2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Aktívne sacharidy"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Aktívny inzulín"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ j"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "j"; - diff --git a/Loop Status Extension/sk.lproj/MainInterface.strings b/Loop Status Extension/sk.lproj/MainInterface.strings deleted file mode 100644 index e249f99412..0000000000 --- a/Loop Status Extension/sk.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Aktívne sacharidy"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 g"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Aktívny inzulín"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 j"; - diff --git a/Loop Status Extension/sv.lproj/InfoPlist.strings b/Loop Status Extension/sv.lproj/InfoPlist.strings deleted file mode 100644 index 1565e025fa..0000000000 --- a/Loop Status Extension/sv.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Loop Status Extension"; - diff --git a/Loop Status Extension/sv.lproj/Localizable.strings b/Loop Status Extension/sv.lproj/Localizable.strings deleted file mode 100644 index fb3f8b00e7..0000000000 --- a/Loop Status Extension/sv.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? g"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? E"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ E"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Aktiva kolhydrater"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Aktivt insulin"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Förväntat %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ E"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dl"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "E"; - diff --git a/Loop Status Extension/sv.lproj/MainInterface.strings b/Loop Status Extension/sv.lproj/MainInterface.strings deleted file mode 100644 index afc966ed37..0000000000 --- a/Loop Status Extension/sv.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Aktiva kolhydrater"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 g"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Aktivt insulin"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 E"; - diff --git a/Loop Status Extension/tr.lproj/InfoPlist.strings b/Loop Status Extension/tr.lproj/InfoPlist.strings deleted file mode 100644 index a67e46ff7e..0000000000 --- a/Loop Status Extension/tr.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Loop Durum Uzantısı"; - diff --git a/Loop Status Extension/tr.lproj/Localizable.strings b/Loop Status Extension/tr.lproj/Localizable.strings deleted file mode 100644 index 0f5ebe9125..0000000000 --- a/Loop Status Extension/tr.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? gr"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? Ü"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ Ü"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Aktif Karb."; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Aktif İnsülin"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Nihai KŞ %1$@"; - -/* The short unit display string for grams */ -"g" = "gr"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "AİNS %1$@ Ü"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "Ü"; - diff --git a/Loop Status Extension/tr.lproj/MainInterface.strings b/Loop Status Extension/tr.lproj/MainInterface.strings deleted file mode 100644 index de7b3fc545..0000000000 --- a/Loop Status Extension/tr.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Aktif Karb."; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 gr"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Aktif İnsülin"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 Ü"; - diff --git a/Loop Status Extension/vi.lproj/InfoPlist.strings b/Loop Status Extension/vi.lproj/InfoPlist.strings deleted file mode 100644 index 034a1e1f6a..0000000000 --- a/Loop Status Extension/vi.lproj/InfoPlist.strings +++ /dev/null @@ -1,3 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - diff --git a/Loop Status Extension/vi.lproj/Localizable.strings b/Loop Status Extension/vi.lproj/Localizable.strings deleted file mode 100644 index a0b94d6a7f..0000000000 --- a/Loop Status Extension/vi.lproj/Localizable.strings +++ /dev/null @@ -1,33 +0,0 @@ -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Lượng Carbs còn hoạt động"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Lượng Insulin còn hoạt động"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Kết quả là %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ U"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "U"; - diff --git a/Loop Status Extension/vi.lproj/MainInterface.strings b/Loop Status Extension/vi.lproj/MainInterface.strings deleted file mode 100644 index c766b97e1b..0000000000 --- a/Loop Status Extension/vi.lproj/MainInterface.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Lượng Carbs còn hoạt động"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Lượng Insulin còn hoạt động"; - diff --git a/Loop Status Extension/zh-Hans.lproj/Localizable.strings b/Loop Status Extension/zh-Hans.lproj/Localizable.strings deleted file mode 100644 index b1d62cfb8c..0000000000 --- a/Loop Status Extension/zh-Hans.lproj/Localizable.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "最终 %1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ 单位"; - diff --git a/Loop Status Extension/zh-Hans.lproj/MainInterface.strings b/Loop Status Extension/zh-Hans.lproj/MainInterface.strings deleted file mode 100644 index 2a063e6084..0000000000 --- a/Loop Status Extension/zh-Hans.lproj/MainInterface.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "最终血糖为92 毫克/分升"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "IOB 1.0 单位"; - diff --git a/Loop Widget Extension/Components/BasalView.swift b/Loop Widget Extension/Components/BasalView.swift index b64bc9f338..224fbc7c27 100644 --- a/Loop Widget Extension/Components/BasalView.swift +++ b/Loop Widget Extension/Components/BasalView.swift @@ -10,8 +10,7 @@ import SwiftUI struct BasalView: View { let netBasal: NetBasalContext - let isOld: Bool - + let isStale: Bool var body: some View { let percent = netBasal.percentage @@ -21,20 +20,20 @@ struct BasalView: View { BasalRateView(percent: percent) .overlay( BasalRateView(percent: percent) - .stroke(isOld ? Color(UIColor.systemGray3) : Color("insulin"), lineWidth: 2) + .stroke(isStale ? Color.staleGray : Color.insulin, lineWidth: 2) ) - .foregroundColor((isOld ? Color(UIColor.systemGray3) : Color("insulin")).opacity(0.5)) + .foregroundColor((isStale ? Color.staleGray : Color.insulin).opacity(0.5)) .frame(width: 44, height: 22) if let rateString = decimalFormatter.string(from: NSNumber(value: rate)) { Text("\(rateString) U") .font(.footnote) - .foregroundColor(Color(isOld ? UIColor.systemGray3 : UIColor.secondaryLabel)) + .foregroundColor(isStale ? .staleGray : .secondary) } else { Text("-U") .font(.footnote) - .foregroundColor(Color(isOld ? UIColor.systemGray3 : UIColor.secondaryLabel)) + .foregroundColor(isStale ? .staleGray : .secondary) } } } diff --git a/Loop Widget Extension/Components/DeeplinkView.swift b/Loop Widget Extension/Components/DeeplinkView.swift new file mode 100644 index 0000000000..79fdf05862 --- /dev/null +++ b/Loop Widget Extension/Components/DeeplinkView.swift @@ -0,0 +1,55 @@ +// +// DeeplinkView.swift +// Loop Widget Extension +// +// Created by Noah Brauner on 8/9/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +fileprivate extension Deeplink { + var deeplinkURL: URL { + URL(string: "loop://\(rawValue)")! + } + + var accentColor: Color { + switch self { + case .carbEntry: + return .carbs + case .bolus: + return .insulin + case .preMeal: + return .carbs + case .customPresets: + return .glucose + } + } + + var icon: Image { + switch self { + case .carbEntry: + return Image(.carbs) + case .bolus: + return Image(.bolus) + case .preMeal: + return Image(.premeal) + case .customPresets: + return Image(.workout) + } + } +} + +struct DeeplinkView: View { + let destination: Deeplink + var isActive: Bool = false + + var body: some View { + Link(destination: destination.deeplinkURL) { + destination.icon + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .foregroundColor(isActive ? .white : destination.accentColor) + .containerRelativeBackground(color: isActive ? destination.accentColor : .widgetSecondaryBackground) + } + } +} diff --git a/Loop Widget Extension/Components/EventualGlucoseView.swift b/Loop Widget Extension/Components/EventualGlucoseView.swift new file mode 100644 index 0000000000..fcb0d742b0 --- /dev/null +++ b/Loop Widget Extension/Components/EventualGlucoseView.swift @@ -0,0 +1,34 @@ +// +// EventualGlucoseView.swift +// Loop Widget Extension +// +// Created by Noah Brauner on 8/8/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct EventualGlucoseView: View { + let entry: StatusWidgetTimelimeEntry + + var body: some View { + if let eventualGlucose = entry.eventualGlucose { + let glucoseFormatter = NumberFormatter.glucoseFormatter(for: eventualGlucose.unit) + if let glucoseString = glucoseFormatter.string(from: eventualGlucose.quantity.doubleValue(for: eventualGlucose.unit)) { + VStack { + Text("Eventual") + .font(.footnote) + .foregroundColor(entry.contextIsStale ? .staleGray : .secondary) + + Text("\(glucoseString)") + .font(.subheadline) + .fontWeight(.heavy) + + Text(eventualGlucose.unit.shortLocalizedUnitString()) + .font(.footnote) + .foregroundColor(entry.contextIsStale ? .staleGray : .secondary) + } + } + } + } +} diff --git a/Loop Widget Extension/Components/GlucoseView.swift b/Loop Widget Extension/Components/GlucoseView.swift index a0d5c5c26b..332d4afee4 100644 --- a/Loop Widget Extension/Components/GlucoseView.swift +++ b/Loop Widget Extension/Components/GlucoseView.swift @@ -12,78 +12,45 @@ import HealthKit import LoopCore struct GlucoseView: View { - var entry: StatusWidgetTimelimeEntry var body: some View { VStack(alignment: .center, spacing: 0) { HStack(spacing: 2) { - if let glucose = entry.currentGlucose, - !entry.glucoseIsStale, - let unit = entry.unit - { - let quantity = glucose.quantity - let glucoseFormatter = NumberFormatter.glucoseFormatter(for: unit) - if let glucoseString = glucoseFormatter.string(from: quantity.doubleValue(for: unit)) { - Text(glucoseString) - .font(.system(size: 24, weight: .heavy, design: .default)) - } - else { - Text("??") - .font(.system(size: 24, weight: .heavy, design: .default)) - } + if !entry.glucoseIsStale, + let glucoseQuantity = entry.currentGlucose?.quantity, + let unit = entry.unit, + let glucoseString = NumberFormatter.glucoseFormatter(for: unit).string(from: glucoseQuantity.doubleValue(for: unit)) { + Text(glucoseString) + .font(.system(size: 24, weight: .heavy, design: .default)) } else { Text("---") .font(.system(size: 24, weight: .heavy, design: .default)) } - if let trendImageName = getArrowImage() { - Image(systemName: trendImageName) + if let trendImage = entry.sensor?.trendType?.image { + Image(uiImage: trendImage) + .renderingMode(.template) } } - // Prevent truncation of text - .fixedSize(horizontal: true, vertical: false) - .foregroundColor(entry.glucoseStatusIsStale ? Color(UIColor.systemGray3) : .primary) + .foregroundColor(entry.glucoseStatusIsStale ? .staleGray : .primary) - let unitString = entry.unit == nil ? "-" : entry.unit!.localizedShortUnitString + let unitString = entry.unit?.localizedShortUnitString ?? "-" if let delta = entry.delta, let unit = entry.unit { let deltaValue = delta.doubleValue(for: unit) let numberFormatter = NumberFormatter.glucoseFormatter(for: unit) let deltaString = (deltaValue < 0 ? "-" : "+") + numberFormatter.string(from: abs(deltaValue))! Text(deltaString + " " + unitString) - // Dynamic text causes string to be cut off - .font(.system(size: 13)) - .foregroundColor(entry.glucoseStatusIsStale ? Color(UIColor.systemGray3) : Color(UIColor.secondaryLabel)) - .fixedSize(horizontal: true, vertical: true) + .font(.footnote) + .foregroundColor(entry.glucoseStatusIsStale ? .staleGray : .secondary) } else { Text(unitString) .font(.footnote) - .foregroundColor(entry.glucoseStatusIsStale ? Color(UIColor.systemGray3) : Color(UIColor.secondaryLabel)) + .foregroundColor(entry.glucoseStatusIsStale ? .staleGray : .secondary) } } } - - private func getArrowImage() -> String? { - switch entry.sensor?.trendType { - case .upUpUp: - return "arrow.double.up.circle" - case .upUp: - return "arrow.up.circle" - case .up: - return "arrow.up.right.circle" - case .flat: - return "arrow.right.circle" - case .down: - return "arrow.down.right.circle" - case .downDown: - return "arrow.down.circle" - case .downDownDown: - return "arrow.double.down.circle" - case .none: - return nil - } - } } diff --git a/Loop Widget Extension/Components/LoopCircleView.swift b/Loop Widget Extension/Components/LoopCircleView.swift deleted file mode 100644 index b45bd47990..0000000000 --- a/Loop Widget Extension/Components/LoopCircleView.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// LoopCircleView.swift -// Loop -// -// Created by Noah Brauner on 8/15/22. -// Copyright © 2022 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import LoopCore - -struct LoopCircleView: View { - var entry: StatusWidgetTimelimeEntry - - var body: some View { - let closeLoop = entry.closeLoop - let lastLoopCompleted = entry.lastLoopCompleted ?? Date().addingTimeInterval(.minutes(16)) - let age = abs(min(0, lastLoopCompleted.timeIntervalSinceNow)) - let freshness = LoopCompletionFreshness(age: age) - - let loopColor = getLoopColor(freshness: freshness) - - Circle() - .trim(from: closeLoop ? 0 : 0.2, to: 1) - .stroke(entry.contextIsStale ? Color(UIColor.systemGray3) : loopColor, lineWidth: 8) - .rotationEffect(Angle(degrees: -126)) - .frame(width: 36, height: 36) - } - - func getLoopColor(freshness: LoopCompletionFreshness) -> Color { - switch freshness { - case .fresh: - return Color("fresh") - case .aging: - return Color("warning") - case .stale: - return Color.red - } - } -} diff --git a/Loop Widget Extension/Components/PumpView.swift b/Loop Widget Extension/Components/PumpView.swift index bee09c1217..1dca02276d 100644 --- a/Loop Widget Extension/Components/PumpView.swift +++ b/Loop Widget Extension/Components/PumpView.swift @@ -9,42 +9,19 @@ import SwiftUI struct PumpView: View { - - var entry: StatusWidgetTimelineProvider.Entry + var entry: StatusWidgetTimelimeEntry var body: some View { - HStack(alignment: .center) { - if let pumpHighlight = entry.pumpHighlight { - HStack { - Image(systemName: pumpHighlight.imageName) - .foregroundColor(pumpHighlight.state == .critical ? .critical : .warning) - Text(pumpHighlight.localizedMessage) - .fontWeight(.heavy) - } - } - else if let netBasal = entry.netBasal { - BasalView(netBasal: netBasal, isOld: entry.contextIsStale) - - if let eventualGlucose = entry.eventualGlucose { - let glucoseFormatter = NumberFormatter.glucoseFormatter(for: eventualGlucose.unit) - if let glucoseString = glucoseFormatter.string(from: eventualGlucose.quantity.doubleValue(for: eventualGlucose.unit)) { - VStack { - Text("Eventual") - .font(.footnote) - .foregroundColor(entry.contextIsStale ? Color(UIColor.systemGray3) : Color(UIColor.secondaryLabel)) - - Text("\(glucoseString)") - .font(.subheadline) - .fontWeight(.heavy) - - Text(eventualGlucose.unit.shortLocalizedUnitString()) - .font(.footnote) - .foregroundColor(entry.contextIsStale ? Color(UIColor.systemGray3) : Color(UIColor.secondaryLabel)) - } - } - } + if let pumpHighlight = entry.pumpHighlight { + HStack { + Image(systemName: pumpHighlight.imageName) + .foregroundColor(pumpHighlight.state == .critical ? .critical : .warning) + Text(pumpHighlight.localizedMessage) + .fontWeight(.heavy) } - + } + else if let netBasal = entry.netBasal { + BasalView(netBasal: netBasal, isStale: entry.contextIsStale) } } } diff --git a/Loop Widget Extension/Components/SystemActionLink.swift b/Loop Widget Extension/Components/SystemActionLink.swift deleted file mode 100644 index eb62bbfa40..0000000000 --- a/Loop Widget Extension/Components/SystemActionLink.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// SystemActionLink.swift -// Loop Widget Extension -// -// Created by Cameron Ingham on 6/26/23. -// Copyright © 2023 LoopKit Authors. All rights reserved. -// - -import Foundation -import SwiftUI - -struct SystemActionLink: View { - enum Destination: String, CaseIterable { - case carbEntry = "carb-entry" - case bolus = "manual-bolus" - case preMeal = "pre-meal-preset" - case customPreset = "custom-presets" - - var deeplink: URL { - URL(string: "loop://\(rawValue)")! - } - } - - let destination: Destination - let active: Bool - - init(to destination: Destination, active: Bool = false) { - self.destination = destination - self.active = active - } - - private func foregroundColor(active: Bool) -> Color { - switch destination { - case .carbEntry: - return Color("fresh") - case .bolus: - return Color("insulin") - case .preMeal: - return active ? .white : Color("fresh") - case .customPreset: - return active ? .white : Color("glucose") - } - } - - private func backgroundColor(active: Bool) -> Color { - switch destination { - case .carbEntry: - return active ? Color("fresh") : Color("WidgetSecondaryBackground") - case .bolus: - return active ? Color("insulin") : Color("WidgetSecondaryBackground") - case .preMeal: - return active ? Color("fresh") : Color("WidgetSecondaryBackground") - case .customPreset: - return active ? Color("glucose") : Color("WidgetSecondaryBackground") - } - } - - private var icon: Image { - switch destination { - case .carbEntry: - return Image("carbs") - case .bolus: - return Image("bolus") - case .preMeal: - return Image("premeal") - case .customPreset: - return Image("workout") - } - } - - var body: some View { - Link(destination: destination.deeplink) { - icon - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .foregroundColor(foregroundColor(active: active)) - .background( - ContainerRelativeShape() - .fill(backgroundColor(active: active)) - ) - } - } -} diff --git a/Loop Widget Extension/DefaultAssets.xcassets/bolus.imageset/Contents.json b/Loop Widget Extension/DerivedAssetsBase.xcassets/bolus.imageset/Contents.json similarity index 100% rename from Loop Widget Extension/DefaultAssets.xcassets/bolus.imageset/Contents.json rename to Loop Widget Extension/DerivedAssetsBase.xcassets/bolus.imageset/Contents.json diff --git a/Loop Widget Extension/DefaultAssets.xcassets/bolus.imageset/bolus.pdf b/Loop Widget Extension/DerivedAssetsBase.xcassets/bolus.imageset/bolus.pdf similarity index 100% rename from Loop Widget Extension/DefaultAssets.xcassets/bolus.imageset/bolus.pdf rename to Loop Widget Extension/DerivedAssetsBase.xcassets/bolus.imageset/bolus.pdf diff --git a/Loop Widget Extension/DefaultAssets.xcassets/carbs.imageset/Contents.json b/Loop Widget Extension/DerivedAssetsBase.xcassets/carbs.imageset/Contents.json similarity index 100% rename from Loop Widget Extension/DefaultAssets.xcassets/carbs.imageset/Contents.json rename to Loop Widget Extension/DerivedAssetsBase.xcassets/carbs.imageset/Contents.json diff --git a/Loop Widget Extension/DefaultAssets.xcassets/carbs.imageset/Meal.pdf b/Loop Widget Extension/DerivedAssetsBase.xcassets/carbs.imageset/Meal.pdf similarity index 100% rename from Loop Widget Extension/DefaultAssets.xcassets/carbs.imageset/Meal.pdf rename to Loop Widget Extension/DerivedAssetsBase.xcassets/carbs.imageset/Meal.pdf diff --git a/Loop Widget Extension/DefaultAssets.xcassets/premeal.imageset/Contents.json b/Loop Widget Extension/DerivedAssetsBase.xcassets/premeal.imageset/Contents.json similarity index 100% rename from Loop Widget Extension/DefaultAssets.xcassets/premeal.imageset/Contents.json rename to Loop Widget Extension/DerivedAssetsBase.xcassets/premeal.imageset/Contents.json diff --git a/Loop Widget Extension/DefaultAssets.xcassets/premeal.imageset/Pre-Meal.pdf b/Loop Widget Extension/DerivedAssetsBase.xcassets/premeal.imageset/Pre-Meal.pdf similarity index 100% rename from Loop Widget Extension/DefaultAssets.xcassets/premeal.imageset/Pre-Meal.pdf rename to Loop Widget Extension/DerivedAssetsBase.xcassets/premeal.imageset/Pre-Meal.pdf diff --git a/Loop Widget Extension/DefaultAssets.xcassets/workout.imageset/Contents.json b/Loop Widget Extension/DerivedAssetsBase.xcassets/workout.imageset/Contents.json similarity index 100% rename from Loop Widget Extension/DefaultAssets.xcassets/workout.imageset/Contents.json rename to Loop Widget Extension/DerivedAssetsBase.xcassets/workout.imageset/Contents.json diff --git a/Loop Widget Extension/DefaultAssets.xcassets/workout.imageset/workout.pdf b/Loop Widget Extension/DerivedAssetsBase.xcassets/workout.imageset/workout.pdf similarity index 100% rename from Loop Widget Extension/DefaultAssets.xcassets/workout.imageset/workout.pdf rename to Loop Widget Extension/DerivedAssetsBase.xcassets/workout.imageset/workout.pdf diff --git a/Loop Widget Extension/Helpers/Color.swift b/Loop Widget Extension/Helpers/Color.swift new file mode 100644 index 0000000000..2ae525abb8 --- /dev/null +++ b/Loop Widget Extension/Helpers/Color.swift @@ -0,0 +1,19 @@ +// +// Color.swift +// Loop +// +// Created by Noah Brauner on 8/9/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +extension Color { + static let widgetBackground = Color(.widgetBackground) + static let widgetSecondaryBackground = Color(.widgetSecondaryBackground) + static let staleGray = Color(.systemGray3) + + static let insulin = Color(.insulin) + static let glucose = Color(.glucose) + static let carbs = Color(.fresh) +} diff --git a/Loop Widget Extension/Helpers/ContentMargin.swift b/Loop Widget Extension/Helpers/ContentMargin.swift index 92a2d41786..dffb63d615 100644 --- a/Loop Widget Extension/Helpers/ContentMargin.swift +++ b/Loop Widget Extension/Helpers/ContentMargin.swift @@ -2,8 +2,8 @@ // ContentMargin.swift // Loop Widget Extension // -// Created by Cameron Ingham on 9/29/23. -// Copyright © 2023 LoopKit Authors. All rights reserved. +// Created by Cameron Ingham on 1/16/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. // import SwiftUI diff --git a/Loop Widget Extension/Helpers/WidgetBackground.swift b/Loop Widget Extension/Helpers/WidgetBackground.swift index f5202f092c..f8d338e6ba 100644 --- a/Loop Widget Extension/Helpers/WidgetBackground.swift +++ b/Loop Widget Extension/Helpers/WidgetBackground.swift @@ -12,11 +12,19 @@ extension View { @ViewBuilder func widgetBackground() -> some View { if #available(iOSApplicationExtension 17.0, *) { - self.containerBackground(for: .widget) { - Color("WidgetBackground") + containerBackground(for: .widget) { + background { Color.widgetBackground } } } else { - self.background { Color("WidgetBackground") } + background { Color.widgetBackground } } } + + @ViewBuilder + func containerRelativeBackground(color: Color = .widgetSecondaryBackground) -> some View { + background( + ContainerRelativeShape() + .fill(color) + ) + } } diff --git a/Loop Widget Extension/LoopWidgets.swift b/Loop Widget Extension/LoopWidgets.swift index 26f92edb45..a73de9b7a7 100644 --- a/Loop Widget Extension/LoopWidgets.swift +++ b/Loop Widget Extension/LoopWidgets.swift @@ -10,7 +10,6 @@ import SwiftUI @main struct LoopWidgets: WidgetBundle { - @WidgetBundleBuilder var body: some Widget { SystemStatusWidget() diff --git a/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift b/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift index 45271bbe14..78cec95b08 100644 --- a/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift +++ b/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift @@ -10,6 +10,8 @@ import HealthKit import LoopCore import LoopKit import WidgetKit +import LoopAlgorithm + struct StatusWidgetTimelimeEntry: TimelineEntry { var date: Date @@ -17,6 +19,8 @@ struct StatusWidgetTimelimeEntry: TimelineEntry { let contextUpdatedAt: Date let lastLoopCompleted: Date? + let mostRecentGlucoseDataDate: Date? + let mostRecentPumpDataDate: Date? let closeLoop: Bool let currentGlucose: GlucoseValue? @@ -53,6 +57,6 @@ struct StatusWidgetTimelimeEntry: TimelineEntry { } let glucoseAge = date - glucoseDate - return glucoseAge >= LoopCoreConstants.inputDataRecencyInterval + return glucoseAge >= LoopAlgorithm.inputDataRecencyInterval } } diff --git a/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift b/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift index beb8bd2f70..b48bb1f7bf 100644 --- a/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift +++ b/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift @@ -11,6 +11,7 @@ import LoopCore import LoopKit import OSLog import WidgetKit +import LoopAlgorithm class StatusWidgetTimelineProvider: TimelineProvider { lazy var defaults = UserDefaults.appGroup @@ -29,15 +30,21 @@ class StatusWidgetTimelineProvider: TimelineProvider { store: cacheStore, expireAfter: localCacheDuration) - lazy var glucoseStore = GlucoseStore( - cacheStore: cacheStore, - provenanceIdentifier: HKSource.default().bundleIdentifier - ) + var glucoseStore: GlucoseStore! + + init() { + Task { + glucoseStore = await GlucoseStore( + cacheStore: cacheStore, + provenanceIdentifier: HKSource.default().bundleIdentifier + ) + } + } func placeholder(in context: Context) -> StatusWidgetTimelimeEntry { log.default("%{public}@: context=%{public}@", #function, String(describing: context)) - return StatusWidgetTimelimeEntry(date: Date(), contextUpdatedAt: Date(), lastLoopCompleted: nil, closeLoop: true, currentGlucose: nil, glucoseFetchedAt: Date(), delta: nil, unit: .milligramsPerDeciliter, sensor: nil, pumpHighlight: nil, netBasal: nil, eventualGlucose: nil, preMealPresetAllowed: true, preMealPresetActive: false, customPresetActive: false) + return StatusWidgetTimelimeEntry(date: Date(), contextUpdatedAt: Date(), lastLoopCompleted: nil, mostRecentGlucoseDataDate: nil, mostRecentPumpDataDate: nil, closeLoop: true, currentGlucose: nil, glucoseFetchedAt: Date(), delta: nil, unit: .milligramsPerDeciliter, sensor: nil, pumpHighlight: nil, netBasal: nil, eventualGlucose: nil, preMealPresetAllowed: true, preMealPresetActive: false, customPresetActive: false) } func getSnapshot(in context: Context, completion: @escaping (StatusWidgetTimelimeEntry) -> ()) { @@ -67,7 +74,7 @@ class StatusWidgetTimelineProvider: TimelineProvider { // Date glucose staleness changes if let lastBGTime = newEntry.currentGlucose?.startDate { - let staleBgRefreshTime = lastBGTime.addingTimeInterval(LoopCoreConstants.inputDataRecencyInterval+1) + let staleBgRefreshTime = lastBGTime.addingTimeInterval(LoopAlgorithm.inputDataRecencyInterval+1) datesToRefreshWidget.append(staleBgRefreshTime) } @@ -89,29 +96,22 @@ class StatusWidgetTimelineProvider: TimelineProvider { } func update(completion: @escaping (StatusWidgetTimelimeEntry) -> Void) { - let group = DispatchGroup() - var glucose: [StoredGlucoseSample] = [] + let startDate = Date(timeIntervalSinceNow: -LoopAlgorithm.inputDataRecencyInterval) + + Task { - let startDate = Date(timeIntervalSinceNow: -LoopCoreConstants.inputDataRecencyInterval) + var glucose: [StoredGlucoseSample] = [] - group.enter() - glucoseStore.getGlucoseSamples(start: startDate) { (result) in - switch result { - case .failure: + do { + glucose = try await glucoseStore.getGlucoseSamples(start: startDate) + self.log.default("Fetched glucose: last = %{public}@, %{public}@", String(describing: glucose.last?.startDate), String(describing: glucose.last?.quantity)) + } catch { self.log.error("Failed to fetch glucose after %{public}@", String(describing: startDate)) - glucose = [] - case .success(let samples): - self.log.default("Fetched glucose: last = %{public}@, %{public}@", String(describing: samples.last?.startDate), String(describing: samples.last?.quantity)) - glucose = samples } - group.leave() - } - group.wait() - let finalGlucose = glucose + let finalGlucose = glucose - Task { @MainActor in guard let defaults = self.defaults, let context = defaults.statusExtensionContext, let contextUpdatedAt = context.createdAt, @@ -158,6 +158,8 @@ class StatusWidgetTimelineProvider: TimelineProvider { date: updateDate, contextUpdatedAt: contextUpdatedAt, lastLoopCompleted: lastCompleted, + mostRecentGlucoseDataDate: context.mostRecentGlucoseDataDate, + mostRecentPumpDataDate: context.mostRecentPumpDataDate, closeLoop: closeLoop, currentGlucose: currentGlucose, glucoseFetchedAt: updateDate, diff --git a/Loop Widget Extension/Widgets/SystemStatusWidget.swift b/Loop Widget Extension/Widgets/SystemStatusWidget.swift index a64096d2ad..2cb9f7fc91 100644 --- a/Loop Widget Extension/Widgets/SystemStatusWidget.swift +++ b/Loop Widget Extension/Widgets/SystemStatusWidget.swift @@ -6,61 +6,81 @@ // Copyright © 2022 LoopKit Authors. All rights reserved. // +import LoopKit +import LoopKitUI import LoopUI import SwiftUI import WidgetKit -struct SystemStatusWidgetEntryView : View { - +struct SystemStatusWidgetEntryView: View { @Environment(\.widgetFamily) private var widgetFamily - var entry: StatusWidgetTimelineProvider.Entry + var entry: StatusWidgetTimelimeEntry + + var freshness: LoopCompletionFreshness { + var age: TimeInterval + + if entry.closeLoop { + let lastLoopCompleted = entry.lastLoopCompleted ?? Date().addingTimeInterval(.minutes(16)) + age = abs(min(0, lastLoopCompleted.timeIntervalSinceNow)) + } else { + let mostRecentGlucoseDataDate = entry.mostRecentGlucoseDataDate ?? Date().addingTimeInterval(.minutes(16)) + let mostRecentPumpDataDate = entry.mostRecentPumpDataDate ?? Date().addingTimeInterval(.minutes(16)) + age = max(abs(min(0, mostRecentPumpDataDate.timeIntervalSinceNow)), abs(min(0, mostRecentGlucoseDataDate.timeIntervalSinceNow))) + } + + return LoopCompletionFreshness(age: age) + } var body: some View { HStack(alignment: .center, spacing: 5) { VStack(alignment: .center, spacing: 5) { - HStack(alignment: .center, spacing: 15) { - LoopCircleView(entry: entry) + HStack(alignment: .center, spacing: 0) { + LoopCircleView(closedLoop: entry.closeLoop, freshness: freshness) + .frame(maxWidth: .infinity, alignment: .center) + .environment(\.loopStatusColorPalette, .loopStatus) + .disabled(entry.contextIsStale) GlucoseView(entry: entry) + .frame(maxWidth: .infinity, alignment: .center) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) .padding(5) - .background( - ContainerRelativeShape() - .fill(Color("WidgetSecondaryBackground")) - ) + .containerRelativeBackground() - PumpView(entry: entry) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .padding(5) - .background( - ContainerRelativeShape() - .fill(Color("WidgetSecondaryBackground")) - ) + HStack(alignment: .center, spacing: 0) { + PumpView(entry: entry) + .frame(maxWidth: .infinity, alignment: .center) + + EventualGlucoseView(entry: entry) + .frame(maxWidth: .infinity, alignment: .center) + } + .frame(maxHeight: .infinity, alignment: .center) + .padding(.vertical, 5) + .containerRelativeBackground() } if widgetFamily != .systemSmall { VStack(alignment: .center, spacing: 5) { HStack(alignment: .center, spacing: 5) { - SystemActionLink(to: .carbEntry) + DeeplinkView(destination: .carbEntry) - SystemActionLink(to: .bolus) + DeeplinkView(destination: .bolus) } HStack(alignment: .center, spacing: 5) { if entry.preMealPresetAllowed { - SystemActionLink(to: .preMeal, active: entry.preMealPresetActive) + DeeplinkView(destination: .preMeal, isActive: entry.preMealPresetActive) } - SystemActionLink(to: .customPreset, active: entry.customPresetActive) + DeeplinkView(destination: .customPresets, isActive: entry.customPresetActive) } } .buttonStyle(.plain) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } } - .foregroundColor(entry.contextIsStale ? Color(UIColor.systemGray3) : nil) + .foregroundColor(entry.contextIsStale ? .staleGray : nil) .padding(5) .widgetBackground() } diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 1181951609..dadeb401bf 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -12,9 +12,15 @@ 1419606A28D955BC00BA86E0 /* MockKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C101947127DD473C004E7EB8 /* MockKitUI.framework */; }; 142CB7592A60BF2E0075748A /* EditMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142CB7582A60BF2E0075748A /* EditMode.swift */; }; 142CB75B2A60BFC30075748A /* FavoriteFoodsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142CB75A2A60BFC30075748A /* FavoriteFoodsView.swift */; }; - 1452F4A92A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1452F4A82A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift */; }; - 1452F4AB2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1452F4AA2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift */; }; + 1452F4A92A851C9400F8B9E4 /* FavoriteFoodAddEditViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1452F4A82A851C9400F8B9E4 /* FavoriteFoodAddEditViewModel.swift */; }; + 1452F4AB2A851EDF00F8B9E4 /* FavoriteFoodAddEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1452F4AA2A851EDF00F8B9E4 /* FavoriteFoodAddEditView.swift */; }; 1452F4AD2A851F8800F8B9E4 /* HowAbsorptionTimeWorksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1452F4AC2A851F8800F8B9E4 /* HowAbsorptionTimeWorksView.swift */; }; + 1455ACA92C66665D004F44F2 /* StateColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428B217806A300FAB378 /* StateColorPalette.swift */; }; + 1455ACAB2C666F9C004F44F2 /* EventualGlucoseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1455ACAA2C666F9C004F44F2 /* EventualGlucoseView.swift */; }; + 1455ACAD2C6675E1004F44F2 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1455ACAC2C6675DF004F44F2 /* Color.swift */; }; + 1455ACB02C667A1F004F44F2 /* DeeplinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1455ACAF2C667A1F004F44F2 /* DeeplinkView.swift */; }; + 1455ACB22C667BEE004F44F2 /* Deeplink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1455ACB12C667BEE004F44F2 /* Deeplink.swift */; }; + 1455ACB32C667C16004F44F2 /* Deeplink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1455ACB12C667BEE004F44F2 /* Deeplink.swift */; }; 147EFE8E2A8BCC5500272438 /* DefaultAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 147EFE8D2A8BCC5500272438 /* DefaultAssets.xcassets */; }; 147EFE902A8BCD8000272438 /* DerivedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 147EFE8F2A8BCD8000272438 /* DerivedAssets.xcassets */; }; 147EFE922A8BCD8A00272438 /* DerivedAssetsBase.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 147EFE912A8BCD8A00272438 /* DerivedAssetsBase.xcassets */; }; @@ -28,7 +34,6 @@ 14B1737228AEDBF6006CCD7C /* BasalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B1736E28AEDBF6006CCD7C /* BasalView.swift */; }; 14B1737328AEDBF6006CCD7C /* SystemStatusWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B1736F28AEDBF6006CCD7C /* SystemStatusWidget.swift */; }; 14B1737428AEDBF6006CCD7C /* GlucoseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B1737028AEDBF6006CCD7C /* GlucoseView.swift */; }; - 14B1737528AEDBF6006CCD7C /* LoopCircleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B1737128AEDBF6006CCD7C /* LoopCircleView.swift */; }; 14B1737628AEDC6C006CCD7C /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F526D5E1DF2459000A04910 /* HKUnit.swift */; }; 14B1737728AEDC6C006CCD7C /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; 14B1737828AEDC6C006CCD7C /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; @@ -41,16 +46,25 @@ 14B1737F28AEDC6C006CCD7C /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16DA84122E8E112008624C2 /* PluginManager.swift */; }; 14B1738028AEDC6C006CCD7C /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D58222F0A27008961AB /* Debug.swift */; }; 14B1738128AEDC70006CCD7C /* StatusExtensionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F70C2111DE900EA006380B7 /* StatusExtensionContext.swift */; }; + 14BBB3B22C629DB100ECB800 /* Character+IsEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14BBB3B12C629DB100ECB800 /* Character+IsEmoji.swift */; }; + 14C970682C5991CD00E8A01B /* LoopChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C970672C5991CD00E8A01B /* LoopChartView.swift */; }; + 14C9706A2C5A833100E8A01B /* CarbEffectChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C970692C5A833100E8A01B /* CarbEffectChartView.swift */; }; + 14C9706C2C5A836000E8A01B /* DoseChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C9706B2C5A836000E8A01B /* DoseChartView.swift */; }; + 14C9706E2C5A83AF00E8A01B /* IOBChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C9706D2C5A83AF00E8A01B /* IOBChartView.swift */; }; + 14C9707E2C5A9EB600E8A01B /* GlucoseCarbChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C9707D2C5A9EB600E8A01B /* GlucoseCarbChartView.swift */; }; + 14C970802C5C0A1500E8A01B /* FavoriteFoodInsightsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C9707F2C5C0A1500E8A01B /* FavoriteFoodInsightsViewModel.swift */; }; + 14C970822C5C2EC100E8A01B /* FavoriteFoodInsightsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C970812C5C2EC100E8A01B /* FavoriteFoodInsightsView.swift */; }; + 14C970842C5C2FB400E8A01B /* HowCarbEffectsWorksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C970832C5C2FB400E8A01B /* HowCarbEffectsWorksView.swift */; }; + 14C970862C5C358C00E8A01B /* FavoriteFoodInsightsChartsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C970852C5C358C00E8A01B /* FavoriteFoodInsightsChartsView.swift */; }; 14D906F42A846510006EB79A /* FavoriteFoodsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D906F32A846510006EB79A /* FavoriteFoodsViewModel.swift */; }; + 14ED83F62C6421F9008B4A5C /* FavoriteFoodInsightsCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14ED83F52C6421F9008B4A5C /* FavoriteFoodInsightsCardView.swift */; }; 1D05219B2469E9DF000EBBDE /* StoredAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D05219A2469E9DF000EBBDE /* StoredAlert.swift */; }; 1D05219D2469F1F5000EBBDE /* AlertStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D05219C2469F1F5000EBBDE /* AlertStore.swift */; }; 1D080CBD2473214A00356610 /* AlertStore.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 1D080CBB2473214A00356610 /* AlertStore.xcdatamodeld */; }; 1D12D3B92548EFDD00B53E8B /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D12D3B82548EFDD00B53E8B /* main.swift */; }; 1D3F0F7526D59B6C004A5960 /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D58222F0A27008961AB /* Debug.swift */; }; - 1D3F0F7626D59DCD004A5960 /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D58222F0A27008961AB /* Debug.swift */; }; 1D3F0F7726D59DCE004A5960 /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D58222F0A27008961AB /* Debug.swift */; }; 1D49795824E7289700948F05 /* ServicesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D49795724E7289700948F05 /* ServicesViewModel.swift */; }; - 1D4990E824A25931005CC357 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E267FB2292456700A3F2AF /* FeatureFlags.swift */; }; 1D4A3E2D2478628500FD601B /* StoredAlert+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D4A3E2B2478628500FD601B /* StoredAlert+CoreDataClass.swift */; }; 1D4A3E2E2478628500FD601B /* StoredAlert+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D4A3E2C2478628500FD601B /* StoredAlert+CoreDataProperties.swift */; }; 1D63DEA526E950D400F46FA5 /* SupportManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D63DEA426E950D400F46FA5 /* SupportManager.swift */; }; @@ -59,7 +73,6 @@ 1D80313D24746274002810DF /* AlertStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D80313C24746274002810DF /* AlertStoreTests.swift */; }; 1D82E6A025377C6B009131FB /* TrustedTimeChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D82E69F25377C6B009131FB /* TrustedTimeChecker.swift */; }; 1D8D55BC252274650044DBB6 /* BolusEntryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D8D55BB252274650044DBB6 /* BolusEntryViewModelTests.swift */; }; - 1D9650C82523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D9650C72523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift */; }; 1DA649A7244126CD00F61E75 /* UserNotificationAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA649A6244126CD00F61E75 /* UserNotificationAlertScheduler.swift */; }; 1DA649A9244126DA00F61E75 /* InAppModalAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA649A8244126DA00F61E75 /* InAppModalAlertScheduler.swift */; }; 1DA7A84224476EAD008257F0 /* AlertManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA7A84124476EAD008257F0 /* AlertManagerTests.swift */; }; @@ -93,9 +106,6 @@ 4344628220A7A37F00C4BE6F /* CoreBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4344628120A7A37E00C4BE6F /* CoreBluetooth.framework */; }; 4344629220A7C19800C4BE6F /* ButtonGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4344629120A7C19800C4BE6F /* ButtonGroup.swift */; }; 4344629820A8B2D700C4BE6F /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4374B5EE209D84BE00D17AA8 /* OSLog.swift */; }; - 4345E3F421F036FC009E00E5 /* Result.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D848AF1E7DCBE100DADCBC /* Result.swift */; }; - 4345E3F521F036FC009E00E5 /* Result.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D848AF1E7DCBE100DADCBC /* Result.swift */; }; - 4345E3FB21F04911009E00E5 /* UIColor+HIG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0C31E4659E700FF19A9 /* UIColor+HIG.swift */; }; 4345E3FC21F04911009E00E5 /* UIColor+HIG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0C31E4659E700FF19A9 /* UIColor+HIG.swift */; }; 4345E40121F67300009E00E5 /* PotentialCarbEntryUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DE92581C5479E4001FFDE1 /* PotentialCarbEntryUserInfo.swift */; }; 4345E40221F67300009E00E5 /* PotentialCarbEntryUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DE92581C5479E4001FFDE1 /* PotentialCarbEntryUserInfo.swift */; }; @@ -149,12 +159,10 @@ 43BFF0B51E45C1E700FF19A9 /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0B31E45C1BE00FF19A9 /* NumberFormatter.swift */; }; 43BFF0B71E45C20C00FF19A9 /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0B31E45C1BE00FF19A9 /* NumberFormatter.swift */; }; 43BFF0C61E465A4400FF19A9 /* UIColor+HIG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0C31E4659E700FF19A9 /* UIColor+HIG.swift */; }; - 43BFF0CD1E466C8400FF19A9 /* StateColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0CC1E466C8400FF19A9 /* StateColorPalette.swift */; }; 43C05CA821EB2B26006FB252 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431E73471FF95A900069B5F7 /* PersistenceController.swift */; }; 43C05CA921EB2B26006FB252 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431E73471FF95A900069B5F7 /* PersistenceController.swift */; }; 43C05CAA21EB2B49006FB252 /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; 43C05CAB21EB2B4A006FB252 /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; - 43C05CAC21EB2B8B006FB252 /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; 43C05CAD21EB2BBF006FB252 /* NSUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430B29892041F54A00BA9F93 /* NSUserDefaults.swift */; }; 43C05CAF21EB2C24006FB252 /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; 43C05CB221EBD88A006FB252 /* LoopCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 43D9002A21EB209400AF44BF /* LoopCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -180,7 +188,6 @@ 43DBF0531C93EC8200B3C386 /* DeviceDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DBF0521C93EC8200B3C386 /* DeviceDataManager.swift */; }; 43DFB62320D4CAE7008A7BAE /* PumpManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C3B6F620BBCAA30026CAFA /* PumpManager.swift */; }; 43E3449F1B9D68E900C85C07 /* StatusTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43E3449E1B9D68E900C85C07 /* StatusTableViewController.swift */; }; - 43E93FB51E4675E800EAB8DB /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0B31E45C1BE00FF19A9 /* NumberFormatter.swift */; }; 43E93FB61E469A4000EAB8DB /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0B31E45C1BE00FF19A9 /* NumberFormatter.swift */; }; 43E93FB71E469A5100EAB8DB /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F526D5E1DF2459000A04910 /* HKUnit.swift */; }; 43F41C371D3BF32400C11ED6 /* UIAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F41C361D3BF32400C11ED6 /* UIAlertController.swift */; }; @@ -190,7 +197,6 @@ 43FCBBC21E51710B00343C1B /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 43776F9A1B8022E90074EA36 /* LaunchScreen.storyboard */; }; 43FCEEA9221A615B0013DD30 /* StatusChartsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43FCEEA8221A615B0013DD30 /* StatusChartsManager.swift */; }; 43FCEEAD221A66780013DD30 /* DateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43FCEEAC221A66780013DD30 /* DateFormatter.swift */; }; - 43FCEEB1221A863E0013DD30 /* StatusChartsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43FCEEB0221A863E0013DD30 /* StatusChartsManager.swift */; }; 4B60626C287E286000BF8BBB /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 4B60626A287E286000BF8BBB /* Localizable.strings */; }; 4B60626D287E286000BF8BBB /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 4B60626A287E286000BF8BBB /* Localizable.strings */; }; 4B67E2C8289B4EDB002D92AF /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 4B67E2C6289B4EDB002D92AF /* InfoPlist.strings */; }; @@ -202,7 +208,6 @@ 4F2C15741E0209F500E160D4 /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; 4F2C15811E0495B200E160D4 /* WatchContext+WatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F2C15801E0495B200E160D4 /* WatchContext+WatchApp.swift */; }; 4F2C15821E074FC600E160D4 /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; - 4F2C15831E0757E600E160D4 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F526D5E1DF2459000A04910 /* HKUnit.swift */; }; 4F2C15851E075B8700E160D4 /* LoopUI.h in Headers */ = {isa = PBXBuildFile; fileRef = 4F75288D1DFE1DC600C322D6 /* LoopUI.h */; settings = {ATTRIBUTES = (Public, ); }; }; 4F2C15931E09BF2C00E160D4 /* HUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F2C15921E09BF2C00E160D4 /* HUDView.swift */; }; 4F2C15951E09BF3C00E160D4 /* HUDView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 4F2C15941E09BF3C00E160D4 /* HUDView.xib */; }; @@ -210,11 +215,7 @@ 4F2C159A1E0C9E5600E160D4 /* LoopUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 4F526D611DF8D9A900A04910 /* NetBasal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F526D601DF8D9A900A04910 /* NetBasal.swift */; }; 4F6663941E905FD2009E74FC /* ChartColorPalette+Loop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F6663931E905FD2009E74FC /* ChartColorPalette+Loop.swift */; }; - 4F70C1E11DE8DCA7006380B7 /* StatusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F70C1E01DE8DCA7006380B7 /* StatusViewController.swift */; }; - 4F70C1E41DE8DCA7006380B7 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4F70C1E21DE8DCA7006380B7 /* MainInterface.storyboard */; }; - 4F70C1E81DE8DCA7006380B7 /* Loop Status Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 4F70C1DC1DE8DCA7006380B7 /* Loop Status Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 4F70C2101DE8FAC5006380B7 /* ExtensionDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F70C20F1DE8FAC5006380B7 /* ExtensionDataManager.swift */; }; - 4F70C2121DE900EA006380B7 /* StatusExtensionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F70C2111DE900EA006380B7 /* StatusExtensionContext.swift */; }; 4F70C2131DE90339006380B7 /* StatusExtensionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F70C2111DE900EA006380B7 /* StatusExtensionContext.swift */; }; 4F7528941DFE1E9500C322D6 /* LoopUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */; }; 4F75289A1DFE1F6000C322D6 /* BasalRateHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CEEBF1CD6FCD8003C8C80 /* BasalRateHUDView.swift */; }; @@ -229,31 +230,30 @@ 4F7E8AC720E2AC0300AEA65E /* WatchPredictedGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */; }; 4F7E8ACB20E2ACB500AEA65E /* WatchPredictedGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */; }; 4F82655020E69F9A0031A8F5 /* HUDInterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F82654F20E69F9A0031A8F5 /* HUDInterfaceController.swift */; }; - 4FAC02541E22F6B20087A773 /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; 4FC8C8011DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC8C8001DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift */; }; - 4FC8C8021DEB943800A1452E /* NSUserDefaults+StatusExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC8C8001DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift */; }; 4FDDD23720DC51DF00D04B16 /* LoopDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FDDD23620DC51DF00D04B16 /* LoopDataManager.swift */; }; 4FF4D0F81E1725B000846527 /* NibLoadable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434F54561D287FDB002A9274 /* NibLoadable.swift */; }; 4FF4D1001E18374700846527 /* WatchContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF4D0FF1E18374700846527 /* WatchContext.swift */; }; 4FF4D1011E18375000846527 /* WatchContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF4D0FF1E18374700846527 /* WatchContext.swift */; }; 63F5E17C297DDF3900A62D4B /* ckcomplication.strings in Resources */ = {isa = PBXBuildFile; fileRef = 63F5E17A297DDF3900A62D4B /* ckcomplication.strings */; }; 7D23667D21250C7E0028B67D /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D23667C21250C7E0028B67D /* LocalizedString.swift */; }; - 7D7076351FE06EDE004AC8EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076371FE06EDE004AC8EA /* Localizable.strings */; }; 7D7076451FE06EE0004AC8EA /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076471FE06EE0004AC8EA /* InfoPlist.strings */; }; 7D70764A1FE06EE1004AC8EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D70764C1FE06EE1004AC8EA /* Localizable.strings */; }; 7D70764F1FE06EE1004AC8EA /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076511FE06EE1004AC8EA /* InfoPlist.strings */; }; 7D70765E1FE06EE3004AC8EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076601FE06EE3004AC8EA /* Localizable.strings */; }; 7D7076631FE06EE4004AC8EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076651FE06EE4004AC8EA /* Localizable.strings */; }; 7E69CFFC2A16A77E00203CBD /* ResetLoopManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */; }; + 8496F7312B5711C4003E672C /* ContentMargin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8496F7302B5711C4003E672C /* ContentMargin.swift */; }; 84AA81D32A4A27A3000B658B /* LoopWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */; }; 84AA81D62A4A28AF000B658B /* WidgetBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */; }; 84AA81D82A4A2910000B658B /* StatusWidgetTimelimeEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D72A4A2910000B658B /* StatusWidgetTimelimeEntry.swift */; }; 84AA81DB2A4A2973000B658B /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81DA2A4A2973000B658B /* Date.swift */; }; 84AA81DD2A4A2999000B658B /* StatusWidgetTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81DC2A4A2999000B658B /* StatusWidgetTimelineProvider.swift */; }; - 84AA81E32A4A36FB000B658B /* SystemActionLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81E22A4A36FB000B658B /* SystemActionLink.swift */; }; 84AA81E52A4A3981000B658B /* DeeplinkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81E42A4A3981000B658B /* DeeplinkManager.swift */; }; 84AA81E72A4A4DEF000B658B /* PumpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81E62A4A4DEF000B658B /* PumpView.swift */; }; 84D2879F2AC756C8007ED283 /* ContentMargin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D2879E2AC756C8007ED283 /* ContentMargin.swift */; }; + 84DEB10D2C18FABA00170734 /* IOSFocusModesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */; }; + 84EC162E2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */; }; 891B508524342BE1005DA578 /* CarbAndBolusFlowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 891B508424342BE1005DA578 /* CarbAndBolusFlowViewModel.swift */; }; 892A5D59222F0A27008961AB /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D58222F0A27008961AB /* Debug.swift */; }; 892A5D692230C41D008961AB /* RangeReplaceableCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D682230C41D008961AB /* RangeReplaceableCollection.swift */; }; @@ -272,7 +272,6 @@ 895788B3242E69A2002CB114 /* ActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895788AB242E69A2002CB114 /* ActionButton.swift */; }; 895FE0952201234000FCF18A /* OverrideSelectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895FE0942201234000FCF18A /* OverrideSelectionViewController.swift */; }; 8968B1122408B3520074BB48 /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8968B1112408B3520074BB48 /* UIFont.swift */; }; - 8968B114240C55F10074BB48 /* LoopSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8968B113240C55F10074BB48 /* LoopSettingsTests.swift */; }; 897A5A9624C2175B00C4E71D /* BolusEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 897A5A9524C2175B00C4E71D /* BolusEntryView.swift */; }; 897A5A9924C22DE800C4E71D /* BolusEntryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 897A5A9824C22DE800C4E71D /* BolusEntryViewModel.swift */; }; 898ECA60218ABD17001E9D35 /* GlucoseChartScaler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 898ECA5E218ABD17001E9D35 /* GlucoseChartScaler.swift */; }; @@ -294,7 +293,6 @@ 89ADE13B226BFA0F0067222B /* TestingScenariosManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */; }; 89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CA2B2F226C0161004D9350 /* DirectoryObserver.swift */; }; 89CA2B32226C18B8004D9350 /* TestingScenariosTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CA2B31226C18B8004D9350 /* TestingScenariosTableViewController.swift */; }; - 89CA2B3D226E6B13004D9350 /* LocalTestingScenariosManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CA2B3C226E6B13004D9350 /* LocalTestingScenariosManager.swift */; }; 89CAB36324C8FE96009EE3CE /* PredictedGlucoseChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CAB36224C8FE95009EE3CE /* PredictedGlucoseChartView.swift */; }; 89D1503E24B506EB00EDE253 /* Dictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89D1503D24B506EB00EDE253 /* Dictionary.swift */; }; 89D6953E23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89D6953D23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift */; }; @@ -303,7 +301,6 @@ 89E08FC6242E7506000D719B /* CarbAndDateInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E08FC5242E7506000D719B /* CarbAndDateInput.swift */; }; 89E08FC8242E76E9000D719B /* AnyTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E08FC7242E76E9000D719B /* AnyTransition.swift */; }; 89E08FCA242E7714000D719B /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E08FC9242E7714000D719B /* UIFont.swift */; }; - 89E08FCC242E790C000D719B /* Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E08FCB242E790C000D719B /* Comparable.swift */; }; 89E08FD0242E8B2B000D719B /* BolusConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E08FCF242E8B2B000D719B /* BolusConfirmationView.swift */; }; 89E267FC2292456700A3F2AF /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E267FB2292456700A3F2AF /* FeatureFlags.swift */; }; 89E267FD2292456700A3F2AF /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E267FB2292456700A3F2AF /* FeatureFlags.swift */; }; @@ -314,8 +311,6 @@ 89F9119424358E4500ECCAF3 /* CarbAbsorptionTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89F9119324358E4500ECCAF3 /* CarbAbsorptionTime.swift */; }; 89F9119624358E6900ECCAF3 /* BolusPickerValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89F9119524358E6900ECCAF3 /* BolusPickerValues.swift */; }; 89FE21AD24AC57E30033F501 /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89FE21AC24AC57E30033F501 /* Collection.swift */; }; - A90EF53C25DEF06200F32D61 /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16DA84122E8E112008624C2 /* PluginManager.swift */; }; - A90EF54425DEF0A000F32D61 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4374B5EE209D84BE00D17AA8 /* OSLog.swift */; }; A91D2A3F26CF0FF80023B075 /* IconTitleSubtitleTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A91D2A3E26CF0FF80023B075 /* IconTitleSubtitleTableViewCell.swift */; }; A91E4C2124F867A700BE9213 /* StoredAlertTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A91E4C2024F867A700BE9213 /* StoredAlertTests.swift */; }; A91E4C2324F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A91E4C2224F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift */; }; @@ -369,24 +364,23 @@ B4001CEE28CBBC82002FB414 /* AlertManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4001CED28CBBC82002FB414 /* AlertManagementView.swift */; }; B405E35924D2A75B00DD058D /* DerivedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A966152923EA5A37005D8B29 /* DerivedAssets.xcassets */; }; B405E35A24D2B1A400DD058D /* HUDAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4F2C15961E09E94E00E160D4 /* HUDAssets.xcassets */; }; - B405E35B24D2E05600DD058D /* HUDAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4F2C15961E09E94E00E160D4 /* HUDAssets.xcassets */; }; B40D07C7251A89D500C1C6D7 /* GlucoseDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40D07C6251A89D500C1C6D7 /* GlucoseDisplay.swift */; }; B42C951424A3C76000857C73 /* CGMStatusHUDViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42C951324A3C76000857C73 /* CGMStatusHUDViewModel.swift */; }; B42D124328D371C400E43D22 /* AlertMuter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42D124228D371C400E43D22 /* AlertMuter.swift */; }; B43CF07E29434EC4008A520B /* HowMuteAlertWorkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43CF07D29434EC4008A520B /* HowMuteAlertWorkView.swift */; }; B43DA44124D9C12100CAFF4E /* DismissibleHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43DA44024D9C12100CAFF4E /* DismissibleHostingController.swift */; }; + B455C7332BD14E25002B847E /* Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B455C7322BD14E25002B847E /* Comparable.swift */; }; + B455C7352BD14E30002B847E /* Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B455C7322BD14E25002B847E /* Comparable.swift */; }; B470F5842AB22B5100049695 /* StatefulPluggable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B470F5832AB22B5100049695 /* StatefulPluggable.swift */; }; B48B0BAC24900093009A48DE /* PumpStatusHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48B0BAB24900093009A48DE /* PumpStatusHUDView.swift */; }; B490A03F24D0550F00F509FA /* GlucoseRangeCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B490A03E24D0550F00F509FA /* GlucoseRangeCategory.swift */; }; B490A04124D0559D00F509FA /* DeviceLifecycleProgressState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B490A04024D0559D00F509FA /* DeviceLifecycleProgressState.swift */; }; B490A04324D055D900F509FA /* DeviceStatusHighlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = B490A04224D055D900F509FA /* DeviceStatusHighlight.swift */; }; - B491B09E24D0B600004CBE8F /* DerivedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A966152523EA5A25005D8B29 /* DerivedAssets.xcassets */; }; B491B0A324D0B66D004CBE8F /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = B490A03C24D04F9400F509FA /* Color.swift */; }; B491B0A424D0B675004CBE8F /* UIColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0B11E45C18400FF19A9 /* UIColor.swift */; }; B4AC0D3F24B9005300CDB0A1 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CEEE31CDE5C0A003C8C80 /* UIImage.swift */; }; B4BC56382518DEA900373647 /* CGMStatusHUDViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4BC56372518DEA900373647 /* CGMStatusHUDViewModelTests.swift */; }; B4C9859425D5A3BB009FD9CA /* StatusBadgeHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C9859325D5A3BB009FD9CA /* StatusBadgeHUDView.swift */; }; - B4CAD8792549D2540057946B /* LoopCompletionFreshnessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4CAD8782549D2540057946B /* LoopCompletionFreshnessTests.swift */; }; B4D4534128E5CA7900F1A8D9 /* AlertMuterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D4534028E5CA7900F1A8D9 /* AlertMuterTests.swift */; }; B4D620D424D9EDB900043B3C /* GuidanceColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D620D324D9EDB900043B3C /* GuidanceColors.swift */; }; B4D904412AA8989100CBD826 /* StatefulPluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D904402AA8989100CBD826 /* StatefulPluginManager.swift */; }; @@ -403,7 +397,6 @@ B4FEEF7D24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4FEEF7C24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift */; }; C1004DF22981F5B700B8CF94 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1004DF02981F5B700B8CF94 /* InfoPlist.strings */; }; C1004DF52981F5B700B8CF94 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1004DF32981F5B700B8CF94 /* Localizable.strings */; }; - C1004DF82981F5B700B8CF94 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1004DF62981F5B700B8CF94 /* InfoPlist.strings */; }; C110888D2A3913C600BA4898 /* BuildDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = C110888C2A3913C600BA4898 /* BuildDetails.swift */; }; C11613492983096D00777E7C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C11613472983096D00777E7C /* InfoPlist.strings */; }; C116134C2983096D00777E7C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C116134A2983096D00777E7C /* Localizable.strings */; }; @@ -416,14 +409,15 @@ C11BD0552523CFED00236B08 /* SimpleBolusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11BD0542523CFED00236B08 /* SimpleBolusViewModel.swift */; }; C1201E2C23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1201E2B23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift */; }; C1201E2D23ECDF3D002DA84A /* WatchContextRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1201E2B23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift */; }; + C129BF4A2B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C129BF492B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift */; }; + C129D3BF2B8697F100FEA6A9 /* TempBasalRecommendationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C129D3BE2B8697F100FEA6A9 /* TempBasalRecommendationTests.swift */; }; C13072BA2A76AF31009A7C58 /* live_capture_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = C13072B92A76AF31009A7C58 /* live_capture_predicted_glucose.json */; }; C13255D6223E7BE2008AF50C /* BolusProgressTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */; }; + C138C6F82C1B8A2C00F08F1A /* GlucoseCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = C138C6F72C1B8A2C00F08F1A /* GlucoseCondition.swift */; }; C13DA2B024F6C7690098BB29 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13DA2AF24F6C7690098BB29 /* UIViewController.swift */; }; C148CEE724FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C148CEE624FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift */; }; - C159C825286785E000A86EC0 /* LoopUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */; }; - C159C828286785E100A86EC0 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C159C8192867857000A86EC0 /* LoopKitUI.framework */; }; - C159C82A286785E300A86EC0 /* MockKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C159C8212867859800A86EC0 /* MockKitUI.framework */; }; - C159C82D2867876500A86EC0 /* NotificationCenter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F70C1DD1DE8DCA7006380B7 /* NotificationCenter.framework */; }; + C152B9F52C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */; }; + C152B9F72C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C152B9F62C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift */; }; C159C82F286787EF00A86EC0 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C159C82E286787EF00A86EC0 /* LoopKit.framework */; }; C15A8C492A7305B1009D736B /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C1E3DC4628595FAA00CA19FF /* SwiftCharts */; }; C165756F2534C468004AE16E /* SimpleBolusViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C165756E2534C468004AE16E /* SimpleBolusViewModelTests.swift */; }; @@ -434,6 +428,8 @@ C16B983E26B4893300256B05 /* DoseEnactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16B983D26B4893300256B05 /* DoseEnactor.swift */; }; C16B984026B4898800256B05 /* DoseEnactorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16B983F26B4898800256B05 /* DoseEnactorTests.swift */; }; C16DA84222E8E112008624C2 /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16DA84122E8E112008624C2 /* PluginManager.swift */; }; + C16F51192B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */; }; + C16F511B2B89363A00EFD7A1 /* SimpleInsulinDose.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */; }; C16FC0B02A99392F0025E239 /* live_capture_input.json in Resources */ = {isa = PBXBuildFile; fileRef = C16FC0AF2A99392F0025E239 /* live_capture_input.json */; }; C1735B1E2A0809830082BB8A /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = C1735B1D2A0809830082BB8A /* ZIPFoundation */; }; C1742332259BEADC00399C9D /* ManualEntryDoseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1742331259BEADC00399C9D /* ManualEntryDoseView.swift */; }; @@ -444,7 +440,13 @@ C17824A51E1AD4D100D9D25C /* ManualBolusRecommendation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17824A41E1AD4D100D9D25C /* ManualBolusRecommendation.swift */; }; C17DDC9C28AC339E005FBF4C /* PersistedProperty.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */; }; C17DDC9D28AC33A1005FBF4C /* PersistedProperty.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */; }; - C18913B52524F24C007B0683 /* DeviceDataManager+SimpleBolusViewModelDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18913B42524F24C007B0683 /* DeviceDataManager+SimpleBolusViewModelDelegate.swift */; }; + C188599B2AF15E1B0010F21F /* DeviceDataManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C188599A2AF15E1B0010F21F /* DeviceDataManagerTests.swift */; }; + C188599E2AF15FAB0010F21F /* AlertMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = C188599D2AF15FAB0010F21F /* AlertMocks.swift */; }; + C18859A02AF1612B0010F21F /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C188599F2AF1612B0010F21F /* PersistenceController.swift */; }; + C18859A22AF165130010F21F /* MockPumpManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18859A12AF165130010F21F /* MockPumpManager.swift */; }; + C18859A42AF165330010F21F /* MockCGMManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18859A32AF165330010F21F /* MockCGMManager.swift */; }; + C18859A82AF292D90010F21F /* MockTrustedTimeChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18859A72AF292D90010F21F /* MockTrustedTimeChecker.swift */; }; + C18859AC2AF29BE50010F21F /* TemporaryPresetsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18859AB2AF29BE50010F21F /* TemporaryPresetsManager.swift */; }; C19008FE25225D3900721625 /* SimpleBolusCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19008FD25225D3900721625 /* SimpleBolusCalculator.swift */; }; C1900900252271BB00721625 /* SimpleBolusCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19008FF252271BB00721625 /* SimpleBolusCalculatorTests.swift */; }; C191D2A125B3ACAA00C26C0B /* DosingStrategySelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C191D2A025B3ACAA00C26C0B /* DosingStrategySelectionView.swift */; }; @@ -459,20 +461,22 @@ C19C8C1E28663B040056D5E4 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4344628320A7A3BE00C4BE6F /* LoopKit.framework */; }; C19C8C1F28663B040056D5E4 /* LoopKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 4344628320A7A3BE00C4BE6F /* LoopKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; C19C8C21286776C20056D5E4 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C19C8C20286776C20056D5E4 /* LoopKit.framework */; }; - C19E96DF23D275F8003F79B0 /* LoopCompletionFreshness.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */; }; - C19E96E023D275FA003F79B0 /* LoopCompletionFreshness.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */; }; C19F48742560ABFB003632D7 /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; C1AD4200256D61E500164DDD /* Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AD41FF256D61E500164DDD /* Comparable.swift */; }; C1AF062329426300002C1B19 /* ManualGlucoseEntryRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */; }; + C1B80D632AF97D7200AB7705 /* LoopDataManager+CarbAbsorption.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B80D622AF97D7200AB7705 /* LoopDataManager+CarbAbsorption.swift */; }; C1C660D1252E4DD5009B5C32 /* LoopConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1C660D0252E4DD5009B5C32 /* LoopConstants.swift */; }; C1C73F0D1DE3D0270022FC89 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1C73F0F1DE3D0270022FC89 /* InfoPlist.strings */; }; C1CCF1122858FA900035389C /* LoopCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43D9FFCF21EAE05D00AF44BF /* LoopCore.framework */; }; - C1CCF1172858FBAD0035389C /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C1CCF1162858FBAD0035389C /* SwiftCharts */; }; C1D0B6302986D4D90098D215 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D0B62F2986D4D90098D215 /* LocalizedString.swift */; }; C1D0B6312986D4D90098D215 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D0B62F2986D4D90098D215 /* LocalizedString.swift */; }; C1D289B522F90A52003FFBD9 /* BasalDeliveryState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D289B422F90A52003FFBD9 /* BasalDeliveryState.swift */; }; - C1D476B42A8ED179002C1C87 /* LoopAlgorithmTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D476B32A8ED179002C1C87 /* LoopAlgorithmTests.swift */; }; C1D6EEA02A06C7270047DE5C /* MKRingProgressView in Frameworks */ = {isa = PBXBuildFile; productRef = C1D6EE9F2A06C7270047DE5C /* MKRingProgressView */; }; + C1DA434F2B164C6C00CBD33F /* MockSettingsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA434E2B164C6C00CBD33F /* MockSettingsProvider.swift */; }; + C1DA43532B19310A00CBD33F /* LoopControlMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA43522B19310A00CBD33F /* LoopControlMock.swift */; }; + C1DA43552B193BCB00CBD33F /* MockUploadEventListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA43542B193BCB00CBD33F /* MockUploadEventListener.swift */; }; + C1DA43572B1A70BE00CBD33F /* SettingsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA43562B1A70BE00CBD33F /* SettingsManagerTests.swift */; }; + C1DA43592B1A784900CBD33F /* MockDeliveryDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA43582B1A784900CBD33F /* MockDeliveryDelegate.swift */; }; C1DE5D23251BFC4D00439E49 /* SimpleBolusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */; }; C1E2773E224177C000354103 /* ClockKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C1E2773D224177C000354103 /* ClockKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; C1E3862628247C6100F561A4 /* StoredLoopNotRunningNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1E3862428247B7100F561A4 /* StoredLoopNotRunningNotification.swift */; }; @@ -482,58 +486,27 @@ C1F00C60285A802A006302C5 /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C1F00C5F285A802A006302C5 /* SwiftCharts */; }; C1F00C78285A8256006302C5 /* SwiftCharts in Embed Frameworks */ = {isa = PBXBuildFile; productRef = C1F00C5F285A802A006302C5 /* SwiftCharts */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; C1F2075C26D6F9B0007AB7EB /* AppExpirationAlerter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */; }; + C1F2CAAA2B76B3EE00D7F581 /* TempBasalRecommendation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F2CAA92B76B3EE00D7F581 /* TempBasalRecommendation.swift */; }; + C1F2CAAC2B7A980600D7F581 /* BasalRelativeDose.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F2CAAB2B7A980600D7F581 /* BasalRelativeDose.swift */; }; C1F7822627CC056900C0919A /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F7822527CC056900C0919A /* SettingsManager.swift */; }; C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F8B1D122375E4200DD66CF /* BolusProgressTableViewCell.swift */; }; C1FB428C217806A400FAB378 /* StateColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428B217806A300FAB378 /* StateColorPalette.swift */; }; - C1FB428D21791D2500FAB378 /* PumpManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C3B6F620BBCAA30026CAFA /* PumpManager.swift */; }; C1FB428F217921D600FAB378 /* PumpManagerUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428E217921D600FAB378 /* PumpManagerUI.swift */; }; - C1FB4290217922A100FAB378 /* PumpManagerUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428E217921D600FAB378 /* PumpManagerUI.swift */; }; DD3DBD292A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */; }; DDC389F62A2B61750066E2E8 /* ApplicationFactorStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC389F52A2B61750066E2E8 /* ApplicationFactorStrategy.swift */; }; DDC389F82A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC389F72A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift */; }; DDC389FA2A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC389F92A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift */; }; DDC389FC2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC389FB2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift */; }; DDC389FE2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */; }; - E90909D124E34AC500F963D2 /* high_and_rising_with_cob_momentum_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909CC24E34AC500F963D2 /* high_and_rising_with_cob_momentum_effect.json */; }; - E90909D224E34AC500F963D2 /* high_and_rising_with_cob_insulin_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909CD24E34AC500F963D2 /* high_and_rising_with_cob_insulin_effect.json */; }; - E90909D324E34AC500F963D2 /* high_and_rising_with_cob_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909CE24E34AC500F963D2 /* high_and_rising_with_cob_predicted_glucose.json */; }; - E90909D424E34AC500F963D2 /* high_and_rising_with_cob_carb_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909CF24E34AC500F963D2 /* high_and_rising_with_cob_carb_effect.json */; }; - E90909D524E34AC500F963D2 /* high_and_rising_with_cob_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909D024E34AC500F963D2 /* high_and_rising_with_cob_counteraction_effect.json */; }; - E90909DC24E34F1600F963D2 /* low_and_falling_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909D724E34F1500F963D2 /* low_and_falling_predicted_glucose.json */; }; - E90909DD24E34F1600F963D2 /* low_and_falling_carb_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909D824E34F1500F963D2 /* low_and_falling_carb_effect.json */; }; - E90909DE24E34F1600F963D2 /* low_and_falling_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909D924E34F1500F963D2 /* low_and_falling_counteraction_effect.json */; }; - E90909DF24E34F1600F963D2 /* low_and_falling_insulin_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909DA24E34F1600F963D2 /* low_and_falling_insulin_effect.json */; }; - E90909E024E34F1600F963D2 /* low_and_falling_momentum_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909DB24E34F1600F963D2 /* low_and_falling_momentum_effect.json */; }; - E90909E724E3530200F963D2 /* low_with_low_treatment_carb_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909E224E3530200F963D2 /* low_with_low_treatment_carb_effect.json */; }; - E90909E824E3530200F963D2 /* low_with_low_treatment_insulin_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909E324E3530200F963D2 /* low_with_low_treatment_insulin_effect.json */; }; - E90909E924E3530200F963D2 /* low_with_low_treatment_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909E424E3530200F963D2 /* low_with_low_treatment_predicted_glucose.json */; }; - E90909EA24E3530200F963D2 /* low_with_low_treatment_momentum_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909E524E3530200F963D2 /* low_with_low_treatment_momentum_effect.json */; }; - E90909EB24E3530200F963D2 /* low_with_low_treatment_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909E624E3530200F963D2 /* low_with_low_treatment_counteraction_effect.json */; }; - E90909EE24E35B4000F963D2 /* high_and_falling_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909ED24E35B4000F963D2 /* high_and_falling_predicted_glucose.json */; }; - E90909F224E35B4D00F963D2 /* high_and_falling_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909EF24E35B4C00F963D2 /* high_and_falling_counteraction_effect.json */; }; - E90909F324E35B4D00F963D2 /* high_and_falling_carb_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909F024E35B4C00F963D2 /* high_and_falling_carb_effect.json */; }; - E90909F424E35B4D00F963D2 /* high_and_falling_insulin_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909F124E35B4C00F963D2 /* high_and_falling_insulin_effect.json */; }; - E90909F624E35B7C00F963D2 /* high_and_falling_momentum_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909F524E35B7C00F963D2 /* high_and_falling_momentum_effect.json */; }; E93E865424DB6CBA00FF40C8 /* retrospective_output.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E865324DB6CBA00FF40C8 /* retrospective_output.json */; }; E93E865624DB731900FF40C8 /* predicted_glucose_without_retrospective.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E865524DB731900FF40C8 /* predicted_glucose_without_retrospective.json */; }; E93E865824DB75BE00FF40C8 /* predicted_glucose_very_negative.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E865724DB75BD00FF40C8 /* predicted_glucose_very_negative.json */; }; E93E86A824DDCC4400FF40C8 /* MockDoseStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E93E86A724DDCC4400FF40C8 /* MockDoseStore.swift */; }; E93E86B024DDE1BD00FF40C8 /* MockGlucoseStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E93E86AF24DDE1BD00FF40C8 /* MockGlucoseStore.swift */; }; E93E86B224DDE21D00FF40C8 /* MockCarbStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E93E86B124DDE21D00FF40C8 /* MockCarbStore.swift */; }; - E93E86BA24E1FDC400FF40C8 /* flat_and_stable_insulin_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86B424E1FDC400FF40C8 /* flat_and_stable_insulin_effect.json */; }; - E93E86BB24E1FDC400FF40C8 /* flat_and_stable_momentum_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86B524E1FDC400FF40C8 /* flat_and_stable_momentum_effect.json */; }; - E93E86BC24E1FDC400FF40C8 /* flat_and_stable_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86B624E1FDC400FF40C8 /* flat_and_stable_predicted_glucose.json */; }; - E93E86BE24E1FDC400FF40C8 /* flat_and_stable_carb_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86B824E1FDC400FF40C8 /* flat_and_stable_carb_effect.json */; }; - E93E86C324E1FE6100FF40C8 /* flat_and_stable_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86C224E1FE6100FF40C8 /* flat_and_stable_counteraction_effect.json */; }; - E93E86CA24E2E02200FF40C8 /* high_and_stable_insulin_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86C524E2E02200FF40C8 /* high_and_stable_insulin_effect.json */; }; - E93E86CB24E2E02200FF40C8 /* high_and_stable_carb_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86C624E2E02200FF40C8 /* high_and_stable_carb_effect.json */; }; - E93E86CC24E2E02200FF40C8 /* high_and_stable_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86C724E2E02200FF40C8 /* high_and_stable_predicted_glucose.json */; }; - E93E86CD24E2E02200FF40C8 /* high_and_stable_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86C824E2E02200FF40C8 /* high_and_stable_counteraction_effect.json */; }; - E93E86CE24E2E02200FF40C8 /* high_and_stable_momentum_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86C924E2E02200FF40C8 /* high_and_stable_momentum_effect.json */; }; E942DE96253BE68F00AC532D /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; E942DE9F253BE6A900AC532D /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; E942DF34253BF87F00AC532D /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 43785E9B2120E7060057DED1 /* Intents.intentdefinition */; }; - E950CA9129002D9000B5B692 /* LoopDataManagerDosingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E950CA9029002D9000B5B692 /* LoopDataManagerDosingTests.swift */; }; E95D380124EADE7C005E2F50 /* DoseStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95D380024EADE7C005E2F50 /* DoseStoreProtocol.swift */; }; E95D380324EADF36005E2F50 /* CarbStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95D380224EADF36005E2F50 /* CarbStoreProtocol.swift */; }; E95D380524EADF78005E2F50 /* GlucoseStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95D380424EADF78005E2F50 /* GlucoseStoreProtocol.swift */; }; @@ -618,13 +591,6 @@ remoteGlobalIDString = 43776F8B1B8022E90074EA36; remoteInfo = Loop; }; - 4F70C1E61DE8DCA7006380B7 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 43776F841B8022E90074EA36 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 4F70C1DB1DE8DCA7006380B7; - remoteInfo = "Loop Status Extension"; - }; 4F7528961DFE1ED400C322D6 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 43776F841B8022E90074EA36 /* Project object */; @@ -639,13 +605,6 @@ remoteGlobalIDString = 43D9001A21EB209400AF44BF; remoteInfo = "LoopCore-watchOS"; }; - C11B9D582867781E00500CF8 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 43776F841B8022E90074EA36 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 4F75288A1DFE1DC600C322D6; - remoteInfo = LoopUI; - }; C1CCF1142858FA900035389C /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 43776F841B8022E90074EA36 /* Project object */; @@ -724,7 +683,6 @@ files = ( 14B1736928AED9EE006CCD7C /* Loop Widget Extension.appex in Embed App Extensions */, E9B07F94253BBA6500BAD8F8 /* Loop Intent Extension.appex in Embed App Extensions */, - 4F70C1E81DE8DCA7006380B7 /* Loop Status Extension.appex in Embed App Extensions */, ); name = "Embed App Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -745,9 +703,13 @@ /* Begin PBXFileReference section */ 142CB7582A60BF2E0075748A /* EditMode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditMode.swift; sourceTree = ""; }; 142CB75A2A60BFC30075748A /* FavoriteFoodsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FavoriteFoodsView.swift; sourceTree = ""; }; - 1452F4A82A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditFavoriteFoodViewModel.swift; sourceTree = ""; }; - 1452F4AA2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditFavoriteFoodView.swift; sourceTree = ""; }; + 1452F4A82A851C9400F8B9E4 /* FavoriteFoodAddEditViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteFoodAddEditViewModel.swift; sourceTree = ""; }; + 1452F4AA2A851EDF00F8B9E4 /* FavoriteFoodAddEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteFoodAddEditView.swift; sourceTree = ""; }; 1452F4AC2A851F8800F8B9E4 /* HowAbsorptionTimeWorksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowAbsorptionTimeWorksView.swift; sourceTree = ""; }; + 1455ACAA2C666F9C004F44F2 /* EventualGlucoseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventualGlucoseView.swift; sourceTree = ""; }; + 1455ACAC2C6675DF004F44F2 /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = ""; }; + 1455ACAF2C667A1F004F44F2 /* DeeplinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeeplinkView.swift; sourceTree = ""; }; + 1455ACB12C667BEE004F44F2 /* Deeplink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Deeplink.swift; sourceTree = ""; }; 147EFE8D2A8BCC5500272438 /* DefaultAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DefaultAssets.xcassets; sourceTree = ""; }; 147EFE8F2A8BCD8000272438 /* DerivedAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DerivedAssets.xcassets; sourceTree = ""; }; 147EFE912A8BCD8A00272438 /* DerivedAssetsBase.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DerivedAssetsBase.xcassets; sourceTree = ""; }; @@ -762,8 +724,18 @@ 14B1736E28AEDBF6006CCD7C /* BasalView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BasalView.swift; sourceTree = ""; }; 14B1736F28AEDBF6006CCD7C /* SystemStatusWidget.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SystemStatusWidget.swift; sourceTree = ""; }; 14B1737028AEDBF6006CCD7C /* GlucoseView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseView.swift; sourceTree = ""; }; - 14B1737128AEDBF6006CCD7C /* LoopCircleView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoopCircleView.swift; sourceTree = ""; }; + 14BBB3B12C629DB100ECB800 /* Character+IsEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Character+IsEmoji.swift"; sourceTree = ""; }; + 14C970672C5991CD00E8A01B /* LoopChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopChartView.swift; sourceTree = ""; }; + 14C970692C5A833100E8A01B /* CarbEffectChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbEffectChartView.swift; sourceTree = ""; }; + 14C9706B2C5A836000E8A01B /* DoseChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoseChartView.swift; sourceTree = ""; }; + 14C9706D2C5A83AF00E8A01B /* IOBChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOBChartView.swift; sourceTree = ""; }; + 14C9707D2C5A9EB600E8A01B /* GlucoseCarbChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseCarbChartView.swift; sourceTree = ""; }; + 14C9707F2C5C0A1500E8A01B /* FavoriteFoodInsightsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteFoodInsightsViewModel.swift; sourceTree = ""; }; + 14C970812C5C2EC100E8A01B /* FavoriteFoodInsightsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteFoodInsightsView.swift; sourceTree = ""; }; + 14C970832C5C2FB400E8A01B /* HowCarbEffectsWorksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowCarbEffectsWorksView.swift; sourceTree = ""; }; + 14C970852C5C358C00E8A01B /* FavoriteFoodInsightsChartsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteFoodInsightsChartsView.swift; sourceTree = ""; }; 14D906F32A846510006EB79A /* FavoriteFoodsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteFoodsViewModel.swift; sourceTree = ""; }; + 14ED83F52C6421F9008B4A5C /* FavoriteFoodInsightsCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteFoodInsightsCardView.swift; sourceTree = ""; }; 1D05219A2469E9DF000EBBDE /* StoredAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredAlert.swift; sourceTree = ""; }; 1D05219C2469F1F5000EBBDE /* AlertStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertStore.swift; sourceTree = ""; }; 1D080CBC2473214A00356610 /* AlertStore.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = AlertStore.xcdatamodel; sourceTree = ""; }; @@ -777,7 +749,6 @@ 1D80313C24746274002810DF /* AlertStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertStoreTests.swift; sourceTree = ""; }; 1D82E69F25377C6B009131FB /* TrustedTimeChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrustedTimeChecker.swift; sourceTree = ""; }; 1D8D55BB252274650044DBB6 /* BolusEntryViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusEntryViewModelTests.swift; sourceTree = ""; }; - 1D9650C72523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceDataManager+BolusEntryViewModelDelegate.swift"; sourceTree = ""; }; 1DA46B5F2492E2E300D71A63 /* NotificationsCriticalAlertPermissionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsCriticalAlertPermissionsView.swift; sourceTree = ""; }; 1DA649A6244126CD00F61E75 /* UserNotificationAlertScheduler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserNotificationAlertScheduler.swift; sourceTree = ""; }; 1DA649A8244126DA00F61E75 /* InAppModalAlertScheduler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppModalAlertScheduler.swift; sourceTree = ""; }; @@ -881,7 +852,6 @@ 43BFF0B11E45C18400FF19A9 /* UIColor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; 43BFF0B31E45C1BE00FF19A9 /* NumberFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NumberFormatter.swift; sourceTree = ""; }; 43BFF0C31E4659E700FF19A9 /* UIColor+HIG.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+HIG.swift"; sourceTree = ""; }; - 43BFF0CC1E466C8400FF19A9 /* StateColorPalette.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateColorPalette.swift; sourceTree = ""; }; 43C05CB021EBBDB9006FB252 /* TimeInRangeLesson.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeInRangeLesson.swift; sourceTree = ""; }; 43C05CB421EBE274006FB252 /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = ""; }; 43C05CB721EBEA54006FB252 /* HKUnit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKUnit.swift; sourceTree = ""; }; @@ -904,7 +874,6 @@ 43CE7CDD1CA8B63E003CC1B0 /* Data.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Data.swift; sourceTree = ""; }; 43D381611EBD9759007F8C8F /* HeaderValuesTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HeaderValuesTableViewCell.swift; sourceTree = ""; }; 43D533BB1CFD1DD7009E3085 /* WatchApp Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = "WatchApp Extension.entitlements"; sourceTree = ""; }; - 43D848AF1E7DCBE100DADCBC /* Result.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Result.swift; sourceTree = ""; }; 43D9002A21EB209400AF44BF /* LoopCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LoopCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 43D9002C21EB225D00AF44BF /* HealthKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = HealthKit.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS.sdk/System/Library/Frameworks/HealthKit.framework; sourceTree = DEVELOPER_DIR; }; 43D9F81721EC51CC000578CD /* DateEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateEntry.swift; sourceTree = ""; }; @@ -937,7 +906,6 @@ 43F89CA222BDFBBC006BB54E /* UIActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIActivityIndicatorView.swift; sourceTree = ""; }; 43FCEEA8221A615B0013DD30 /* StatusChartsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusChartsManager.swift; sourceTree = ""; }; 43FCEEAC221A66780013DD30 /* DateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatter.swift; sourceTree = ""; }; - 43FCEEB0221A863E0013DD30 /* StatusChartsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusChartsManager.swift; sourceTree = ""; }; 4B60626B287E286000BF8BBB /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; 4B67E2C7289B4EDB002D92AF /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; 4D3B40021D4A9DFE00BC6334 /* G4ShareSpy.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = G4ShareSpy.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -951,12 +919,7 @@ 4F526D5E1DF2459000A04910 /* HKUnit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HKUnit.swift; sourceTree = ""; }; 4F526D601DF8D9A900A04910 /* NetBasal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetBasal.swift; sourceTree = ""; }; 4F6663931E905FD2009E74FC /* ChartColorPalette+Loop.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ChartColorPalette+Loop.swift"; sourceTree = ""; }; - 4F70C1DC1DE8DCA7006380B7 /* Loop Status Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Loop Status Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 4F70C1DD1DE8DCA7006380B7 /* NotificationCenter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NotificationCenter.framework; path = System/Library/Frameworks/NotificationCenter.framework; sourceTree = SDKROOT; }; - 4F70C1E01DE8DCA7006380B7 /* StatusViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = StatusViewController.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; - 4F70C1E31DE8DCA7006380B7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; - 4F70C1E51DE8DCA7006380B7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 4F70C1FD1DE8E662006380B7 /* Loop Status Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Loop Status Extension.entitlements"; sourceTree = ""; }; 4F70C20F1DE8FAC5006380B7 /* ExtensionDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExtensionDataManager.swift; sourceTree = ""; }; 4F70C2111DE900EA006380B7 /* StatusExtensionContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusExtensionContext.swift; sourceTree = ""; }; 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LoopUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -972,85 +935,65 @@ 4FFEDFBE20E5CF22000BFC58 /* ChartHUDController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartHUDController.swift; sourceTree = ""; }; 63F5E17B297DDF3900A62D4B /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/ckcomplication.strings; sourceTree = ""; }; 7D199D93212A067600241026 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Main.strings; sourceTree = ""; }; - 7D199D94212A067600241026 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/MainInterface.strings; sourceTree = ""; }; 7D199D95212A067600241026 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Interface.strings; sourceTree = ""; }; 7D199D96212A067600241026 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; 7D199D97212A067600241026 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; - 7D199D99212A067600241026 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; 7D199D9A212A067600241026 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; 7D199D9D212A067700241026 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; 7D23667521250BE30028B67D /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/Localizable.strings; sourceTree = ""; }; 7D23667621250BF70028B67D /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/InfoPlist.strings; sourceTree = ""; }; - 7D23667821250C2D0028B67D /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/Localizable.strings; sourceTree = ""; }; 7D23667921250C440028B67D /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/Localizable.strings; sourceTree = ""; }; 7D23667A21250C480028B67D /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/InfoPlist.strings; sourceTree = ""; }; 7D23667C21250C7E0028B67D /* LocalizedString.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = LocalizedString.swift; path = LoopUI/Common/LocalizedString.swift; sourceTree = SOURCE_ROOT; }; 7D23667E21250CAC0028B67D /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/InfoPlist.strings; sourceTree = ""; }; 7D23667F21250CB80028B67D /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/Localizable.strings; sourceTree = ""; }; 7D23668521250D180028B67D /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Main.strings; sourceTree = ""; }; - 7D23668621250D180028B67D /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/MainInterface.strings; sourceTree = ""; }; 7D23668721250D180028B67D /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Interface.strings; sourceTree = ""; }; 7D23668821250D180028B67D /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; 7D23668921250D180028B67D /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; - 7D23668B21250D180028B67D /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; 7D23668C21250D190028B67D /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; 7D23668F21250D190028B67D /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; 7D23669521250D220028B67D /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Main.strings; sourceTree = ""; }; - 7D23669621250D230028B67D /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/MainInterface.strings; sourceTree = ""; }; 7D23669721250D230028B67D /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Interface.strings; sourceTree = ""; }; 7D23669821250D230028B67D /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; 7D23669921250D230028B67D /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; - 7D23669B21250D230028B67D /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; 7D23669C21250D230028B67D /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; 7D23669F21250D240028B67D /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; 7D2366A521250D2C0028B67D /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Main.strings"; sourceTree = ""; }; - 7D2366A621250D2C0028B67D /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/MainInterface.strings"; sourceTree = ""; }; 7D2366A721250D2C0028B67D /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Interface.strings"; sourceTree = ""; }; 7D2366A821250D2C0028B67D /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; 7D2366A921250D2C0028B67D /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; - 7D2366AB21250D2D0028B67D /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; 7D2366AC21250D2D0028B67D /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; 7D2366AF21250D2D0028B67D /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; 7D2366B421250D350028B67D /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Interface.strings; sourceTree = ""; }; 7D2366B721250D360028B67D /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Main.strings; sourceTree = ""; }; - 7D2366B821250D360028B67D /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/MainInterface.strings; sourceTree = ""; }; 7D2366B921250D360028B67D /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 7D2366BA21250D360028B67D /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; - 7D2366BC21250D360028B67D /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 7D2366BD21250D360028B67D /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 7D2366BF21250D370028B67D /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 7D2366C521250D3F0028B67D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Main.strings; sourceTree = ""; }; - 7D2366C621250D3F0028B67D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/MainInterface.strings; sourceTree = ""; }; 7D2366C721250D3F0028B67D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Interface.strings; sourceTree = ""; }; 7D2366C821250D400028B67D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 7D2366C921250D400028B67D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; - 7D2366CB21250D400028B67D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 7D2366CC21250D400028B67D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 7D2366CF21250D400028B67D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 7D2366D521250D4A0028B67D /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Main.strings; sourceTree = ""; }; - 7D2366D621250D4A0028B67D /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/MainInterface.strings; sourceTree = ""; }; 7D2366D721250D4A0028B67D /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Interface.strings; sourceTree = ""; }; 7D2366D821250D4A0028B67D /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; 7D2366D921250D4A0028B67D /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; - 7D2366DB21250D4A0028B67D /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; 7D2366DC21250D4B0028B67D /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; 7D2366DF21250D4B0028B67D /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; 7D68AAAA1FE2DB0A00522C49 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Main.strings; sourceTree = ""; }; - 7D68AAAB1FE2DB0A00522C49 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/MainInterface.strings; sourceTree = ""; }; 7D68AAAC1FE2DB0A00522C49 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Interface.strings; sourceTree = ""; }; - 7D68AAAD1FE2E8D400522C49 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 7D68AAB31FE2E8D500522C49 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 7D68AAB41FE2E8D600522C49 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; 7D68AAB71FE2E8D600522C49 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 7D68AAB81FE2E8D700522C49 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; - 7D7076361FE06EDE004AC8EA /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; 7D70764B1FE06EE1004AC8EA /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; 7D70765F1FE06EE3004AC8EA /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; 7D7076641FE06EE4004AC8EA /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEED52335A3CB005DCFD6 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; 7D9BEED72335A489005DCFD6 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Main.strings; sourceTree = ""; }; - 7D9BEED82335A4F7005DCFD6 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BEEDA2335A522005DCFD6 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/MainInterface.strings; sourceTree = ""; }; 7D9BEEDB2335A587005DCFD6 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEEDD2335A5CC005DCFD6 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Interface.strings; sourceTree = ""; }; 7D9BEEDE2335A5F7005DCFD6 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; @@ -1086,71 +1029,59 @@ 7D9BEF122335D694005DCFD6 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Main.strings; sourceTree = ""; }; 7D9BEF132335EC4B005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Intents.strings; sourceTree = ""; }; 7D9BEF152335EC4B005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Main.strings; sourceTree = ""; }; - 7D9BEF162335EC4B005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/MainInterface.strings; sourceTree = ""; }; 7D9BEF172335EC4C005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Interface.strings; sourceTree = ""; }; 7D9BEF182335EC4C005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Main.strings; sourceTree = ""; }; 7D9BEF1A2335EC4C005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF1B2335EC4C005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF1C2335EC4C005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = ""; }; - 7D9BEF1E2335EC4D005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF1F2335EC4D005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF222335EC4D005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF282335EC4E005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF292335EC58005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Intents.strings"; sourceTree = ""; }; 7D9BEF2B2335EC59005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Main.strings"; sourceTree = ""; }; - 7D9BEF2C2335EC59005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/MainInterface.strings"; sourceTree = ""; }; 7D9BEF2D2335EC59005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Interface.strings"; sourceTree = ""; }; 7D9BEF2E2335EC59005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Main.strings"; sourceTree = ""; }; 7D9BEF302335EC59005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; 7D9BEF312335EC59005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; 7D9BEF322335EC59005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/InfoPlist.strings"; sourceTree = ""; }; - 7D9BEF342335EC59005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; 7D9BEF352335EC59005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; 7D9BEF382335EC5A005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; 7D9BEF3E2335EC5A005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; 7D9BEF3F2335EC62005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Intents.strings; sourceTree = ""; }; 7D9BEF412335EC62005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Main.strings; sourceTree = ""; }; - 7D9BEF422335EC62005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/MainInterface.strings; sourceTree = ""; }; 7D9BEF432335EC62005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Interface.strings; sourceTree = ""; }; 7D9BEF442335EC62005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Main.strings; sourceTree = ""; }; 7D9BEF462335EC62005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF472335EC62005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BEF4A2335EC63005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF4B2335EC63005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF4E2335EC63005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF542335EC64005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF552335EC6E005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Intents.strings; sourceTree = ""; }; 7D9BEF572335EC6E005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Main.strings; sourceTree = ""; }; - 7D9BEF582335EC6E005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/MainInterface.strings; sourceTree = ""; }; 7D9BEF592335EC6E005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Interface.strings; sourceTree = ""; }; 7D9BEF5A2335EC6E005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Main.strings; sourceTree = ""; }; 7D9BEF5C2335EC6F005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF5D2335EC6F005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF5E2335EC6F005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; - 7D9BEF602335EC6F005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF612335EC6F005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF642335EC6F005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF6A2335EC70005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF6B2335EC7D005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Intents.strings; sourceTree = ""; }; 7D9BEF6D2335EC7D005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Main.strings; sourceTree = ""; }; - 7D9BEF6E2335EC7D005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/MainInterface.strings; sourceTree = ""; }; 7D9BEF6F2335EC7D005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Interface.strings; sourceTree = ""; }; 7D9BEF702335EC7D005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Main.strings; sourceTree = ""; }; 7D9BEF722335EC7D005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF732335EC7D005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BEF762335EC7D005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF772335EC7E005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF7A2335EC7E005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF802335EC7E005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF812335EC8B005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Intents.strings; sourceTree = ""; }; 7D9BEF832335EC8B005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Main.strings; sourceTree = ""; }; - 7D9BEF842335EC8B005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/MainInterface.strings; sourceTree = ""; }; 7D9BEF852335EC8B005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Interface.strings; sourceTree = ""; }; 7D9BEF862335EC8B005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Main.strings; sourceTree = ""; }; 7D9BEF882335EC8C005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF892335EC8C005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF8A2335EC8C005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; - 7D9BEF8C2335EC8C005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF8D2335EC8C005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF902335EC8C005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF962335EC8D005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; @@ -1160,30 +1091,30 @@ 7D9BEF9A233600D9005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/InfoPlist.strings; sourceTree = ""; }; 7D9BF13A23370E8B005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Intents.strings; sourceTree = ""; }; 7D9BF13B23370E8B005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Main.strings; sourceTree = ""; }; - 7D9BF13C23370E8B005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/MainInterface.strings; sourceTree = ""; }; 7D9BF13D23370E8B005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Interface.strings; sourceTree = ""; }; 7D9BF13E23370E8C005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Main.strings; sourceTree = ""; }; 7D9BF13F23370E8C005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; 7D9BF14023370E8C005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; 7D9BF14123370E8C005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; }; - 7D9BF14223370E8C005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; 7D9BF14323370E8C005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; 7D9BF14423370E8D005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; 7D9BF14623370E8D005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; 7DD382771F8DBFC60071272B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Main.strings; sourceTree = ""; }; - 7DD382781F8DBFC60071272B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/MainInterface.strings; sourceTree = ""; }; 7DD382791F8DBFC60071272B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Interface.strings; sourceTree = ""; }; 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetLoopManager.swift; sourceTree = ""; }; 80F864E52433BF5D0026EC26 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; + 840A2F0D2C0F978E003D5E90 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 8496F7302B5711C4003E672C /* ContentMargin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentMargin.swift; sourceTree = ""; }; 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopWidgets.swift; sourceTree = ""; }; 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBackground.swift; sourceTree = ""; }; 84AA81D72A4A2910000B658B /* StatusWidgetTimelimeEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusWidgetTimelimeEntry.swift; sourceTree = ""; }; 84AA81DA2A4A2973000B658B /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = ""; }; 84AA81DC2A4A2999000B658B /* StatusWidgetTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusWidgetTimelineProvider.swift; sourceTree = ""; }; - 84AA81E22A4A36FB000B658B /* SystemActionLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemActionLink.swift; sourceTree = ""; }; 84AA81E42A4A3981000B658B /* DeeplinkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeeplinkManager.swift; sourceTree = ""; }; 84AA81E62A4A4DEF000B658B /* PumpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpView.swift; sourceTree = ""; }; 84D2879E2AC756C8007ED283 /* ContentMargin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentMargin.swift; sourceTree = ""; }; + 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSFocusModesView.swift; sourceTree = ""; }; + 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = DIYLoopUnitTestPlan.xctestplan; sourceTree = ""; }; 891B508424342BE1005DA578 /* CarbAndBolusFlowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbAndBolusFlowViewModel.swift; sourceTree = ""; }; 892A5D29222EF60A008961AB /* MockKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; name = MockKit.framework; path = Carthage/Build/iOS/MockKit.framework; sourceTree = SOURCE_ROOT; }; 892A5D2B222EF60A008961AB /* MockKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; name = MockKitUI.framework; path = Carthage/Build/iOS/MockKitUI.framework; sourceTree = SOURCE_ROOT; }; @@ -1205,7 +1136,6 @@ 895788AB242E69A2002CB114 /* ActionButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionButton.swift; sourceTree = ""; }; 895FE0942201234000FCF18A /* OverrideSelectionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideSelectionViewController.swift; sourceTree = ""; }; 8968B1112408B3520074BB48 /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; - 8968B113240C55F10074BB48 /* LoopSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopSettingsTests.swift; sourceTree = ""; }; 897A5A9524C2175B00C4E71D /* BolusEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusEntryView.swift; sourceTree = ""; }; 897A5A9824C22DE800C4E71D /* BolusEntryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusEntryViewModel.swift; sourceTree = ""; }; 898ECA5E218ABD17001E9D35 /* GlucoseChartScaler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseChartScaler.swift; sourceTree = ""; }; @@ -1228,7 +1158,6 @@ 89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestingScenariosManager.swift; sourceTree = ""; }; 89CA2B2F226C0161004D9350 /* DirectoryObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectoryObserver.swift; sourceTree = ""; }; 89CA2B31226C18B8004D9350 /* TestingScenariosTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestingScenariosTableViewController.swift; sourceTree = ""; }; - 89CA2B3C226E6B13004D9350 /* LocalTestingScenariosManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalTestingScenariosManager.swift; sourceTree = ""; }; 89CAB36224C8FE95009EE3CE /* PredictedGlucoseChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PredictedGlucoseChartView.swift; sourceTree = ""; }; 89D1503D24B506EB00EDE253 /* Dictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dictionary.swift; sourceTree = ""; }; 89D6953D23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PotentialCarbEntryTableViewCell.swift; sourceTree = ""; }; @@ -1237,7 +1166,6 @@ 89E08FC5242E7506000D719B /* CarbAndDateInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbAndDateInput.swift; sourceTree = ""; }; 89E08FC7242E76E9000D719B /* AnyTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyTransition.swift; sourceTree = ""; }; 89E08FC9242E7714000D719B /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; - 89E08FCB242E790C000D719B /* Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Comparable.swift; sourceTree = ""; }; 89E08FCF242E8B2B000D719B /* BolusConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusConfirmationView.swift; sourceTree = ""; }; 89E267FB2292456700A3F2AF /* FeatureFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; 89E267FE229267DF00A3F2AF /* Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Optional.swift; sourceTree = ""; }; @@ -1302,6 +1230,7 @@ B42D124228D371C400E43D22 /* AlertMuter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlertMuter.swift; sourceTree = ""; }; B43CF07D29434EC4008A520B /* HowMuteAlertWorkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowMuteAlertWorkView.swift; sourceTree = ""; }; B43DA44024D9C12100CAFF4E /* DismissibleHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissibleHostingController.swift; sourceTree = ""; }; + B455C7322BD14E25002B847E /* Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Comparable.swift; sourceTree = ""; }; B470F5832AB22B5100049695 /* StatefulPluggable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatefulPluggable.swift; sourceTree = ""; }; B48B0BAB24900093009A48DE /* PumpStatusHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpStatusHUDView.swift; sourceTree = ""; }; B490A03C24D04F9400F509FA /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = ""; }; @@ -1310,7 +1239,6 @@ B490A04224D055D900F509FA /* DeviceStatusHighlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusHighlight.swift; sourceTree = ""; }; B4BC56372518DEA900373647 /* CGMStatusHUDViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStatusHUDViewModelTests.swift; sourceTree = ""; }; B4C9859325D5A3BB009FD9CA /* StatusBadgeHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBadgeHUDView.swift; sourceTree = ""; }; - B4CAD8782549D2540057946B /* LoopCompletionFreshnessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopCompletionFreshnessTests.swift; sourceTree = ""; }; B4D4534028E5CA7900F1A8D9 /* AlertMuterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertMuterTests.swift; sourceTree = ""; }; B4D620D324D9EDB900043B3C /* GuidanceColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuidanceColors.swift; sourceTree = ""; }; B4D904402AA8989100CBD826 /* StatefulPluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatefulPluginManager.swift; sourceTree = ""; }; @@ -1328,7 +1256,6 @@ C1004DEF2981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; C1004DF12981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; C1004DF42981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; - C1004DF72981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; C1004DF92981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; C1004DFA2981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; C1004DFB2981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1336,7 +1263,6 @@ C1004DFD2981F67A00B8CF94 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/InfoPlist.strings; sourceTree = ""; }; C1004DFE2981F67A00B8CF94 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/InfoPlist.strings; sourceTree = ""; }; C1004DFF2981F67A00B8CF94 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; - C1004E002981F67A00B8CF94 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E012981F67A00B8CF94 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; C1004E022981F67A00B8CF94 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E032981F67A00B8CF94 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1344,7 +1270,6 @@ C1004E052981F6A100B8CF94 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E062981F6A100B8CF94 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E072981F6A100B8CF94 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; - C1004E082981F6A100B8CF94 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E092981F6A100B8CF94 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; C1004E0A2981F6A100B8CF94 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E0B2981F6A100B8CF94 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1352,7 +1277,6 @@ C1004E0D2981F6E200B8CF94 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E0E2981F6E200B8CF94 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E0F2981F6E200B8CF94 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; - C1004E102981F6E200B8CF94 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E112981F6E200B8CF94 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; C1004E122981F6E200B8CF94 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E132981F6E200B8CF94 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1360,7 +1284,6 @@ C1004E152981F6F500B8CF94 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E162981F6F500B8CF94 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E172981F6F500B8CF94 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; - C1004E182981F6F500B8CF94 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E192981F6F500B8CF94 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; C1004E1A2981F6F500B8CF94 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E1B2981F6F500B8CF94 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1368,26 +1291,22 @@ C1004E1D2981F72D00B8CF94 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E1E2981F72D00B8CF94 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E1F2981F72D00B8CF94 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; - C1004E202981F72D00B8CF94 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E212981F72D00B8CF94 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; C1004E222981F72D00B8CF94 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E232981F72D00B8CF94 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E242981F72D00B8CF94 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E252981F74300B8CF94 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E262981F74300B8CF94 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; - C1004E272981F74300B8CF94 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E282981F74300B8CF94 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; C1004E292981F74300B8CF94 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E2A2981F74300B8CF94 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E2B2981F74300B8CF94 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E2C2981F75B00B8CF94 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; - C1004E2D2981F75B00B8CF94 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E2E2981F75B00B8CF94 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E2F2981F75B00B8CF94 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E302981F77B00B8CF94 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E312981F77B00B8CF94 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E322981F77B00B8CF94 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; - C1004E332981F77B00B8CF94 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E342981F77B00B8CF94 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E352981F77B00B8CF94 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; C101947127DD473C004E7EB8 /* MockKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MockKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1417,6 +1336,8 @@ C122DEFE29BBFAAE00321F8D /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/ckcomplication.strings; sourceTree = ""; }; C122DEFF29BBFAAE00321F8D /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; C122DF0029BBFAAE00321F8D /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; + C129BF492B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryPresetsManagerTests.swift; sourceTree = ""; }; + C129D3BE2B8697F100FEA6A9 /* TempBasalRecommendationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempBasalRecommendationTests.swift; sourceTree = ""; }; C12BCCF929BBFA480066A158 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; C12CB9AC23106A3C00F84978 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Intents.strings; sourceTree = ""; }; C12CB9AE23106A5C00F84978 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Intents.strings; sourceTree = ""; }; @@ -1426,10 +1347,12 @@ C12CB9B623106A6200F84978 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Intents.strings; sourceTree = ""; }; C12CB9B823106A6300F84978 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Intents.strings; sourceTree = ""; }; C13072B92A76AF31009A7C58 /* live_capture_predicted_glucose.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = live_capture_predicted_glucose.json; sourceTree = ""; }; + C138C6F72C1B8A2C00F08F1A /* GlucoseCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseCondition.swift; sourceTree = ""; }; C13DA2AF24F6C7690098BB29 /* UIViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; C148CEE624FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeliveryUncertaintyAlertManager.swift; sourceTree = ""; }; C14952142995822A0095AA84 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; - C14952152995822A0095AA84 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; + C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationHistoryEntry.swift; sourceTree = ""; }; + C152B9F62C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationHistoryEntryTests.swift; sourceTree = ""; }; C155A8F32986396E009BD257 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; C155A8F42986396E009BD257 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; C155A8F52986396E009BD257 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/ckcomplication.strings; sourceTree = ""; }; @@ -1437,7 +1360,6 @@ C159C8212867859800A86EC0 /* MockKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MockKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C159C82E286787EF00A86EC0 /* LoopKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C15A581F29C7866600D3A5A1 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; - C15A582029C7866600D3A5A1 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/InfoPlist.strings; sourceTree = ""; }; C15A582129C7866600D3A5A1 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; C15A582229C7866600D3A5A1 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; C15A582329C7866600D3A5A1 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1448,10 +1370,11 @@ C16B983D26B4893300256B05 /* DoseEnactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoseEnactor.swift; sourceTree = ""; }; C16B983F26B4898800256B05 /* DoseEnactorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoseEnactorTests.swift; sourceTree = ""; }; C16DA84122E8E112008624C2 /* PluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManager.swift; sourceTree = ""; }; + C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDataAlgorithmInput.swift; sourceTree = ""; }; + C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleInsulinDose.swift; sourceTree = ""; }; C16FC0AF2A99392F0025E239 /* live_capture_input.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = live_capture_input.json; sourceTree = ""; }; C1742331259BEADC00399C9D /* ManualEntryDoseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEntryDoseView.swift; sourceTree = ""; }; C174233B259BEB0F00399C9D /* ManualEntryDoseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEntryDoseViewModel.swift; sourceTree = ""; }; - C174571329830930009EFCF2 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; C174571429830930009EFCF2 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/InfoPlist.strings; sourceTree = ""; }; C174571529830930009EFCF2 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; C1750AEB255B013300B8011C /* Minizip.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Minizip.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1461,10 +1384,16 @@ C17824A41E1AD4D100D9D25C /* ManualBolusRecommendation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ManualBolusRecommendation.swift; sourceTree = ""; }; C1814B85225E507C008D2D8E /* Sequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sequence.swift; sourceTree = ""; }; C186B73F298309A700F83024 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; + C188599A2AF15E1B0010F21F /* DeviceDataManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDataManagerTests.swift; sourceTree = ""; }; + C188599D2AF15FAB0010F21F /* AlertMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertMocks.swift; sourceTree = ""; }; + C188599F2AF1612B0010F21F /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; + C18859A12AF165130010F21F /* MockPumpManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPumpManager.swift; sourceTree = ""; }; + C18859A32AF165330010F21F /* MockCGMManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCGMManager.swift; sourceTree = ""; }; + C18859A72AF292D90010F21F /* MockTrustedTimeChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTrustedTimeChecker.swift; sourceTree = ""; }; + C18859AB2AF29BE50010F21F /* TemporaryPresetsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryPresetsManager.swift; sourceTree = ""; }; C18886E629830A5E004C982D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; C18886E729830A5E004C982D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; C18886E829830A5E004C982D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/ckcomplication.strings; sourceTree = ""; }; - C18913B42524F24C007B0683 /* DeviceDataManager+SimpleBolusViewModelDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceDataManager+SimpleBolusViewModelDelegate.swift"; sourceTree = ""; }; C18A491222FCC22800FDA733 /* build-derived-assets.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = "build-derived-assets.sh"; sourceTree = ""; }; C18A491322FCC22900FDA733 /* make_scenario.py */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.python; path = make_scenario.py; sourceTree = ""; }; C18A491522FCC22900FDA733 /* copy-plugins.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = "copy-plugins.sh"; sourceTree = ""; }; @@ -1475,7 +1404,6 @@ C19008FF252271BB00721625 /* SimpleBolusCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleBolusCalculatorTests.swift; sourceTree = ""; }; C191D2A025B3ACAA00C26C0B /* DosingStrategySelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DosingStrategySelectionView.swift; sourceTree = ""; }; C192C5FE29C78711001EFEA6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; - C192C5FF29C78711001EFEA6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/InfoPlist.strings; sourceTree = ""; }; C192C60029C78711001EFEA6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; C192C60129C78711001EFEA6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/InfoPlist.strings; sourceTree = ""; }; C192C60229C78711001EFEA6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; @@ -1489,8 +1417,6 @@ C19E387B298638CE00851444 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; C19E387C298638CE00851444 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; C19E387D298638CE00851444 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; - C19E387E298638CE00851444 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; - C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopCompletionFreshness.swift; sourceTree = ""; }; C19F496225630504003632D7 /* Minizip.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Minizip.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C1AD41FF256D61E500164DDD /* Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Comparable.swift; sourceTree = ""; }; C1AD48CE298639890013B994 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1499,7 +1425,6 @@ C1AD630029BBFAA80002685D /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/ckcomplication.strings; sourceTree = ""; }; C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualGlucoseEntryRow.swift; sourceTree = ""; }; C1B0CFD429C786BF0045B04D /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; - C1B0CFD529C786BF0045B04D /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = ""; }; C1B0CFD629C786BF0045B04D /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; C1B0CFD729C786BF0045B04D /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = ""; }; C1B0CFD829C786BF0045B04D /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; @@ -1510,10 +1435,10 @@ C1B2679B2995824000BCB7C1 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; C1B2679C2995824000BCB7C1 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/ckcomplication.strings; sourceTree = ""; }; C1B2679D2995824000BCB7C1 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; + C1B80D622AF97D7200AB7705 /* LoopDataManager+CarbAbsorption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoopDataManager+CarbAbsorption.swift"; sourceTree = ""; }; C1BCB5AF298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; C1BCB5B0298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; C1BCB5B1298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; - C1BCB5B2298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; C1BCB5B3298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; C1BCB5B4298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; C1BCB5B5298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1525,13 +1450,9 @@ C1C247892995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; C1C2478B2995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/InfoPlist.strings; sourceTree = ""; }; C1C2478C2995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; - C1C2478D2995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; - C1C2478E2995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/MainInterface.strings; sourceTree = ""; }; - C1C2478F2995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/InfoPlist.strings; sourceTree = ""; }; C1C247902995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/InfoPlist.strings; sourceTree = ""; }; C1C247912995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; C1C31277297E4BFE00296DA4 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Main.strings; sourceTree = ""; }; - C1C31278297E4BFE00296DA4 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/MainInterface.strings; sourceTree = ""; }; C1C31279297E4BFE00296DA4 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Interface.strings; sourceTree = ""; }; C1C3127A297E4BFE00296DA4 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Main.strings; sourceTree = ""; }; C1C3127C297E4BFE00296DA4 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; @@ -1546,8 +1467,12 @@ C1D0B62F2986D4D90098D215 /* LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; C1D197FE232CF92D0096D646 /* capture-build-details.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = "capture-build-details.sh"; sourceTree = ""; }; C1D289B422F90A52003FFBD9 /* BasalDeliveryState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasalDeliveryState.swift; sourceTree = ""; }; - C1D476B32A8ED179002C1C87 /* LoopAlgorithmTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopAlgorithmTests.swift; sourceTree = ""; }; C1D70F7A2A914F71009FE129 /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/InfoPlist.strings; sourceTree = ""; }; + C1DA434E2B164C6C00CBD33F /* MockSettingsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSettingsProvider.swift; sourceTree = ""; }; + C1DA43522B19310A00CBD33F /* LoopControlMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopControlMock.swift; sourceTree = ""; }; + C1DA43542B193BCB00CBD33F /* MockUploadEventListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUploadEventListener.swift; sourceTree = ""; }; + C1DA43562B1A70BE00CBD33F /* SettingsManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerTests.swift; sourceTree = ""; }; + C1DA43582B1A784900CBD33F /* MockDeliveryDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDeliveryDelegate.swift; sourceTree = ""; }; C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistedProperty.swift; sourceTree = ""; }; C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SimpleBolusView.swift; sourceTree = ""; }; C1E2773D224177C000354103 /* ClockKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ClockKit.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS.sdk/System/Library/Frameworks/ClockKit.framework; sourceTree = DEVELOPER_DIR; }; @@ -1555,7 +1480,6 @@ C1E3862428247B7100F561A4 /* StoredLoopNotRunningNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredLoopNotRunningNotification.swift; sourceTree = ""; }; C1E5A6DE29C7870100703C90 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; C1E693CA29C786E200410918 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; - C1E693CB29C786E200410918 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/InfoPlist.strings"; sourceTree = ""; }; C1E693CC29C786E200410918 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; C1E693CD29C786E200410918 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/InfoPlist.strings"; sourceTree = ""; }; C1E693CE29C786E200410918 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; @@ -1571,10 +1495,11 @@ C1EE9E802A38D0FB0064784A /* BuildDetails.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = BuildDetails.plist; sourceTree = ""; }; C1EF747128D6A44A00C8C083 /* CrashRecoveryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashRecoveryManager.swift; sourceTree = ""; }; C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppExpirationAlerter.swift; sourceTree = ""; }; + C1F2CAA92B76B3EE00D7F581 /* TempBasalRecommendation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempBasalRecommendation.swift; sourceTree = ""; }; + C1F2CAAB2B7A980600D7F581 /* BasalRelativeDose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasalRelativeDose.swift; sourceTree = ""; }; C1F48FF62995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; C1F48FF72995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; C1F48FF82995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; - C1F48FF92995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; C1F48FFA2995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; C1F48FFB2995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; C1F48FFC2995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1602,7 +1527,6 @@ C1FDCC0229C786F90056E652 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/InfoPlist.strings; sourceTree = ""; }; C1FDCC0329C786F90056E652 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Interface.strings; sourceTree = ""; }; C1FF3D4929C786A900BDC1EC /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; - C1FF3D4A29C786A900BDC1EC /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/InfoPlist.strings; sourceTree = ""; }; C1FF3D4B29C786A900BDC1EC /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; C1FF3D4C29C786A900BDC1EC /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; C1FF3D4D29C786A900BDC1EC /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1612,44 +1536,13 @@ DDC389F92A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConstantApplicationFactorStrategy.swift; sourceTree = ""; }; DDC389FB2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsView+algorithmExperimentsSection.swift"; sourceTree = ""; }; DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseBasedApplicationFactorSelectionView.swift; sourceTree = ""; }; - E90909CC24E34AC500F963D2 /* high_and_rising_with_cob_momentum_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_rising_with_cob_momentum_effect.json; sourceTree = ""; }; - E90909CD24E34AC500F963D2 /* high_and_rising_with_cob_insulin_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_rising_with_cob_insulin_effect.json; sourceTree = ""; }; - E90909CE24E34AC500F963D2 /* high_and_rising_with_cob_predicted_glucose.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_rising_with_cob_predicted_glucose.json; sourceTree = ""; }; - E90909CF24E34AC500F963D2 /* high_and_rising_with_cob_carb_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_rising_with_cob_carb_effect.json; sourceTree = ""; }; - E90909D024E34AC500F963D2 /* high_and_rising_with_cob_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_rising_with_cob_counteraction_effect.json; sourceTree = ""; }; - E90909D724E34F1500F963D2 /* low_and_falling_predicted_glucose.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_and_falling_predicted_glucose.json; sourceTree = ""; }; - E90909D824E34F1500F963D2 /* low_and_falling_carb_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_and_falling_carb_effect.json; sourceTree = ""; }; - E90909D924E34F1500F963D2 /* low_and_falling_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_and_falling_counteraction_effect.json; sourceTree = ""; }; - E90909DA24E34F1600F963D2 /* low_and_falling_insulin_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_and_falling_insulin_effect.json; sourceTree = ""; }; - E90909DB24E34F1600F963D2 /* low_and_falling_momentum_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_and_falling_momentum_effect.json; sourceTree = ""; }; - E90909E224E3530200F963D2 /* low_with_low_treatment_carb_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_with_low_treatment_carb_effect.json; sourceTree = ""; }; - E90909E324E3530200F963D2 /* low_with_low_treatment_insulin_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_with_low_treatment_insulin_effect.json; sourceTree = ""; }; - E90909E424E3530200F963D2 /* low_with_low_treatment_predicted_glucose.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_with_low_treatment_predicted_glucose.json; sourceTree = ""; }; - E90909E524E3530200F963D2 /* low_with_low_treatment_momentum_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_with_low_treatment_momentum_effect.json; sourceTree = ""; }; - E90909E624E3530200F963D2 /* low_with_low_treatment_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_with_low_treatment_counteraction_effect.json; sourceTree = ""; }; - E90909ED24E35B4000F963D2 /* high_and_falling_predicted_glucose.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_falling_predicted_glucose.json; sourceTree = ""; }; - E90909EF24E35B4C00F963D2 /* high_and_falling_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_falling_counteraction_effect.json; sourceTree = ""; }; - E90909F024E35B4C00F963D2 /* high_and_falling_carb_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_falling_carb_effect.json; sourceTree = ""; }; - E90909F124E35B4C00F963D2 /* high_and_falling_insulin_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_falling_insulin_effect.json; sourceTree = ""; }; - E90909F524E35B7C00F963D2 /* high_and_falling_momentum_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_falling_momentum_effect.json; sourceTree = ""; }; E93E865324DB6CBA00FF40C8 /* retrospective_output.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = retrospective_output.json; sourceTree = ""; }; E93E865524DB731900FF40C8 /* predicted_glucose_without_retrospective.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = predicted_glucose_without_retrospective.json; sourceTree = ""; }; E93E865724DB75BD00FF40C8 /* predicted_glucose_very_negative.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = predicted_glucose_very_negative.json; sourceTree = ""; }; E93E86A724DDCC4400FF40C8 /* MockDoseStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDoseStore.swift; sourceTree = ""; }; E93E86AF24DDE1BD00FF40C8 /* MockGlucoseStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGlucoseStore.swift; sourceTree = ""; }; E93E86B124DDE21D00FF40C8 /* MockCarbStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCarbStore.swift; sourceTree = ""; }; - E93E86B424E1FDC400FF40C8 /* flat_and_stable_insulin_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = flat_and_stable_insulin_effect.json; sourceTree = ""; }; - E93E86B524E1FDC400FF40C8 /* flat_and_stable_momentum_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = flat_and_stable_momentum_effect.json; sourceTree = ""; }; - E93E86B624E1FDC400FF40C8 /* flat_and_stable_predicted_glucose.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = flat_and_stable_predicted_glucose.json; sourceTree = ""; }; - E93E86B824E1FDC400FF40C8 /* flat_and_stable_carb_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = flat_and_stable_carb_effect.json; sourceTree = ""; }; - E93E86C224E1FE6100FF40C8 /* flat_and_stable_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = flat_and_stable_counteraction_effect.json; sourceTree = ""; }; - E93E86C524E2E02200FF40C8 /* high_and_stable_insulin_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_stable_insulin_effect.json; sourceTree = ""; }; - E93E86C624E2E02200FF40C8 /* high_and_stable_carb_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_stable_carb_effect.json; sourceTree = ""; }; - E93E86C724E2E02200FF40C8 /* high_and_stable_predicted_glucose.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_stable_predicted_glucose.json; sourceTree = ""; }; - E93E86C824E2E02200FF40C8 /* high_and_stable_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_stable_counteraction_effect.json; sourceTree = ""; }; - E93E86C924E2E02200FF40C8 /* high_and_stable_momentum_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_stable_momentum_effect.json; sourceTree = ""; }; E942DE6D253BE5E100AC532D /* Loop Intent Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Loop Intent Extension.entitlements"; sourceTree = ""; }; - E950CA9029002D9000B5B692 /* LoopDataManagerDosingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopDataManagerDosingTests.swift; sourceTree = ""; }; E95D380024EADE7C005E2F50 /* DoseStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoseStoreProtocol.swift; sourceTree = ""; }; E95D380224EADF36005E2F50 /* CarbStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbStoreProtocol.swift; sourceTree = ""; }; E95D380424EADF78005E2F50 /* GlucoseStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStoreProtocol.swift; sourceTree = ""; }; @@ -1689,13 +1582,11 @@ E9C58A7B24DB529A00487A17 /* insulin_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = insulin_effect.json; sourceTree = ""; }; F5D9C01727DABBE0002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Intents.strings; sourceTree = ""; }; F5D9C01927DABBE0002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Main.strings; sourceTree = ""; }; - F5D9C01A27DABBE1002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/MainInterface.strings; sourceTree = ""; }; F5D9C01B27DABBE1002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Interface.strings; sourceTree = ""; }; F5D9C01C27DABBE1002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Main.strings; sourceTree = ""; }; F5D9C01E27DABBE2002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; F5D9C01F27DABBE2002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; F5D9C02027DABBE2002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; - F5D9C02127DABBE3002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; F5D9C02227DABBE3002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; F5D9C02327DABBE3002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; F5D9C02427DABBE3002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1703,13 +1594,11 @@ F5D9C02727DABBE4002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; F5E0BDD327E1D71C0033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Intents.strings; sourceTree = ""; }; F5E0BDD527E1D71D0033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Main.strings; sourceTree = ""; }; - F5E0BDD627E1D71D0033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/MainInterface.strings; sourceTree = ""; }; F5E0BDD727E1D71E0033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Interface.strings; sourceTree = ""; }; F5E0BDD827E1D71E0033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Main.strings; sourceTree = ""; }; F5E0BDDA27E1D71F0033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; F5E0BDDB27E1D7200033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; F5E0BDDC27E1D7200033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/InfoPlist.strings; sourceTree = ""; }; - F5E0BDDD27E1D7210033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; F5E0BDDE27E1D7210033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; F5E0BDDF27E1D7210033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/InfoPlist.strings; sourceTree = ""; }; F5E0BDE027E1D7220033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1794,18 +1683,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 4F70C1D91DE8DCA7006380B7 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - C159C825286785E000A86EC0 /* LoopUI.framework in Frameworks */, - C1CCF1172858FBAD0035389C /* SwiftCharts in Frameworks */, - C159C828286785E100A86EC0 /* LoopKitUI.framework in Frameworks */, - C159C82A286785E300A86EC0 /* MockKitUI.framework in Frameworks */, - C159C82D2867876500A86EC0 /* NotificationCenter.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 4F7528871DFE1DC600C322D6 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -1842,6 +1719,32 @@ path = "Loop Widget Extension"; sourceTree = ""; }; + 14BBB3AE2C61274400ECB800 /* Favorite Foods */ = { + isa = PBXGroup; + children = ( + 1452F4AA2A851EDF00F8B9E4 /* FavoriteFoodAddEditView.swift */, + 149A28E32A8A63A700052EDF /* FavoriteFoodDetailView.swift */, + 14ED83F52C6421F9008B4A5C /* FavoriteFoodInsightsCardView.swift */, + 14C970852C5C358C00E8A01B /* FavoriteFoodInsightsChartsView.swift */, + 14C970812C5C2EC100E8A01B /* FavoriteFoodInsightsView.swift */, + 142CB75A2A60BFC30075748A /* FavoriteFoodsView.swift */, + ); + path = "Favorite Foods"; + sourceTree = ""; + }; + 14C970662C59918100E8A01B /* Charts */ = { + isa = PBXGroup; + children = ( + 14C970692C5A833100E8A01B /* CarbEffectChartView.swift */, + 14C9706B2C5A836000E8A01B /* DoseChartView.swift */, + 14C9707D2C5A9EB600E8A01B /* GlucoseCarbChartView.swift */, + 14C9706D2C5A83AF00E8A01B /* IOBChartView.swift */, + 14C970672C5991CD00E8A01B /* LoopChartView.swift */, + 89CAB36224C8FE95009EE3CE /* PredictedGlucoseChartView.swift */, + ); + path = Charts; + sourceTree = ""; + }; 1DA6499D2441266400F61E75 /* Alerts */ = { isa = PBXGroup; children = ( @@ -1863,13 +1766,14 @@ 1DA7A84024476E98008257F0 /* Alerts */, C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */, A91E4C2224F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift */, + C188599A2AF15E1B0010F21F /* DeviceDataManagerTests.swift */, C16B983F26B4898800256B05 /* DoseEnactorTests.swift */, E9C58A7124DB489100487A17 /* LoopDataManagerTests.swift */, - E950CA9029002D9000B5B692 /* LoopDataManagerDosingTests.swift */, E9B3552C293592B40076AB04 /* MealDetectionManagerTests.swift */, + C1DA43562B1A70BE00CBD33F /* SettingsManagerTests.swift */, 1D70C40026EC0F9D00C62570 /* SupportManagerTests.swift */, + C129BF492B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift */, A9F5F1F4251050EC00E7C8A4 /* ZipArchiveTests.swift */, - C1D476B32A8ED179002C1C87 /* LoopAlgorithmTests.swift */, ); path = Managers; sourceTree = ""; @@ -1907,18 +1811,18 @@ 4328E01F1CFBE2B100E199AA /* Extensions */ = { isa = PBXGroup; children = ( - 898ECA68218ABDA9001E9D35 /* CLKTextProvider+Compound.h */, - 898ECA66218ABDA8001E9D35 /* WatchApp Extension-Bridging-Header.h */, - 898ECA67218ABDA8001E9D35 /* CLKTextProvider+Compound.m */, 4344629120A7C19800C4BE6F /* ButtonGroup.swift */, 898ECA64218ABD9A001E9D35 /* CGRect.swift */, 4328E0221CFBE2C500E199AA /* CLKComplicationTemplate.swift */, + 898ECA68218ABDA9001E9D35 /* CLKTextProvider+Compound.h */, + 898ECA67218ABDA8001E9D35 /* CLKTextProvider+Compound.m */, 89FE21AC24AC57E30033F501 /* Collection.swift */, - 89E08FCB242E790C000D719B /* Comparable.swift */, 4F7E8AC420E2AB9600AEA65E /* Date.swift */, + C138C6F72C1B8A2C00F08F1A /* GlucoseCondition.swift */, 43785E952120E4010057DED1 /* INRelevantShortcutStore+Loop.swift */, 4328E0231CFBE2C500E199AA /* NSUserDefaults+WatchApp.swift */, 4328E0241CFBE2C500E199AA /* UIColor.swift */, + 898ECA66218ABDA8001E9D35 /* WatchApp Extension-Bridging-Header.h */, 4F2C15801E0495B200E160D4 /* WatchContext+WatchApp.swift */, 43CB2B2A1D924D450079823D /* WCSession.swift */, 4328E0251CFBE2C500E199AA /* WKAlertAction.swift */, @@ -1945,6 +1849,7 @@ A9FB75F0252BE320004C7D3F /* BolusDosingDecision.swift */, DDC389F92A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift */, C1EF747128D6A44A00C8C083 /* CrashRecoveryManager.swift */, + 1455ACB12C667BEE004F44F2 /* Deeplink.swift */, DDC389F72A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift */, B40D07C6251A89D500C1C6D7 /* GlucoseDisplay.swift */, 43C2FAE01EB656A500364AFF /* GlucoseEffectVelocity.swift */, @@ -1955,11 +1860,13 @@ C17824A41E1AD4D100D9D25C /* ManualBolusRecommendation.swift */, 4F526D601DF8D9A900A04910 /* NetBasal.swift */, 438D42F81D7C88BC003244B0 /* PredictionInputEffect.swift */, - A99A114029A581D6007919CE /* Remote */, C19008FD25225D3900721625 /* SimpleBolusCalculator.swift */, C1E3862428247B7100F561A4 /* StoredLoopNotRunningNotification.swift */, 4328E0311CFC068900E199AA /* WatchContext+LoopKit.swift */, A987CD4824A58A0100439ADC /* ZipArchive.swift */, + C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */, + C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */, + C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */, ); path = Models; sourceTree = ""; @@ -1970,7 +1877,6 @@ C18A491122FCC20B00FDA733 /* Scripts */, 4FF4D0FA1E1834BD00846527 /* Common */, 43776F8E1B8022E90074EA36 /* Loop */, - 4F70C1DF1DE8DCA7006380B7 /* Loop Status Extension */, 43D9FFD021EAE05D00AF44BF /* LoopCore */, 4F75288C1DFE1DC600C322D6 /* LoopUI */, 43A943731B926B7B0051FA24 /* WatchApp */, @@ -1994,7 +1900,6 @@ 43A943721B926B7B0051FA24 /* WatchApp.app */, 43A9437E1B926B7B0051FA24 /* WatchApp Extension.appex */, 43E2D90B1D20C581004DA55F /* LoopTests.xctest */, - 4F70C1DC1DE8DCA7006380B7 /* Loop Status Extension.appex */, 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */, 43D9FFCF21EAE05D00AF44BF /* LoopCore.framework */, 43D9002A21EB209400AF44BF /* LoopCore.framework */, @@ -2163,10 +2068,8 @@ 43DE92581C5479E4001FFDE1 /* PotentialCarbEntryUserInfo.swift */, 43C05CB721EBEA54006FB252 /* HKUnit.swift */, 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */, - C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */, 430B29892041F54A00BA9F93 /* NSUserDefaults.swift */, 431E73471FF95A900069B5F7 /* PersistenceController.swift */, - 43D848AF1E7DCBE100DADCBC /* Result.swift */, 43D9FFD121EAE05D00AF44BF /* LoopCore.h */, 43D9FFD221EAE05D00AF44BF /* Info.plist */, 4B60626A287E286000BF8BBB /* Localizable.strings */, @@ -2183,17 +2086,17 @@ children = ( A98556842493F901000FD662 /* AlertStore+SimulatedCoreData.swift */, C1D289B422F90A52003FFBD9 /* BasalDeliveryState.swift */, + C1F2CAAB2B7A980600D7F581 /* BasalRelativeDose.swift */, A9F703722489BC8500C98AD8 /* CarbStore+SimulatedCoreData.swift */, C17824991E1999FA00D9D25C /* CaseCountable.swift */, + 14BBB3B12C629DB100ECB800 /* Character+IsEmoji.swift */, 4F6663931E905FD2009E74FC /* ChartColorPalette+Loop.swift */, 4389916A1E91B689000EEF90 /* ChartSettings+Loop.swift */, 4F08DE8E1E7BB871006741EA /* CollectionType+Loop.swift */, 43CE7CDD1CA8B63E003CC1B0 /* Data.swift */, 892A5D58222F0A27008961AB /* Debug.swift */, - 1D9650C72523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift */, B4FEEF7C24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift */, A96DAC232838325900D94E38 /* DiagnosticLog.swift */, - C18913B42524F24C007B0683 /* DeviceDataManager+SimpleBolusViewModelDelegate.swift */, A9C62D832331700D00535612 /* DiagnosticLog+Subsystem.swift */, 89D1503D24B506EB00EDE253 /* Dictionary.swift */, 89CA2B2F226C0161004D9350 /* DirectoryObserver.swift */, @@ -2211,6 +2114,7 @@ 892A5D682230C41D008961AB /* RangeReplaceableCollection.swift */, A9CBE45B248ACC03008E7BA2 /* SettingsStore+SimulatedCoreData.swift */, C1FB428B217806A300FAB378 /* StateColorPalette.swift */, + C1F2CAA92B76B3EE00D7F581 /* TempBasalRecommendation.swift */, 43F89CA222BDFBBC006BB54E /* UIActivityIndicatorView.swift */, 43F41C361D3BF32400C11ED6 /* UIAlertController.swift */, A9F66FC2247F451500096EA7 /* UIDevice+Loop.swift */, @@ -2244,20 +2148,20 @@ 43F5C2CF1B92A2ED003EB13D /* Views */ = { isa = PBXGroup; children = ( - 1452F4AA2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift */, B4001CED28CBBC82002FB414 /* AlertManagementView.swift */, 897A5A9524C2175B00C4E71D /* BolusEntryView.swift */, C1F8B1D122375E4200DD66CF /* BolusProgressTableViewCell.swift */, C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */, 43B260481ED248FB008CAA77 /* CarbEntryTableViewCell.swift */, 149A28BC2A853E6C00052EDF /* CarbEntryView.swift */, + 14C970662C59918100E8A01B /* Charts */, 431A8C3F1EC6E8AB00823B9C /* CircleMaskView.swift */, A9A056B224B93C62007CF06D /* CriticalEventLogExportView.swift */, C191D2A025B3ACAA00C26C0B /* DosingStrategySelectionView.swift */, - 149A28E32A8A63A700052EDF /* FavoriteFoodDetailView.swift */, - 142CB75A2A60BFC30075748A /* FavoriteFoodsView.swift */, + 14BBB3AE2C61274400ECB800 /* Favorite Foods */, 43D381611EBD9759007F8C8F /* HeaderValuesTableViewCell.swift */, 1452F4AC2A851F8800F8B9E4 /* HowAbsorptionTimeWorksView.swift */, + 14C970832C5C2FB400E8A01B /* HowCarbEffectsWorksView.swift */, B43CF07D29434EC4008A520B /* HowMuteAlertWorkView.swift */, 430D85881F44037000AF2D4F /* HUDViewTableViewCell.swift */, A91D2A3E26CF0FF80023B075 /* IconTitleSubtitleTableViewCell.swift */, @@ -2265,7 +2169,6 @@ 1DA46B5F2492E2E300D71A63 /* NotificationsCriticalAlertPermissionsView.swift */, 899433B723FE129700FA4BEA /* OverrideBadgeView.swift */, 89D6953D23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift */, - 89CAB36224C8FE95009EE3CE /* PredictedGlucoseChartView.swift */, 438D42FA1D7D11A4003244B0 /* PredictionInputEffectTableViewCell.swift */, 439706E522D2E84900C81566 /* PredictionSettingTableViewCell.swift */, 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */, @@ -2276,6 +2179,7 @@ C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */, DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */, DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */, + 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */, ); path = Views; sourceTree = ""; @@ -2285,40 +2189,41 @@ children = ( B42D124228D371C400E43D22 /* AlertMuter.swift */, 1D6B1B6626866D89009AC446 /* AlertPermissionsChecker.swift */, + 1DA6499D2441266400F61E75 /* Alerts */, 439897361CD2F80600223065 /* AnalyticsServicesManager.swift */, + C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */, B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */, 439BED291E76093C00B0AED5 /* CGMManager.swift */, C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */, A977A2F324ACFECF0059C207 /* CriticalEventLogExportManager.swift */, + 84AA81E42A4A3981000B658B /* DeeplinkManager.swift */, C148CEE624FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift */, 43DBF0521C93EC8200B3C386 /* DeviceDataManager.swift */, C16B983D26B4893300256B05 /* DoseEnactor.swift */, - 89CA2B3C226E6B13004D9350 /* LocalTestingScenariosManager.swift */, + 4F70C20F1DE8FAC5006380B7 /* ExtensionDataManager.swift */, A9C62D862331703000535612 /* LoggingServicesManager.swift */, A9D5C5B525DC6C6A00534873 /* LoopAppManager.swift */, 43A567681C94880B00334FAC /* LoopDataManager.swift */, + E9B355232935906B0076AB04 /* Missed Meal Detection */, 43C094491CACCC73001F6403 /* NotificationManager.swift */, A97F250725E056D500F0EE19 /* OnboardingManager.swift */, 432E73CA1D24B3D6009AD15D /* RemoteDataServicesManager.swift */, - B4D904402AA8989100CBD826 /* StatefulPluginManager.swift */, + 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */, A9C62D852331703000535612 /* Service.swift */, A9C62D872331703000535612 /* ServicesManager.swift */, C1F7822527CC056900C0919A /* SettingsManager.swift */, + A96DAC2B2838F31200D94E38 /* SharedLogging.swift */, E9BB27AA23B85C3500FB4987 /* SleepStore.swift */, B470F5832AB22B5100049695 /* StatefulPluggable.swift */, + B4D904402AA8989100CBD826 /* StatefulPluginManager.swift */, 43FCEEA8221A615B0013DD30 /* StatusChartsManager.swift */, + E95D37FF24EADE68005E2F50 /* Store Protocols */, 1D63DEA426E950D400F46FA5 /* SupportManager.swift */, - 4F70C20F1DE8FAC5006380B7 /* ExtensionDataManager.swift */, + C18859AB2AF29BE50010F21F /* TemporaryPresetsManager.swift */, 89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */, 1D82E69F25377C6B009131FB /* TrustedTimeChecker.swift */, 4328E0341CFC0AE100E199AA /* WatchDataManager.swift */, - 1DA6499D2441266400F61E75 /* Alerts */, - E95D37FF24EADE68005E2F50 /* Store Protocols */, - E9B355232935906B0076AB04 /* Missed Meal Detection */, - C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */, - A96DAC2B2838F31200D94E38 /* SharedLogging.swift */, - 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */, - 84AA81E42A4A3981000B658B /* DeeplinkManager.swift */, + C1B80D622AF97D7200AB7705 /* LoopDataManager+CarbAbsorption.swift */, ); path = Managers; sourceTree = ""; @@ -2326,36 +2231,21 @@ 43F78D2C1C8FC58F002152D1 /* LoopTests */ = { isa = PBXGroup; children = ( - E9C58A7624DB510500487A17 /* Fixtures */, - B4CAD8772549D2330057946B /* LoopCore */, - 1DA7A83F24476E8C008257F0 /* Managers */, - A9E6DFED246A0460005B1A1C /* Models */, - B4BC56362518DE8800373647 /* ViewModels */, - 43E2D90F1D20C581004DA55F /* Info.plist */, A9DF02CA24F72B9E00B7C988 /* CriticalEventLogTests.swift */, A96DAC292838EF8A00D94E38 /* DiagnosticLogTests.swift */, + E9C58A7624DB510500487A17 /* Fixtures */, + 43E2D90F1D20C581004DA55F /* Info.plist */, A9DAE7CF2332D77F006AE942 /* LoopTests.swift */, - 8968B113240C55F10074BB48 /* LoopSettingsTests.swift */, + 1DA7A83F24476E8C008257F0 /* Managers */, E93E86AC24DDE02C00FF40C8 /* Mock Stores */, + C188599C2AF15F9A0010F21F /* Mocks */, + A9E6DFED246A0460005B1A1C /* Models */, + B4BC56362518DE8800373647 /* ViewModels */, + 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */, ); path = LoopTests; sourceTree = ""; }; - 4F70C1DF1DE8DCA7006380B7 /* Loop Status Extension */ = { - isa = PBXGroup; - children = ( - 7D7076371FE06EDE004AC8EA /* Localizable.strings */, - 4F70C1FD1DE8E662006380B7 /* Loop Status Extension.entitlements */, - 4F70C1E51DE8DCA7006380B7 /* Info.plist */, - C1004DF62981F5B700B8CF94 /* InfoPlist.strings */, - 43BFF0CC1E466C8400FF19A9 /* StateColorPalette.swift */, - 43FCEEB0221A863E0013DD30 /* StatusChartsManager.swift */, - 4F70C1E01DE8DCA7006380B7 /* StatusViewController.swift */, - 4F70C1E21DE8DCA7006380B7 /* MainInterface.storyboard */, - ); - path = "Loop Status Extension"; - sourceTree = ""; - }; 4F75288C1DFE1DC600C322D6 /* LoopUI */ = { isa = PBXGroup; children = ( @@ -2480,9 +2370,10 @@ 4FF4D0FC1E1834CC00846527 /* Extensions */ = { isa = PBXGroup; children = ( + B455C7322BD14E25002B847E /* Comparable.swift */, 4372E48A213CB5F00068E043 /* Double.swift */, - 4F526D5E1DF2459000A04910 /* HKUnit.swift */, 43C513181E864C4E001547C7 /* GlucoseRangeSchedule.swift */, + 4F526D5E1DF2459000A04910 /* HKUnit.swift */, 43785E922120A01B0057DED1 /* NewCarbEntryIntent+Loop.swift */, 430DA58D1D4AEC230097D1CA /* NSBundle.swift */, 439897341CD2F7DE00223065 /* NSTimeInterval.swift */, @@ -2510,9 +2401,9 @@ isa = PBXGroup; children = ( 14B1736E28AEDBF6006CCD7C /* BasalView.swift */, + 1455ACAF2C667A1F004F44F2 /* DeeplinkView.swift */, + 1455ACAA2C666F9C004F44F2 /* EventualGlucoseView.swift */, 14B1737028AEDBF6006CCD7C /* GlucoseView.swift */, - 14B1737128AEDBF6006CCD7C /* LoopCircleView.swift */, - 84AA81E22A4A36FB000B658B /* SystemActionLink.swift */, 84AA81E62A4A4DEF000B658B /* PumpView.swift */, ); path = Components; @@ -2532,6 +2423,8 @@ 84AA81D92A4A2966000B658B /* Helpers */ = { isa = PBXGroup; children = ( + 1455ACAC2C6675DF004F44F2 /* Color.swift */, + 8496F7302B5711C4003E672C /* ContentMargin.swift */, 84AA81DA2A4A2973000B658B /* Date.swift */, 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */, 84D2879E2AC756C8007ED283 /* ContentMargin.swift */, @@ -2618,10 +2511,11 @@ 897A5A9724C22DCE00C4E71D /* View Models */ = { isa = PBXGroup; children = ( - 1452F4A82A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift */, 897A5A9824C22DE800C4E71D /* BolusEntryViewModel.swift */, 149A28BA2A853E5100052EDF /* CarbEntryViewModel.swift */, A9A056B424B94123007CF06D /* CriticalEventLogExportViewModel.swift */, + 1452F4A82A851C9400F8B9E4 /* FavoriteFoodAddEditViewModel.swift */, + 14C9707F2C5C0A1500E8A01B /* FavoriteFoodInsightsViewModel.swift */, 14D906F32A846510006EB79A /* FavoriteFoodsViewModel.swift */, C11BD0542523CFED00236B08 /* SimpleBolusViewModel.swift */, 1DB1CA4E24A56D7600B3B94C /* SettingsViewModel.swift */, @@ -2663,6 +2557,7 @@ 968DCD53F724DE56FFE51920 /* Frameworks */ = { isa = PBXGroup; children = ( + 840A2F0D2C0F978E003D5E90 /* LoopKitUI.framework */, C159C82E286787EF00A86EC0 /* LoopKit.framework */, C159C8212867859800A86EC0 /* MockKitUI.framework */, C159C8192867857000A86EC0 /* LoopKitUI.framework */, @@ -2713,22 +2608,17 @@ path = Shortcuts; sourceTree = ""; }; - A99A114029A581D6007919CE /* Remote */ = { - isa = PBXGroup; - children = ( - ); - path = Remote; - sourceTree = ""; - }; A9E6DFED246A0460005B1A1C /* Models */ = { isa = PBXGroup; children = ( + C152B9F62C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift */, A9DFAFB224F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift */, A963B279252CEBAE0062AA12 /* SetBolusUserInfoTests.swift */, - A9DFAFB424F048A000950D1E /* WatchHistoricalCarbsTests.swift */, C19008FF252271BB00721625 /* SimpleBolusCalculatorTests.swift */, - A9C1719625366F780053BCBD /* WatchHistoricalGlucoseTest.swift */, + C129D3BE2B8697F100FEA6A9 /* TempBasalRecommendationTests.swift */, A9BD28E6272226B40071DF15 /* TestLocalizedError.swift */, + A9DFAFB424F048A000950D1E /* WatchHistoricalCarbsTests.swift */, + A9C1719625366F780053BCBD /* WatchHistoricalGlucoseTest.swift */, ); path = Models; sourceTree = ""; @@ -2752,14 +2642,6 @@ path = ViewModels; sourceTree = ""; }; - B4CAD8772549D2330057946B /* LoopCore */ = { - isa = PBXGroup; - children = ( - B4CAD8782549D2540057946B /* LoopCompletionFreshnessTests.swift */, - ); - path = LoopCore; - sourceTree = ""; - }; C13072B82A76AF0A009A7C58 /* live_capture */ = { isa = PBXGroup; children = ( @@ -2777,6 +2659,22 @@ path = Plugins; sourceTree = ""; }; + C188599C2AF15F9A0010F21F /* Mocks */ = { + isa = PBXGroup; + children = ( + C188599D2AF15FAB0010F21F /* AlertMocks.swift */, + C18859A32AF165330010F21F /* MockCGMManager.swift */, + C18859A12AF165130010F21F /* MockPumpManager.swift */, + C18859A72AF292D90010F21F /* MockTrustedTimeChecker.swift */, + C188599F2AF1612B0010F21F /* PersistenceController.swift */, + C1DA434E2B164C6C00CBD33F /* MockSettingsProvider.swift */, + C1DA43522B19310A00CBD33F /* LoopControlMock.swift */, + C1DA43542B193BCB00CBD33F /* MockUploadEventListener.swift */, + C1DA43582B1A784900CBD33F /* MockDeliveryDelegate.swift */, + ); + path = Mocks; + sourceTree = ""; + }; C18A491122FCC20B00FDA733 /* Scripts */ = { isa = PBXGroup; children = ( @@ -2790,54 +2688,6 @@ path = Scripts; sourceTree = ""; }; - E90909CB24E34A7A00F963D2 /* high_and_rising_with_cob */ = { - isa = PBXGroup; - children = ( - E90909CF24E34AC500F963D2 /* high_and_rising_with_cob_carb_effect.json */, - E90909D024E34AC500F963D2 /* high_and_rising_with_cob_counteraction_effect.json */, - E90909CD24E34AC500F963D2 /* high_and_rising_with_cob_insulin_effect.json */, - E90909CC24E34AC500F963D2 /* high_and_rising_with_cob_momentum_effect.json */, - E90909CE24E34AC500F963D2 /* high_and_rising_with_cob_predicted_glucose.json */, - ); - path = high_and_rising_with_cob; - sourceTree = ""; - }; - E90909D624E34EC200F963D2 /* low_and_falling */ = { - isa = PBXGroup; - children = ( - E90909D824E34F1500F963D2 /* low_and_falling_carb_effect.json */, - E90909D924E34F1500F963D2 /* low_and_falling_counteraction_effect.json */, - E90909DA24E34F1600F963D2 /* low_and_falling_insulin_effect.json */, - E90909DB24E34F1600F963D2 /* low_and_falling_momentum_effect.json */, - E90909D724E34F1500F963D2 /* low_and_falling_predicted_glucose.json */, - ); - path = low_and_falling; - sourceTree = ""; - }; - E90909E124E352C300F963D2 /* low_with_low_treatment */ = { - isa = PBXGroup; - children = ( - E90909E224E3530200F963D2 /* low_with_low_treatment_carb_effect.json */, - E90909E624E3530200F963D2 /* low_with_low_treatment_counteraction_effect.json */, - E90909E324E3530200F963D2 /* low_with_low_treatment_insulin_effect.json */, - E90909E524E3530200F963D2 /* low_with_low_treatment_momentum_effect.json */, - E90909E424E3530200F963D2 /* low_with_low_treatment_predicted_glucose.json */, - ); - path = low_with_low_treatment; - sourceTree = ""; - }; - E90909EC24E35B3400F963D2 /* high_and_falling */ = { - isa = PBXGroup; - children = ( - E90909F524E35B7C00F963D2 /* high_and_falling_momentum_effect.json */, - E90909F024E35B4C00F963D2 /* high_and_falling_carb_effect.json */, - E90909EF24E35B4C00F963D2 /* high_and_falling_counteraction_effect.json */, - E90909F124E35B4C00F963D2 /* high_and_falling_insulin_effect.json */, - E90909ED24E35B4000F963D2 /* high_and_falling_predicted_glucose.json */, - ); - path = high_and_falling; - sourceTree = ""; - }; E93E86AC24DDE02C00FF40C8 /* Mock Stores */ = { isa = PBXGroup; children = ( @@ -2851,30 +2701,6 @@ path = "Mock Stores"; sourceTree = ""; }; - E93E86B324E1FD8700FF40C8 /* flat_and_stable */ = { - isa = PBXGroup; - children = ( - E93E86C224E1FE6100FF40C8 /* flat_and_stable_counteraction_effect.json */, - E93E86B824E1FDC400FF40C8 /* flat_and_stable_carb_effect.json */, - E93E86B424E1FDC400FF40C8 /* flat_and_stable_insulin_effect.json */, - E93E86B524E1FDC400FF40C8 /* flat_and_stable_momentum_effect.json */, - E93E86B624E1FDC400FF40C8 /* flat_and_stable_predicted_glucose.json */, - ); - path = flat_and_stable; - sourceTree = ""; - }; - E93E86C424E2DF6700FF40C8 /* high_and_stable */ = { - isa = PBXGroup; - children = ( - E93E86C624E2E02200FF40C8 /* high_and_stable_carb_effect.json */, - E93E86C824E2E02200FF40C8 /* high_and_stable_counteraction_effect.json */, - E93E86C524E2E02200FF40C8 /* high_and_stable_insulin_effect.json */, - E93E86C924E2E02200FF40C8 /* high_and_stable_momentum_effect.json */, - E93E86C724E2E02200FF40C8 /* high_and_stable_predicted_glucose.json */, - ); - path = high_and_stable; - sourceTree = ""; - }; E95D37FF24EADE68005E2F50 /* Store Protocols */ = { isa = PBXGroup; children = ( @@ -2927,12 +2753,6 @@ children = ( C13072B82A76AF0A009A7C58 /* live_capture */, E9B355312937068A0076AB04 /* meal_detection */, - E90909EC24E35B3400F963D2 /* high_and_falling */, - E90909E124E352C300F963D2 /* low_with_low_treatment */, - E90909D624E34EC200F963D2 /* low_and_falling */, - E90909CB24E34A7A00F963D2 /* high_and_rising_with_cob */, - E93E86C424E2DF6700FF40C8 /* high_and_stable */, - E93E86B324E1FD8700FF40C8 /* flat_and_stable */, E93E865724DB75BD00FF40C8 /* predicted_glucose_very_negative.json */, E93E865524DB731900FF40C8 /* predicted_glucose_without_retrospective.json */, E9C58A7824DB529A00487A17 /* basal_profile.json */, @@ -3014,7 +2834,6 @@ dependencies = ( 4F7528971DFE1ED400C322D6 /* PBXTargetDependency */, 43A943931B926B7B0051FA24 /* PBXTargetDependency */, - 4F70C1E71DE8DCA7006380B7 /* PBXTargetDependency */, 43D9FFD521EAE05D00AF44BF /* PBXTargetDependency */, E9B07F93253BBA6500BAD8F8 /* PBXTargetDependency */, 14B1736828AED9EE006CCD7C /* PBXTargetDependency */, @@ -3125,27 +2944,6 @@ productReference = 43E2D90B1D20C581004DA55F /* LoopTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; - 4F70C1DB1DE8DCA7006380B7 /* Loop Status Extension */ = { - isa = PBXNativeTarget; - buildConfigurationList = 4F70C1EB1DE8DCA8006380B7 /* Build configuration list for PBXNativeTarget "Loop Status Extension" */; - buildPhases = ( - 4F70C1D81DE8DCA7006380B7 /* Sources */, - 4F70C1D91DE8DCA7006380B7 /* Frameworks */, - 4F70C1DA1DE8DCA7006380B7 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - C11B9D592867781E00500CF8 /* PBXTargetDependency */, - ); - name = "Loop Status Extension"; - packageProductDependencies = ( - C1CCF1162858FBAD0035389C /* SwiftCharts */, - ); - productName = "Loop Status Extension"; - productReference = 4F70C1DC1DE8DCA7006380B7 /* Loop Status Extension.appex */; - productType = "com.apple.product-type.app-extension"; - }; 4F75288A1DFE1DC600C322D6 /* LoopUI */ = { isa = PBXNativeTarget; buildConfigurationList = 4F7528921DFE1DC600C322D6 /* Build configuration list for PBXNativeTarget "LoopUI" */; @@ -3191,7 +2989,7 @@ 43776F841B8022E90074EA36 /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 1340; + LastSwiftUpdateCheck = 1520; LastUpgradeCheck = 1010; ORGANIZATIONNAME = "LoopKit Authors"; TargetAttributes = { @@ -3270,16 +3068,6 @@ ProvisioningStyle = Automatic; TestTargetID = 43776F8B1B8022E90074EA36; }; - 4F70C1DB1DE8DCA7006380B7 = { - CreatedOnToolsVersion = 8.1; - LastSwiftMigration = 1020; - ProvisioningStyle = Automatic; - SystemCapabilities = { - com.apple.ApplicationGroups.iOS = { - enabled = 1; - }; - }; - }; 4F75288A1DFE1DC600C322D6 = { CreatedOnToolsVersion = 8.1; LastSwiftMigration = 1020; @@ -3331,7 +3119,6 @@ projectRoot = ""; targets = ( 43776F8B1B8022E90074EA36 /* Loop */, - 4F70C1DB1DE8DCA7006380B7 /* Loop Status Extension */, 43A943711B926B7B0051FA24 /* WatchApp */, 43A9437D1B926B7B0051FA24 /* WatchApp Extension */, 14B1735B28AED9EC006CCD7C /* Loop Widget Extension */, @@ -3416,7 +3203,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - E93E86CE24E2E02200FF40C8 /* high_and_stable_momentum_effect.json in Resources */, C13072BA2A76AF31009A7C58 /* live_capture_predicted_glucose.json in Resources */, E93E865424DB6CBA00FF40C8 /* retrospective_output.json in Resources */, E9C58A7F24DB529A00487A17 /* counteraction_effect_falling_glucose.json in Resources */, @@ -3424,56 +3210,16 @@ E9B3553D293706CB0076AB04 /* long_interval_counteraction_effect.json in Resources */, E9B3553A293706CB0076AB04 /* missed_meal_counteraction_effect.json in Resources */, E9B35538293706CB0076AB04 /* needs_clamping_counteraction_effect.json in Resources */, - E90909D424E34AC500F963D2 /* high_and_rising_with_cob_carb_effect.json in Resources */, - E93E86CC24E2E02200FF40C8 /* high_and_stable_predicted_glucose.json in Resources */, - E90909DC24E34F1600F963D2 /* low_and_falling_predicted_glucose.json in Resources */, - E90909DE24E34F1600F963D2 /* low_and_falling_counteraction_effect.json in Resources */, - E90909D224E34AC500F963D2 /* high_and_rising_with_cob_insulin_effect.json in Resources */, - E90909F224E35B4D00F963D2 /* high_and_falling_counteraction_effect.json in Resources */, - E90909EE24E35B4000F963D2 /* high_and_falling_predicted_glucose.json in Resources */, - E90909DD24E34F1600F963D2 /* low_and_falling_carb_effect.json in Resources */, - E90909E924E3530200F963D2 /* low_with_low_treatment_predicted_glucose.json in Resources */, - E90909D124E34AC500F963D2 /* high_and_rising_with_cob_momentum_effect.json in Resources */, E9C58A8024DB529A00487A17 /* insulin_effect.json in Resources */, - E90909DF24E34F1600F963D2 /* low_and_falling_insulin_effect.json in Resources */, + 84EC162E2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan in Resources */, C16FC0B02A99392F0025E239 /* live_capture_input.json in Resources */, - E93E86BE24E1FDC400FF40C8 /* flat_and_stable_carb_effect.json in Resources */, - E90909E824E3530200F963D2 /* low_with_low_treatment_insulin_effect.json in Resources */, E9B3553B293706CB0076AB04 /* noisy_cgm_counteraction_effect.json in Resources */, E9B3553C293706CB0076AB04 /* realistic_report_counteraction_effect.json in Resources */, E93E865824DB75BE00FF40C8 /* predicted_glucose_very_negative.json in Resources */, - E93E86BC24E1FDC400FF40C8 /* flat_and_stable_predicted_glucose.json in Resources */, - E90909EB24E3530200F963D2 /* low_with_low_treatment_counteraction_effect.json in Resources */, - E90909F424E35B4D00F963D2 /* high_and_falling_insulin_effect.json in Resources */, E9B35539293706CB0076AB04 /* dynamic_autofill_counteraction_effect.json in Resources */, - E90909F624E35B7C00F963D2 /* high_and_falling_momentum_effect.json in Resources */, - E93E86BA24E1FDC400FF40C8 /* flat_and_stable_insulin_effect.json in Resources */, - E90909E724E3530200F963D2 /* low_with_low_treatment_carb_effect.json in Resources */, - E90909EA24E3530200F963D2 /* low_with_low_treatment_momentum_effect.json in Resources */, - E90909D324E34AC500F963D2 /* high_and_rising_with_cob_predicted_glucose.json in Resources */, - E90909D524E34AC500F963D2 /* high_and_rising_with_cob_counteraction_effect.json in Resources */, E9C58A7D24DB529A00487A17 /* basal_profile.json in Resources */, - E93E86CD24E2E02200FF40C8 /* high_and_stable_counteraction_effect.json in Resources */, - E93E86C324E1FE6100FF40C8 /* flat_and_stable_counteraction_effect.json in Resources */, E9C58A7E24DB529A00487A17 /* dynamic_glucose_effect_partially_observed.json in Resources */, - E90909F324E35B4D00F963D2 /* high_and_falling_carb_effect.json in Resources */, - E93E86CA24E2E02200FF40C8 /* high_and_stable_insulin_effect.json in Resources */, - E93E86BB24E1FDC400FF40C8 /* flat_and_stable_momentum_effect.json in Resources */, - E93E86CB24E2E02200FF40C8 /* high_and_stable_carb_effect.json in Resources */, E9C58A7C24DB529A00487A17 /* momentum_effect_bouncing.json in Resources */, - E90909E024E34F1600F963D2 /* low_and_falling_momentum_effect.json in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 4F70C1DA1DE8DCA7006380B7 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - B491B09E24D0B600004CBE8F /* DerivedAssets.xcassets in Resources */, - 4F70C1E41DE8DCA7006380B7 /* MainInterface.storyboard in Resources */, - B405E35B24D2E05600DD058D /* HUDAssets.xcassets in Resources */, - 7D7076351FE06EDE004AC8EA /* Localizable.strings in Resources */, - C1004DF82981F5B700B8CF94 /* InfoPlist.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3624,29 +3370,33 @@ buildActionMask = 2147483647; files = ( 14B1738128AEDC70006CCD7C /* StatusExtensionContext.swift in Sources */, + 1455ACA92C66665D004F44F2 /* StateColorPalette.swift in Sources */, 14B1737628AEDC6C006CCD7C /* HKUnit.swift in Sources */, 14B1737728AEDC6C006CCD7C /* NSBundle.swift in Sources */, 84AA81D62A4A28AF000B658B /* WidgetBackground.swift in Sources */, 84AA81D82A4A2910000B658B /* StatusWidgetTimelimeEntry.swift in Sources */, 84AA81D32A4A27A3000B658B /* LoopWidgets.swift in Sources */, - 84AA81E32A4A36FB000B658B /* SystemActionLink.swift in Sources */, 14B1737828AEDC6C006CCD7C /* NSTimeInterval.swift in Sources */, + 1455ACB32C667C16004F44F2 /* Deeplink.swift in Sources */, 14B1737928AEDC6C006CCD7C /* NSUserDefaults+StatusExtension.swift in Sources */, 14B1737A28AEDC6C006CCD7C /* NumberFormatter.swift in Sources */, + 1455ACAB2C666F9C004F44F2 /* EventualGlucoseView.swift in Sources */, 14B1737B28AEDC6C006CCD7C /* OSLog.swift in Sources */, 14B1737C28AEDC6C006CCD7C /* PumpManager.swift in Sources */, 14B1737D28AEDC6C006CCD7C /* PumpManagerUI.swift in Sources */, 14B1737E28AEDC6C006CCD7C /* FeatureFlags.swift in Sources */, 84AA81E72A4A4DEF000B658B /* PumpView.swift in Sources */, + 1455ACAD2C6675E1004F44F2 /* Color.swift in Sources */, 14B1737F28AEDC6C006CCD7C /* PluginManager.swift in Sources */, 14B1738028AEDC6C006CCD7C /* Debug.swift in Sources */, 84AA81DB2A4A2973000B658B /* Date.swift in Sources */, 14B1737228AEDBF6006CCD7C /* BasalView.swift in Sources */, 14B1737428AEDBF6006CCD7C /* GlucoseView.swift in Sources */, + 1455ACB02C667A1F004F44F2 /* DeeplinkView.swift in Sources */, 14B1737328AEDBF6006CCD7C /* SystemStatusWidget.swift in Sources */, 84AA81DD2A4A2999000B658B /* StatusWidgetTimelineProvider.swift in Sources */, 14B1737528AEDBF6006CCD7C /* LoopCircleView.swift in Sources */, - 84D2879F2AC756C8007ED283 /* ContentMargin.swift in Sources */, + 8496F7312B5711C4003E672C /* ContentMargin.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3666,6 +3416,7 @@ C1F2075C26D6F9B0007AB7EB /* AppExpirationAlerter.swift in Sources */, B4FEEF7D24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift in Sources */, 142CB7592A60BF2E0075748A /* EditMode.swift in Sources */, + 14C9706E2C5A83AF00E8A01B /* IOBChartView.swift in Sources */, E95D380324EADF36005E2F50 /* CarbStoreProtocol.swift in Sources */, B470F5842AB22B5100049695 /* StatefulPluggable.swift in Sources */, E98A55ED24EDD6380008715D /* LatestStoredSettingsProvider.swift in Sources */, @@ -3683,25 +3434,31 @@ C1F7822627CC056900C0919A /* SettingsManager.swift in Sources */, 1452F4AD2A851F8800F8B9E4 /* HowAbsorptionTimeWorksView.swift in Sources */, C16575712538A36B004AE16E /* CGMStalenessMonitor.swift in Sources */, + 14C970822C5C2EC100E8A01B /* FavoriteFoodInsightsView.swift in Sources */, 1D080CBD2473214A00356610 /* AlertStore.xcdatamodeld in Sources */, C11BD0552523CFED00236B08 /* SimpleBolusViewModel.swift in Sources */, + C1F2CAAA2B76B3EE00D7F581 /* TempBasalRecommendation.swift in Sources */, + 14C970802C5C0A1500E8A01B /* FavoriteFoodInsightsViewModel.swift in Sources */, 149A28BB2A853E5100052EDF /* CarbEntryViewModel.swift in Sources */, C19008FE25225D3900721625 /* SimpleBolusCalculator.swift in Sources */, C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */, 89D6953E23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift in Sources */, 89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */, + C18859AC2AF29BE50010F21F /* TemporaryPresetsManager.swift in Sources */, 1DA649A7244126CD00F61E75 /* UserNotificationAlertScheduler.swift in Sources */, 439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */, 149A28BD2A853E6C00052EDF /* CarbEntryView.swift in Sources */, 4F11D3C020DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */, 89E267FC2292456700A3F2AF /* FeatureFlags.swift in Sources */, 43A567691C94880B00334FAC /* LoopDataManager.swift in Sources */, + C152B9F52C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift in Sources */, 1DA649A9244126DA00F61E75 /* InAppModalAlertScheduler.swift in Sources */, 43B260491ED248FB008CAA77 /* CarbEntryTableViewCell.swift in Sources */, 142CB75B2A60BFC30075748A /* FavoriteFoodsView.swift in Sources */, A9D5C5B625DC6C6A00534873 /* LoopAppManager.swift in Sources */, 4302F4E11D4E9C8900F0FCAF /* TextFieldTableViewController.swift in Sources */, - 1452F4AB2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift in Sources */, + C16F51192B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift in Sources */, + 1452F4AB2A851EDF00F8B9E4 /* FavoriteFoodAddEditView.swift in Sources */, C1742332259BEADC00399C9D /* ManualEntryDoseView.swift in Sources */, 43F64DD91D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift in Sources */, 43FCEEA9221A615B0013DD30 /* StatusChartsManager.swift in Sources */, @@ -3717,10 +3474,13 @@ A9B996F027235191002DC09C /* LoopWarning.swift in Sources */, C17824A01E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift in Sources */, 4372E487213C86240068E043 /* SampleValue.swift in Sources */, + 14C970682C5991CD00E8A01B /* LoopChartView.swift in Sources */, 437CEEE41CDE5C0A003C8C80 /* UIImage.swift in Sources */, C1201E2C23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift in Sources */, 1D49795824E7289700948F05 /* ServicesViewModel.swift in Sources */, 1D4A3E2D2478628500FD601B /* StoredAlert+CoreDataClass.swift in Sources */, + 14BBB3B22C629DB100ECB800 /* Character+IsEmoji.swift in Sources */, + C1B80D632AF97D7200AB7705 /* LoopDataManager+CarbAbsorption.swift in Sources */, DDC389FA2A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift in Sources */, B4E202302661063E009421B5 /* AutomaticDosingStatus.swift in Sources */, C191D2A125B3ACAA00C26C0B /* DosingStrategySelectionView.swift in Sources */, @@ -3728,10 +3488,10 @@ 89CA2B32226C18B8004D9350 /* TestingScenariosTableViewController.swift in Sources */, 43E93FB71E469A5100EAB8DB /* HKUnit.swift in Sources */, 43C05CAF21EB2C24006FB252 /* NSBundle.swift in Sources */, + B455C7332BD14E25002B847E /* Comparable.swift in Sources */, A91D2A3F26CF0FF80023B075 /* IconTitleSubtitleTableViewCell.swift in Sources */, A967D94C24F99B9300CDDF8A /* OutputStream.swift in Sources */, 1DB1065124467E18005542BD /* AlertManager.swift in Sources */, - 1D9650C82523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift in Sources */, 43C0944A1CACCC73001F6403 /* NotificationManager.swift in Sources */, 149A28E42A8A63A700052EDF /* FavoriteFoodDetailView.swift in Sources */, 1DDE274024AEA4F200796622 /* NotificationsCriticalAlertPermissionsView.swift in Sources */, @@ -3744,6 +3504,7 @@ E9C00EF524C623EF00628F35 /* LoopSettings+Loop.swift in Sources */, 4389916B1E91B689000EEF90 /* ChartSettings+Loop.swift in Sources */, C178249A1E1999FA00D9D25C /* CaseCountable.swift in Sources */, + C1F2CAAC2B7A980600D7F581 /* BasalRelativeDose.swift in Sources */, B4F3D25124AF890C0095CE44 /* BluetoothStateManager.swift in Sources */, 1DDE273D24AEA4B000796622 /* SettingsViewModel.swift in Sources */, DD3DBD292A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift in Sources */, @@ -3775,16 +3536,21 @@ 4302F4E31D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift in Sources */, 1D63DEA526E950D400F46FA5 /* SupportManager.swift in Sources */, 4FC8C8011DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift in Sources */, + 14C9707E2C5A9EB600E8A01B /* GlucoseCarbChartView.swift in Sources */, 43E93FB61E469A4000EAB8DB /* NumberFormatter.swift in Sources */, + 14C9706C2C5A836000E8A01B /* DoseChartView.swift in Sources */, C1FB428C217806A400FAB378 /* StateColorPalette.swift in Sources */, B43CF07E29434EC4008A520B /* HowMuteAlertWorkView.swift in Sources */, + 14C970842C5C2FB400E8A01B /* HowCarbEffectsWorksView.swift in Sources */, 84AA81E52A4A3981000B658B /* DeeplinkManager.swift in Sources */, 1D6B1B6726866D89009AC446 /* AlertPermissionsChecker.swift in Sources */, + C16F511B2B89363A00EFD7A1 /* SimpleInsulinDose.swift in Sources */, 4F08DE8F1E7BB871006741EA /* CollectionType+Loop.swift in Sources */, A9F703772489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift in Sources */, E9B080B1253BDA6300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */, C1AF062329426300002C1B19 /* ManualGlucoseEntryRow.swift in Sources */, C148CEE724FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift in Sources */, + 84DEB10D2C18FABA00170734 /* IOSFocusModesView.swift in Sources */, DDC389FC2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift in Sources */, 1D12D3B92548EFDD00B53E8B /* main.swift in Sources */, 435400341C9F878D00D5819C /* SetBolusUserInfo.swift in Sources */, @@ -3810,8 +3576,8 @@ 436A0DA51D236A2A00104B24 /* LoopError.swift in Sources */, 4F11D3C220DD80B3006E072C /* WatchHistoricalGlucose.swift in Sources */, 4372E490213CFCE70068E043 /* LoopSettingsUserInfo.swift in Sources */, + 14ED83F62C6421F9008B4A5C /* FavoriteFoodInsightsCardView.swift in Sources */, C174233C259BEB0F00399C9D /* ManualEntryDoseViewModel.swift in Sources */, - 89CA2B3D226E6B13004D9350 /* LocalTestingScenariosManager.swift in Sources */, 1D05219B2469E9DF000EBBDE /* StoredAlert.swift in Sources */, E9B0802B253BBDFF00BAD8F8 /* IntentExtensionInfo.swift in Sources */, C1E3862628247C6100F561A4 /* StoredLoopNotRunningNotification.swift in Sources */, @@ -3820,11 +3586,13 @@ 892A5D692230C41D008961AB /* RangeReplaceableCollection.swift in Sources */, DDC389F62A2B61750066E2E8 /* ApplicationFactorStrategy.swift in Sources */, 4F70C2101DE8FAC5006380B7 /* ExtensionDataManager.swift in Sources */, + 1455ACB22C667BEE004F44F2 /* Deeplink.swift in Sources */, 43DFB62320D4CAE7008A7BAE /* PumpManager.swift in Sources */, A9FB75F1252BE320004C7D3F /* BolusDosingDecision.swift in Sources */, 892A5D59222F0A27008961AB /* Debug.swift in Sources */, 431A8C401EC6E8AB00823B9C /* CircleMaskView.swift in Sources */, 1D05219D2469F1F5000EBBDE /* AlertStore.swift in Sources */, + 14C9706A2C5A833100E8A01B /* CarbEffectChartView.swift in Sources */, 439897371CD2F80600223065 /* AnalyticsServicesManager.swift in Sources */, 14D906F42A846510006EB79A /* FavoriteFoodsViewModel.swift in Sources */, DDC389FE2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift in Sources */, @@ -3836,15 +3604,15 @@ 430D85891F44037000AF2D4F /* HUDViewTableViewCell.swift in Sources */, 43A51E211EB6DBDD000736CC /* LoopChartsTableViewController.swift in Sources */, 8968B1122408B3520074BB48 /* UIFont.swift in Sources */, - 1452F4A92A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift in Sources */, + 1452F4A92A851C9400F8B9E4 /* FavoriteFoodAddEditViewModel.swift in Sources */, 438D42FB1D7D11A4003244B0 /* PredictionInputEffectTableViewCell.swift in Sources */, 89A1B66E24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift in Sources */, C1C660D1252E4DD5009B5C32 /* LoopConstants.swift in Sources */, 432E73CB1D24B3D6009AD15D /* RemoteDataServicesManager.swift in Sources */, - C18913B52524F24C007B0683 /* DeviceDataManager+SimpleBolusViewModelDelegate.swift in Sources */, 7E69CFFC2A16A77E00203CBD /* ResetLoopManager.swift in Sources */, B40D07C7251A89D500C1C6D7 /* GlucoseDisplay.swift in Sources */, 43C2FAE11EB656A500364AFF /* GlucoseEffectVelocity.swift in Sources */, + 14C970862C5C358C00E8A01B /* FavoriteFoodInsightsChartsView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3853,6 +3621,7 @@ buildActionMask = 2147483647; files = ( 894F6DDD243C0A2300CCE676 /* CarbAmountLabel.swift in Sources */, + B455C7352BD14E30002B847E /* Comparable.swift in Sources */, 89A605E524327F45009C1096 /* DoseVolumeInput.swift in Sources */, 4372E488213C862B0068E043 /* SampleValue.swift in Sources */, 89A605EB243288E4009C1096 /* TopDownTriangle.swift in Sources */, @@ -3893,6 +3662,7 @@ A9347F3324E7522900C99C34 /* WatchHistoricalCarbs.swift in Sources */, 895788AD242E69A2002CB114 /* AbsorptionTimeSelection.swift in Sources */, 89A605EF2432925D009C1096 /* CompletionCheckmark.swift in Sources */, + C138C6F82C1B8A2C00F08F1A /* GlucoseCondition.swift in Sources */, 89F9119624358E6900ECCAF3 /* BolusPickerValues.swift in Sources */, 4328E02B1CFBE2C500E199AA /* WKAlertAction.swift in Sources */, 4F7E8AC720E2AC0300AEA65E /* WatchPredictedGlucose.swift in Sources */, @@ -3905,7 +3675,6 @@ 89E08FCA242E7714000D719B /* UIFont.swift in Sources */, 4328E0281CFBE2C500E199AA /* CLKComplicationTemplate.swift in Sources */, 4328E01E1CFBE25F00E199AA /* CarbAndBolusFlowController.swift in Sources */, - 89E08FCC242E790C000D719B /* Comparable.swift in Sources */, 432CF87520D8AC950066B889 /* NSUserDefaults+WatchApp.swift in Sources */, 89A1B66F24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift in Sources */, 43027F0F1DFE0EC900C51989 /* HKUnit.swift in Sources */, @@ -3949,8 +3718,6 @@ E9C00EF324C6222400628F35 /* LoopSettings.swift in Sources */, C1D0B6312986D4D90098D215 /* LocalizedString.swift in Sources */, 43C05CB821EBEA54006FB252 /* HKUnit.swift in Sources */, - 4345E3F421F036FC009E00E5 /* Result.swift in Sources */, - C19E96E023D275FA003F79B0 /* LoopCompletionFreshness.swift in Sources */, 43D9002021EB209400AF44BF /* NSTimeInterval.swift in Sources */, C16575762539FEF3004AE16E /* LoopCoreConstants.swift in Sources */, C17DDC9D28AC33A1005FBF4C /* PersistedProperty.swift in Sources */, @@ -3970,8 +3737,6 @@ E9C00EF224C6221B00628F35 /* LoopSettings.swift in Sources */, C1D0B6302986D4D90098D215 /* LocalizedString.swift in Sources */, 43C05CB921EBEA54006FB252 /* HKUnit.swift in Sources */, - 4345E3F521F036FC009E00E5 /* Result.swift in Sources */, - C19E96DF23D275F8003F79B0 /* LoopCompletionFreshness.swift in Sources */, 43D9FFFB21EAF3D300AF44BF /* NSTimeInterval.swift in Sources */, C16575752539FD60004AE16E /* LoopCoreConstants.swift in Sources */, C17DDC9C28AC339E005FBF4C /* PersistedProperty.swift in Sources */, @@ -3993,61 +3758,48 @@ C1777A6625A125F100595963 /* ManualEntryDoseViewModelTests.swift in Sources */, C16B984026B4898800256B05 /* DoseEnactorTests.swift in Sources */, A9A63F8E246B271600588D5B /* NSTimeInterval.swift in Sources */, + C152B9F72C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift in Sources */, A9DFAFB324F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift in Sources */, A963B27A252CEBAE0062AA12 /* SetBolusUserInfoTests.swift in Sources */, A9DFAFB524F048A000950D1E /* WatchHistoricalCarbsTests.swift in Sources */, C16575732538AFF6004AE16E /* CGMStalenessMonitorTests.swift in Sources */, 1DA7A84424477698008257F0 /* InAppModalAlertSchedulerTests.swift in Sources */, + C1DA43532B19310A00CBD33F /* LoopControlMock.swift in Sources */, 1D70C40126EC0F9D00C62570 /* SupportManagerTests.swift in Sources */, E93E86A824DDCC4400FF40C8 /* MockDoseStore.swift in Sources */, B4D4534128E5CA7900F1A8D9 /* AlertMuterTests.swift in Sources */, E98A55F124EDD85E0008715D /* MockDosingDecisionStore.swift in Sources */, - C1D476B42A8ED179002C1C87 /* LoopAlgorithmTests.swift in Sources */, - 8968B114240C55F10074BB48 /* LoopSettingsTests.swift in Sources */, A9BD28E7272226B40071DF15 /* TestLocalizedError.swift in Sources */, A9F5F1F5251050EC00E7C8A4 /* ZipArchiveTests.swift in Sources */, E9B3552D293592B40076AB04 /* MealDetectionManagerTests.swift in Sources */, - E950CA9129002D9000B5B692 /* LoopDataManagerDosingTests.swift in Sources */, - B4CAD8792549D2540057946B /* LoopCompletionFreshnessTests.swift in Sources */, + C1DA43552B193BCB00CBD33F /* MockUploadEventListener.swift in Sources */, + C1DA43572B1A70BE00CBD33F /* SettingsManagerTests.swift in Sources */, 1D8D55BC252274650044DBB6 /* BolusEntryViewModelTests.swift in Sources */, + C188599E2AF15FAB0010F21F /* AlertMocks.swift in Sources */, A91E4C2124F867A700BE9213 /* StoredAlertTests.swift in Sources */, 1DA7A84224476EAD008257F0 /* AlertManagerTests.swift in Sources */, A91E4C2324F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift in Sources */, + C188599B2AF15E1B0010F21F /* DeviceDataManagerTests.swift in Sources */, + C18859A42AF165330010F21F /* MockCGMManager.swift in Sources */, E9C58A7324DB4A2700487A17 /* LoopDataManagerTests.swift in Sources */, + C18859A22AF165130010F21F /* MockPumpManager.swift in Sources */, E98A55F324EDD9530008715D /* MockSettingsStore.swift in Sources */, C165756F2534C468004AE16E /* SimpleBolusViewModelTests.swift in Sources */, A96DAC2A2838EF8A00D94E38 /* DiagnosticLogTests.swift in Sources */, A9DAE7D02332D77F006AE942 /* LoopTests.swift in Sources */, + C129BF4A2B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift in Sources */, E93E86B024DDE1BD00FF40C8 /* MockGlucoseStore.swift in Sources */, + C18859A82AF292D90010F21F /* MockTrustedTimeChecker.swift in Sources */, + C129D3BF2B8697F100FEA6A9 /* TempBasalRecommendationTests.swift in Sources */, 1DFE9E172447B6270082C280 /* UserNotificationAlertSchedulerTests.swift in Sources */, E9B3552F2935968E0076AB04 /* HKHealthStoreMock.swift in Sources */, B4BC56382518DEA900373647 /* CGMStatusHUDViewModelTests.swift in Sources */, + C1DA434F2B164C6C00CBD33F /* MockSettingsProvider.swift in Sources */, C1900900252271BB00721625 /* SimpleBolusCalculatorTests.swift in Sources */, A9C1719725366F780053BCBD /* WatchHistoricalGlucoseTest.swift in Sources */, E93E86B224DDE21D00FF40C8 /* MockCarbStore.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 4F70C1D81DE8DCA7006380B7 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 43FCEEB1221A863E0013DD30 /* StatusChartsManager.swift in Sources */, - 43C05CAC21EB2B8B006FB252 /* NSBundle.swift in Sources */, - 4FAC02541E22F6B20087A773 /* NSTimeInterval.swift in Sources */, - 4F2C15831E0757E600E160D4 /* HKUnit.swift in Sources */, - C1FB4290217922A100FAB378 /* PumpManagerUI.swift in Sources */, - 1D4990E824A25931005CC357 /* FeatureFlags.swift in Sources */, - A90EF53C25DEF06200F32D61 /* PluginManager.swift in Sources */, - C1FB428D21791D2500FAB378 /* PumpManager.swift in Sources */, - 43E93FB51E4675E800EAB8DB /* NumberFormatter.swift in Sources */, - 4345E3FB21F04911009E00E5 /* UIColor+HIG.swift in Sources */, - 43BFF0CD1E466C8400FF19A9 /* StateColorPalette.swift in Sources */, - 4FC8C8021DEB943800A1452E /* NSUserDefaults+StatusExtension.swift in Sources */, - 4F70C2121DE900EA006380B7 /* StatusExtensionContext.swift in Sources */, - 1D3F0F7626D59DCD004A5960 /* Debug.swift in Sources */, - 4F70C1E11DE8DCA7006380B7 /* StatusViewController.swift in Sources */, - A90EF54425DEF0A000F32D61 /* OSLog.swift in Sources */, + C1DA43592B1A784900CBD33F /* MockDeliveryDelegate.swift in Sources */, + C18859A02AF1612B0010F21F /* PersistenceController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4142,11 +3894,6 @@ target = 43776F8B1B8022E90074EA36 /* Loop */; targetProxy = 43E2D9101D20C581004DA55F /* PBXContainerItemProxy */; }; - 4F70C1E71DE8DCA7006380B7 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 4F70C1DB1DE8DCA7006380B7 /* Loop Status Extension */; - targetProxy = 4F70C1E61DE8DCA7006380B7 /* PBXContainerItemProxy */; - }; 4F7528971DFE1ED400C322D6 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 4F75288A1DFE1DC600C322D6 /* LoopUI */; @@ -4157,11 +3904,6 @@ target = 43D9001A21EB209400AF44BF /* LoopCore-watchOS */; targetProxy = C117ED70232EDB3200DA57CD /* PBXContainerItemProxy */; }; - C11B9D592867781E00500CF8 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 4F75288A1DFE1DC600C322D6 /* LoopUI */; - targetProxy = C11B9D582867781E00500CF8 /* PBXContainerItemProxy */; - }; C1CCF1152858FA900035389C /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 43D9FFCE21EAE05D00AF44BF /* LoopCore */; @@ -4357,35 +4099,6 @@ name = InfoPlist.strings; sourceTree = ""; }; - 4F70C1E21DE8DCA7006380B7 /* MainInterface.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 4F70C1E31DE8DCA7006380B7 /* Base */, - 7DD382781F8DBFC60071272B /* es */, - 7D68AAAB1FE2DB0A00522C49 /* ru */, - 7D23668621250D180028B67D /* fr */, - 7D23669621250D230028B67D /* de */, - 7D2366A621250D2C0028B67D /* zh-Hans */, - 7D2366B821250D360028B67D /* it */, - 7D2366C621250D3F0028B67D /* nl */, - 7D2366D621250D4A0028B67D /* nb */, - 7D199D94212A067600241026 /* pl */, - 7D9BEEDA2335A522005DCFD6 /* en */, - 7D9BEF162335EC4B005DCFD6 /* ja */, - 7D9BEF2C2335EC59005DCFD6 /* pt-BR */, - 7D9BEF422335EC62005DCFD6 /* vi */, - 7D9BEF582335EC6E005DCFD6 /* da */, - 7D9BEF6E2335EC7D005DCFD6 /* sv */, - 7D9BEF842335EC8B005DCFD6 /* fi */, - 7D9BF13C23370E8B005DCFD6 /* ro */, - F5D9C01A27DABBE1002E48F6 /* tr */, - F5E0BDD627E1D71D0033557E /* he */, - C1C31278297E4BFE00296DA4 /* ar */, - C1C2478E2995823200371B88 /* sk */, - ); - name = MainInterface.storyboard; - sourceTree = ""; - }; 63F5E17A297DDF3900A62D4B /* ckcomplication.strings */ = { isa = PBXVariantGroup; children = ( @@ -4406,35 +4119,6 @@ name = ckcomplication.strings; sourceTree = ""; }; - 7D7076371FE06EDE004AC8EA /* Localizable.strings */ = { - isa = PBXVariantGroup; - children = ( - 7D7076361FE06EDE004AC8EA /* es */, - 7D68AAAD1FE2E8D400522C49 /* ru */, - 7D23667821250C2D0028B67D /* Base */, - 7D23668B21250D180028B67D /* fr */, - 7D23669B21250D230028B67D /* de */, - 7D2366AB21250D2D0028B67D /* zh-Hans */, - 7D2366BC21250D360028B67D /* it */, - 7D2366CB21250D400028B67D /* nl */, - 7D2366DB21250D4A0028B67D /* nb */, - 7D199D99212A067600241026 /* pl */, - 7D9BEED82335A4F7005DCFD6 /* en */, - 7D9BEF1E2335EC4D005DCFD6 /* ja */, - 7D9BEF342335EC59005DCFD6 /* pt-BR */, - 7D9BEF4A2335EC63005DCFD6 /* vi */, - 7D9BEF602335EC6F005DCFD6 /* da */, - 7D9BEF762335EC7D005DCFD6 /* sv */, - 7D9BEF8C2335EC8C005DCFD6 /* fi */, - 7D9BF14223370E8C005DCFD6 /* ro */, - F5D9C02127DABBE3002E48F6 /* tr */, - F5E0BDDD27E1D7210033557E /* he */, - C174571329830930009EFCF2 /* ar */, - C1C2478D2995823200371B88 /* sk */, - ); - name = Localizable.strings; - sourceTree = ""; - }; 7D7076471FE06EE0004AC8EA /* InfoPlist.strings */ = { isa = PBXVariantGroup; children = ( @@ -4705,32 +4389,6 @@ name = Localizable.strings; sourceTree = ""; }; - C1004DF62981F5B700B8CF94 /* InfoPlist.strings */ = { - isa = PBXVariantGroup; - children = ( - C1004DF72981F5B700B8CF94 /* da */, - C1004E002981F67A00B8CF94 /* sv */, - C1004E082981F6A100B8CF94 /* ro */, - C1004E102981F6E200B8CF94 /* nl */, - C1004E182981F6F500B8CF94 /* nb */, - C1004E202981F72D00B8CF94 /* fr */, - C1004E272981F74300B8CF94 /* fi */, - C1004E2D2981F75B00B8CF94 /* es */, - C1004E332981F77B00B8CF94 /* de */, - C1BCB5B2298309C4001C50FF /* it */, - C19E387E298638CE00851444 /* tr */, - C1F48FF92995821600C8BD69 /* pl */, - C14952152995822A0095AA84 /* ru */, - C1C2478F2995823200371B88 /* sk */, - C15A582029C7866600D3A5A1 /* ar */, - C1FF3D4A29C786A900BDC1EC /* he */, - C1B0CFD529C786BF0045B04D /* ja */, - C1E693CB29C786E200410918 /* pt-BR */, - C192C5FF29C78711001EFEA6 /* vi */, - ); - name = InfoPlist.strings; - sourceTree = ""; - }; C11613472983096D00777E7C /* InfoPlist.strings */ = { isa = PBXVariantGroup; children = ( @@ -5012,7 +4670,7 @@ TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; WARNING_CFLAGS = "-Wall"; - WATCHOS_DEPLOYMENT_TARGET = 7.1; + WATCHOS_DEPLOYMENT_TARGET = 8.0; }; name = Debug; }; @@ -5122,14 +4780,13 @@ VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; WARNING_CFLAGS = "-Wall"; - WATCHOS_DEPLOYMENT_TARGET = 7.1; + WATCHOS_DEPLOYMENT_TARGET = 8.0; }; name = Release; }; 43776FB71B8022E90074EA36 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; CODE_SIGN_ENTITLEMENTS = "$(LOOP_ENTITLEMENTS)"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; @@ -5159,7 +4816,6 @@ 43776FB81B8022E90074EA36 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; CODE_SIGN_ENTITLEMENTS = "$(LOOP_ENTITLEMENTS)"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; @@ -5240,7 +4896,6 @@ 43A9439A1B926B7B0051FA24 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; @@ -5264,7 +4919,6 @@ 43A9439B1B926B7B0051FA24 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; @@ -5414,49 +5068,301 @@ }; name = Release; }; - 4F70C1E91DE8DCA8006380B7 /* Debug */ = { + 4F7528901DFE1DC600C322D6 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - CODE_SIGN_ENTITLEMENTS = "Loop Status Extension/Loop Status Extension.entitlements"; - CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; + APPLICATION_EXTENSION_API_ONLY = YES; + DEFINES_MODULE = YES; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = LoopUI/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopUI"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + }; + name = Debug; + }; + 4F7528911DFE1DC600C322D6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + DEFINES_MODULE = YES; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = LoopUI/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopUI"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + }; + name = Release; + }; + B4E7CF912AD00A39009B4DF2 /* Testflight */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 437D9BA11D7B5203007245E8 /* Loop.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + APP_GROUP_IDENTIFIER = "group.$(MAIN_APP_BUNDLE_IDENTIFIER)Group"; + CLANG_ANALYZER_GCD_PERFORMANCE = YES; + CLANG_ANALYZER_LOCALIZABILITY_EMPTY_CONTEXT = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_ANALYZER_SECURITY_FLOATLOOPCOUNTER = YES; + CLANG_ANALYZER_SECURITY_INSECUREAPI_RAND = YES; + CLANG_ANALYZER_SECURITY_INSECUREAPI_STRCPY = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_ASSIGN_ENUM = YES; + CLANG_WARN_ATOMIC_IMPLICIT_SEQ_CST = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES_ERROR; + CLANG_WARN_BOOL_CONVERSION = YES_ERROR; + CLANG_WARN_COMMA = YES_ERROR; + CLANG_WARN_CONSTANT_CONVERSION = YES_ERROR; + CLANG_WARN_CXX0X_EXTENSIONS = YES; + CLANG_WARN_DELETE_NON_VIRTUAL_DTOR = YES_ERROR; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES_ERROR; + CLANG_WARN_FLOAT_CONVERSION = YES_ERROR; + CLANG_WARN_IMPLICIT_SIGN_CONVERSION = YES_ERROR; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES_ERROR; + CLANG_WARN_MISSING_NOESCAPE = YES_ERROR; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES_ERROR; + CLANG_WARN_OBJC_EXPLICIT_OWNERSHIP_TYPE = YES; + CLANG_WARN_OBJC_IMPLICIT_ATOMIC_PROPERTIES = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_INTERFACE_IVARS = YES_ERROR; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES_ERROR; + CLANG_WARN_OBJC_MISSING_PROPERTY_SYNTHESIS = YES; + CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES_AGGRESSIVE; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_PRAGMA_PACK = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SEMICOLON_BEFORE_METHOD_BODY = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES_ERROR; + CLANG_WARN_SUSPICIOUS_IMPLICIT_CONVERSION = YES_ERROR; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES_AGGRESSIVE; + CLANG_WARN_VEXING_PARSE = YES_ERROR; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CLANG_WARN__EXIT_TIME_DESTRUCTORS = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; + DYLIB_COMPATIBILITY_VERSION = 1; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_TREAT_IMPLICIT_FUNCTION_DECLARATIONS_AS_ERRORS = YES; + GCC_TREAT_INCOMPATIBLE_POINTER_TYPE_WARNINGS_AS_ERRORS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES_ERROR; + GCC_WARN_ABOUT_MISSING_FIELD_INITIALIZERS = YES; + GCC_WARN_ABOUT_MISSING_NEWLINE = YES; + GCC_WARN_ABOUT_MISSING_PROTOTYPES = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_FOUR_CHARACTER_CONSTANTS = YES; + GCC_WARN_HIDDEN_VIRTUAL_FUNCTIONS = YES; + GCC_WARN_INITIALIZER_NOT_FULLY_BRACKETED = YES; + GCC_WARN_NON_VIRTUAL_DESTRUCTOR = YES; + GCC_WARN_SHADOW = YES; + GCC_WARN_SIGN_COMPARE = YES; + GCC_WARN_STRICT_SELECTOR_MATCH = YES; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNKNOWN_PRAGMAS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_LABEL = YES; + GCC_WARN_UNUSED_PARAMETER = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LOCALIZED_STRING_MACRO_NAMES = ( + NSLocalizedString, + CFLocalizedString, + LocalizedString, + ); + MAIN_APP_BUNDLE_IDENTIFIER = "$(inherited).Loop"; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PROVISIONING_PROFILE = ""; + PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + WARNING_CFLAGS = "-Wall"; + WATCHOS_DEPLOYMENT_TARGET = 8.0; + }; + name = Testflight; + }; + B4E7CF922AD00A39009B4DF2 /* Testflight */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; + CODE_SIGN_ENTITLEMENTS = "$(LOOP_ENTITLEMENTS)"; + CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "Loop Status Extension/Info.plist"; + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + INFOPLIST_FILE = Loop/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_LDFLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_RELEASE)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Testflight; + }; + B4E7CF942AD00A39009B4DF2 /* Testflight */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; + CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; + CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + FRAMEWORK_SEARCH_PATHS = ""; + IBSC_MODULE = WatchApp_Extension; + INFOPLIST_FILE = WatchApp/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWatch"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_RELEASE)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = 4; + }; + name = Testflight; + }; + B4E7CF952AD00A39009B4DF2 /* Testflight */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_COMPLICATION_NAME = Complication; + CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; + CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; + CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + FRAMEWORK_SEARCH_PATHS = ""; + INFOPLIST_FILE = "WatchApp Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).statuswidget"; + PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWatch.watchkitextension"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_STATUS_EXTENSION_DEBUG)"; + PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_EXTENSION_RELEASE)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + SWIFT_OBJC_BRIDGING_HEADER = "WatchApp Extension/Extensions/WatchApp Extension-Bridging-Header.h"; + SWIFT_PRECOMPILE_BRIDGING_HEADER = NO; + TARGETED_DEVICE_FAMILY = 4; + }; + name = Testflight; + }; + B4E7CF962AD00A39009B4DF2 /* Testflight */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CODE_SIGN_ENTITLEMENTS = "Loop Widget Extension/Bootstrap/LoopWidgetExtension.entitlements"; + CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; + CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + GCC_DYNAMIC_NO_PIC = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "Loop Widget Extension/Bootstrap/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "Loop Widgets"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 LoopKit Authors. All rights reserved."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWidgetExtension"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WIDGET_EXTENSION_RELEASE)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; - name = Debug; + name = Testflight; }; - 4F70C1EA1DE8DCA8006380B7 /* Release */ = { + B4E7CF972AD00A39009B4DF2 /* Testflight */ = { isa = XCBuildConfiguration; buildSettings = { - CODE_SIGN_ENTITLEMENTS = "Loop Status Extension/Loop Status Extension.entitlements"; + CODE_SIGN_ENTITLEMENTS = "Loop Intent Extension/Loop Intent Extension.entitlements"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; - INFOPLIST_FILE = "Loop Status Extension/Info.plist"; + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + INFOPLIST_FILE = "Loop Intent Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).statuswidget"; + PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).Loop-Intent-Extension"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_STATUS_EXTENSION_RELEASE)"; + PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_INTENT_EXTENSION_RELEASE)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -5464,29 +5370,55 @@ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = 1; }; - name = Release; + name = Testflight; }; - 4F7528901DFE1DC600C322D6 /* Debug */ = { + B4E7CF982AD00A39009B4DF2 /* Testflight */ = { isa = XCBuildConfiguration; buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = YES; DEFINES_MODULE = YES; DYLIB_INSTALL_NAME_BASE = "@rpath"; - INFOPLIST_FILE = LoopUI/Info.plist; + INFOPLIST_FILE = LoopCore/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopUI"; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.loopkit.LoopCore; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SWIFT_INSTALL_OBJC_HEADER = NO; }; - name = Debug; + name = Testflight; }; - 4F7528911DFE1DC600C322D6 /* Release */ = { + B4E7CF992AD00A39009B4DF2 /* Testflight */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + DEFINES_MODULE = YES; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + FRAMEWORK_SEARCH_PATHS = ""; + INFOPLIST_FILE = LoopCore/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_NO_PIE = NO; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.loopkit.LoopCore; + PRODUCT_NAME = LoopCore; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + TARGETED_DEVICE_FAMILY = 4; + }; + name = Testflight; + }; + B4E7CF9A2AD00A39009B4DF2 /* Testflight */ = { isa = XCBuildConfiguration; buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; @@ -5504,7 +5436,25 @@ SKIP_INSTALL = YES; SWIFT_INSTALL_OBJC_HEADER = NO; }; - name = Release; + name = Testflight; + }; + B4E7CF9B2AD00A39009B4DF2 /* Testflight */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + INFOPLIST_FILE = LoopTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.loopkit.LoopTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_INSTALL_OBJC_HEADER = NO; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Loop.app/Loop"; + }; + name = Testflight; }; E9B07F95253BBA6500BAD8F8 /* Debug */ = { isa = XCBuildConfiguration; @@ -5567,6 +5517,7 @@ isa = XCConfigurationList; buildConfigurations = ( 14B1736A28AED9EE006CCD7C /* Debug */, + B4E7CF962AD00A39009B4DF2 /* Testflight */, 14B1736B28AED9EE006CCD7C /* Release */, ); defaultConfigurationIsVisible = 0; @@ -5576,6 +5527,7 @@ isa = XCConfigurationList; buildConfigurations = ( 43776FB41B8022E90074EA36 /* Debug */, + B4E7CF912AD00A39009B4DF2 /* Testflight */, 43776FB51B8022E90074EA36 /* Release */, ); defaultConfigurationIsVisible = 0; @@ -5585,6 +5537,7 @@ isa = XCConfigurationList; buildConfigurations = ( 43776FB71B8022E90074EA36 /* Debug */, + B4E7CF922AD00A39009B4DF2 /* Testflight */, 43776FB81B8022E90074EA36 /* Release */, ); defaultConfigurationIsVisible = 0; @@ -5594,6 +5547,7 @@ isa = XCConfigurationList; buildConfigurations = ( 43A943961B926B7B0051FA24 /* Debug */, + B4E7CF952AD00A39009B4DF2 /* Testflight */, 43A943971B926B7B0051FA24 /* Release */, ); defaultConfigurationIsVisible = 0; @@ -5603,6 +5557,7 @@ isa = XCConfigurationList; buildConfigurations = ( 43A9439A1B926B7B0051FA24 /* Debug */, + B4E7CF942AD00A39009B4DF2 /* Testflight */, 43A9439B1B926B7B0051FA24 /* Release */, ); defaultConfigurationIsVisible = 0; @@ -5612,6 +5567,7 @@ isa = XCConfigurationList; buildConfigurations = ( 43D9002821EB209400AF44BF /* Debug */, + B4E7CF992AD00A39009B4DF2 /* Testflight */, 43D9002921EB209400AF44BF /* Release */, ); defaultConfigurationIsVisible = 0; @@ -5621,6 +5577,7 @@ isa = XCConfigurationList; buildConfigurations = ( 43D9FFD921EAE05D00AF44BF /* Debug */, + B4E7CF982AD00A39009B4DF2 /* Testflight */, 43D9FFDA21EAE05D00AF44BF /* Release */, ); defaultConfigurationIsVisible = 0; @@ -5630,24 +5587,17 @@ isa = XCConfigurationList; buildConfigurations = ( 43E2D9131D20C581004DA55F /* Debug */, + B4E7CF9B2AD00A39009B4DF2 /* Testflight */, 43E2D9141D20C581004DA55F /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 4F70C1EB1DE8DCA8006380B7 /* Build configuration list for PBXNativeTarget "Loop Status Extension" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 4F70C1E91DE8DCA8006380B7 /* Debug */, - 4F70C1EA1DE8DCA8006380B7 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; 4F7528921DFE1DC600C322D6 /* Build configuration list for PBXNativeTarget "LoopUI" */ = { isa = XCConfigurationList; buildConfigurations = ( 4F7528901DFE1DC600C322D6 /* Debug */, + B4E7CF9A2AD00A39009B4DF2 /* Testflight */, 4F7528911DFE1DC600C322D6 /* Release */, ); defaultConfigurationIsVisible = 0; @@ -5657,6 +5607,7 @@ isa = XCConfigurationList; buildConfigurations = ( E9B07F95253BBA6500BAD8F8 /* Debug */, + B4E7CF972AD00A39009B4DF2 /* Testflight */, E9B07F96253BBA6500BAD8F8 /* Release */, ); defaultConfigurationIsVisible = 0; @@ -5669,8 +5620,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/LoopKit/ZIPFoundation.git"; requirement = { - branch = "stream-entry"; - kind = branch; + kind = revision; + revision = c67b7509ec82ee2b4b0ab3f97742b94ed9692494; }; }; C1CCF10B2858F4F70035389C /* XCRemoteSwiftPackageReference "SwiftCharts" */ = { @@ -5702,11 +5653,6 @@ package = C1735B1C2A0809830082BB8A /* XCRemoteSwiftPackageReference "ZIPFoundation" */; productName = ZIPFoundation; }; - C1CCF1162858FBAD0035389C /* SwiftCharts */ = { - isa = XCSwiftPackageProductDependency; - package = C1CCF10B2858F4F70035389C /* XCRemoteSwiftPackageReference "SwiftCharts" */; - productName = SwiftCharts; - }; C1D6EE9F2A06C7270047DE5C /* MKRingProgressView */ = { isa = XCSwiftPackageProductDependency; package = C1D6EE9E2A06C7270047DE5C /* XCRemoteSwiftPackageReference "MKRingProgressView" */; diff --git a/Loop/AppDelegate.swift b/Loop/AppDelegate.swift index ebb05d5c12..b84ecaeffd 100644 --- a/Loop/AppDelegate.swift +++ b/Loop/AppDelegate.swift @@ -24,9 +24,14 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, WindowProvider { log.default("lastPathComponent = %{public}@", String(describing: Bundle.main.appStoreReceiptURL?.lastPathComponent)) - loopAppManager.initialize(windowProvider: self, launchOptions: launchOptions) - loopAppManager.launch() - return loopAppManager.isLaunchComplete + // Avoid doing full initialization when running tests + if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil { + loopAppManager.initialize(windowProvider: self, launchOptions: launchOptions) + loopAppManager.launch() + return loopAppManager.isLaunchComplete + } else { + return true + } } // MARK: - UIApplicationDelegate - Life Cycle diff --git a/Loop/DefaultAssets.xcassets/hardware.imageset/Contents.json b/Loop/DefaultAssets.xcassets/hardware.imageset/Contents.json index 579e60790c..f7a99d2ae3 100644 --- a/Loop/DefaultAssets.xcassets/hardware.imageset/Contents.json +++ b/Loop/DefaultAssets.xcassets/hardware.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Group 3403.pdf", + "filename" : "hardware.pdf", "idiom" : "universal" } ], diff --git a/Loop/DefaultAssets.xcassets/hardware.imageset/Group 3403.pdf b/Loop/DefaultAssets.xcassets/hardware.imageset/Group 3403.pdf deleted file mode 100644 index 14057221ed..0000000000 Binary files a/Loop/DefaultAssets.xcassets/hardware.imageset/Group 3403.pdf and /dev/null differ diff --git a/Loop/DefaultAssets.xcassets/hardware.imageset/hardware.pdf b/Loop/DefaultAssets.xcassets/hardware.imageset/hardware.pdf new file mode 100644 index 0000000000..fc430ce5cd Binary files /dev/null and b/Loop/DefaultAssets.xcassets/hardware.imageset/hardware.pdf differ diff --git a/Loop/DefaultAssets.xcassets/phone.imageset/Contents.json b/Loop/DefaultAssets.xcassets/phone.imageset/Contents.json index 507753a905..7a89bd061f 100644 --- a/Loop/DefaultAssets.xcassets/phone.imageset/Contents.json +++ b/Loop/DefaultAssets.xcassets/phone.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Group 3405.pdf", + "filename" : "phone.pdf", "idiom" : "universal" } ], diff --git a/Loop/DefaultAssets.xcassets/phone.imageset/Group 3405.pdf b/Loop/DefaultAssets.xcassets/phone.imageset/Group 3405.pdf deleted file mode 100644 index fc12ec3959..0000000000 Binary files a/Loop/DefaultAssets.xcassets/phone.imageset/Group 3405.pdf and /dev/null differ diff --git a/Loop/DefaultAssets.xcassets/phone.imageset/phone.pdf b/Loop/DefaultAssets.xcassets/phone.imageset/phone.pdf new file mode 100644 index 0000000000..5b7c630b7c Binary files /dev/null and b/Loop/DefaultAssets.xcassets/phone.imageset/phone.pdf differ diff --git a/Loop/Extensions/BasalDeliveryState.swift b/Loop/Extensions/BasalDeliveryState.swift index 7aef479ccf..32cf77c930 100644 --- a/Loop/Extensions/BasalDeliveryState.swift +++ b/Loop/Extensions/BasalDeliveryState.swift @@ -8,9 +8,10 @@ import LoopKit import LoopCore +import LoopAlgorithm extension PumpManagerStatus.BasalDeliveryState { - func getNetBasal(basalSchedule: BasalRateSchedule, settings: LoopSettings) -> NetBasal? { + func getNetBasal(basalSchedule: BasalRateSchedule, maximumBasalRatePerHour: Double?) -> NetBasal? { func scheduledBasal(for date: Date) -> AbsoluteScheduleValue? { return basalSchedule.between(start: date, end: date).first } @@ -20,7 +21,7 @@ extension PumpManagerStatus.BasalDeliveryState { if let scheduledBasal = scheduledBasal(for: dose.startDate) { return NetBasal( lastTempBasal: dose, - maxBasal: settings.maximumBasalRatePerHour, + maxBasal: maximumBasalRatePerHour, scheduledBasal: scheduledBasal ) } else { @@ -30,7 +31,7 @@ extension PumpManagerStatus.BasalDeliveryState { if let scheduledBasal = scheduledBasal(for: date) { return NetBasal( suspendedAt: date, - maxBasal: settings.maximumBasalRatePerHour, + maxBasal: maximumBasalRatePerHour, scheduledBasal: scheduledBasal ) } else { diff --git a/Loop/Extensions/BasalRelativeDose.swift b/Loop/Extensions/BasalRelativeDose.swift new file mode 100644 index 0000000000..d78d5ad967 --- /dev/null +++ b/Loop/Extensions/BasalRelativeDose.swift @@ -0,0 +1,51 @@ +// +// BasalRelativeDose.swift +// Loop +// +// Created by Pete Schwamb on 2/12/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopAlgorithm + +public extension Array where Element == BasalRelativeDose { + func trimmed(from start: Date? = nil, to end: Date? = nil) -> [BasalRelativeDose] { + return self.compactMap { (dose) -> BasalRelativeDose? in + if let start, dose.endDate < start { + return nil + } + if let end, dose.startDate > end { + return nil + } + if dose.type == .bolus { + // Do not split boluses + return dose + } + return dose.trimmed(from: start, to: end) + } + } +} + +extension BasalRelativeDose { + public func trimmed(from start: Date? = nil, to end: Date? = nil, syncIdentifier: String? = nil) -> BasalRelativeDose { + + let originalDuration = endDate.timeIntervalSince(startDate) + + let startDate = max(start ?? .distantPast, self.startDate) + let endDate = max(startDate, min(end ?? .distantFuture, self.endDate)) + + var trimmedVolume: Double = volume + + if originalDuration > .ulpOfOne && (startDate > self.startDate || endDate < self.endDate) { + trimmedVolume = volume * (endDate.timeIntervalSince(startDate) / originalDuration) + } + + return BasalRelativeDose( + type: self.type, + startDate: startDate, + endDate: endDate, + volume: trimmedVolume + ) + } +} diff --git a/Loop/Extensions/Character+IsEmoji.swift b/Loop/Extensions/Character+IsEmoji.swift new file mode 100644 index 0000000000..fe19295350 --- /dev/null +++ b/Loop/Extensions/Character+IsEmoji.swift @@ -0,0 +1,15 @@ +// +// Character+IsEmoji.swift +// Loop +// +// Created by Noah Brauner on 8/6/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation + +extension Character { + public var isEmoji: Bool { + unicodeScalars.contains(where: { $0.properties.isEmoji }) + } +} diff --git a/Loop/Extensions/CollectionType+Loop.swift b/Loop/Extensions/CollectionType+Loop.swift index 1ca70b1ff9..8740bdb453 100644 --- a/Loop/Extensions/CollectionType+Loop.swift +++ b/Loop/Extensions/CollectionType+Loop.swift @@ -8,6 +8,7 @@ import Foundation import LoopKit +import LoopAlgorithm public extension Sequence where Element: TimelineValue { diff --git a/Loop/Extensions/DeviceDataManager+BolusEntryViewModelDelegate.swift b/Loop/Extensions/DeviceDataManager+BolusEntryViewModelDelegate.swift deleted file mode 100644 index 25173f92d8..0000000000 --- a/Loop/Extensions/DeviceDataManager+BolusEntryViewModelDelegate.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// DeviceDataManager+BolusEntryViewModelDelegate.swift -// Loop -// -// Created by Rick Pasetto on 9/29/20. -// Copyright © 2020 LoopKit Authors. All rights reserved. -// - -import HealthKit -import LoopCore -import LoopKit - -extension DeviceDataManager: CarbEntryViewModelDelegate { - var defaultAbsorptionTimes: LoopKit.CarbStore.DefaultAbsorptionTimes { - return carbStore.defaultAbsorptionTimes - } -} - -extension DeviceDataManager: BolusEntryViewModelDelegate, ManualDoseViewModelDelegate { - - func addManuallyEnteredDose(startDate: Date, units: Double, insulinType: InsulinType?) { - loopManager.addManuallyEnteredDose(startDate: startDate, units: units, insulinType: insulinType) - } - - func withLoopState(do block: @escaping (LoopState) -> Void) { - loopManager.getLoopState { block($1) } - } - - func saveGlucose(sample: NewGlucoseSample) async -> StoredGlucoseSample? { - return await withCheckedContinuation { continuation in - loopManager.addGlucoseSamples([sample]) { result in - switch result { - case .success(let samples): - continuation.resume(returning: samples.first) - case .failure: - continuation.resume(returning: nil) - } - } - } - } - - func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?, completion: @escaping (Result) -> Void) { - loopManager.addCarbEntry(carbEntry, replacing: replacingEntry, completion: completion) - } - - func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) { - loopManager.storeManualBolusDosingDecision(bolusDosingDecision, withDate: date) - } - - func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) { - glucoseStore.getGlucoseSamples(start: start, end: end, completion: completion) - } - - func insulinOnBoard(at date: Date, completion: @escaping (DoseStoreResult) -> Void) { - doseStore.insulinOnBoard(at: date, completion: completion) - } - - func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult) -> Void) { - carbStore.carbsOnBoard(at: date, effectVelocities: effectVelocities, completion: completion) - } - - func ensureCurrentPumpData(completion: @escaping (Date?) -> Void) { - pumpManager?.ensureCurrentPumpData(completion: completion) - } - - var mostRecentGlucoseDataDate: Date? { - return glucoseStore.latestGlucose?.startDate - } - - var mostRecentPumpDataDate: Date? { - return doseStore.lastAddedPumpData - } - - var isPumpConfigured: Bool { - return pumpManager != nil - } - - var preferredGlucoseUnit: HKUnit { - return displayGlucosePreference.unit - } - - var pumpInsulinType: InsulinType? { - return pumpManager?.status.insulinType - } - - func insulinActivityDuration(for type: InsulinType?) -> TimeInterval { - return doseStore.insulinModelProvider.model(for: type).effectDuration - } - - var settings: LoopSettings { - return loopManager.settings - } - - func updateRemoteRecommendation() { - loopManager.updateRemoteRecommendation() - } -} diff --git a/Loop/Extensions/DeviceDataManager+DeviceStatus.swift b/Loop/Extensions/DeviceDataManager+DeviceStatus.swift index fbcf52b983..5f08105b2e 100644 --- a/Loop/Extensions/DeviceDataManager+DeviceStatus.swift +++ b/Loop/Extensions/DeviceDataManager+DeviceStatus.swift @@ -41,16 +41,16 @@ extension DeviceDataManager { } else if pumpManager == nil { return DeviceDataManager.addPumpStatusHighlight } else { - return pumpManager?.pumpStatusHighlight + return (pumpManager as? PumpManagerUI)?.pumpStatusHighlight } } var pumpStatusBadge: DeviceStatusBadge? { - return pumpManager?.pumpStatusBadge + return (pumpManager as? PumpManagerUI)?.pumpStatusBadge } var pumpLifecycleProgress: DeviceLifecycleProgress? { - return pumpManager?.pumpLifecycleProgress + return (pumpManager as? PumpManagerUI)?.pumpLifecycleProgress } static var resumeOnboardingStatusHighlight: ResumeOnboardingStatusHighlight { @@ -104,18 +104,12 @@ extension DeviceDataManager { let action = pumpManagerHUDProvider.didTapOnHUDView(view, allowDebugFeatures: FeatureFlags.allowDebugFeatures) { return action - } else if let pumpManager = pumpManager { + } else if let pumpManager = pumpManager as? PumpManagerUI { return .presentViewController(pumpManager.settingsViewController(bluetoothProvider: bluetoothProvider, colorPalette: .default, allowDebugFeatures: FeatureFlags.allowDebugFeatures, allowedInsulinTypes: allowedInsulinTypes)) } else { return .setupNewPump } - } - - var isGlucoseValueStale: Bool { - guard let latestGlucoseDataDate = glucoseStore.latestGlucose?.startDate else { return true } - - return Date().timeIntervalSince(latestGlucoseDataDate) > LoopCoreConstants.inputDataRecencyInterval - } + } } // MARK: - BluetoothState diff --git a/Loop/Extensions/DeviceDataManager+SimpleBolusViewModelDelegate.swift b/Loop/Extensions/DeviceDataManager+SimpleBolusViewModelDelegate.swift deleted file mode 100644 index 4192700ef4..0000000000 --- a/Loop/Extensions/DeviceDataManager+SimpleBolusViewModelDelegate.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// DeviceDataManager+SimpleBolusViewModelDelegate.swift -// Loop -// -// Created by Pete Schwamb on 9/30/20. -// Copyright © 2020 LoopKit Authors. All rights reserved. -// - -import HealthKit -import LoopCore -import LoopKit - -extension DeviceDataManager: SimpleBolusViewModelDelegate { - func addGlucose(_ samples: [NewGlucoseSample], completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) { - loopManager.addGlucoseSamples(samples, completion: completion) - } - - func enactBolus(units: Double, activationType: BolusActivationType) { - enactBolus(units: units, activationType: activationType) { (_) in } - } - - func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? { - return loopManager.generateSimpleBolusRecommendation(at: date, mealCarbs: mealCarbs, manualGlucose: manualGlucose) - } - - var maximumBolus: Double { - return loopManager.settings.maximumBolus! - } - - var suspendThreshold: HKQuantity { - return loopManager.settings.suspendThreshold!.quantity - } -} diff --git a/Loop/Extensions/DoseStore+SimulatedCoreData.swift b/Loop/Extensions/DoseStore+SimulatedCoreData.swift index 6036f7d08c..066e1306a0 100644 --- a/Loop/Extensions/DoseStore+SimulatedCoreData.swift +++ b/Loop/Extensions/DoseStore+SimulatedCoreData.swift @@ -19,8 +19,9 @@ extension DoseStore { private var simulatedBasalStartDateInterval: TimeInterval { .minutes(5) } private var simulatedOtherPerDay: Int { 1 } private var simulatedLimit: Int { 10000 } + private var suspendDuration: TimeInterval { .minutes(30) } - func generateSimulatedHistoricalPumpEvents(completion: @escaping (Error?) -> Void) { + func generateSimulatedHistoricalPumpEvents() async throws { var startDate = Calendar.current.startOfDay(for: cacheStartDate) let endDate = Calendar.current.startOfDay(for: historicalEndDate) var index = 0 @@ -31,22 +32,32 @@ extension DoseStore { let basalEvent: PersistedPumpEvent? - // Suspends last for 30m - if let suspendedTime = suspendedAt, startDate.timeIntervalSince(suspendedTime) >= .minutes(30) { - basalEvent = PersistedPumpEvent.simulatedResume(date: startDate) + if let suspendedTime = suspendedAt, startDate.timeIntervalSince(suspendedTime) > suspendDuration { + // suspend is over, allow for other basal events suspendedAt = nil - } else if Double.random(in: 0...1) > 0.98 { // 2% chance of this being a suspend - basalEvent = PersistedPumpEvent.simulatedSuspend(date: startDate) - suspendedAt = startDate - } else if Double.random(in: 0...1) < 0.98 { // 98% chance of a successful basal - let rate = [0, 0.5, 1, 1.5, 2, 6].randomElement()! - basalEvent = PersistedPumpEvent.simulatedTempBasal(date: startDate, duration: .minutes(5), rate: rate, scheduledRate: 1) + } + + if suspendedAt == nil { // if suspended, no other basal events + if Double.random(in: 0...1) > 0.98 { // 2% chance of this being a suspend + basalEvent = PersistedPumpEvent.simulatedSuspend(date: startDate) + suspendedAt = startDate + } else if suspendedAt == nil, Double.random(in: 0...1) < 0.98 { // 98% chance of a successful basal + let rate = [0, 0.5, 1, 1.5, 2, 6].randomElement()! + basalEvent = PersistedPumpEvent.simulatedTempBasal(date: startDate, duration: .minutes(5), rate: rate, scheduledRate: 1) + } else { + basalEvent = nil + } } else { basalEvent = nil } if let basalEvent = basalEvent { simulated.append(basalEvent) + if basalEvent.type == .suspend { + // Report the resume immediately to avoid reconcilation issues + let resumeBasalEvent = PersistedPumpEvent.simulatedResume(date: basalEvent.date.addingTimeInterval(suspendDuration)) + simulated.append(resumeBasalEvent) + } } if Double.random(in: 0...1) > 0.98 { // 2% chance of some other event @@ -68,10 +79,7 @@ extension DoseStore { // Process about a day's worth at a time if simulated.count >= 300 { - if let error = addPumpEvents(events: simulated) { - completion(error) - return - } + try await addPumpEvents(events: simulated) simulated = [] } @@ -79,11 +87,11 @@ extension DoseStore { startDate = startDate.addingTimeInterval(simulatedBasalStartDateInterval) } - completion(addPumpEvents(events: simulated)) + try await addPumpEvents(events: simulated) } - func purgeHistoricalPumpEvents(completion: @escaping (Error?) -> Void) { - purgePumpEventObjects(before: historicalEndDate, completion: completion) + func purgeHistoricalPumpEvents() async throws { + try await purgePumpEventObjects(before: historicalEndDate) } } diff --git a/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift b/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift index 53f81c5209..ae4b0c05bc 100644 --- a/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift +++ b/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift @@ -9,6 +9,7 @@ import Foundation import HealthKit import LoopKit +import LoopAlgorithm // MARK: - Simulated Core Data @@ -168,8 +169,7 @@ fileprivate extension StoredDosingDecision { duration: .minutes(30)), bolusUnits: 1.25) let manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: 0.2, - pendingInsulin: 0.75, - notice: .predictedGlucoseBelowTarget(minGlucose: PredictedGlucoseValue(startDate: date.addingTimeInterval(.minutes(30)), + notice: .predictedGlucoseBelowTarget(minGlucose: SimpleGlucoseValue(startDate: date.addingTimeInterval(.minutes(30)), quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 95.0)))), date: date.addingTimeInterval(-.minutes(1))) let manualBolusRequested = 0.5 diff --git a/Loop/Extensions/GlucoseStore+SimulatedCoreData.swift b/Loop/Extensions/GlucoseStore+SimulatedCoreData.swift index e5cc830a70..e30a548a4a 100644 --- a/Loop/Extensions/GlucoseStore+SimulatedCoreData.swift +++ b/Loop/Extensions/GlucoseStore+SimulatedCoreData.swift @@ -82,8 +82,8 @@ extension GlucoseStore { return addError } - func purgeHistoricalGlucoseObjects(completion: @escaping (Error?) -> Void) { - purgeCachedGlucoseObjects(before: historicalEndDate, completion: completion) + func purgeHistoricalGlucoseObjects() async throws { + try await purgeCachedGlucoseObjects(before: historicalEndDate) } } diff --git a/Loop/Extensions/PersistentDeviceLog+SimulatedCoreData.swift b/Loop/Extensions/PersistentDeviceLog+SimulatedCoreData.swift index d39848337d..1651404078 100644 --- a/Loop/Extensions/PersistentDeviceLog+SimulatedCoreData.swift +++ b/Loop/Extensions/PersistentDeviceLog+SimulatedCoreData.swift @@ -14,7 +14,7 @@ import LoopKit extension PersistentDeviceLog { private var historicalEndDate: Date { Date(timeIntervalSinceNow: -.hours(24)) } - private var simulatedPerHour: Int { 250 } + private var simulatedPerHour: Int { 60 } private var simulatedLimit: Int { 10000 } func generateSimulatedHistoricalDeviceLogEntries(completion: @escaping (Error?) -> Void) { diff --git a/Loop/Extensions/SettingsStore+SimulatedCoreData.swift b/Loop/Extensions/SettingsStore+SimulatedCoreData.swift index 80c990bb38..e633401d0d 100644 --- a/Loop/Extensions/SettingsStore+SimulatedCoreData.swift +++ b/Loop/Extensions/SettingsStore+SimulatedCoreData.swift @@ -9,6 +9,7 @@ import Foundation import HealthKit import LoopKit +import LoopAlgorithm // MARK: - Simulated Core Data @@ -154,9 +155,7 @@ fileprivate extension StoredSettings { glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, preMealTargetRange: DoubleRange(minValue: 80.0, maxValue: 90.0).quantityRange(for: .milligramsPerDeciliter), workoutTargetRange: DoubleRange(minValue: 150.0, maxValue: 160.0).quantityRange(for: .milligramsPerDeciliter), - overridePresets: nil, - scheduleOverride: nil, - preMealOverride: preMealOverride, + overridePresets: [], maximumBasalRatePerHour: 3.5, maximumBolus: 10.0, suspendThreshold: GlucoseThreshold(unit: .milligramsPerDeciliter, value: 75.0), diff --git a/Loop/Extensions/TempBasalRecommendation.swift b/Loop/Extensions/TempBasalRecommendation.swift new file mode 100644 index 0000000000..8d60a52687 --- /dev/null +++ b/Loop/Extensions/TempBasalRecommendation.swift @@ -0,0 +1,67 @@ +// +// TempBasalRecommendation.swift +// Loop +// +// Created by Pete Schwamb on 2/9/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import LoopAlgorithm + +extension TempBasalRecommendation { + /// Equates the recommended rate with another rate + /// + /// - Parameter unitsPerHour: The rate to compare + /// - Returns: Whether the rates are equal within Double precision + private func matchesRate(_ unitsPerHour: Double) -> Bool { + return abs(self.unitsPerHour - unitsPerHour) < .ulpOfOne + } + + /// Adjusts a recommendation based on the current state of pump delivery. If the current temp basal matches + /// the recommendation, and enough time is remaining, then recommend no action. If we are running a temp basal + /// and the new rate matches the scheduled rate, then cancel the currently running temp basal. If the current scheduled + /// rate matches the recommended rate, then recommend no action. Otherwise, set a new temp basal of the + /// recommended rate. + /// + /// - Parameters: + /// - date: The date the recommendation would be delivered + /// - neutralBasalRate: The scheduled basal rate at `date` + /// - lastTempBasal: The previously set temp basal + /// - continuationInterval: The duration of time before an ongoing temp basal should be continued with a new command + /// - neutralBasalRateMatchesPump: A flag describing whether `neutralBasalRate` matches the scheduled basal rate of the pump. + /// If `false` and the recommendation matches `neutralBasalRate`, the temp will be recommended + /// at the scheduled basal rate rather than recommending no temp. + /// - Returns: A temp basal recommendation + func adjustForCurrentDelivery( + at date: Date, + neutralBasalRate: Double, + currentTempBasal: DoseEntry?, + continuationInterval: TimeInterval, + neutralBasalRateMatchesPump: Bool + ) -> TempBasalRecommendation? { + // Adjust behavior for the currently active temp basal + if let currentTempBasal, currentTempBasal.type == .tempBasal, currentTempBasal.endDate > date + { + /// If the last temp basal has the same rate, and has more than `continuationInterval` of time remaining, don't set a new temp + if matchesRate(currentTempBasal.unitsPerHour), + currentTempBasal.endDate.timeIntervalSince(date) > continuationInterval { + return nil + } else if matchesRate(neutralBasalRate), neutralBasalRateMatchesPump { + // If our new temp matches the scheduled rate of the pump, cancel the current temp + return .cancel + } + } else if matchesRate(neutralBasalRate), neutralBasalRateMatchesPump { + // If we recommend the in-progress scheduled basal rate of the pump, do nothing + return nil + } + + return self + } + + public static var cancel: TempBasalRecommendation { + return self.init(unitsPerHour: 0, duration: 0) + } +} + diff --git a/Loop/Extensions/UIDevice+Loop.swift b/Loop/Extensions/UIDevice+Loop.swift index f8df9f58be..a9655723ed 100644 --- a/Loop/Extensions/UIDevice+Loop.swift +++ b/Loop/Extensions/UIDevice+Loop.swift @@ -37,7 +37,7 @@ extension UIDevice { } extension UIDevice { - func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) { + func generateDiagnosticReport() -> String { var report: [String] = [ "## Device", "", @@ -53,7 +53,7 @@ extension UIDevice { "* batteryState: \(String(describing: batteryState))", ] } - completion(report.joined(separator: "\n")) + return report.joined(separator: "\n") } } diff --git a/Loop/Extensions/UserDefaults+Loop.swift b/Loop/Extensions/UserDefaults+Loop.swift index 4894dcc777..fe57219067 100644 --- a/Loop/Extensions/UserDefaults+Loop.swift +++ b/Loop/Extensions/UserDefaults+Loop.swift @@ -7,6 +7,7 @@ import Foundation import LoopKit +import LoopAlgorithm extension UserDefaults { @@ -17,6 +18,7 @@ extension UserDefaults { case loopNotRunningNotifications = "com.loopkit.Loop.loopNotRunningNotifications" case inFlightAutomaticDose = "com.loopkit.Loop.inFlightAutomaticDose" case favoriteFoods = "com.loopkit.Loop.favoriteFoods" + case automationHistory = "com.loopkit.Loop.automationHistory" } var legacyPumpManagerRawValue: PumpManager.RawValue? { @@ -109,4 +111,23 @@ extension UserDefaults { } } } + + var automationHistory: [AutomationHistoryEntry] { + get { + let decoder = JSONDecoder() + guard let data = object(forKey: Key.automationHistory.rawValue) as? Data else { + return [] + } + return (try? decoder.decode([AutomationHistoryEntry].self, from: data)) ?? [] + } + set { + do { + let encoder = JSONEncoder() + let data = try encoder.encode(newValue) + set(data, forKey: Key.automationHistory.rawValue) + } catch { + assertionFailure("Unable to encode automation history") + } + } + } } diff --git a/Loop/Extensions/UserNotifications+Loop.swift b/Loop/Extensions/UserNotifications+Loop.swift index cd1959c907..dd5eec862d 100644 --- a/Loop/Extensions/UserNotifications+Loop.swift +++ b/Loop/Extensions/UserNotifications+Loop.swift @@ -9,26 +9,25 @@ import UserNotifications extension UNUserNotificationCenter { - func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) { - getNotificationSettings() { notificationSettings in - let report: [String] = [ - "## NotificationSettings", - "", - "* authorizationStatus: \(String(describing: notificationSettings.authorizationStatus))", - "* soundSetting: \(String(describing: notificationSettings.soundSetting))", - "* badgeSetting: \(String(describing: notificationSettings.badgeSetting))", - "* alertSetting: \(String(describing: notificationSettings.alertSetting))", - "* notificationCenterSetting: \(String(describing: notificationSettings.notificationCenterSetting))", - "* lockScreenSetting: \(String(describing: notificationSettings.lockScreenSetting))", - "* carPlaySetting: \(String(describing: notificationSettings.carPlaySetting))", - "* alertStyle: \(String(describing: notificationSettings.alertStyle))", - "* showPreviewsSetting: \(String(describing: notificationSettings.showPreviewsSetting))", - "* criticalAlertSetting: \(String(describing: notificationSettings.criticalAlertSetting))", - "* providesAppNotificationSettings: \(String(describing: notificationSettings.providesAppNotificationSettings))", - "* announcementSetting: \(String(describing: notificationSettings.announcementSetting))", - ] - completion(report.joined(separator: "\n")) - } + func generateDiagnosticReport() async -> String { + let notificationSettings = await notificationSettings() + let report: [String] = [ + "## NotificationSettings", + "", + "* authorizationStatus: \(String(describing: notificationSettings.authorizationStatus))", + "* soundSetting: \(String(describing: notificationSettings.soundSetting))", + "* badgeSetting: \(String(describing: notificationSettings.badgeSetting))", + "* alertSetting: \(String(describing: notificationSettings.alertSetting))", + "* notificationCenterSetting: \(String(describing: notificationSettings.notificationCenterSetting))", + "* lockScreenSetting: \(String(describing: notificationSettings.lockScreenSetting))", + "* carPlaySetting: \(String(describing: notificationSettings.carPlaySetting))", + "* alertStyle: \(String(describing: notificationSettings.alertStyle))", + "* showPreviewsSetting: \(String(describing: notificationSettings.showPreviewsSetting))", + "* criticalAlertSetting: \(String(describing: notificationSettings.criticalAlertSetting))", + "* providesAppNotificationSettings: \(String(describing: notificationSettings.providesAppNotificationSettings))", + "* announcementSetting: \(String(describing: notificationSettings.announcementSetting))", + ] + return report.joined(separator: "\n") } } diff --git a/Loop/Managers/AlertPermissionsChecker.swift b/Loop/Managers/AlertPermissionsChecker.swift index bae4512e6a..f2bd2a7cee 100644 --- a/Loop/Managers/AlertPermissionsChecker.swift +++ b/Loop/Managers/AlertPermissionsChecker.swift @@ -12,7 +12,7 @@ import LoopKit import SwiftUI protocol AlertPermissionsCheckerDelegate: AnyObject { - func notificationsPermissions(requiresRiskMitigation: Bool, scheduledDeliveryEnabled: Bool) + func notificationsPermissions(requiresRiskMitigation: Bool, scheduledDeliveryEnabled: Bool, permissions: NotificationCenterSettingsFlags) } public class AlertPermissionsChecker: ObservableObject { @@ -34,7 +34,7 @@ public class AlertPermissionsChecker: ObservableObject { init() { // Check on loop complete, but only while in the background. - NotificationCenter.default.publisher(for: .LoopCompleted) + NotificationCenter.default.publisher(for: .LoopCycleCompleted) .receive(on: RunLoop.main) .sink { [weak self] _ in guard let self = self else { return } @@ -75,7 +75,7 @@ public class AlertPermissionsChecker: ObservableObject { } if #available(iOS 15.0, *) { newSettings.scheduledDeliveryEnabled = settings.scheduledDeliverySetting == .enabled - newSettings.timeSensitiveNotificationsDisabled = settings.alertSetting != .disabled && settings.timeSensitiveSetting == .disabled + newSettings.timeSensitiveDisabled = settings.alertSetting != .disabled && settings.timeSensitiveSetting == .disabled } self.notificationCenterSettings = newSettings completion?() @@ -106,44 +106,151 @@ extension AlertPermissionsChecker { } // MARK: Unsafe Notification Permissions Alert - static let unsafeNotificationPermissionsAlertIdentifier = Alert.Identifier(managerIdentifier: "LoopAppManager", alertIdentifier: "unsafeNotificationPermissionsAlert") - - private static let unsafeNotificationPermissionsAlertContent = Alert.Content( - title: NSLocalizedString("Warning! Safety notifications are turned OFF", - comment: "Alert Permissions Need Attention alert title"), - body: String(format: NSLocalizedString("You may not get sound, visual or vibration alerts regarding critical safety information.\n\nTo fix the issue, tap ‘Settings’ and make sure Notifications, Critical Alerts and Time Sensitive Notifications are turned ON.", - comment: "Format for Notifications permissions disabled alert body. (1: app name)"), - Bundle.main.bundleDisplayName), - acknowledgeActionButtonLabel: NSLocalizedString("OK", comment: "Notifications permissions disabled alert button") - ) - - static let unsafeNotificationPermissionsAlert = Alert(identifier: unsafeNotificationPermissionsAlertIdentifier, - foregroundContent: nil, - backgroundContent: unsafeNotificationPermissionsAlertContent, - trigger: .immediate) + + enum UnsafeNotificationPermissionAlert: Hashable, CaseIterable { + case notificationsDisabled + case criticalAlertsDisabled + case timeSensitiveDisabled + case criticalAlertsAndNotificationDisabled + case criticalAlertsAndTimeSensitiveDisabled + + var alertTitle: String { + switch self { + case .criticalAlertsAndNotificationDisabled, .criticalAlertsAndTimeSensitiveDisabled: + NSLocalizedString("Turn On Critical Alerts and Time Sensitive Notifications", comment: "Both Critical Alerts and Time Sensitive Notifications disabled alert title") + case .criticalAlertsDisabled: + NSLocalizedString("Turn On Critical Alerts", comment: "Critical alerts disabled alert title") + case .timeSensitiveDisabled, .notificationsDisabled: + NSLocalizedString("Turn On Time Sensitive Notifications ", comment: "Time sensitive notifications disabled alert title") + } + } + + var notificationTitle: String { + switch self { + case .criticalAlertsAndNotificationDisabled, .criticalAlertsAndTimeSensitiveDisabled: + NSLocalizedString("Turn On Critical Alerts and Time Sensitive Notifications", comment: "Both Critical Alerts and Time Sensitive Notifications disabled notification title") + case .criticalAlertsDisabled: + NSLocalizedString("Turn On Critical Alerts", comment: "Critical alerts disabled notification title") + case .timeSensitiveDisabled, .notificationsDisabled: + NSLocalizedString("Turn On Time Sensitive Notifications", comment: "Time sensitive notifications disabled alert title") + } + } + + var bannerTitle: String { + switch self { + case .criticalAlertsAndNotificationDisabled, .criticalAlertsAndTimeSensitiveDisabled: + NSLocalizedString("Critical Alerts and Time Sensitive Notifications are turned OFF", comment: "Both Critical Alerts and Time Sensitive Notifications disabled banner title") + case .criticalAlertsDisabled: + NSLocalizedString("Critical Alerts are turned OFF", comment: "Critical alerts disabled banner title") + case .timeSensitiveDisabled, .notificationsDisabled: + NSLocalizedString("Time Sensitive Notifications are turned OFF", comment: "Time sensitive notifications disabled banner title") + } + } + + var alertBody: String { + switch self { + case .notificationsDisabled: + NSLocalizedString("Time Sensitive Notifications are turned OFF. You may not get sound, visual or vibration alerts regarding critical safety information.\n\nTo fix the issue, tap ‘Settings’ and make sure Notifications are turned ON.", comment: "Notifications disabled alert body") + case .criticalAlertsAndNotificationDisabled: + NSLocalizedString("Critical Alerts and Time Sensitive Notifications are turned off. You may not get sound, visual or vibration alerts regarding critical safety information.\n\nTo fix the issue, tap ‘Settings’ and make sure Notifications and Critical Alerts are turned ON.", comment: "Both Notifications and Critical Alerts disabled alert body") + case .criticalAlertsAndTimeSensitiveDisabled: + NSLocalizedString("Critical Alerts and Time Sensitive Notifications are turned off. You may not get sound, visual or vibration alerts regarding critical safety information.\n\nTo fix the issue, tap ‘Settings’ and make sure Critical Alerts and Time Sensitive Notifications are turned ON.", comment: "Both Critical Alerts and Time Sensitive Notifications disabled alert body") + case .criticalAlertsDisabled: + NSLocalizedString("Critical Alerts are turned off. You may not get sound, visual or vibration alerts regarding critical safety information.\n\nTo fix the issue, tap ‘Settings’ and make sure Critical Alerts are turned ON.", comment: "Critical alerts disabled alert body") + case .timeSensitiveDisabled: + NSLocalizedString("Time Sensitive Notifications are turned OFF. You may not get sound, visual or vibration alerts regarding critical safety information.\n\nTo fix the issue, tap ‘Settings’ and make sure Time Sensitive Notifications are turned ON.", comment: "Time sensitive notifications disabled alert body") + } + } + + var notificationBody: String { + switch self { + case .criticalAlertsAndNotificationDisabled, .criticalAlertsAndTimeSensitiveDisabled: + NSLocalizedString("Critical Alerts and Time Sensitive Notifications are turned OFF. Go to the App to fix the issue now.", comment: "Both Critical Alerts and Time Sensitive Notifications disabled notification body") + case .criticalAlertsDisabled: + NSLocalizedString("Critical Alerts are turned OFF. Go to the App to fix the issue now.", comment: "Critical alerts disabled notification body") + case .timeSensitiveDisabled, .notificationsDisabled: + NSLocalizedString("Time Sensitive notifications are turned OFF. Go to the App to fix the issue now.", comment: "Time sensitive notifications disabled notification body") + } + } + + var bannerBody: String { + switch self { + case .notificationsDisabled: + NSLocalizedString("Fix now by turning Notifications ON.", comment: "Notifications disabled banner body") + case .criticalAlertsAndNotificationDisabled: + NSLocalizedString("Fix now by turning Notifications and Critical Alerts ON.", comment: "Both Critical Alerts and Notifications disabled banner body") + case .criticalAlertsAndTimeSensitiveDisabled: + NSLocalizedString("Fix now by turning Critical Alerts and Time Sensitive Notifications ON.", comment: "Both Critical Alerts and Time Sensitive Notifications disabled banner body") + case .criticalAlertsDisabled: + NSLocalizedString("Fix now by turning Critical Alerts ON.", comment: "Critical alerts disabled banner body") + case .timeSensitiveDisabled: + NSLocalizedString("Fix now by turning Time Sensitive Notifications ON.", comment: "Time sensitive notifications disabled banner body") + } + } + + var alertIdentifier: LoopKit.Alert.Identifier { + switch self { + case .notificationsDisabled: + Alert.Identifier(managerIdentifier: "LoopAppManager", alertIdentifier: "unsafeNotificationPermissionsAlert") + case .criticalAlertsAndNotificationDisabled: + Alert.Identifier(managerIdentifier: "LoopAppManager", alertIdentifier: "unsafeCriticalAlertAndNotificationPermissionsAlert") + case .criticalAlertsAndTimeSensitiveDisabled: + Alert.Identifier(managerIdentifier: "LoopAppManager", alertIdentifier: "unsafeCriticalAlertAndTimeSensitivePermissionsAlert") + case .criticalAlertsDisabled: + Alert.Identifier(managerIdentifier: "LoopAppManager", alertIdentifier: "unsafeCrititalAlertPermissionsAlert") + case .timeSensitiveDisabled: + Alert.Identifier(managerIdentifier: "LoopAppManager", alertIdentifier: "unsafeTimeSensitiveNotificationPermissionsAlert") + } + } + + var alertContent: LoopKit.Alert.Content { + Alert.Content( + title: alertTitle, + body: alertBody, + acknowledgeActionButtonLabel: NSLocalizedString("OK", comment: "Notifications permissions disabled alert button") + ) + } + + var alert: LoopKit.Alert { + Alert( + identifier: alertIdentifier, + foregroundContent: nil, + backgroundContent: alertContent, + trigger: .immediate + ) + } + + init?(permissions: NotificationCenterSettingsFlags) { + switch permissions { + case .notificationsDisabled: + self = .notificationsDisabled + case .timeSensitiveDisabled, NotificationCenterSettingsFlags(rawValue: 5): + self = .timeSensitiveDisabled + case .criticalAlertsDisabled: + self = .criticalAlertsDisabled + case NotificationCenterSettingsFlags(rawValue: 3): + self = .criticalAlertsAndNotificationDisabled + case NotificationCenterSettingsFlags(rawValue: 6): + self = .criticalAlertsAndTimeSensitiveDisabled + default: + return nil + } + } + } - static func constructUnsafeNotificationPermissionsInAppAlert(acknowledgementCompletion: @escaping () -> Void ) -> UIAlertController { + static func constructUnsafeNotificationPermissionsInAppAlert(alert: UnsafeNotificationPermissionAlert, acknowledgementCompletion: @escaping () -> Void ) -> UIAlertController { dispatchPrecondition(condition: .onQueue(.main)) - let alertController = UIAlertController(title: Self.unsafeNotificationPermissionsAlertContent.title, - message: Self.unsafeNotificationPermissionsAlertContent.body, + let alertController = UIAlertController(title: alert.alertTitle, + message: alert.alertBody, preferredStyle: .alert) let titleImageAttachment = NSTextAttachment() titleImageAttachment.image = UIImage(systemName: "exclamationmark.triangle.fill")?.withTintColor(.critical) titleImageAttachment.bounds = CGRect(x: titleImageAttachment.bounds.origin.x, y: -10, width: 40, height: 35) let titleWithImage = NSMutableAttributedString(attachment: titleImageAttachment) titleWithImage.append(NSMutableAttributedString(string: "\n\n", attributes: [.font: UIFont.systemFont(ofSize: 8)])) - titleWithImage.append(NSMutableAttributedString(string: Self.unsafeNotificationPermissionsAlertContent.title, attributes: [.font: UIFont.preferredFont(forTextStyle: .headline)])) + titleWithImage.append(NSMutableAttributedString(string: alert.alertTitle, attributes: [.font: UIFont.preferredFont(forTextStyle: .headline)])) alertController.setValue(titleWithImage, forKey: "attributedTitle") - - let messageImageAttachment = NSTextAttachment() - messageImageAttachment.image = UIImage(named: "notification-permissions-on") - messageImageAttachment.bounds = CGRect(x: 0, y: -12, width: 228, height: 126) - let messageWithImageAttributed = NSMutableAttributedString(string: "\n", attributes: [.font: UIFont.systemFont(ofSize: 8)]) - messageWithImageAttributed.append(NSMutableAttributedString(string: Self.unsafeNotificationPermissionsAlertContent.body, attributes: [.font: UIFont.preferredFont(forTextStyle: .footnote)])) - messageWithImageAttributed.append(NSMutableAttributedString(string: "\n\n", attributes: [.font: UIFont.systemFont(ofSize: 12)])) - messageWithImageAttributed.append(NSMutableAttributedString(attachment: messageImageAttachment)) - alertController.setValue(messageWithImageAttributed, forKey: "attributedMessage") - + alertController.addAction(UIAlertAction(title: NSLocalizedString("Settings", comment: "Label of button that navigation user to iOS Settings"), style: .default, handler: { _ in @@ -178,7 +285,7 @@ extension AlertPermissionsChecker { trigger: .immediate) private func notificationCenterSettingsChanged(_ newValue: NotificationCenterSettingsFlags) { - delegate?.notificationsPermissions(requiresRiskMitigation: newValue.requiresRiskMitigation, scheduledDeliveryEnabled: newValue.scheduledDeliveryEnabled) + delegate?.notificationsPermissions(requiresRiskMitigation: newValue.requiresRiskMitigation, scheduledDeliveryEnabled: newValue.scheduledDeliveryEnabled, permissions: newValue) } } @@ -188,10 +295,10 @@ struct NotificationCenterSettingsFlags: OptionSet { static let none = NotificationCenterSettingsFlags([]) static let notificationsDisabled = NotificationCenterSettingsFlags(rawValue: 1 << 0) static let criticalAlertsDisabled = NotificationCenterSettingsFlags(rawValue: 1 << 1) - static let timeSensitiveNotificationsDisabled = NotificationCenterSettingsFlags(rawValue: 1 << 2) + static let timeSensitiveDisabled = NotificationCenterSettingsFlags(rawValue: 1 << 2) static let scheduledDeliveryEnabled = NotificationCenterSettingsFlags(rawValue: 1 << 3) - static let requiresRiskMitigation: NotificationCenterSettingsFlags = [ .notificationsDisabled, .criticalAlertsDisabled, .timeSensitiveNotificationsDisabled ] + static let requiresRiskMitigation: NotificationCenterSettingsFlags = [ .notificationsDisabled, .criticalAlertsDisabled, .timeSensitiveDisabled ] } extension NotificationCenterSettingsFlags { @@ -211,12 +318,12 @@ extension NotificationCenterSettingsFlags { update(.criticalAlertsDisabled, newValue) } } - var timeSensitiveNotificationsDisabled: Bool { + var timeSensitiveDisabled: Bool { get { - contains(.timeSensitiveNotificationsDisabled) + contains(.timeSensitiveDisabled) } set { - update(.timeSensitiveNotificationsDisabled, newValue) + update(.timeSensitiveDisabled, newValue) } } var scheduledDeliveryEnabled: Bool { diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index 50b99666e2..e30acf9d69 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -24,6 +24,7 @@ public enum AlertUserNotificationUserInfoKey: String { /// - managing the different responders that might acknowledge the alert /// - serializing alerts to storage /// - etc. +@MainActor public final class AlertManager { private static let soundsDirectoryURL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).last!.appendingPathComponent("Sounds") @@ -36,6 +37,7 @@ public final class AlertManager { // Defer issuance of new alerts until playback is done private var deferredAlerts: [Alert] = [] + private var deferredRetractions: [Alert.Identifier] = [] private var playbackFinished: Bool private let fileManager: FileManager @@ -58,14 +60,14 @@ public final class AlertManager { var getCurrentDate = { return Date() } init(alertPresenter: AlertPresenter, - modalAlertScheduler: InAppModalAlertScheduler? = nil, - userNotificationAlertScheduler: UserNotificationAlertScheduler, - fileManager: FileManager = FileManager.default, - alertStore: AlertStore? = nil, - expireAfter: TimeInterval = 24 /* hours */ * 60 /* minutes */ * 60 /* seconds */, - bluetoothProvider: BluetoothProvider, - analyticsServicesManager: AnalyticsServicesManager, - preventIssuanceBeforePlayback: Bool = true + modalAlertScheduler: InAppModalAlertScheduler? = nil, + userNotificationAlertScheduler: UserNotificationAlertScheduler, + fileManager: FileManager = FileManager.default, + alertStore: AlertStore? = nil, + expireAfter: TimeInterval = 24 /* hours */ * 60 /* minutes */ * 60 /* seconds */, + bluetoothProvider: BluetoothProvider, + analyticsServicesManager: AnalyticsServicesManager, + preventIssuanceBeforePlayback: Bool = true ) { self.fileManager = fileManager self.analyticsServicesManager = analyticsServicesManager @@ -88,10 +90,12 @@ public final class AlertManager { bluetoothProvider.addBluetoothObserver(self, queue: .main) - NotificationCenter.default.publisher(for: .LoopCompleted) + NotificationCenter.default.publisher(for: .LoopCycleCompleted) .sink { [weak self] publisher in if let loopDataManager = publisher.object as? LoopDataManager { - self?.loopDidComplete(loopDataManager.lastLoopCompleted) + Task { @MainActor in + self?.loopDidComplete(loopDataManager.lastLoopCompleted) + } } } .store(in: &cancellables) @@ -367,19 +371,18 @@ extension AlertManager: AlertIssuer { } public func retractAlert(identifier: Alert.Identifier) { + guard playbackFinished else { + deferredRetractions.append(identifier) + return + } unscheduleAlertWithSchedulers(identifier: identifier) alertStore.recordRetraction(of: identifier) } private func replayAlert(_ alert: Alert) { - guard alert.identifier != AlertPermissionsChecker.unsafeNotificationPermissionsAlertIdentifier else { - // this alert does not replay through the alert system, since it provides a button to navigate to settings - presentUnsafeNotificationPermissionsInAppAlert() - return - } - - // Only alerts with foreground content are replayed - if alert.foregroundContent != nil { + if let unsafeNotificationPermissionsAlert = AlertPermissionsChecker.UnsafeNotificationPermissionAlert.allCases.first(where: { $0.alertIdentifier == alert.identifier }) { + presentUnsafeNotificationPermissionsInAppAlert(unsafeNotificationPermissionsAlert) + } else if alert.foregroundContent != nil { modalAlertScheduler.scheduleAlert(alert) } } @@ -404,10 +407,12 @@ extension AlertManager: AlertIssuer { extension AlertManager { + nonisolated public static func soundURL(for alert: Alert) -> URL? { return soundURL(managerIdentifier: alert.identifier.managerIdentifier, sound: alert.sound) } + nonisolated private static func soundURL(managerIdentifier: String, sound: Alert.Sound?) -> URL? { guard let soundFileName = sound?.filename else { return nil } @@ -440,6 +445,7 @@ extension AlertManager { extension AlertManager { func playbackAlertsFromPersistence() { + guard !playbackFinished else { return } playbackAlertsFromAlertStore() } @@ -486,6 +492,9 @@ extension AlertManager { for alert in self.deferredAlerts { self.issueAlert(alert) } + for identifier in self.deferredRetractions { + self.retractAlert(identifier: identifier) + } } } @@ -494,31 +503,35 @@ extension AlertManager { // MARK: Alert storage access extension AlertManager { - func getStoredEntries(startDate: Date, completion: @escaping (_ report: String) -> Void) { - alertStore.executeQuery(since: startDate, limit: 100) { result in - switch result { - case .failure(let error): - completion("Error: \(error)") - case .success(_, let objects): - let encoder = JSONEncoder() - let report = "## Alerts\n" + objects.map { object in - return """ - **\(object.title ?? "??")** - - * identifier: \(object.identifier.value) - * issued: \(object.issuedDate) - * acknowledged: \(object.acknowledgedDate?.description ?? "n/a") - * retracted: \(object.retractedDate?.description ?? "n/a") - * trigger: \(object.trigger) - * interruptionLevel: \(object.interruptionLevel) - * foregroundContent: \((try? encoder.encodeToStringIfPresent(object.foregroundContent)) ?? "n/a") - * backgroundContent: \((try? encoder.encodeToStringIfPresent(object.backgroundContent)) ?? "n/a") - * sound: \((try? encoder.encodeToStringIfPresent(object.sound)) ?? "n/a") - * metadata: \((try? encoder.encodeToStringIfPresent(object.metadata)) ?? "n/a") - - """ - }.joined(separator: "\n") - completion(report) + func generateDiagnosticReport() async -> String { + await withCheckedContinuation { continuation in + let startDate = Date() - .days(3.5) // Report the last 3 and half days of alerts + let header = "## Alerts\n" + alertStore.executeQuery(since: startDate, limit: 100, ascending: false) { result in + switch result { + case .failure: + continuation.resume(returning: header) + case .success(_, let objects): + let encoder = JSONEncoder() + let report = header + objects.map { object in + return """ + **\(object.title ?? "??")** + + * identifier: \(object.identifier.value) + * issued: \(object.issuedDate) + * acknowledged: \(object.acknowledgedDate?.description ?? "n/a") + * retracted: \(object.retractedDate?.description ?? "n/a") + * trigger: \(object.trigger) + * interruptionLevel: \(object.interruptionLevel) + * foregroundContent: \((try? encoder.encodeToStringIfPresent(object.foregroundContent)) ?? "n/a") + * backgroundContent: \((try? encoder.encodeToStringIfPresent(object.backgroundContent)) ?? "n/a") + * sound: \((try? encoder.encodeToStringIfPresent(object.sound)) ?? "n/a") + * metadata: \((try? encoder.encodeToStringIfPresent(object.metadata)) ?? "n/a") + + """ + }.joined(separator: "\n") + continuation.resume(returning: report) + } } } } @@ -716,28 +729,47 @@ extension AlertManager: PresetActivationObserver { // MARK: - Issue/Retract Alert Permissions Warning extension AlertManager: AlertPermissionsCheckerDelegate { - func notificationsPermissions(requiresRiskMitigation: Bool, scheduledDeliveryEnabled: Bool) { - if !issueOrRetract(alert: AlertPermissionsChecker.unsafeNotificationPermissionsAlert, - condition: requiresRiskMitigation, - alreadyIssued: UserDefaults.standard.hasIssuedNotificationPermissionsAlert, - setAlreadyIssued: { UserDefaults.standard.hasIssuedNotificationPermissionsAlert = $0 }, - issueHandler: { alert in - // in-app modal is presented with a button to navigate to settings - self.presentUnsafeNotificationPermissionsInAppAlert() - self.userNotificationAlertScheduler.scheduleAlert(alert, muted: self.alertMuter.shouldMuteAlert(alert)) - self.recordIssued(alert: alert) - }, - retractionHandler: { alert in - // need to dismiss the in-app alert outside of the alert system - self.recordRetractedAlert(alert, at: Date()) - self.dismissUnsafeNotificationPermissionsInAppAlert() - }) { - _ = issueOrRetract(alert: AlertPermissionsChecker.scheduledDeliveryEnabledAlert, - condition: scheduledDeliveryEnabled, - alreadyIssued: UserDefaults.standard.hasIssuedScheduledDeliveryEnabledAlert, - setAlreadyIssued: { UserDefaults.standard.hasIssuedScheduledDeliveryEnabledAlert = $0 }, - issueHandler: { alert in self.issueAlert(alert) }, - retractionHandler: { alert in self.retractAlert(identifier: alert.identifier) }) + func notificationsPermissions(requiresRiskMitigation: Bool, scheduledDeliveryEnabled: Bool, permissions: NotificationCenterSettingsFlags) { + guard let unsafeNotificationAlert = AlertPermissionsChecker.UnsafeNotificationPermissionAlert(permissions: permissions) else { + return + } + + if !issueOrRetract( + alert: unsafeNotificationAlert.alert, + condition: requiresRiskMitigation, + alreadyIssued: UserDefaults.standard.hasIssuedNotificationPermissionsAlert, + setAlreadyIssued: { + UserDefaults.standard.hasIssuedNotificationPermissionsAlert = $0 + }, + issueHandler: { alert in + // in-app modal is presented with a button to navigate to settings + self.presentUnsafeNotificationPermissionsInAppAlert(unsafeNotificationAlert) + self.userNotificationAlertScheduler.scheduleAlert( + alert, + muted: self.alertMuter.shouldMuteAlert(alert) + ) + self.recordIssued(alert: alert) + }, + retractionHandler: { alert in + // need to dismiss the in-app alert outside of the alert system + self.recordRetractedAlert(alert, at: Date()) + self.dismissUnsafeNotificationPermissionsInAppAlert() + } + ) { + _ = issueOrRetract( + alert: AlertPermissionsChecker.scheduledDeliveryEnabledAlert, + condition: scheduledDeliveryEnabled, + alreadyIssued: UserDefaults.standard.hasIssuedScheduledDeliveryEnabledAlert, + setAlreadyIssued: { + UserDefaults.standard.hasIssuedScheduledDeliveryEnabledAlert = $0 + }, + issueHandler: { + alert in self.issueAlert(alert) + }, + retractionHandler: { + alert in self.retractAlert(identifier: alert.identifier) + } + ) } } @@ -763,11 +795,17 @@ extension AlertManager: AlertPermissionsCheckerDelegate { } } - private func presentUnsafeNotificationPermissionsInAppAlert() { + private func presentUnsafeNotificationPermissionsInAppAlert(_ alert: AlertPermissionsChecker.UnsafeNotificationPermissionAlert) { DispatchQueue.main.async { - let alertController = AlertPermissionsChecker.constructUnsafeNotificationPermissionsInAppAlert() { [weak self] in - self?.acknowledgeAlert(identifier: AlertPermissionsChecker.unsafeNotificationPermissionsAlertIdentifier) + let alertController = AlertPermissionsChecker.constructUnsafeNotificationPermissionsInAppAlert(alert: alert) { [weak self] in + AlertPermissionsChecker.UnsafeNotificationPermissionAlert.allCases.forEach { [weak self] in + UserDefaults.standard.hasIssuedNotificationPermissionsAlert = false + self?.acknowledgeAlert( + identifier: $0.alertIdentifier + ) + } } + self.alertPresenter.present(alertController, animated: true) { [weak self] in // the completion is called after the alert is presented self?.unsafeNotificationPermissionsAlertController = alertController diff --git a/Loop/Managers/Alerts/AlertStore.swift b/Loop/Managers/Alerts/AlertStore.swift index d8d6db7e5c..cc2e7837eb 100644 --- a/Loop/Managers/Alerts/AlertStore.swift +++ b/Loop/Managers/Alerts/AlertStore.swift @@ -422,15 +422,15 @@ extension AlertStore { case failure(Error) } - func executeQuery(fromQueryAnchor queryAnchor: QueryAnchor? = nil, since date: Date, excludingFutureAlerts: Bool = true, now: Date = Date(), limit: Int, completion: @escaping (AlertQueryResult) -> Void) { + func executeQuery(fromQueryAnchor queryAnchor: QueryAnchor? = nil, since date: Date, excludingFutureAlerts: Bool = true, now: Date = Date(), limit: Int, ascending: Bool = true, completion: @escaping (AlertQueryResult) -> Void) { let sinceDateFilter = SinceDateFilter(predicateExpressionNotYetExpired: predicateExpressionNotYetExpired, date: date, excludingFutureAlerts: excludingFutureAlerts, now: now) - executeAlertQuery(fromQueryAnchor: queryAnchor, queryFilter: sinceDateFilter, limit: limit, completion: completion) + executeAlertQuery(fromQueryAnchor: queryAnchor, queryFilter: sinceDateFilter, limit: limit, ascending: ascending, completion: completion) } - func executeAlertQuery(fromQueryAnchor queryAnchor: QueryAnchor?, queryFilter: QueryFilter? = nil, limit: Int, completion: @escaping (AlertQueryResult) -> Void) { + func executeAlertQuery(fromQueryAnchor queryAnchor: QueryAnchor?, queryFilter: QueryFilter? = nil, limit: Int, ascending: Bool = true, completion: @escaping (AlertQueryResult) -> Void) { var queryAnchor = queryAnchor ?? QueryAnchor() var queryResult = [SyncAlertObject]() var queryError: Error? @@ -449,7 +449,7 @@ extension AlertStore { } else { storedRequest.predicate = queryAnchorPredicate } - storedRequest.sortDescriptors = [NSSortDescriptor(key: "modificationCounter", ascending: true)] + storedRequest.sortDescriptors = [NSSortDescriptor(key: "modificationCounter", ascending: ascending)] storedRequest.fetchLimit = limit do { diff --git a/Loop/Managers/AnalyticsServicesManager.swift b/Loop/Managers/AnalyticsServicesManager.swift index 808a34c81a..8528318d6c 100644 --- a/Loop/Managers/AnalyticsServicesManager.swift +++ b/Loop/Managers/AnalyticsServicesManager.swift @@ -143,10 +143,6 @@ final class AnalyticsServicesManager { logEvent("Therapy schedule time zone change") } - if newValue.scheduleOverride != oldValue.scheduleOverride { - logEvent("Temporary schedule override change") - } - if newValue.glucoseTargetRangeSchedule != oldValue.glucoseTargetRangeSchedule { logEvent("Glucose target range change") } @@ -170,8 +166,8 @@ final class AnalyticsServicesManager { logEvent("CGM Added", withProperties: ["identifier" : identifier]) } - func didAddCarbs(source: String, amount: Double, inSession: Bool = false) { - logEvent("Carb entry created", withProperties: ["source" : source, "amount": "\(amount)"], outOfSession: inSession) + func didAddCarbs(source: String, amount: Double, isFavoriteFood: Bool = false, inSession: Bool = false) { + logEvent("Carb entry created", withProperties: ["source" : source, "amount": "\(amount)", "isFavoriteFood": isFavoriteFood], outOfSession: inSession) } func didRetryBolus() { diff --git a/Loop/Managers/AppExpirationAlerter.swift b/Loop/Managers/AppExpirationAlerter.swift index d5dd84518f..a18ca48308 100644 --- a/Loop/Managers/AppExpirationAlerter.swift +++ b/Loop/Managers/AppExpirationAlerter.swift @@ -124,9 +124,9 @@ class AppExpirationAlerter { static func isTestFlightBuild() -> Bool { // If the target environment is a simulator, then // this is not a TestFlight distribution. Return false. - #if targetEnvironment(simulator) +#if targetEnvironment(simulator) return false - #else +#else // If an "embedded.mobileprovision" is present in the main bundle, then // this is an Xcode, Ad-Hoc, or Enterprise distribution. Return false. @@ -143,7 +143,7 @@ class AppExpirationAlerter { // A TestFlight distribution presents a "sandboxReceipt", while an App Store // distribution presents a "receipt". Return true if we have a TestFlight receipt. return "sandboxReceipt".caseInsensitiveCompare(receiptName) == .orderedSame - #endif +#endif } static func calculateExpirationDate(profileExpiration: Date) -> Date { diff --git a/Loop/Managers/CGMManager.swift b/Loop/Managers/CGMManager.swift index fe39e3926c..6f261c4308 100644 --- a/Loop/Managers/CGMManager.swift +++ b/Loop/Managers/CGMManager.swift @@ -10,13 +10,13 @@ import LoopKitUI import MockKit let staticCGMManagersByIdentifier: [String: CGMManager.Type] = [ - MockCGMManager.pluginIdentifier: MockCGMManager.self + MockCGMManager.managerIdentifier: MockCGMManager.self ] var availableStaticCGMManagers: [CGMManagerDescriptor] { if FeatureFlags.allowSimulators { return [ - CGMManagerDescriptor(identifier: MockCGMManager.pluginIdentifier, localizedTitle: MockCGMManager.localizedTitle) + CGMManagerDescriptor(identifier: MockCGMManager.managerIdentifier, localizedTitle: MockCGMManager.localizedTitle) ] } else { return [] diff --git a/Loop/Managers/CGMStalenessMonitor.swift b/Loop/Managers/CGMStalenessMonitor.swift index 82cdc9267d..25c0365e1e 100644 --- a/Loop/Managers/CGMStalenessMonitor.swift +++ b/Loop/Managers/CGMStalenessMonitor.swift @@ -9,9 +9,10 @@ import Foundation import LoopKit import LoopCore +import LoopAlgorithm protocol CGMStalenessMonitorDelegate: AnyObject { - func getLatestCGMGlucose(since: Date, completion: @escaping (_ result: Swift.Result) -> Void) + func getLatestCGMGlucose(since: Date) async throws -> StoredGlucoseSample? } class CGMStalenessMonitor { @@ -20,13 +21,7 @@ class CGMStalenessMonitor { private var cgmStalenessTimer: Timer? - weak var delegate: CGMStalenessMonitorDelegate? = nil { - didSet { - if delegate != nil { - checkCGMStaleness() - } - } - } + weak var delegate: CGMStalenessMonitorDelegate? @Published var cgmDataIsStale: Bool = true { didSet { @@ -43,9 +38,9 @@ class CGMStalenessMonitor { let mostRecentGlucose = samples.map { $0.date }.max()! let cgmDataAge = -mostRecentGlucose.timeIntervalSinceNow - if cgmDataAge < LoopCoreConstants.inputDataRecencyInterval { + if cgmDataAge < LoopAlgorithm.inputDataRecencyInterval { self.cgmDataIsStale = false - self.updateCGMStalenessTimer(expiration: mostRecentGlucose.addingTimeInterval(LoopCoreConstants.inputDataRecencyInterval)) + self.updateCGMStalenessTimer(expiration: mostRecentGlucose.addingTimeInterval(LoopAlgorithm.inputDataRecencyInterval)) } else { self.cgmDataIsStale = true } @@ -56,29 +51,27 @@ class CGMStalenessMonitor { cgmStalenessTimer?.invalidate() cgmStalenessTimer = Timer.scheduledTimer(withTimeInterval: expiration.timeIntervalSinceNow, repeats: false) { [weak self] _ in self?.log.debug("cgmStalenessTimer fired") - self?.checkCGMStaleness() + Task { + await self?.checkCGMStaleness() + } } cgmStalenessTimer?.tolerance = CGMStalenessMonitor.cgmStalenessTimerTolerance } - private func checkCGMStaleness() { - delegate?.getLatestCGMGlucose(since: Date(timeIntervalSinceNow: -LoopCoreConstants.inputDataRecencyInterval)) { (result) in - DispatchQueue.main.async { - self.log.debug("Fetched latest CGM Glucose for checkCGMStaleness: %{public}@", String(describing: result)) - switch result { - case .success(let sample): - if let sample = sample { - self.cgmDataIsStale = false - self.updateCGMStalenessTimer(expiration: sample.startDate.addingTimeInterval(LoopCoreConstants.inputDataRecencyInterval + CGMStalenessMonitor.cgmStalenessTimerTolerance)) - } else { - self.cgmDataIsStale = true - } - case .failure(let error): - self.log.error("Unable to get latest CGM clucose: %{public}@ ", String(describing: error)) - // Some kind of system error; check again in 5 minutes - self.updateCGMStalenessTimer(expiration: Date(timeIntervalSinceNow: .minutes(5))) - } + func checkCGMStaleness() async { + do { + let sample = try await delegate?.getLatestCGMGlucose(since: Date(timeIntervalSinceNow: -LoopAlgorithm.inputDataRecencyInterval)) + self.log.debug("Fetched latest CGM Glucose for checkCGMStaleness: %{public}@", String(describing: sample)) + if let sample = sample { + self.cgmDataIsStale = false + self.updateCGMStalenessTimer(expiration: sample.startDate.addingTimeInterval(LoopAlgorithm.inputDataRecencyInterval + CGMStalenessMonitor.cgmStalenessTimerTolerance)) + } else { + self.cgmDataIsStale = true } + } catch { + self.log.error("Unable to get latest CGM clucose: %{public}@ ", String(describing: error)) + // Some kind of system error; check again in 5 minutes + self.updateCGMStalenessTimer(expiration: Date(timeIntervalSinceNow: .minutes(5))) } } } diff --git a/Loop/Managers/CriticalEventLogExportManager.swift b/Loop/Managers/CriticalEventLogExportManager.swift index 6b8f699e5c..50489ff1a7 100644 --- a/Loop/Managers/CriticalEventLogExportManager.swift +++ b/Loop/Managers/CriticalEventLogExportManager.swift @@ -9,6 +9,8 @@ import os.log import UIKit import LoopKit +import BackgroundTasks + public enum CriticalEventLogExportError: Error { case exportInProgress @@ -551,3 +553,78 @@ fileprivate extension FileManager { return temporaryDirectory.appendingPathComponent(UUID().uuidString) } } + +// MARK: - Critical Event Log Export + +extension CriticalEventLogExportManager { + static var historicalExportBackgroundTaskIdentifier: String { "com.loopkit.background-task.critical-event-log.historical-export" } + + public func handleCriticalEventLogHistoricalExportBackgroundTask(_ task: BGProcessingTask) { + dispatchPrecondition(condition: .notOnQueue(.main)) + + scheduleCriticalEventLogHistoricalExportBackgroundTask(isRetry: true) + + let exporter = createHistoricalExporter() + + task.expirationHandler = { + self.log.default("Invoked critical event log historical export background task expiration handler - cancelling exporter") + exporter.cancel() + } + + DispatchQueue.global(qos: .background).async { + exporter.export() { error in + if let error = error { + self.log.error("Critical event log historical export errored: %{public}@", String(describing: error)) + } + + self.scheduleCriticalEventLogHistoricalExportBackgroundTask(isRetry: error != nil && !exporter.isCancelled) + task.setTaskCompleted(success: error == nil) + + self.log.default("Completed critical event log historical export background task") + } + } + } + + public func scheduleCriticalEventLogHistoricalExportBackgroundTask(isRetry: Bool = false) { + do { + let earliestBeginDate = isRetry ? retryExportHistoricalDate() : nextExportHistoricalDate() + let request = BGProcessingTaskRequest(identifier: Self.historicalExportBackgroundTaskIdentifier) + request.earliestBeginDate = earliestBeginDate + request.requiresExternalPower = true + + try BGTaskScheduler.shared.submit(request) + + log.default("Scheduled critical event log historical export background task: %{public}@", ISO8601DateFormatter().string(from: earliestBeginDate)) + } catch let error { + #if IOS_SIMULATOR + log.debug("Failed to schedule critical event log export background task due to running on simulator") + #else + log.error("Failed to schedule critical event log export background task: %{public}@", String(describing: error)) + #endif + } + } + + public func removeExportsDirectory() -> Error? { + let fileManager = FileManager.default + let exportsDirectoryURL = fileManager.exportsDirectoryURL + + guard fileManager.fileExists(atPath: exportsDirectoryURL.path) else { + return nil + } + + do { + try fileManager.removeItem(at: exportsDirectoryURL) + } catch let error { + return error + } + + return nil + } +} + +extension FileManager { + var exportsDirectoryURL: URL { + let applicationSupportDirectory = try! url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + return applicationSupportDirectory.appendingPathComponent(Bundle.main.bundleIdentifier!).appendingPathComponent("Exports") + } +} diff --git a/Loop/Managers/DeeplinkManager.swift b/Loop/Managers/DeeplinkManager.swift index 86b17f625b..80e3df02b2 100644 --- a/Loop/Managers/DeeplinkManager.swift +++ b/Loop/Managers/DeeplinkManager.swift @@ -8,21 +8,6 @@ import UIKit -enum Deeplink: String, CaseIterable { - case carbEntry = "carb-entry" - case bolus = "manual-bolus" - case preMeal = "pre-meal-preset" - case customPresets = "custom-presets" - - init?(url: URL?) { - guard let url, let host = url.host, let deeplink = Deeplink.allCases.first(where: { $0.rawValue == host }) else { - return nil - } - - self = deeplink - } -} - class DeeplinkManager { private weak var rootViewController: UIViewController? diff --git a/Loop/Managers/DeliveryUncertaintyAlertManager.swift b/Loop/Managers/DeliveryUncertaintyAlertManager.swift index d163d9d227..8bd74b7ef7 100644 --- a/Loop/Managers/DeliveryUncertaintyAlertManager.swift +++ b/Loop/Managers/DeliveryUncertaintyAlertManager.swift @@ -23,6 +23,7 @@ class DeliveryUncertaintyAlertManager { private func showUncertainDeliveryRecoveryView() { var controller = pumpManager.deliveryUncertaintyRecoveryViewController(colorPalette: .default, allowDebugFeatures: FeatureFlags.allowDebugFeatures) controller.completionDelegate = self + controller.modalPresentationStyle = .fullScreen self.alertPresenter.present(controller, animated: true) } diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 2751f18f50..7000ac6a66 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -6,7 +6,6 @@ // Copyright © 2015 Nathan Racklyeft. All rights reserved. // -import BackgroundTasks import HealthKit import LoopKit import LoopKitUI @@ -14,11 +13,30 @@ import LoopCore import LoopTestingKit import UserNotifications import Combine +import LoopAlgorithm +protocol LoopControl { + var lastLoopCompleted: Date? { get } + func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason) async throws + func loop() async +} + +protocol ActiveServicesProvider { + var activeServices: [Service] { get } +} + +protocol ActiveStatefulPluginsProvider { + var activeStatefulPlugins: [StatefulPluggable] { get } +} + + +protocol UploadEventListener { + func triggerUpload(for triggeringType: RemoteDataType) +} + +@MainActor final class DeviceDataManager { - private let queue = DispatchQueue(label: "com.loopkit.DeviceManagerQueue", qos: .utility) - private let log = DiagnosticLog(category: "DeviceDataManager") let pluginManager: PluginManager @@ -30,10 +48,9 @@ final class DeviceDataManager { private let launchDate = Date() /// The last error recorded by a device manager - /// Should be accessed only on the main queue private(set) var lastError: (date: Date, error: Error)? - private var deviceLog: PersistentDeviceLog + var deviceLog: PersistentDeviceLog // MARK: - App-level responsibilities @@ -84,17 +101,12 @@ final class DeviceDataManager { private var cgmStalenessMonitor: CGMStalenessMonitor - private var displayGlucoseUnitObservers = WeakSynchronizedSet() - - public private(set) var displayGlucosePreference: DisplayGlucosePreference - var deviceWhitelist = DeviceWhitelist() // MARK: - CGM var cgmManager: CGMManager? { didSet { - dispatchPrecondition(condition: .onQueue(.main)) setupCGM() if cgmManager?.pluginIdentifier != oldValue?.pluginIdentifier { @@ -116,10 +128,8 @@ final class DeviceDataManager { // MARK: - Pump - var pumpManager: PumpManagerUI? { + var pumpManager: PumpManager? { didSet { - dispatchPrecondition(condition: .onQueue(.main)) - // If the current CGMManager is a PumpManager, we clear it out. if cgmManager is PumpManagerUI { cgmManager = nil @@ -149,20 +159,13 @@ final class DeviceDataManager { var doseEnactor = DoseEnactor() // MARK: Stores - let healthStore: HKHealthStore - - let carbStore: CarbStore - - let doseStore: DoseStore - - let glucoseStore: GlucoseStore - - let cgmEventStore: CgmEventStore - + private let healthStore: HKHealthStore + private let carbStore: CarbStore + private let doseStore: DoseStore + private let glucoseStore: GlucoseStore private let cacheStore: PersistenceController + private let cgmEventStore: CgmEventStore - let dosingDecisionStore: DosingDecisionStore - /// All the HealthKit types to be read by stores private var readTypes: Set { var readTypes: Set = [] @@ -207,162 +210,111 @@ final class DeviceDataManager { sleepDataAuthorizationRequired } - private(set) var statefulPluginManager: StatefulPluginManager! - // MARK: Services - private(set) var servicesManager: ServicesManager! - - var analyticsServicesManager: AnalyticsServicesManager - - var settingsManager: SettingsManager + private var analyticsServicesManager: AnalyticsServicesManager + private var uploadEventListener: UploadEventListener + private var activeServicesProvider: ActiveServicesProvider - var remoteDataServicesManager: RemoteDataServicesManager { return servicesManager.remoteDataServicesManager } + // MARK: Misc Managers - var criticalEventLogExportManager: CriticalEventLogExportManager! - - var crashRecoveryManager: CrashRecoveryManager + private let settingsManager: SettingsManager + private let crashRecoveryManager: CrashRecoveryManager + private let activeStatefulPluginsProvider: ActiveStatefulPluginsProvider private(set) var pumpManagerHUDProvider: HUDProvider? - private var trustedTimeChecker: TrustedTimeChecker - - // MARK: - WatchKit - - private var watchManager: WatchDataManager! - - // MARK: - Status Extension - - private var statusExtensionManager: ExtensionDataManager! + public private(set) var displayGlucosePreference: DisplayGlucosePreference - // MARK: - Initialization + private(set) var loopControl: LoopControl - private(set) var loopManager: LoopDataManager! + private weak var displayGlucoseUnitBroadcaster: DisplayGlucoseUnitBroadcaster? init(pluginManager: PluginManager, + deviceLog: PersistentDeviceLog, alertManager: AlertManager, settingsManager: SettingsManager, - loggingServicesManager: LoggingServicesManager, + healthStore: HKHealthStore, + carbStore: CarbStore, + doseStore: DoseStore, + glucoseStore: GlucoseStore, + cgmEventStore: CgmEventStore, + uploadEventListener: UploadEventListener, + crashRecoveryManager: CrashRecoveryManager, + loopControl: LoopControl, analyticsServicesManager: AnalyticsServicesManager, + activeServicesProvider: ActiveServicesProvider, + activeStatefulPluginsProvider: ActiveStatefulPluginsProvider, bluetoothProvider: BluetoothProvider, alertPresenter: AlertPresenter, automaticDosingStatus: AutomaticDosingStatus, cacheStore: PersistenceController, localCacheDuration: TimeInterval, - overrideHistory: TemporaryScheduleOverrideHistory, - trustedTimeChecker: TrustedTimeChecker) - { - - let fileManager = FileManager.default - let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! - let deviceLogDirectory = documentsDirectory.appendingPathComponent("DeviceLog") - if !fileManager.fileExists(atPath: deviceLogDirectory.path) { - do { - try fileManager.createDirectory(at: deviceLogDirectory, withIntermediateDirectories: false) - } catch let error { - preconditionFailure("Could not create DeviceLog directory: \(error)") - } - } - deviceLog = PersistentDeviceLog(storageFile: deviceLogDirectory.appendingPathComponent("Storage.sqlite"), maxEntryAge: localCacheDuration) + displayGlucosePreference: DisplayGlucosePreference, + displayGlucoseUnitBroadcaster: DisplayGlucoseUnitBroadcaster + ) { self.pluginManager = pluginManager + self.deviceLog = deviceLog self.alertManager = alertManager + self.settingsManager = settingsManager + self.healthStore = healthStore + self.carbStore = carbStore + self.doseStore = doseStore + self.glucoseStore = glucoseStore + self.cgmEventStore = cgmEventStore + self.loopControl = loopControl + self.analyticsServicesManager = analyticsServicesManager self.bluetoothProvider = bluetoothProvider self.alertPresenter = alertPresenter - - self.healthStore = HKHealthStore() + self.automaticDosingStatus = automaticDosingStatus self.cacheStore = cacheStore - self.settingsManager = settingsManager - - let absorptionTimes = LoopCoreConstants.defaultCarbAbsorptionTimes - let sensitivitySchedule = settingsManager.latestSettings.insulinSensitivitySchedule - - let carbHealthStore = HealthKitSampleStore( - healthStore: healthStore, - observeHealthKitSamplesFromOtherApps: FeatureFlags.observeHealthKitCarbSamplesFromOtherApps, // At some point we should let the user decide which apps they would like to import from. - type: HealthKitSampleStore.carbType, - observationStart: Date().addingTimeInterval(-absorptionTimes.slow * 2) - ) - - self.carbStore = CarbStore( - healthKitSampleStore: carbHealthStore, - cacheStore: cacheStore, - cacheLength: localCacheDuration, - defaultAbsorptionTimes: absorptionTimes, - carbRatioSchedule: settingsManager.latestSettings.carbRatioSchedule, - insulinSensitivitySchedule: sensitivitySchedule, - overrideHistory: overrideHistory, - carbAbsorptionModel: FeatureFlags.nonlinearCarbModelEnabled ? .nonlinear : .linear, - provenanceIdentifier: HKSource.default().bundleIdentifier - ) - - let insulinModelProvider: InsulinModelProvider - if FeatureFlags.adultChildInsulinModelSelectionEnabled { - insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: settingsManager.latestSettings.defaultRapidActingModel?.presetForRapidActingInsulin) - } else { - insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: nil) - } - - self.analyticsServicesManager = analyticsServicesManager + self.crashRecoveryManager = crashRecoveryManager + self.activeStatefulPluginsProvider = activeStatefulPluginsProvider + self.uploadEventListener = uploadEventListener + self.activeServicesProvider = activeServicesProvider + self.displayGlucosePreference = displayGlucosePreference + self.displayGlucoseUnitBroadcaster = displayGlucoseUnitBroadcaster - let insulinHealthStore = HealthKitSampleStore( - healthStore: healthStore, - observeHealthKitSamplesFromOtherApps: FeatureFlags.observeHealthKitDoseSamplesFromOtherApps, - type: HealthKitSampleStore.insulinQuantityType, - observationStart: Date().addingTimeInterval(-absorptionTimes.slow * 2) - ) - - self.doseStore = DoseStore( - healthKitSampleStore: insulinHealthStore, - cacheStore: cacheStore, - cacheLength: localCacheDuration, - insulinModelProvider: insulinModelProvider, - longestEffectDuration: ExponentialInsulinModelPreset.rapidActingAdult.effectDuration, - basalProfile: settingsManager.latestSettings.basalRateSchedule, - insulinSensitivitySchedule: sensitivitySchedule, - overrideHistory: overrideHistory, - lastPumpEventsReconciliation: nil, // PumpManager is nil at this point. Will update this via addPumpEvents below - provenanceIdentifier: HKSource.default().bundleIdentifier - ) - - let glucoseHealthStore = HealthKitSampleStore( - healthStore: healthStore, - observeHealthKitSamplesFromOtherApps: FeatureFlags.observeHealthKitGlucoseSamplesFromOtherApps, - type: HealthKitSampleStore.glucoseType, - observationStart: Date().addingTimeInterval(-.hours(24)) - ) - - self.glucoseStore = GlucoseStore( - healthKitSampleStore: glucoseHealthStore, - cacheStore: cacheStore, - cacheLength: localCacheDuration, - provenanceIdentifier: HKSource.default().bundleIdentifier - ) - cgmStalenessMonitor = CGMStalenessMonitor() cgmStalenessMonitor.delegate = glucoseStore - cgmEventStore = CgmEventStore(cacheStore: cacheStore, cacheLength: localCacheDuration) - - dosingDecisionStore = DosingDecisionStore(store: cacheStore, expireAfter: localCacheDuration) - cgmHasValidSensorSession = false pumpIsAllowingAutomation = true - self.automaticDosingStatus = automaticDosingStatus - // HealthStorePreferredGlucoseUnitDidChange will be notified once the user completes the health access form. Set to .milligramsPerDeciliter until then - displayGlucosePreference = DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) + alertManager.alertStore.delegate = self + carbStore.delegate = self + doseStore.delegate = self + glucoseStore.delegate = self + cgmEventStore.delegate = self + doseStore.insulinDeliveryStore.delegate = self + + Task { + await cgmStalenessMonitor.checkCGMStaleness() + } - self.trustedTimeChecker = trustedTimeChecker + setupPump() + setupCGM() - crashRecoveryManager = CrashRecoveryManager(alertIssuer: alertManager) - alertManager.addAlertResponder(managerIdentifier: crashRecoveryManager.managerIdentifier, alertResponder: crashRecoveryManager) + cgmStalenessMonitor.$cgmDataIsStale + .combineLatest($cgmHasValidSensorSession) + .map { $0 == false || $1 } + .combineLatest($pumpIsAllowingAutomation) + .map { $0 && $1 } + .receive(on: RunLoop.main) + .removeDuplicates() + .assign(to: \.automaticDosingStatus.isAutomaticDosingAllowed, on: self) + .store(in: &cancellables) + } + func instantiateDeviceManagers() { if let pumpManagerRawValue = rawPumpManager ?? UserDefaults.appGroup?.legacyPumpManagerRawValue { pumpManager = pumpManagerFromRawValue(pumpManagerRawValue) // Update lastPumpEventsReconciliation on DoseStore if let lastSync = pumpManager?.lastSync { - doseStore.addPumpEvents([], lastReconciliation: lastSync) { _ in } + Task { + try? await doseStore.addPumpEvents([], lastReconciliation: lastSync) + } } if let status = pumpManager?.status { updatePumpIsAllowingAutomation(status: status) @@ -379,100 +331,6 @@ final class DeviceDataManager { cgmManager = pumpManager as? CGMManager } } - - //TODO The instantiation of these non-device related managers should be moved to LoopAppManager, and then LoopAppManager can wire up the connections between them. - statusExtensionManager = ExtensionDataManager(deviceDataManager: self, automaticDosingStatus: automaticDosingStatus) - - loopManager = LoopDataManager( - lastLoopCompleted: ExtensionDataManager.lastLoopCompleted, - basalDeliveryState: pumpManager?.status.basalDeliveryState, - settings: settingsManager.loopSettings, - overrideHistory: overrideHistory, - analyticsServicesManager: analyticsServicesManager, - localCacheDuration: localCacheDuration, - doseStore: doseStore, - glucoseStore: glucoseStore, - carbStore: carbStore, - dosingDecisionStore: dosingDecisionStore, - latestStoredSettingsProvider: settingsManager, - pumpInsulinType: pumpManager?.status.insulinType, - automaticDosingStatus: automaticDosingStatus, - trustedTimeOffset: { trustedTimeChecker.detectedSystemTimeOffset } - ) - cacheStore.delegate = loopManager - loopManager.presetActivationObservers.append(alertManager) - loopManager.presetActivationObservers.append(analyticsServicesManager) - - watchManager = WatchDataManager(deviceManager: self, healthStore: healthStore) - - let remoteDataServicesManager = RemoteDataServicesManager( - alertStore: alertManager.alertStore, - carbStore: carbStore, - doseStore: doseStore, - dosingDecisionStore: dosingDecisionStore, - glucoseStore: glucoseStore, - cgmEventStore: cgmEventStore, - settingsStore: settingsManager.settingsStore, - overrideHistory: overrideHistory, - insulinDeliveryStore: doseStore.insulinDeliveryStore - ) - - settingsManager.remoteDataServicesManager = remoteDataServicesManager - - servicesManager = ServicesManager( - pluginManager: pluginManager, - alertManager: alertManager, - analyticsServicesManager: analyticsServicesManager, - loggingServicesManager: loggingServicesManager, - remoteDataServicesManager: remoteDataServicesManager, - settingsManager: settingsManager, - servicesManagerDelegate: loopManager, - servicesManagerDosingDelegate: self - ) - - statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) - - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] - criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, - directory: FileManager.default.exportsDirectoryURL, - historicalDuration: Bundle.main.localCacheDuration) - - loopManager.delegate = self - - alertManager.alertStore.delegate = self - carbStore.delegate = self - doseStore.delegate = self - dosingDecisionStore.delegate = self - glucoseStore.delegate = self - cgmEventStore.delegate = self - doseStore.insulinDeliveryStore.delegate = self - remoteDataServicesManager.delegate = self - - setupPump() - setupCGM() - - cgmStalenessMonitor.$cgmDataIsStale - .combineLatest($cgmHasValidSensorSession) - .map { $0 == false || $1 } - .combineLatest($pumpIsAllowingAutomation) - .map { $0 && $1 } - .receive(on: RunLoop.main) - .removeDuplicates() - .assign(to: \.automaticDosingStatus.isAutomaticDosingAllowed, on: self) - .store(in: &cancellables) - - NotificationCenter.default.addObserver(forName: .HealthStorePreferredGlucoseUnitDidChange, object: healthStore, queue: nil) { [weak self] _ in - guard let self else { - return - } - - Task { @MainActor in - if let unit = await self.healthStore.cachedPreferredUnits(for: .bloodGlucose) { - self.displayGlucosePreference.unitDidChange(to: unit) - self.notifyObserversOfDisplayGlucoseUnitChange(to: unit) - } - } - } } var availablePumpManagers: [PumpManagerDescriptor] { @@ -521,7 +379,7 @@ final class DeviceDataManager { } public func saveUpdatedBasalRateSchedule(_ basalRateSchedule: BasalRateSchedule) { - var therapySettings = self.loopManager.therapySettings + var therapySettings = self.settingsManager.therapySettings therapySettings.basalRateSchedule = basalRateSchedule self.saveCompletion(therapySettings: therapySettings) } @@ -548,7 +406,7 @@ final class DeviceDataManager { return Manager.init(rawState: rawState) as? PumpManagerUI } - private func checkPumpDataAndLoop() { + private func checkPumpDataAndLoop() async { guard !crashRecoveryManager.pendingCrashRecovery else { self.log.default("Loop paused pending crash recovery acknowledgement.") return @@ -557,34 +415,48 @@ final class DeviceDataManager { self.log.default("Asserting current pump data") guard let pumpManager = pumpManager else { // Run loop, even if pump is missing, to ensure stored dosing decision - self.loopManager.loop() + await self.loopControl.loop() + return + } + + let _ = await pumpManager.ensureCurrentPumpData() + await self.loopControl.loop() + } + + + /// An active high temp basal (greater than the basal schedule) is cancelled when the CGM data is unreliable. + private func receivedUnreliableCGMReading() async { + guard case .tempBasal(let tempBasal) = pumpManager?.status.basalDeliveryState else { return } - pumpManager.ensureCurrentPumpData() { (lastSync) in - self.loopManager.loop() + guard let scheduledBasalRate = settingsManager.settings.basalRateSchedule?.value(at: tempBasal.startDate), + tempBasal.unitsPerHour > scheduledBasalRate else + { + return } + + // Cancel active high temp basal + try? await loopControl.cancelActiveTempBasal(for: .unreliableCGMData) } - private func processCGMReadingResult(_ manager: CGMManager, readingResult: CGMReadingResult, completion: @escaping () -> Void) { + private func processCGMReadingResult(_ manager: CGMManager, readingResult: CGMReadingResult) async { switch readingResult { case .newData(let values): - loopManager.addGlucoseSamples(values) { result in - if !values.isEmpty { - DispatchQueue.main.async { - self.cgmStalenessMonitor.cgmGlucoseSamplesAvailable(values) - } - } - completion() + do { + let _ = try await glucoseStore.addGlucoseSamples(values) + } catch { + log.error("Unable to store glucose: %{public}@", String(describing: error)) + } + if !values.isEmpty { + self.cgmStalenessMonitor.cgmGlucoseSamplesAvailable(values) } case .unreliableData: - loopManager.receivedUnreliableCGMReading() - completion() + await self.receivedUnreliableCGMReading() case .noData: - completion() + break case .error(let error): self.setLastError(error: error) - completion() } updatePumpManagerBLEHeartbeatPreference() } @@ -643,7 +515,7 @@ final class DeviceDataManager { public func cgmManagerTypeByIdentifier(_ identifier: String) -> CGMManagerUI.Type? { return pluginManager.getCGMManagerTypeByIdentifier(identifier) ?? staticCGMManagersByIdentifier[identifier] as? CGMManagerUI.Type } - + public func setupCGMManagerFromPumpManager(withIdentifier identifier: String) -> CGMManager? { guard identifier == pumpManager?.pluginIdentifier, let cgmManager = pumpManager as? CGMManager else { return nil @@ -674,9 +546,7 @@ final class DeviceDataManager { func checkDeliveryUncertaintyState() { if let pumpManager = pumpManager, pumpManager.status.deliveryIsUncertain { - DispatchQueue.main.async { - self.deliveryUncertaintyAlertManager?.showAlert() - } + self.deliveryUncertaintyAlertManager?.showAlert() } } @@ -700,14 +570,49 @@ final class DeviceDataManager { self.getHealthStoreAuthorization(completion) } } + + private func refreshCGM() async { + guard let cgmManager = cgmManager else { + return + } + + let result = await cgmManager.fetchNewDataIfNeeded() + + if case .newData = result { + self.analyticsServicesManager.didFetchNewCGMData() + } + + await self.processCGMReadingResult(cgmManager, readingResult: result) + + let lastLoopCompleted = self.loopControl.lastLoopCompleted + + if lastLoopCompleted == nil || lastLoopCompleted!.timeIntervalSinceNow < -.minutes(4.2) { + self.log.default("Triggering Loop from refreshCGM()") + await self.checkPumpDataAndLoop() + } + } + + func refreshDeviceData() async { + await refreshCGM() + + guard let pumpManager = self.pumpManager, pumpManager.isOnboarded else { + return + } + + await pumpManager.ensureCurrentPumpData() + } + + var isGlucoseValueStale: Bool { + guard let latestGlucoseDataDate = glucoseStore.latestGlucose?.startDate else { return true } + + return Date().timeIntervalSince(latestGlucoseDataDate) > LoopAlgorithm.inputDataRecencyInterval + } } private extension DeviceDataManager { func setupCGM() { - dispatchPrecondition(condition: .onQueue(.main)) - cgmManager?.cgmManagerDelegate = self - cgmManager?.delegateQueue = queue + cgmManager?.delegateQueue = DispatchQueue.main reportPluginInitializationComplete() glucoseStore.managedDataInterval = cgmManager?.managedDataInterval @@ -725,7 +630,7 @@ private extension DeviceDataManager { } if let cgmManagerUI = cgmManager as? CGMManagerUI { - addDisplayGlucoseUnitObserver(cgmManagerUI) + displayGlucoseUnitBroadcaster?.addDisplayGlucoseUnitObserver(cgmManagerUI) } } @@ -733,17 +638,17 @@ private extension DeviceDataManager { dispatchPrecondition(condition: .onQueue(.main)) pumpManager?.pumpManagerDelegate = self - pumpManager?.delegateQueue = queue + pumpManager?.delegateQueue = DispatchQueue.main reportPluginInitializationComplete() doseStore.device = pumpManager?.status.device - pumpManagerHUDProvider = pumpManager?.hudProvider(bluetoothProvider: bluetoothProvider, colorPalette: .default, allowedInsulinTypes: allowedInsulinTypes) + pumpManagerHUDProvider = (pumpManager as? PumpManagerUI)?.hudProvider(bluetoothProvider: bluetoothProvider, colorPalette: .default, allowedInsulinTypes: allowedInsulinTypes) // Proliferate PumpModel preferences to DoseStore if let pumpRecordsBasalProfileStartEvents = pumpManager?.pumpRecordsBasalProfileStartEvents { doseStore.pumpRecordsBasalProfileStartEvents = pumpRecordsBasalProfileStartEvents } - if let pumpManager = pumpManager { + if let pumpManager = pumpManager as? PumpManagerUI { alertManager?.addAlertResponder(managerIdentifier: pumpManager.pluginIdentifier, alertResponder: pumpManager) alertManager?.addAlertSoundVendor(managerIdentifier: pumpManager.pluginIdentifier, @@ -769,11 +674,11 @@ extension DeviceDataManager { func reportPluginInitializationComplete() { let allActivePlugins = self.allActivePlugins - for plugin in servicesManager.activeServices { + for plugin in activeServicesProvider.activeServices { plugin.initializationComplete(for: allActivePlugins) } - for plugin in statefulPluginManager.activeStatefulPlugins { + for plugin in activeStatefulPluginsProvider.activeStatefulPlugins { plugin.initializationComplete(for: allActivePlugins) } @@ -786,9 +691,9 @@ extension DeviceDataManager { } var allActivePlugins: [Pluggable] { - var allActivePlugins: [Pluggable] = servicesManager.activeServices + var allActivePlugins: [Pluggable] = activeServicesProvider.activeServices - for plugin in statefulPluginManager.activeStatefulPlugins { + for plugin in activeStatefulPluginsProvider.activeStatefulPlugins { if !allActivePlugins.contains(where: { $0.pluginIdentifier == plugin.pluginIdentifier }) { allActivePlugins.append(plugin) } @@ -818,13 +723,12 @@ extension DeviceDataManager { // MARK: - Client API extension DeviceDataManager { - func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ error: Error?) -> Void = { _ in }) { + func enactBolus(units: Double, activationType: BolusActivationType) async throws { guard let pumpManager = pumpManager else { - completion(LoopError.configurationError(.pumpManager)) - return + throw LoopError.configurationError(.pumpManager) } - self.loopManager.addRequestedBolus(DoseEntry(type: .bolus, startDate: Date(), value: units, unit: .units, isMutable: true)) { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in pumpManager.enactBolus(units: units, activationType: activationType) { (error) in if let error = error { self.log.error("%{public}@", String(describing: error)) @@ -838,33 +742,14 @@ extension DeviceDataManager { NotificationManager.sendBolusFailureNotification(for: error, units: units, at: Date(), activationType: activationType) } } - - self.loopManager.bolusRequestFailed(error) { - completion(error) - } + continuation.resume(throwing: error) } else { - self.loopManager.bolusConfirmed() { - completion(nil) - } + continuation.resume() } } - // Trigger forecast/recommendation update for remote clients - self.loopManager.updateRemoteRecommendation() } } - func enactBolus(units: Double, activationType: BolusActivationType) async throws { - return try await withCheckedThrowingContinuation { continuation in - enactBolus(units: units, activationType: activationType) { error in - if let error = error { - continuation.resume(throwing: error) - return - } - continuation.resume() - } - } - } - var pumpManagerStatus: PumpManagerStatus? { return pumpManager?.status } @@ -954,6 +839,7 @@ extension DeviceDataManager: PersistedAlertStore { precondition(alertManager != nil) alertManager.doesIssuedAlertExist(identifier: identifier, completion: completion) } + func lookupAllUnretracted(managerIdentifier: String, completion: @escaping (Swift.Result<[PersistedAlert], Error>) -> Void) { precondition(alertManager != nil) alertManager.lookupAllUnretracted(managerIdentifier: managerIdentifier, completion: completion) @@ -972,34 +858,36 @@ extension DeviceDataManager: PersistedAlertStore { // MARK: - CGMManagerDelegate extension DeviceDataManager: CGMManagerDelegate { - func cgmManagerWantsDeletion(_ manager: CGMManager) { - dispatchPrecondition(condition: .onQueue(queue)) - - log.default("CGM manager with identifier '%{public}@' wants deletion", manager.pluginIdentifier) - - DispatchQueue.main.async { - if let cgmManagerUI = self.cgmManager as? CGMManagerUI { - self.removeDisplayGlucoseUnitObserver(cgmManagerUI) + nonisolated + func cgmManagerWantsDeletion(_ manager: CGMManager) async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { + self.log.default("CGM manager with identifier '%{public}@' wants deletion", manager.pluginIdentifier) + if let cgmManagerUI = self.cgmManager as? CGMManagerUI { + self.displayGlucoseUnitBroadcaster?.removeDisplayGlucoseUnitObserver(cgmManagerUI) + } + self.cgmManager = nil + self.settingsManager.storeSettings() + continuation.resume() } - self.cgmManager = nil - self.displayGlucoseUnitObservers.cleanupDeallocatedElements() - self.settingsManager.storeSettings() } } + nonisolated func cgmManager(_ manager: CGMManager, hasNew readingResult: CGMReadingResult) { - dispatchPrecondition(condition: .onQueue(queue)) - log.default("CGMManager:%{public}@ did update with %{public}@", String(describing: type(of: manager)), String(describing: readingResult)) - processCGMReadingResult(manager, readingResult: readingResult) { + Task { @MainActor in + log.default("CGMManager:%{public}@ did update with %{public}@", String(describing: type(of: manager)), String(describing: readingResult)) + await processCGMReadingResult(manager, readingResult: readingResult) let now = Date() if case .newData = readingResult, now.timeIntervalSince(self.lastCGMLoopTrigger) > .minutes(4.2) { self.log.default("Triggering loop from new CGM data at %{public}@", String(describing: now)) self.lastCGMLoopTrigger = now - self.checkPumpDataAndLoop() + await self.checkPumpDataAndLoop() } } } + nonisolated func cgmManager(_ manager: LoopKit.CGMManager, hasNew events: [PersistedCgmEvent]) { Task { do { @@ -1011,12 +899,12 @@ extension DeviceDataManager: CGMManagerDelegate { } func startDateToFilterNewData(for manager: CGMManager) -> Date? { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) return glucoseStore.latestGlucose?.startDate } func cgmManagerDidUpdateState(_ manager: CGMManager) { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) rawCGMManager = manager.rawValue } @@ -1025,6 +913,7 @@ extension DeviceDataManager: CGMManagerDelegate { return UUID().uuidString } + nonisolated func cgmManager(_ manager: CGMManager, didUpdate status: CGMManagerStatus) { DispatchQueue.main.async { if self.cgmHasValidSensorSession != status.hasValidSensorSession { @@ -1046,24 +935,27 @@ extension DeviceDataManager: CGMManagerOnboardingDelegate { precondition(cgmManager.isOnboarded) log.default("CGM manager with identifier '%{public}@' onboarded", cgmManager.pluginIdentifier) - DispatchQueue.main.async { - self.refreshDeviceData() - self.settingsManager.storeSettings() + Task { @MainActor in + await refreshDeviceData() + settingsManager.storeSettings() } } } // MARK: - PumpManagerDelegate extension DeviceDataManager: PumpManagerDelegate { + + var detectedSystemTimeOffset: TimeInterval { UserDefaults.standard.detectedSystemTimeOffset ?? 0 } + func pumpManager(_ pumpManager: PumpManager, didAdjustPumpClockBy adjustment: TimeInterval) { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) log.default("PumpManager:%{public}@ did adjust pump clock by %fs", String(describing: type(of: pumpManager)), adjustment) analyticsServicesManager.pumpTimeDidDrift(adjustment) } func pumpManagerDidUpdateState(_ pumpManager: PumpManager) { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) log.default("PumpManager:%{public}@ did update state", String(describing: type(of: pumpManager))) rawPumpManager = pumpManager.rawValue @@ -1075,47 +967,14 @@ extension DeviceDataManager: PumpManagerDelegate { } func pumpManagerBLEHeartbeatDidFire(_ pumpManager: PumpManager) { - dispatchPrecondition(condition: .onQueue(queue)) - log.default("PumpManager:%{public}@ did fire heartbeat", String(describing: type(of: pumpManager))) - refreshCGM() - } - - private func refreshCGM(_ completion: (() -> Void)? = nil) { - guard let cgmManager = cgmManager else { - completion?() - return - } - - cgmManager.fetchNewDataIfNeeded { (result) in - if case .newData = result { - self.analyticsServicesManager.didFetchNewCGMData() - } - - self.queue.async { - self.processCGMReadingResult(cgmManager, readingResult: result) { - if self.loopManager.lastLoopCompleted == nil || self.loopManager.lastLoopCompleted!.timeIntervalSinceNow < -.minutes(4.2) { - self.log.default("Triggering Loop from refreshCGM()") - self.checkPumpDataAndLoop() - } - completion?() - } - } - } - } - - func refreshDeviceData() { - refreshCGM() { - self.queue.async { - guard let pumpManager = self.pumpManager, pumpManager.isOnboarded else { - return - } - pumpManager.ensureCurrentPumpData(completion: nil) - } + Task { @MainActor in + log.default("PumpManager:%{public}@ did fire heartbeat", String(describing: type(of: pumpManager))) + await refreshCGM() } } func pumpManagerMustProvideBLEHeartbeat(_ pumpManager: PumpManager) -> Bool { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) return pumpManagerMustProvideBLEHeartbeat } @@ -1128,7 +987,7 @@ extension DeviceDataManager: PumpManagerDelegate { } func pumpManager(_ pumpManager: PumpManager, didUpdate status: PumpManagerStatus, oldStatus: PumpManagerStatus) { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) log.default("PumpManager:%{public}@ did update status: %{public}@", String(describing: type(of: pumpManager)), String(describing: status)) doseStore.device = status.device @@ -1139,19 +998,11 @@ extension DeviceDataManager: PumpManagerDelegate { analyticsServicesManager.pumpBatteryWasReplaced() } - if status.basalDeliveryState != oldStatus.basalDeliveryState { - loopManager.basalDeliveryState = status.basalDeliveryState - } - updatePumpIsAllowingAutomation(status: status) // Update the pump-schedule based settings - loopManager.setScheduleTimeZone(status.timeZone) - - if status.insulinType != oldStatus.insulinType { - loopManager.pumpInsulinType = status.insulinType - } - + settingsManager.setScheduleTimeZone(status.timeZone) + if status.deliveryIsUncertain != oldStatus.deliveryIsUncertain { DispatchQueue.main.async { if status.deliveryIsUncertain { @@ -1175,26 +1026,23 @@ extension DeviceDataManager: PumpManagerDelegate { } func pumpManagerWillDeactivate(_ pumpManager: PumpManager) { - dispatchPrecondition(condition: .onQueue(queue)) - + dispatchPrecondition(condition: .onQueue(.main)) log.default("Pump manager with identifier '%{public}@' will deactivate", pumpManager.pluginIdentifier) - DispatchQueue.main.async { - self.pumpManager = nil - self.deliveryUncertaintyAlertManager = nil - self.settingsManager.storeSettings() - } + self.pumpManager = nil + deliveryUncertaintyAlertManager = nil + settingsManager.storeSettings() } func pumpManager(_ pumpManager: PumpManager, didUpdatePumpRecordsBasalProfileStartEvents pumpRecordsBasalProfileStartEvents: Bool) { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) log.default("PumpManager:%{public}@ did update pumpRecordsBasalProfileStartEvents to %{public}@", String(describing: type(of: pumpManager)), String(describing: pumpRecordsBasalProfileStartEvents)) doseStore.pumpRecordsBasalProfileStartEvents = pumpRecordsBasalProfileStartEvents } func pumpManager(_ pumpManager: PumpManager, didError error: PumpManagerError) { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) log.error("PumpManager:%{public}@ did error: %{public}@", String(describing: type(of: pumpManager)), String(describing: error)) setLastError(error: error) @@ -1207,39 +1055,44 @@ extension DeviceDataManager: PumpManagerDelegate { replacePendingEvents: Bool, completion: @escaping (_ error: Error?) -> Void) { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) log.default("PumpManager:%{public}@ hasNewPumpEvents (lastReconciliation = %{public}@)", String(describing: type(of: pumpManager)), String(describing: lastReconciliation)) - doseStore.addPumpEvents(events, lastReconciliation: lastReconciliation, replacePendingEvents: replacePendingEvents) { (error) in - if let error = error { + Task { + do { + try await doseStore.addPumpEvents(events, lastReconciliation: lastReconciliation, replacePendingEvents: replacePendingEvents) + } catch { self.log.error("Failed to addPumpEvents to DoseStore: %{public}@", String(describing: error)) + completion(error) + return } - - completion(error) - - if error == nil { - NotificationCenter.default.post(name: .PumpEventsAdded, object: self, userInfo: nil) - } + completion(nil) + NotificationCenter.default.post(name: .PumpEventsAdded, object: self, userInfo: nil) } } - func pumpManager(_ pumpManager: PumpManager, didReadReservoirValue units: Double, at date: Date, completion: @escaping (_ result: Swift.Result<(newValue: ReservoirValue, lastValue: ReservoirValue?, areStoredValuesContinuous: Bool), Error>) -> Void) { - dispatchPrecondition(condition: .onQueue(queue)) - log.default("PumpManager:%{public}@ did read reservoir value", String(describing: type(of: pumpManager))) + func pumpManager( + _ pumpManager: PumpManager, + didReadReservoirValue units: Double, + at date: Date, + completion: @escaping (_ result: Swift.Result<(newValue: ReservoirValue, lastValue: ReservoirValue?, areStoredValuesContinuous: Bool), Error>) -> Void + ) { + Task { @MainActor in + dispatchPrecondition(condition: .onQueue(.main)) + log.default("PumpManager:%{public}@ did read reservoir value", String(describing: type(of: pumpManager))) - loopManager.addReservoirValue(units, at: date) { (result) in - switch result { - case .failure(let error): + do { + let (newValue, lastValue, areStoredValuesContinuous) = try await doseStore.addReservoirValue(units, at: date) + completion(.success((newValue: newValue, lastValue: lastValue, areStoredValuesContinuous: areStoredValuesContinuous))) + } catch { self.log.error("Failed to addReservoirValue: %{public}@", String(describing: error)) completion(.failure(error)) - case .success(let (newValue, lastValue, areStoredValuesContinuous)): - completion(.success((newValue: newValue, lastValue: lastValue, areStoredValuesContinuous: areStoredValuesContinuous))) } } } func startDateToFilterNewPumpEvents(for manager: PumpManager) -> Date { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) return doseStore.pumpEventQueryAfterDate } @@ -1257,12 +1110,12 @@ extension DeviceDataManager: PumpManagerOnboardingDelegate { } func pumpManagerOnboarding(didOnboardPumpManager pumpManager: PumpManagerUI) { - precondition(pumpManager.isOnboarded) - log.default("Pump manager with identifier '%{public}@' onboarded", pumpManager.pluginIdentifier) + Task { @MainActor in + precondition(pumpManager.isOnboarded) + log.default("Pump manager with identifier '%{public}@' onboarded", pumpManager.pluginIdentifier) - DispatchQueue.main.async { - self.refreshDeviceData() - self.settingsManager.storeSettings() + await refreshDeviceData() + settingsManager.storeSettings() } } @@ -1274,14 +1127,14 @@ extension DeviceDataManager: PumpManagerOnboardingDelegate { // MARK: - AlertStoreDelegate extension DeviceDataManager: AlertStoreDelegate { func alertStoreHasUpdatedAlertData(_ alertStore: AlertStore) { - remoteDataServicesManager.triggerUpload(for: .alert) + uploadEventListener.triggerUpload(for: .alert) } } // MARK: - CarbStoreDelegate extension DeviceDataManager: CarbStoreDelegate { func carbStoreHasUpdatedCarbData(_ carbStore: CarbStore) { - remoteDataServicesManager.triggerUpload(for: .carb) + uploadEventListener.triggerUpload(for: .carb) } func carbStore(_ carbStore: CarbStore, didError error: CarbStore.CarbStoreError) {} @@ -1289,143 +1142,85 @@ extension DeviceDataManager: CarbStoreDelegate { // MARK: - DoseStoreDelegate extension DeviceDataManager: DoseStoreDelegate { + func scheduledBasalHistory(from start: Date, to end: Date) async throws -> [AbsoluteScheduleValue] { + try await settingsManager.getBasalHistory(startDate: start, endDate: end) + } + func doseStoreHasUpdatedPumpEventData(_ doseStore: DoseStore) { - remoteDataServicesManager.triggerUpload(for: .pumpEvent) + uploadEventListener.triggerUpload(for: .pumpEvent) } } // MARK: - DosingDecisionStoreDelegate extension DeviceDataManager: DosingDecisionStoreDelegate { func dosingDecisionStoreHasUpdatedDosingDecisionData(_ dosingDecisionStore: DosingDecisionStore) { - remoteDataServicesManager.triggerUpload(for: .dosingDecision) + uploadEventListener.triggerUpload(for: .dosingDecision) } } // MARK: - GlucoseStoreDelegate extension DeviceDataManager: GlucoseStoreDelegate { func glucoseStoreHasUpdatedGlucoseData(_ glucoseStore: GlucoseStore) { - remoteDataServicesManager.triggerUpload(for: .glucose) + uploadEventListener.triggerUpload(for: .glucose) } } // MARK: - InsulinDeliveryStoreDelegate extension DeviceDataManager: InsulinDeliveryStoreDelegate { func insulinDeliveryStoreHasUpdatedDoseData(_ insulinDeliveryStore: InsulinDeliveryStore) { - remoteDataServicesManager.triggerUpload(for: .dose) + uploadEventListener.triggerUpload(for: .dose) } } // MARK: - CgmEventStoreDelegate extension DeviceDataManager: CgmEventStoreDelegate { func cgmEventStoreHasUpdatedData(_ cgmEventStore: LoopKit.CgmEventStore) { - remoteDataServicesManager.triggerUpload(for: .cgmEvent) + uploadEventListener.triggerUpload(for: .cgmEvent) } } // MARK: - TestingPumpManager extension DeviceDataManager { - func deleteTestingPumpData(completion: ((Error?) -> Void)? = nil) { + func deleteTestingPumpData() async throws { guard let testingPumpManager = pumpManager as? TestingPumpManager else { - completion?(nil) return } - let devicePredicate = HKQuery.predicateForObjects(from: [testingPumpManager.testingDevice]) let insulinDeliveryStore = doseStore.insulinDeliveryStore - - doseStore.resetPumpData { doseStoreError in - guard doseStoreError == nil else { - completion?(doseStoreError!) - return - } - let insulinSharingDenied = self.healthStore.authorizationStatus(for: HealthKitSampleStore.insulinQuantityType) == .sharingDenied - guard !insulinSharingDenied else { - // only clear cache since access to health kit is denied - insulinDeliveryStore.purgeCachedInsulinDeliveryObjects() { error in - completion?(error) - } - return - } - - insulinDeliveryStore.purgeAllDoseEntries(healthKitPredicate: devicePredicate) { error in - completion?(error) - } + try await doseStore.resetPumpData() + + let insulinSharingDenied = self.healthStore.authorizationStatus(for: HealthKitSampleStore.insulinQuantityType) == .sharingDenied + guard !insulinSharingDenied else { + // only clear cache since access to health kit is denied + await insulinDeliveryStore.purgeCachedInsulinDeliveryObjects() + return } + + try await insulinDeliveryStore.purgeDoseEntriesForDevice(testingPumpManager.testingDevice) } - func deleteTestingCGMData(completion: ((Error?) -> Void)? = nil) { + func deleteTestingCGMData() async throws { guard let testingCGMManager = cgmManager as? TestingCGMManager else { - completion?(nil) return } - + let glucoseSharingDenied = self.healthStore.authorizationStatus(for: HealthKitSampleStore.glucoseType) == .sharingDenied guard !glucoseSharingDenied else { // only clear cache since access to health kit is denied - glucoseStore.purgeCachedGlucoseObjects() { error in - completion?(error) - } + try await glucoseStore.purgeCachedGlucoseObjects() return } - let predicate = HKQuery.predicateForObjects(from: [testingCGMManager.testingDevice]) - glucoseStore.purgeAllGlucoseSamples(healthKitPredicate: predicate) { error in - completion?(error) - } + try await glucoseStore.purgeAllGlucose(for: testingCGMManager.testingDevice) } } -// MARK: - LoopDataManagerDelegate -extension DeviceDataManager: LoopDataManagerDelegate { - func roundBasalRate(unitsPerHour: Double) -> Double { - guard let pumpManager = pumpManager else { - return unitsPerHour - } - - return pumpManager.roundToSupportedBasalRate(unitsPerHour: unitsPerHour) - } - - func roundBolusVolume(units: Double) -> Double { - guard let pumpManager = pumpManager else { - return units - } - - let rounded = pumpManager.roundToSupportedBolusVolume(units: units) - self.log.default("Rounded %{public}@ to %{public}@", String(describing: units), String(describing: rounded)) - - return rounded - } - - func loopDataManager(_ manager: LoopDataManager, estimateBolusDuration units: Double) -> TimeInterval? { - pumpManager?.estimatedDuration(toBolus: units) - } - - func loopDataManager( - _ manager: LoopDataManager, - didRecommend automaticDose: (recommendation: AutomaticDoseRecommendation, date: Date), - completion: @escaping (LoopError?) -> Void - ) { - guard let pumpManager = pumpManager else { - completion(LoopError.configurationError(.pumpManager)) - return - } - - guard !pumpManager.status.deliveryIsUncertain else { - completion(LoopError.connectionError) - return - } - - log.default("LoopManager did recommend dose: %{public}@", String(describing: automaticDose.recommendation)) - - crashRecoveryManager.dosingStarted(dose: automaticDose.recommendation) - doseEnactor.enact(recommendation: automaticDose.recommendation, with: pumpManager) { pumpManagerError in - completion(pumpManagerError.map { .pumpManagerError($0) }) - self.crashRecoveryManager.dosingFinished() - } +extension DeviceDataManager: BolusDurationEstimator { + func estimateBolusDuration(bolusUnits: Double) -> TimeInterval? { + pumpManager?.estimatedDuration(toBolus: bolusUnits) } - } extension Notification.Name { @@ -1434,158 +1229,13 @@ extension Notification.Name { static let PumpEventsAdded = Notification.Name(rawValue: "com.loopKit.notification.PumpEventsAdded") } -// MARK: - ServicesManagerDosingDelegate - -extension DeviceDataManager: ServicesManagerDosingDelegate { - - func deliverBolus(amountInUnits: Double) async throws { - try await enactBolus(units: amountInUnits, activationType: .manualNoRecommendation) - } - -} - -// MARK: - Critical Event Log Export - -extension DeviceDataManager { - private static var criticalEventLogHistoricalExportBackgroundTaskIdentifier: String { "com.loopkit.background-task.critical-event-log.historical-export" } - - public static func registerCriticalEventLogHistoricalExportBackgroundTask(_ handler: @escaping (BGProcessingTask) -> Void) -> Bool { - return BGTaskScheduler.shared.register(forTaskWithIdentifier: criticalEventLogHistoricalExportBackgroundTaskIdentifier, using: nil) { handler($0 as! BGProcessingTask) } - } - - public func handleCriticalEventLogHistoricalExportBackgroundTask(_ task: BGProcessingTask) { - dispatchPrecondition(condition: .notOnQueue(.main)) - - scheduleCriticalEventLogHistoricalExportBackgroundTask(isRetry: true) - - let exporter = criticalEventLogExportManager.createHistoricalExporter() - - task.expirationHandler = { - self.log.default("Invoked critical event log historical export background task expiration handler - cancelling exporter") - exporter.cancel() - } - - DispatchQueue.global(qos: .background).async { - exporter.export() { error in - if let error = error { - self.log.error("Critical event log historical export errored: %{public}@", String(describing: error)) - } - - self.scheduleCriticalEventLogHistoricalExportBackgroundTask(isRetry: error != nil && !exporter.isCancelled) - task.setTaskCompleted(success: error == nil) - - self.log.default("Completed critical event log historical export background task") - } - } - } - - public func scheduleCriticalEventLogHistoricalExportBackgroundTask(isRetry: Bool = false) { - do { - let earliestBeginDate = isRetry ? criticalEventLogExportManager.retryExportHistoricalDate() : criticalEventLogExportManager.nextExportHistoricalDate() - let request = BGProcessingTaskRequest(identifier: Self.criticalEventLogHistoricalExportBackgroundTaskIdentifier) - request.earliestBeginDate = earliestBeginDate - request.requiresExternalPower = true - - try BGTaskScheduler.shared.submit(request) - - log.default("Scheduled critical event log historical export background task: %{public}@", ISO8601DateFormatter().string(from: earliestBeginDate)) - } catch let error { - #if IOS_SIMULATOR - log.debug("Failed to schedule critical event log export background task due to running on simulator") - #else - log.error("Failed to schedule critical event log export background task: %{public}@", String(describing: error)) - #endif - } - } - - public func removeExportsDirectory() -> Error? { - let fileManager = FileManager.default - let exportsDirectoryURL = fileManager.exportsDirectoryURL - - guard fileManager.fileExists(atPath: exportsDirectoryURL.path) else { - return nil - } - - do { - try fileManager.removeItem(at: exportsDirectoryURL) - } catch let error { - return error - } - - return nil - } -} - -// MARK: - Simulated Core Data - -extension DeviceDataManager { - func generateSimulatedHistoricalCoreData(completion: @escaping (Error?) -> Void) { - guard FeatureFlags.simulatedCoreDataEnabled else { - fatalError("\(#function) should be invoked only when simulated core data is enabled") - } - - settingsManager.settingsStore.generateSimulatedHistoricalSettingsObjects() { error in - guard error == nil else { - completion(error) - return - } - self.loopManager.generateSimulatedHistoricalCoreData() { error in - guard error == nil else { - completion(error) - return - } - self.deviceLog.generateSimulatedHistoricalDeviceLogEntries() { error in - guard error == nil else { - completion(error) - return - } - self.alertManager.alertStore.generateSimulatedHistoricalStoredAlerts(completion: completion) - } - } - } - } - - func purgeHistoricalCoreData(completion: @escaping (Error?) -> Void) { - guard FeatureFlags.simulatedCoreDataEnabled else { - fatalError("\(#function) should be invoked only when simulated core data is enabled") - } - - alertManager.alertStore.purgeHistoricalStoredAlerts() { error in - guard error == nil else { - completion(error) - return - } - self.deviceLog.purgeHistoricalDeviceLogEntries() { error in - guard error == nil else { - completion(error) - return - } - self.loopManager.purgeHistoricalCoreData { error in - guard error == nil else { - completion(error) - return - } - self.settingsManager.purgeHistoricalSettingsObjects(completion: completion) - } - } - } - } -} - -fileprivate extension FileManager { - var exportsDirectoryURL: URL { - let applicationSupportDirectory = try! url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) - return applicationSupportDirectory.appendingPathComponent(Bundle.main.bundleIdentifier!).appendingPathComponent("Exports") - } -} - //MARK: - CGMStalenessMonitorDelegate protocol conformance extension GlucoseStore : CGMStalenessMonitorDelegate { } //MARK: TherapySettingsViewModelDelegate -struct CancelTempBasalFailedError: LocalizedError { +struct CancelTempBasalFailedMaximumBasalRateChangedError: LocalizedError { let reason: Error? var errorDescription: String? { @@ -1609,7 +1259,7 @@ struct CancelTempBasalFailedError: LocalizedError { //MARK: - RemoteDataServicesManagerDelegate protocol conformance extension DeviceDataManager : RemoteDataServicesManagerDelegate { - var shouldSyncToRemoteService: Bool { + var shouldSyncGlucoseToRemoteService: Bool { guard let cgmManager = cgmManager else { return true } @@ -1623,22 +1273,22 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { pumpManager?.syncBasalRateSchedule(items: items, completion: completion) } - func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Swift.Result) -> Void) { + func syncDeliveryLimits(deliveryLimits: DeliveryLimits) async throws -> DeliveryLimits + { // FIRST we need to check to make sure if we have to cancel temp basal first - loopManager.maxTempBasalSavePreflight(unitsPerHour: deliveryLimits.maximumBasalRate?.doubleValue(for: .internationalUnitsPerHour)) { [weak self] error in - if let error = error { - completion(.failure(CancelTempBasalFailedError(reason: error))) - } else if let pumpManager = self?.pumpManager { - pumpManager.syncDeliveryLimits(limits: deliveryLimits, completion: completion) - } else { - completion(.success(deliveryLimits)) - } + if let maxRate = deliveryLimits.maximumBasalRate?.doubleValue(for: .internationalUnitsPerHour), + case .tempBasal(let dose) = basalDeliveryState, + dose.unitsPerHour > maxRate + { + // Temp basal is higher than proposed rate, so should cancel + try await self.loopControl.cancelActiveTempBasal(for: .maximumBasalRateChanged) } + + return try await pumpManager?.syncDeliveryLimits(limits: deliveryLimits) ?? deliveryLimits } - - func saveCompletion(therapySettings: TherapySettings) { - loopManager.mutateSettings { settings in + func saveCompletion(therapySettings: TherapySettings) { + settingsManager.mutateLoopSettings { settings in settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule settings.preMealTargetRange = therapySettings.correctionRangeOverrides?.preMeal settings.legacyWorkoutTargetRange = therapySettings.correctionRangeOverrides?.workout @@ -1662,90 +1312,83 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { } } -extension DeviceDataManager { - func addDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) { - let queue = DispatchQueue.main - displayGlucoseUnitObservers.insert(observer, queue: queue) - queue.async { - observer.unitDidChange(to: self.displayGlucosePreference.unit) - } +extension DeviceDataManager: DeviceSupportDelegate { + var availableSupports: [SupportUI] { [cgmManager, pumpManager].compactMap { $0 as? SupportUI } } + + func generateDiagnosticReport() async -> String { + let report = [ + "", + "## DeviceDataManager", + "* launchDate: \(self.launchDate)", + "* lastError: \(String(describing: self.lastError))", + "", + "cacheStore: \(String(reflecting: self.cacheStore))", + "", + self.cgmManager != nil ? String(reflecting: self.cgmManager!) : "cgmManager: nil", + "", + self.pumpManager != nil ? String(reflecting: self.pumpManager!) : "pumpManager: nil", + "", + await deviceLog.generateDiagnosticReport() + ] + return report.joined(separator: "\n") } +} - func removeDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) { - displayGlucoseUnitObservers.removeElement(observer) +extension DeviceDataManager: DeliveryDelegate { + var isPumpConfigured: Bool { + return pumpManager != nil } - func notifyObserversOfDisplayGlucoseUnitChange(to displayGlucoseUnit: HKUnit) { - self.displayGlucoseUnitObservers.forEach { - $0.unitDidChange(to: displayGlucoseUnit) + func roundBasalRate(unitsPerHour: Double) -> Double { + guard let pumpManager = pumpManager else { + return unitsPerHour } - } -} -extension DeviceDataManager: DeviceSupportDelegate { - var availableSupports: [SupportUI] { [cgmManager, pumpManager].compactMap { $0 as? SupportUI } } + return pumpManager.roundToSupportedBasalRate(unitsPerHour: unitsPerHour) + } - func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) { - self.loopManager.generateDiagnosticReport { (loopReport) in + func roundBolusVolume(units: Double) -> Double { + guard let pumpManager = pumpManager else { + return units + } - let logDurationHours = 84.0 + return pumpManager.roundToSupportedBolusVolume(units: units) + } - self.alertManager.getStoredEntries(startDate: Date() - .hours(logDurationHours)) { (alertReport) in - self.deviceLog.getLogEntries(startDate: Date() - .hours(logDurationHours)) { (result) in - let deviceLogReport: String - switch result { - case .failure(let error): - deviceLogReport = "Error fetching entries: \(error)" - case .success(let entries): - deviceLogReport = entries.map { "* \($0.timestamp) \($0.managerIdentifier) \($0.deviceIdentifier ?? "") \($0.type) \($0.message)" }.joined(separator: "\n") - } + var pumpInsulinType: InsulinType? { + return pumpManager?.status.insulinType + } + + var isSuspended: Bool { + return pumpManager?.status.basalDeliveryState?.isSuspended ?? false + } + + func enact(_ recommendation: AutomaticDoseRecommendation) async throws { + guard let pumpManager = pumpManager else { + throw LoopError.configurationError(.pumpManager) + } - let report = [ - "## Build Details", - "* appNameAndVersion: \(Bundle.main.localizedNameAndVersion)", - "* profileExpiration: \(BuildDetails.default.profileExpirationString)", - "* gitRevision: \(BuildDetails.default.gitRevision ?? "N/A")", - "* gitBranch: \(BuildDetails.default.gitBranch ?? "N/A")", - "* workspaceGitRevision: \(BuildDetails.default.workspaceGitRevision ?? "N/A")", - "* workspaceGitBranch: \(BuildDetails.default.workspaceGitBranch ?? "N/A")", - "* sourceRoot: \(BuildDetails.default.sourceRoot ?? "N/A")", - "* buildDateString: \(BuildDetails.default.buildDateString ?? "N/A")", - "* xcodeVersion: \(BuildDetails.default.xcodeVersion ?? "N/A")", - "", - "## FeatureFlags", - "\(FeatureFlags)", - "", - alertReport, - "", - "## DeviceDataManager", - "* launchDate: \(self.launchDate)", - "* lastError: \(String(describing: self.lastError))", - "", - "cacheStore: \(String(reflecting: self.cacheStore))", - "", - self.cgmManager != nil ? String(reflecting: self.cgmManager!) : "cgmManager: nil", - "", - self.pumpManager != nil ? String(reflecting: self.pumpManager!) : "pumpManager: nil", - "", - "## Device Communication Log", - deviceLogReport, - "", - String(reflecting: self.watchManager!), - "", - String(reflecting: self.statusExtensionManager!), - "", - loopReport, - ].joined(separator: "\n") - - completion(report) - } - } + guard !pumpManager.status.deliveryIsUncertain else { + throw LoopError.connectionError } + + log.default("Enacting dose: %{public}@", String(describing: recommendation)) + + crashRecoveryManager.dosingStarted(dose: recommendation) + defer { self.crashRecoveryManager.dosingFinished() } + + try await doseEnactor.enact(recommendation: recommendation, with: pumpManager) + } + + var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? { + return pumpManager?.status.basalDeliveryState } } extension DeviceDataManager: DeviceStatusProvider {} -extension DeviceDataManager { - var detectedSystemTimeOffset: TimeInterval { trustedTimeChecker.detectedSystemTimeOffset } +extension DeviceDataManager: BolusStateProvider { + var bolusState: LoopKit.PumpManagerStatus.BolusState? { + return pumpManager?.status.bolusState + } } diff --git a/Loop/Managers/DoseEnactor.swift b/Loop/Managers/DoseEnactor.swift index 55c782c96c..6777802a5d 100644 --- a/Loop/Managers/DoseEnactor.swift +++ b/Loop/Managers/DoseEnactor.swift @@ -1,4 +1,4 @@ - // +// // DoseEnactor.swift // Loop // @@ -8,6 +8,7 @@ import Foundation import LoopKit +import LoopAlgorithm class DoseEnactor { @@ -15,47 +16,17 @@ class DoseEnactor { private let log = DiagnosticLog(category: "DoseEnactor") - func enact(recommendation: AutomaticDoseRecommendation, with pumpManager: PumpManager, completion: @escaping (PumpManagerError?) -> Void) { - - dosingQueue.async { - let doseDispatchGroup = DispatchGroup() - - var tempBasalError: PumpManagerError? = nil - var bolusError: PumpManagerError? = nil - - if let basalAdjustment = recommendation.basalAdjustment { - self.log.default("Enacting recommend basal change") + func enact(recommendation: AutomaticDoseRecommendation, with pumpManager: PumpManager) async throws { - doseDispatchGroup.enter() - pumpManager.enactTempBasal(unitsPerHour: basalAdjustment.unitsPerHour, for: basalAdjustment.duration, completion: { error in - if let error = error { - tempBasalError = error - } - doseDispatchGroup.leave() - }) - } - - doseDispatchGroup.wait() + if let basalAdjustment = recommendation.basalAdjustment { + self.log.default("Enacting recommended basal change") + try await pumpManager.enactTempBasal(unitsPerHour: basalAdjustment.unitsPerHour, for: basalAdjustment.duration) + } - guard tempBasalError == nil else { - completion(tempBasalError) - return - } - - if let bolusUnits = recommendation.bolusUnits, bolusUnits > 0 { - self.log.default("Enacting recommended bolus dose") - doseDispatchGroup.enter() - pumpManager.enactBolus(units: bolusUnits, activationType: .automatic) { (error) in - if let error = error { - bolusError = error - } else { - self.log.default("PumpManager successfully issued bolus command") - } - doseDispatchGroup.leave() - } - } - doseDispatchGroup.wait() - completion(bolusError) + if let bolusUnits = recommendation.bolusUnits, bolusUnits > 0 { + self.log.default("Enacting recommended bolus dose") + try await pumpManager.enactBolus(units: bolusUnits, activationType: .automatic) } } } + diff --git a/Loop/Managers/ExtensionDataManager.swift b/Loop/Managers/ExtensionDataManager.swift index 9261dcfc43..37e1a21ed6 100644 --- a/Loop/Managers/ExtensionDataManager.swift +++ b/Loop/Managers/ExtensionDataManager.swift @@ -10,18 +10,27 @@ import HealthKit import UIKit import LoopKit - +@MainActor final class ExtensionDataManager { unowned let deviceManager: DeviceDataManager + unowned let loopDataManager: LoopDataManager + unowned let settingsManager: SettingsManager + unowned let temporaryPresetsManager: TemporaryPresetsManager private let automaticDosingStatus: AutomaticDosingStatus init(deviceDataManager: DeviceDataManager, - automaticDosingStatus: AutomaticDosingStatus) - { + loopDataManager: LoopDataManager, + automaticDosingStatus: AutomaticDosingStatus, + settingsManager: SettingsManager, + temporaryPresetsManager: TemporaryPresetsManager + ) { self.deviceManager = deviceDataManager + self.loopDataManager = loopDataManager + self.settingsManager = settingsManager + self.temporaryPresetsManager = temporaryPresetsManager self.automaticDosingStatus = automaticDosingStatus - NotificationCenter.default.addObserver(self, selector: #selector(notificationReceived(_:)), name: .LoopDataUpdated, object: deviceDataManager.loopManager) + NotificationCenter.default.addObserver(self, selector: #selector(notificationReceived(_:)), name: .LoopDataUpdated, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(notificationReceived(_:)), name: .PumpManagerChanged, object: nil) // Wait until LoopDataManager has had a chance to initialize itself @@ -61,114 +70,110 @@ final class ExtensionDataManager { } private func update() { - createStatusContext(glucoseUnit: deviceManager.preferredGlucoseUnit) { (context) in - if let context = context { + Task { @MainActor in + if let context = await createStatusContext(glucoseUnit: deviceManager.displayGlucosePreference.unit) { ExtensionDataManager.context = context } - } - - createIntentsContext { (info) in - if let info = info, ExtensionDataManager.intentExtensionInfo?.overridePresetNames != info.overridePresetNames { + + if let info = createIntentsContext(), ExtensionDataManager.intentExtensionInfo?.overridePresetNames != info.overridePresetNames { ExtensionDataManager.intentExtensionInfo = info } } } - private func createIntentsContext(_ completion: @escaping (_ context: IntentExtensionInfo?) -> Void) { - let presets = deviceManager.loopManager.settings.overridePresets + private func createIntentsContext() -> IntentExtensionInfo? { + let presets = settingsManager.settings.overridePresets let info = IntentExtensionInfo(overridePresetNames: presets.map { $0.name }) - completion(info) + return info } - private func createStatusContext(glucoseUnit: HKUnit, _ completionHandler: @escaping (_ context: StatusExtensionContext?) -> Void) { + private func createStatusContext(glucoseUnit: HKUnit) async -> StatusExtensionContext? { let basalDeliveryState = deviceManager.pumpManager?.status.basalDeliveryState - deviceManager.loopManager.getLoopState { (manager, state) in - let dataManager = self.deviceManager - var context = StatusExtensionContext() - - context.createdAt = Date() - - #if IOS_SIMULATOR - // If we're in the simulator, there's a higher likelihood that we don't have - // a fully configured app. Inject some baseline debug data to let us test the - // experience. This data will be overwritten by actual data below, if available. - context.batteryPercentage = 0.25 - context.netBasal = NetBasalContext( - rate: 2.1, - percentage: 0.6, - start: - Date(timeIntervalSinceNow: -250), - end: Date(timeIntervalSinceNow: .minutes(30)) - ) - context.predictedGlucose = PredictedGlucoseContext( - values: (1...36).map { 89.123 + Double($0 * 5) }, // 3 hours of linear data - unit: HKUnit.milligramsPerDeciliter, - startDate: Date(), - interval: TimeInterval(minutes: 5)) - - let lastLoopCompleted = Date(timeIntervalSinceNow: -TimeInterval(minutes: 0)) - #else - let lastLoopCompleted = manager.lastLoopCompleted - #endif - - context.lastLoopCompleted = lastLoopCompleted - - context.isClosedLoop = self.automaticDosingStatus.automaticDosingEnabled - - context.preMealPresetAllowed = self.automaticDosingStatus.automaticDosingEnabled && manager.settings.preMealTargetRange != nil - context.preMealPresetActive = manager.settings.preMealTargetEnabled() - context.customPresetActive = manager.settings.nonPreMealOverrideEnabled() - - // 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.predictedGlucoseIncludingPendingInsulin?.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)) - } - - if let basalDeliveryState = basalDeliveryState, - let basalSchedule = manager.basalRateScheduleApplyingOverrideHistory, - let netBasal = basalDeliveryState.getNetBasal(basalSchedule: basalSchedule, settings: manager.settings) - { - context.netBasal = NetBasalContext(rate: netBasal.rate, percentage: netBasal.percent, start: netBasal.start, end: netBasal.end) - } + let state = loopDataManager.algorithmState + + let dataManager = self.deviceManager + var context = StatusExtensionContext() + + context.createdAt = Date() + + #if IOS_SIMULATOR + // If we're in the simulator, there's a higher likelihood that we don't have + // a fully configured app. Inject some baseline debug data to let us test the + // experience. This data will be overwritten by actual data below, if available. + context.batteryPercentage = 0.25 + context.netBasal = NetBasalContext( + rate: 2.1, + percentage: 0.6, + start: + Date(timeIntervalSinceNow: -250), + end: Date(timeIntervalSinceNow: .minutes(30)) + ) + context.predictedGlucose = PredictedGlucoseContext( + values: (1...36).map { 89.123 + Double($0 * 5) }, // 3 hours of linear data + unit: HKUnit.milligramsPerDeciliter, + startDate: Date(), + interval: TimeInterval(minutes: 5)) + #endif + + context.lastLoopCompleted = loopDataManager.lastLoopCompleted + context.mostRecentGlucoseDataDate = loopDataManager.mostRecentGlucoseDataDate + context.mostRecentPumpDataDate = loopDataManager.mostRecentPumpDataDate + + context.isClosedLoop = self.automaticDosingStatus.automaticDosingEnabled + + context.preMealPresetAllowed = self.automaticDosingStatus.automaticDosingEnabled && self.settingsManager.settings.preMealTargetRange != nil + context.preMealPresetActive = self.temporaryPresetsManager.preMealTargetEnabled() + context.customPresetActive = self.temporaryPresetsManager.nonPreMealOverrideEnabled() + + // 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.output?.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)) + } - context.batteryPercentage = dataManager.pumpManager?.status.pumpBatteryChargeRemaining - context.reservoirCapacity = dataManager.pumpManager?.pumpReservoirCapacity - - if let glucoseDisplay = dataManager.glucoseDisplay(for: dataManager.glucoseStore.latestGlucose) { - context.glucoseDisplay = GlucoseDisplayableContext( - isStateValid: glucoseDisplay.isStateValid, - stateDescription: glucoseDisplay.stateDescription, - trendType: glucoseDisplay.trendType, - trendRate: glucoseDisplay.trendRate, - isLocal: glucoseDisplay.isLocal, - glucoseRangeCategory: glucoseDisplay.glucoseRangeCategory - ) - } - - if let pumpManagerHUDProvider = dataManager.pumpManagerHUDProvider { - context.pumpManagerHUDViewContext = PumpManagerHUDViewContext(pumpManagerHUDViewRawValue: PumpManagerHUDViewRawValueFromHUDProvider(pumpManagerHUDProvider)) - } - - context.pumpStatusHighlightContext = DeviceStatusHighlightContext(from: dataManager.pumpStatusHighlight) - context.pumpLifecycleProgressContext = DeviceLifecycleProgressContext(from: dataManager.pumpLifecycleProgress) + if let basalDeliveryState = basalDeliveryState, + let basalSchedule = self.temporaryPresetsManager.basalRateScheduleApplyingOverrideHistory, + let netBasal = basalDeliveryState.getNetBasal(basalSchedule: basalSchedule, maximumBasalRatePerHour: self.settingsManager.settings.maximumBasalRatePerHour) + { + context.netBasal = NetBasalContext(rate: netBasal.rate, percentage: netBasal.percent, start: netBasal.start, end: netBasal.end) + } - context.cgmStatusHighlightContext = DeviceStatusHighlightContext(from: dataManager.cgmStatusHighlight) - context.cgmLifecycleProgressContext = DeviceLifecycleProgressContext(from: dataManager.cgmLifecycleProgress) + context.batteryPercentage = dataManager.pumpManager?.status.pumpBatteryChargeRemaining + context.reservoirCapacity = dataManager.pumpManager?.pumpReservoirCapacity + + if let glucoseDisplay = dataManager.glucoseDisplay(for: loopDataManager.latestGlucose) { + context.glucoseDisplay = GlucoseDisplayableContext( + isStateValid: glucoseDisplay.isStateValid, + stateDescription: glucoseDisplay.stateDescription, + trendType: glucoseDisplay.trendType, + trendRate: glucoseDisplay.trendRate, + isLocal: glucoseDisplay.isLocal, + glucoseRangeCategory: glucoseDisplay.glucoseRangeCategory + ) + } - context.carbsOnBoard = state.carbsOnBoard?.quantity.doubleValue(for: .gram()) - - completionHandler(context) + if let pumpManagerHUDProvider = dataManager.pumpManagerHUDProvider { + context.pumpManagerHUDViewContext = PumpManagerHUDViewContext(pumpManagerHUDViewRawValue: PumpManagerHUDViewRawValueFromHUDProvider(pumpManagerHUDProvider)) } + + context.pumpStatusHighlightContext = DeviceStatusHighlightContext(from: dataManager.pumpStatusHighlight) + context.pumpLifecycleProgressContext = DeviceLifecycleProgressContext(from: dataManager.pumpLifecycleProgress) + + context.cgmStatusHighlightContext = DeviceStatusHighlightContext(from: dataManager.cgmStatusHighlight) + context.cgmLifecycleProgressContext = DeviceLifecycleProgressContext(from: dataManager.cgmLifecycleProgress) + + context.carbsOnBoard = state.activeCarbs?.value + + return context } } diff --git a/Loop/Managers/LocalTestingScenariosManager.swift b/Loop/Managers/LocalTestingScenariosManager.swift deleted file mode 100644 index bd1e7e087a..0000000000 --- a/Loop/Managers/LocalTestingScenariosManager.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// LocalTestingScenariosManager.swift -// Loop -// -// Created by Michael Pangburn on 4/22/19. -// Copyright © 2019 LoopKit Authors. All rights reserved. -// - -import Foundation -import LoopKit -import LoopTestingKit -import OSLog - -final class LocalTestingScenariosManager: TestingScenariosManagerRequirements, DirectoryObserver { - - unowned let deviceManager: DeviceDataManager - unowned let supportManager: SupportManager - - let log = DiagnosticLog(category: "LocalTestingScenariosManager") - - private let fileManager = FileManager.default - private let scenariosSource: URL - private var directoryObservationToken: DirectoryObservationToken? - - private(set) var scenarioURLs: [URL] = [] - var activeScenarioURL: URL? - var activeScenario: TestingScenario? - - weak var delegate: TestingScenariosManagerDelegate? { - didSet { - delegate?.testingScenariosManager(self, didUpdateScenarioURLs: scenarioURLs) - } - } - - var pluginManager: PluginManager { - deviceManager.pluginManager - } - - init(deviceManager: DeviceDataManager, supportManager: SupportManager) { - guard FeatureFlags.scenariosEnabled else { - fatalError("\(#function) should be invoked only when scenarios are enabled") - } - - self.deviceManager = deviceManager - self.supportManager = supportManager - self.scenariosSource = Bundle.main.bundleURL.appendingPathComponent("Scenarios") - - log.debug("Loading testing scenarios from %{public}@", scenariosSource.path) - if !fileManager.fileExists(atPath: scenariosSource.path) { - do { - try fileManager.createDirectory(at: scenariosSource, withIntermediateDirectories: false) - } catch { - log.error("%{public}@", String(describing: error)) - } - } - - directoryObservationToken = observeDirectory(at: scenariosSource) { [weak self] in - self?.reloadScenarioURLs() - } - reloadScenarioURLs() - } - - func fetchScenario(from url: URL, completion: (Result) -> Void) { - let result = Result(catching: { try TestingScenario(source: url) }) - completion(result) - } - - private func reloadScenarioURLs() { - do { - let scenarioURLs = try fileManager.contentsOfDirectory(at: scenariosSource, includingPropertiesForKeys: nil) - .filter { $0.pathExtension == "json" } - self.scenarioURLs = scenarioURLs - delegate?.testingScenariosManager(self, didUpdateScenarioURLs: scenarioURLs) - log.debug("Reloaded scenario URLs") - } catch { - log.error("%{public}@", String(describing: error)) - } - } -} diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index b8e23d0bba..935726187a 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -8,12 +8,15 @@ import UIKit import Intents +import BackgroundTasks import Combine import LoopKit import LoopKitUI import MockKit import HealthKit import WidgetKit +import LoopCore +import LoopAlgorithm #if targetEnvironment(simulator) enum SimulatorError: Error { @@ -55,6 +58,7 @@ protocol WindowProvider: AnyObject { var window: UIWindow? { get } } +@MainActor class LoopAppManager: NSObject { private enum State: Int { case initialize @@ -74,6 +78,11 @@ class LoopAppManager: NSObject { private var bluetoothStateManager: BluetoothStateManager! private var alertManager: AlertManager! private var trustedTimeChecker: TrustedTimeChecker! + private var healthStore: HKHealthStore! + private var carbStore: CarbStore! + private var doseStore: DoseStore! + private var glucoseStore: GlucoseStore! + private var dosingDecisionStore: DosingDecisionStore! private var deviceDataManager: DeviceDataManager! private var onboardingManager: OnboardingManager! private var alertPermissionsChecker: AlertPermissionsChecker! @@ -84,8 +93,23 @@ class LoopAppManager: NSObject { private(set) var testingScenariosManager: TestingScenariosManager? private var resetLoopManager: ResetLoopManager! private var deeplinkManager: DeeplinkManager! - - private var overrideHistory = UserDefaults.appGroup?.overrideHistory ?? TemporaryScheduleOverrideHistory.init() + private var temporaryPresetsManager: TemporaryPresetsManager! + private var loopDataManager: LoopDataManager! + private var mealDetectionManager: MealDetectionManager! + private var statusExtensionManager: ExtensionDataManager! + private var watchManager: WatchDataManager! + private var crashRecoveryManager: CrashRecoveryManager! + private var cgmEventStore: CgmEventStore! + private var servicesManager: ServicesManager! + private var remoteDataServicesManager: RemoteDataServicesManager! + private var statefulPluginManager: StatefulPluginManager! + private var criticalEventLogExportManager: CriticalEventLogExportManager! + private var deviceLog: PersistentDeviceLog! + + // HealthStorePreferredGlucoseUnitDidChange will be notified once the user completes the health access form. Set to .milligramsPerDeciliter until then + public private(set) var displayGlucosePreference = DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) + + private var displayGlucoseUnitObservers = WeakSynchronizedSet() private var state: State = .initialize @@ -107,43 +131,51 @@ class LoopAppManager: NSObject { INPreferences.requestSiriAuthorization { _ in } } - registerBackgroundTasks() + self.state = state.next + } - if FeatureFlags.remoteCommandsEnabled { - DispatchQueue.main.async { -#if targetEnvironment(simulator) - self.remoteNotificationRegistrationDidFinish(.failure(SimulatorError.remoteNotificationsNotAvailable)) -#else - UIApplication.shared.registerForRemoteNotifications() -#endif + func registerBackgroundTasks() { + let taskIdentifier = CriticalEventLogExportManager.historicalExportBackgroundTaskIdentifier + let registered = BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in + guard let criticalEventLogExportManager = self.criticalEventLogExportManager else { + self.log.error("Critical event log export launch handler called before initialization complete!") + return } + criticalEventLogExportManager.handleCriticalEventLogHistoricalExportBackgroundTask(task as! BGProcessingTask) + } + if registered { + log.debug("Critical event log export background task registered") + } else { + log.error("Critical event log export background task not registered") } - self.state = state.next } func launch() { - dispatchPrecondition(condition: .onQueue(.main)) precondition(isLaunchPending) - resumeLaunch() + registerBackgroundTasks() + + Task { + await resumeLaunch() + } } var isLaunchPending: Bool { state == .checkProtectedDataAvailable } var isLaunchComplete: Bool { state == .launchComplete } - private func resumeLaunch() { + private func resumeLaunch() async { if state == .checkProtectedDataAvailable { checkProtectedDataAvailable() } if state == .launchManagers { - launchManagers() + await launchManagers() } if state == .launchOnboarding { launchOnboarding() } if state == .launchHomeScreen { - launchHomeScreen() + await launchHomeScreen() } askUserToConfirmLoopReset() @@ -161,7 +193,7 @@ class LoopAppManager: NSObject { self.state = state.next } - private func launchManagers() { + private func launchManagers() async { dispatchPrecondition(condition: .onQueue(.main)) precondition(state == .launchManagers) @@ -187,48 +219,247 @@ class LoopAppManager: NSObject { alertPermissionsChecker = AlertPermissionsChecker() alertPermissionsChecker.delegate = alertManager - trustedTimeChecker = TrustedTimeChecker(alertManager: alertManager) + trustedTimeChecker = LoopTrustedTimeChecker(alertManager: alertManager) + + settingsManager = SettingsManager( + cacheStore: cacheStore, + expireAfter: localCacheDuration, + alertMuter: alertManager.alertMuter, + analyticsServicesManager: analyticsServicesManager + ) + + // Once settings manager is initialized, we can register for remote notifications + if FeatureFlags.remoteCommandsEnabled { + DispatchQueue.main.async { +#if targetEnvironment(simulator) + self.remoteNotificationRegistrationDidFinish(.failure(SimulatorError.remoteNotificationsNotAvailable)) +#else + UIApplication.shared.registerForRemoteNotifications() +#endif + } + } - settingsManager = SettingsManager(cacheStore: cacheStore, - expireAfter: localCacheDuration, - alertMuter: alertManager.alertMuter) + healthStore = HKHealthStore() + + let carbHealthStore = HealthKitSampleStore( + healthStore: healthStore, + observeHealthKitSamplesFromOtherApps: FeatureFlags.observeHealthKitCarbSamplesFromOtherApps, // At some point we should let the user decide which apps they would like to import from. + type: HealthKitSampleStore.carbType, + observationStart: Date().addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval) + ) + + temporaryPresetsManager = TemporaryPresetsManager(settingsProvider: settingsManager) + temporaryPresetsManager.overrideHistory.delegate = self + + temporaryPresetsManager.addTemporaryPresetObserver(alertManager) + temporaryPresetsManager.addTemporaryPresetObserver(analyticsServicesManager) + + self.carbStore = CarbStore( + healthKitSampleStore: carbHealthStore, + cacheStore: cacheStore, + cacheLength: localCacheDuration + ) + + let insulinHealthStore = HealthKitSampleStore( + healthStore: healthStore, + observeHealthKitSamplesFromOtherApps: FeatureFlags.observeHealthKitDoseSamplesFromOtherApps, + type: HealthKitSampleStore.insulinQuantityType, + observationStart: Date().addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval) + ) + + self.doseStore = await DoseStore( + healthKitSampleStore: insulinHealthStore, + cacheStore: cacheStore, + cacheLength: localCacheDuration, + longestEffectDuration: ExponentialInsulinModelPreset.rapidActingAdult.effectDuration, + lastPumpEventsReconciliation: nil // PumpManager is nil at this point. Will update this via addPumpEvents below + ) + + let glucoseHealthStore = HealthKitSampleStore( + healthStore: healthStore, + observeHealthKitSamplesFromOtherApps: FeatureFlags.observeHealthKitGlucoseSamplesFromOtherApps, + type: HealthKitSampleStore.glucoseType, + observationStart: Date().addingTimeInterval(-.hours(24)) + ) + + self.glucoseStore = await GlucoseStore( + healthKitSampleStore: glucoseHealthStore, + cacheStore: cacheStore, + cacheLength: localCacheDuration, + provenanceIdentifier: HKSource.default().bundleIdentifier + ) + + dosingDecisionStore = DosingDecisionStore(store: cacheStore, expireAfter: localCacheDuration) + + + NotificationCenter.default.addObserver(forName: .HealthStorePreferredGlucoseUnitDidChange, object: healthStore, queue: nil) { [weak self] _ in + guard let self else { + return + } + + Task { @MainActor in + if let unit = await self.healthStore.cachedPreferredUnits(for: .bloodGlucose) { + self.displayGlucosePreference.unitDidChange(to: unit) + self.notifyObserversOfDisplayGlucoseUnitChange(to: unit) + } + } + } + + let carbModel: CarbAbsorptionModel = FeatureFlags.nonlinearCarbModelEnabled ? .piecewiseLinear : .linear + + loopDataManager = LoopDataManager( + lastLoopCompleted: ExtensionDataManager.context?.lastLoopCompleted, + temporaryPresetsManager: temporaryPresetsManager, + settingsProvider: settingsManager, + doseStore: doseStore, + glucoseStore: glucoseStore, + carbStore: carbStore, + dosingDecisionStore: dosingDecisionStore, + automaticDosingStatus: automaticDosingStatus, + trustedTimeOffset: { self.trustedTimeChecker.detectedSystemTimeOffset }, + analyticsServicesManager: analyticsServicesManager, + carbAbsorptionModel: carbModel + ) + + cacheStore.delegate = loopDataManager + + crashRecoveryManager = CrashRecoveryManager(alertIssuer: alertManager) + + Task { @MainActor in + alertManager.addAlertResponder(managerIdentifier: crashRecoveryManager.managerIdentifier, alertResponder: crashRecoveryManager) + } + + cgmEventStore = CgmEventStore(cacheStore: cacheStore, cacheLength: localCacheDuration) + + let fileManager = FileManager.default + let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! + let deviceLogDirectory = documentsDirectory.appendingPathComponent("DeviceLog") + if !fileManager.fileExists(atPath: deviceLogDirectory.path) { + do { + try fileManager.createDirectory(at: deviceLogDirectory, withIntermediateDirectories: false) + } catch let error { + preconditionFailure("Could not create DeviceLog directory: \(error)") + } + } + deviceLog = PersistentDeviceLog(storageFile: deviceLogDirectory.appendingPathComponent("Storage.sqlite"), maxEntryAge: localCacheDuration) + + + remoteDataServicesManager = RemoteDataServicesManager( + alertStore: alertManager.alertStore, + carbStore: carbStore, + doseStore: doseStore, + dosingDecisionStore: dosingDecisionStore, + glucoseStore: glucoseStore, + cgmEventStore: cgmEventStore, + settingsProvider: settingsManager, + overrideHistory: temporaryPresetsManager.overrideHistory, + insulinDeliveryStore: doseStore.insulinDeliveryStore, + deviceLog: deviceLog, + automationHistoryProvider: loopDataManager + ) + + settingsManager.remoteDataServicesManager = remoteDataServicesManager + + remoteDataServicesManager.triggerAllUploads() + + servicesManager = ServicesManager( + pluginManager: pluginManager, + alertManager: alertManager, + analyticsServicesManager: analyticsServicesManager, + loggingServicesManager: loggingServicesManager, + remoteDataServicesManager: remoteDataServicesManager, + settingsManager: settingsManager, + servicesManagerDelegate: loopDataManager, + servicesManagerDosingDelegate: self + ) + + statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) deviceDataManager = DeviceDataManager(pluginManager: pluginManager, + deviceLog: deviceLog, alertManager: alertManager, settingsManager: settingsManager, - loggingServicesManager: loggingServicesManager, + healthStore: healthStore, + carbStore: carbStore, + doseStore: doseStore, + glucoseStore: glucoseStore, + cgmEventStore: cgmEventStore, + uploadEventListener: remoteDataServicesManager, + crashRecoveryManager: crashRecoveryManager, + loopControl: loopDataManager, analyticsServicesManager: analyticsServicesManager, + activeServicesProvider: servicesManager, + activeStatefulPluginsProvider: statefulPluginManager, bluetoothProvider: bluetoothStateManager, alertPresenter: self, automaticDosingStatus: automaticDosingStatus, cacheStore: cacheStore, localCacheDuration: localCacheDuration, - overrideHistory: overrideHistory, - trustedTimeChecker: trustedTimeChecker + displayGlucosePreference: displayGlucosePreference, + displayGlucoseUnitBroadcaster: self ) - settingsManager.deviceStatusProvider = deviceDataManager - settingsManager.displayGlucosePreference = deviceDataManager.displayGlucosePreference + + dosingDecisionStore.delegate = deviceDataManager + remoteDataServicesManager.delegate = deviceDataManager - overrideHistory.delegate = self + + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceDataManager.deviceLog, alertManager.alertStore] + criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, + directory: FileManager.default.exportsDirectoryURL, + historicalDuration: localCacheDuration) + + statusExtensionManager = ExtensionDataManager( + deviceDataManager: deviceDataManager, + loopDataManager: loopDataManager, + automaticDosingStatus: automaticDosingStatus, + settingsManager: settingsManager, + temporaryPresetsManager: temporaryPresetsManager + ) + + watchManager = WatchDataManager( + deviceManager: deviceDataManager, + settingsManager: settingsManager, + loopDataManager: loopDataManager, + carbStore: carbStore, + glucoseStore: glucoseStore, + analyticsServicesManager: analyticsServicesManager, + temporaryPresetsManager: temporaryPresetsManager, + healthStore: healthStore + ) + + self.mealDetectionManager = MealDetectionManager( + algorithmStateProvider: loopDataManager, + settingsProvider: temporaryPresetsManager, + bolusStateProvider: deviceDataManager + ) + + loopDataManager.deliveryDelegate = deviceDataManager + + deviceDataManager.instantiateDeviceManagers() + + settingsManager.deviceStatusProvider = deviceDataManager + settingsManager.displayGlucosePreference = displayGlucosePreference SharedLogging.instance = loggingServicesManager - scheduleBackgroundTasks() + criticalEventLogExportManager.scheduleCriticalEventLogHistoricalExportBackgroundTask() + supportManager = SupportManager(pluginManager: pluginManager, deviceSupportDelegate: deviceDataManager, - servicesManager: deviceDataManager.servicesManager, + servicesManager: servicesManager, alertIssuer: alertManager) setWhitelistedDevices() onboardingManager = OnboardingManager(pluginManager: pluginManager, bluetoothProvider: bluetoothStateManager, - deviceDataManager: deviceDataManager, - statefulPluginManager: deviceDataManager.statefulPluginManager, - servicesManager: deviceDataManager.servicesManager, - loopDataManager: deviceDataManager.loopManager, + deviceDataManager: deviceDataManager, + settingsManager: settingsManager, + statefulPluginManager: statefulPluginManager, + servicesManager: servicesManager, + loopDataManager: loopDataManager, supportManager: supportManager, windowProvider: windowProvider, userDefaults: UserDefaults.appGroup!) @@ -252,23 +483,45 @@ class LoopAppManager: NSObject { } analyticsServicesManager.identify("Dosing Strategy", value: settingsManager.loopSettings.automaticDosingStrategy.analyticsValue) - let serviceNames = deviceDataManager.servicesManager.activeServices.map { $0.pluginIdentifier } + let serviceNames = servicesManager.activeServices.map { $0.pluginIdentifier } analyticsServicesManager.identify("Services", array: serviceNames) if FeatureFlags.scenariosEnabled { - testingScenariosManager = LocalTestingScenariosManager(deviceManager: deviceDataManager, supportManager: supportManager) + testingScenariosManager = TestingScenariosManager( + deviceManager: deviceDataManager, + supportManager: supportManager, + pluginManager: pluginManager, + carbStore: carbStore, + settingsManager: settingsManager + ) } analyticsServicesManager.application(didFinishLaunchingWithOptions: launchOptions) - automaticDosingStatus.$isAutomaticDosingAllowed - .combineLatest(deviceDataManager.loopManager.$dosingEnabled) + .combineLatest(settingsManager.$dosingEnabled) .map { $0 && $1 } .assign(to: \.automaticDosingStatus.automaticDosingEnabled, on: self) .store(in: &cancellables) state = state.next + + await loopDataManager.updateDisplayState() + + NotificationCenter.default.publisher(for: .LoopCycleCompleted) + .sink { [weak self] _ in + Task { + await self?.loopCycleDidComplete() + } + } + .store(in: &cancellables) + } + + private func loopCycleDidComplete() async { + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + self.widgetLog.default("Refreshing widget. Reason: Loop completed") + WidgetCenter.shared.reloadAllTimelines() + } } private func launchOnboarding() { @@ -278,12 +531,15 @@ class LoopAppManager: NSObject { onboardingManager.launch { DispatchQueue.main.async { self.state = self.state.next - self.resumeLaunch() + self.alertManager.playbackAlertsFromPersistence() + Task { + await self.resumeLaunch() + } } } } - private func launchHomeScreen() { + private func launchHomeScreen() async { dispatchPrecondition(condition: .onQueue(.main)) precondition(state == .launchHomeScreen) @@ -296,6 +552,16 @@ class LoopAppManager: NSObject { statusTableViewController.onboardingManager = onboardingManager statusTableViewController.supportManager = supportManager statusTableViewController.testingScenariosManager = testingScenariosManager + statusTableViewController.settingsManager = settingsManager + statusTableViewController.temporaryPresetsManager = temporaryPresetsManager + statusTableViewController.loopManager = loopDataManager + statusTableViewController.diagnosticReportGenerator = self + statusTableViewController.simulatedData = self + statusTableViewController.analyticsServicesManager = analyticsServicesManager + statusTableViewController.servicesManager = servicesManager + statusTableViewController.carbStore = carbStore + statusTableViewController.doseStore = doseStore + statusTableViewController.criticalEventLogExportManager = criticalEventLogExportManager bluetoothStateManager.addBluetoothObserver(statusTableViewController) var rootNavigationController = rootViewController as? RootNavigationController @@ -306,7 +572,7 @@ class LoopAppManager: NSObject { rootNavigationController?.setViewControllers([statusTableViewController], animated: true) - deviceDataManager.refreshDeviceData() + await deviceDataManager.refreshDeviceData() handleRemoteNotificationFromLaunchOptions() @@ -325,7 +591,7 @@ class LoopAppManager: NSObject { } settingsManager?.didBecomeActive() deviceDataManager?.didBecomeActive() - alertManager.inferDeliveredLoopNotRunningNotifications() + alertManager?.inferDeliveredLoopNotRunningNotifications() widgetLog.default("Refreshing widget. Reason: App didBecomeActive") WidgetCenter.shared.reloadAllTimelines() @@ -333,7 +599,7 @@ class LoopAppManager: NSObject { // MARK: - Remote Notification - func remoteNotificationRegistrationDidFinish(_ result: Result) { + func remoteNotificationRegistrationDidFinish(_ result: Swift.Result) { if case .success(let token) = result { log.default("DeviceToken: %{public}@", token.hexadecimalString) } @@ -349,7 +615,7 @@ class LoopAppManager: NSObject { guard let notification = notification else { return false } - deviceDataManager?.servicesManager.handleRemoteNotification(notification) + servicesManager.handleRemoteNotification(notification) return true } @@ -395,20 +661,6 @@ class LoopAppManager: NSObject { } } - // MARK: - Background Tasks - - private func registerBackgroundTasks() { - if DeviceDataManager.registerCriticalEventLogHistoricalExportBackgroundTask({ self.deviceDataManager?.handleCriticalEventLogHistoricalExportBackgroundTask($0) }) { - log.debug("Critical event log export background task registered") - } else { - log.error("Critical event log export background task not registered") - } - } - - private func scheduleBackgroundTasks() { - deviceDataManager?.scheduleCriticalEventLogHistoricalExportBackgroundTask() - } - // MARK: - Private private func setWhitelistedDevices() { @@ -509,6 +761,33 @@ extension LoopAppManager: AlertPresenter { } } +protocol DisplayGlucoseUnitBroadcaster: AnyObject { + func addDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) + func removeDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) + func notifyObserversOfDisplayGlucoseUnitChange(to displayGlucoseUnit: HKUnit) +} + +extension LoopAppManager: DisplayGlucoseUnitBroadcaster { + func addDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) { + let queue = DispatchQueue.main + displayGlucoseUnitObservers.insert(observer, queue: queue) + queue.async { + observer.unitDidChange(to: self.displayGlucosePreference.unit) + } + } + + func removeDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) { + displayGlucoseUnitObservers.removeElement(observer) + displayGlucoseUnitObservers.cleanupDeallocatedElements() + } + + func notifyObserversOfDisplayGlucoseUnitChange(to displayGlucoseUnit: HKUnit) { + self.displayGlucoseUnitObservers.forEach { + $0.unitDidChange(to: displayGlucoseUnit) + } + } +} + // MARK: - DeviceOrientationController extension LoopAppManager: DeviceOrientationController { @@ -548,14 +827,12 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { let activationType = BolusActivationType(rawValue: activationTypeRawValue), startDate.timeIntervalSinceNow >= TimeInterval(minutes: -5) { - deviceDataManager?.analyticsServicesManager.didRetryBolus() + analyticsServicesManager.didRetryBolus() - deviceDataManager?.enactBolus(units: units, activationType: activationType) { (_) in - DispatchQueue.main.async { - completionHandler() - } + Task { @MainActor in + try? await deviceDataManager?.enactBolus(units: units, activationType: activationType) + completionHandler() } - return } case NotificationManager.Action.acknowledgeAlert.rawValue: let userInfo = response.notification.request.content.userInfo @@ -600,8 +877,7 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { extension LoopAppManager: TemporaryScheduleOverrideHistoryDelegate { func temporaryScheduleOverrideHistoryDidUpdate(_ history: TemporaryScheduleOverrideHistory) { UserDefaults.appGroup?.overrideHistory = history - - deviceDataManager.remoteDataServicesManager.triggerUpload(for: .overrides) + remoteDataServicesManager.triggerUpload(for: .overrides) } } @@ -632,8 +908,16 @@ extension LoopAppManager: ResetLoopManagerDelegate { } func resetTestingData(completion: @escaping () -> Void) { - deviceDataManager.deleteTestingCGMData { [weak deviceDataManager] _ in - deviceDataManager?.deleteTestingPumpData { _ in + Task { [weak self] in + await withTaskGroup(of: Void.self) { group in + group.addTask { + try? await self?.deviceDataManager.deleteTestingCGMData() + } + group.addTask { + try? await self?.deviceDataManager?.deleteTestingPumpData() + } + + await group.waitForAll() completion() } } @@ -643,3 +927,169 @@ extension LoopAppManager: ResetLoopManagerDelegate { alertManager.presentCouldNotResetLoopAlert(error: error) } } + +// MARK: - ServicesManagerDosingDelegate + +extension LoopAppManager: ServicesManagerDosingDelegate { + func deliverBolus(amountInUnits: Double) async throws { + try await deviceDataManager.enactBolus(units: amountInUnits, activationType: .manualNoRecommendation) + } +} + +protocol DiagnosticReportGenerator: AnyObject { + func generateDiagnosticReport() async -> String +} + + +extension LoopAppManager: DiagnosticReportGenerator { + /// 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() async -> String { + + let entries: [String] = [ + "## Build Details", + "* appNameAndVersion: \(Bundle.main.localizedNameAndVersion)", + "* profileExpiration: \(BuildDetails.default.profileExpirationString)", + "* gitRevision: \(BuildDetails.default.gitRevision ?? "N/A")", + "* gitBranch: \(BuildDetails.default.gitBranch ?? "N/A")", + "* workspaceGitRevision: \(BuildDetails.default.workspaceGitRevision ?? "N/A")", + "* workspaceGitBranch: \(BuildDetails.default.workspaceGitBranch ?? "N/A")", + "* sourceRoot: \(BuildDetails.default.sourceRoot ?? "N/A")", + "* buildDateString: \(BuildDetails.default.buildDateString ?? "N/A")", + "* xcodeVersion: \(BuildDetails.default.xcodeVersion ?? "N/A")", + "", + "## FeatureFlags", + "\(FeatureFlags)", + "", + await alertManager.generateDiagnosticReport(), + await deviceDataManager.generateDiagnosticReport(), + "", + String(reflecting: self.watchManager), + "", + String(reflecting: self.statusExtensionManager), + "", + await loopDataManager.generateDiagnosticReport(), + "", + await self.glucoseStore.generateDiagnosticReport(), + "", + await self.carbStore.generateDiagnosticReport(), + "", + await self.doseStore.generateDiagnosticReport(), + "", + await self.mealDetectionManager.generateDiagnosticReport(), + "", + await UNUserNotificationCenter.current().generateDiagnosticReport(), + "", + UIDevice.current.generateDiagnosticReport(), + "" + ] + return entries.joined(separator: "\n") + } +} + + +// MARK: SimulatedData + +protocol SimulatedData { + func generateSimulatedHistoricalCoreData(completion: @escaping (Error?) -> Void) + func purgeHistoricalCoreData(completion: @escaping (Error?) -> Void) +} + +extension LoopAppManager: SimulatedData { + func generateSimulatedHistoricalCoreData(completion: @escaping (Error?) -> Void) { + guard FeatureFlags.simulatedCoreDataEnabled else { + fatalError("\(#function) should be invoked only when simulated core data is enabled") + } + + settingsManager.settingsStore.generateSimulatedHistoricalSettingsObjects() { error in + guard error == nil else { + completion(error) + return + } + Task { @MainActor in + guard FeatureFlags.simulatedCoreDataEnabled else { + fatalError("\(#function) should be invoked only when simulated core data is enabled") + } + + self.glucoseStore.generateSimulatedHistoricalGlucoseObjects() { error in + guard error == nil else { + completion(error) + return + } + self.carbStore.generateSimulatedHistoricalCarbObjects() { error in + guard error == nil else { + completion(error) + return + } + self.dosingDecisionStore.generateSimulatedHistoricalDosingDecisionObjects() { error in + Task { + guard error == nil else { + completion(error) + return + } + do { + try await self.doseStore.generateSimulatedHistoricalPumpEvents() + } catch { + completion(error) + return + } + self.deviceDataManager.deviceLog.generateSimulatedHistoricalDeviceLogEntries() { error in + guard error == nil else { + completion(error) + return + } + self.alertManager.alertStore.generateSimulatedHistoricalStoredAlerts(completion: completion) + } + } + } + } + } + } + } + } + + func purgeHistoricalCoreData(completion: @escaping (Error?) -> Void) { + guard FeatureFlags.simulatedCoreDataEnabled else { + fatalError("\(#function) should be invoked only when simulated core data is enabled") + } + + alertManager.alertStore.purgeHistoricalStoredAlerts() { error in + guard error == nil else { + completion(error) + return + } + self.deviceDataManager.deviceLog.purgeHistoricalDeviceLogEntries() { error in + guard error == nil else { + completion(error) + return + } + Task { @MainActor in + do { + try await self.doseStore.purgeHistoricalPumpEvents() + try await self.glucoseStore.purgeHistoricalGlucoseObjects() + } catch { + completion(error) + return + } + self.dosingDecisionStore.purgeHistoricalDosingDecisionObjects() { error in + guard error == nil else { + completion(error) + return + } + self.carbStore.purgeHistoricalCarbObjects() { error in + guard error == nil else { + completion(error) + return + } + self.settingsManager.purgeHistoricalSettingsObjects(completion: completion) + } + } + } + } + } + } +} + diff --git a/Loop/Managers/LoopDataManager+CarbAbsorption.swift b/Loop/Managers/LoopDataManager+CarbAbsorption.swift new file mode 100644 index 0000000000..8f532a4e02 --- /dev/null +++ b/Loop/Managers/LoopDataManager+CarbAbsorption.swift @@ -0,0 +1,106 @@ +// +// LoopDataManager+CarbAbsorption.swift +// Loop +// +// Created by Pete Schwamb on 11/6/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import HealthKit +import LoopAlgorithm + +struct CarbAbsorptionReview { + var carbEntries: [StoredCarbEntry] + var carbStatuses: [CarbStatus] + var effectsVelocities: [GlucoseEffectVelocity] + var carbEffects: [GlucoseEffect] +} + +extension LoopDataManager { + + func dynamicCarbsOnBoard(from start: Date? = nil, to end: Date? = nil) async -> [CarbValue] { + if let effects = displayState.output?.effects { + return effects.carbStatus.dynamicCarbsOnBoard(from: start, to: end, absorptionModel: carbAbsorptionModel.model) + } else { + return [] + } + } + + func fetchCarbAbsorptionReview(start: Date, end: Date) async throws -> CarbAbsorptionReview { + // Need to get insulin data from any active doses that might affect this time range + var dosesStart = start.addingTimeInterval(-InsulinMath.defaultInsulinActivityDuration) + let doses = try await doseStore.getNormalizedDoseEntries( + start: dosesStart, + end: end + ).map { $0.simpleDose(with: insulinModel(for: $0.insulinType)) } + + dosesStart = doses.map { $0.startDate }.min() ?? dosesStart + + let basal = try await settingsProvider.getBasalHistory(startDate: dosesStart, endDate: end) + + let carbEntries = try await carbStore.getCarbEntries(start: start, end: end) + + let carbRatio = try await settingsProvider.getCarbRatioHistory(startDate: start, endDate: end) + + let glucose = try await glucoseStore.getGlucoseSamples(start: start, end: end) + + let sensitivityStart = min(start, dosesStart) + + let sensitivity = try await settingsProvider.getInsulinSensitivityHistory(startDate: sensitivityStart, endDate: end) + + let overrides = temporaryPresetsManager.overrideHistory.getOverrideHistory(startDate: sensitivityStart, endDate: end) + + guard !sensitivity.isEmpty else { + throw LoopError.configurationError(.insulinSensitivitySchedule) + } + + let sensitivityWithOverrides = overrides.applySensitivity(over: sensitivity) + + guard !basal.isEmpty else { + throw LoopError.configurationError(.basalRateSchedule) + } + let basalWithOverrides = overrides.applyBasal(over: basal) + + guard !carbRatio.isEmpty else { + throw LoopError.configurationError(.carbRatioSchedule) + } + let carbRatioWithOverrides = overrides.applyCarbRatio(over: carbRatio) + + let carbModel: CarbAbsorptionModel = FeatureFlags.nonlinearCarbModelEnabled ? .piecewiseLinear : .linear + + // Overlay basal history on basal doses, splitting doses to get amount delivered relative to basal + let annotatedDoses = doses.annotated(with: basalWithOverrides) + + let insulinEffects = annotatedDoses.glucoseEffects( + insulinSensitivityHistory: sensitivityWithOverrides, + from: start.addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval).dateFlooredToTimeInterval(GlucoseMath.defaultDelta), + to: nil) + + // ICE + let insulinCounteractionEffects = glucose.counteractionEffects(to: insulinEffects) + + // Carb Effects + let carbStatus = carbEntries.map( + to: insulinCounteractionEffects, + carbRatio: carbRatioWithOverrides, + insulinSensitivity: sensitivityWithOverrides + ) + + let carbEffects = carbStatus.dynamicGlucoseEffects( + from: end, + to: end.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration), + carbRatios: carbRatioWithOverrides, + insulinSensitivities: sensitivityWithOverrides, + absorptionModel: carbModel.model + ) + + return CarbAbsorptionReview( + carbEntries: carbEntries, + carbStatuses: carbStatus, + effectsVelocities: insulinCounteractionEffects, + carbEffects: carbEffects + ) + } +} diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 2319f4eceb..a314e43f44 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -10,150 +10,182 @@ import Foundation import Combine import HealthKit import LoopKit +import LoopKitUI import LoopCore import WidgetKit +import LoopAlgorithm -protocol PresetActivationObserver: AnyObject { - func presetActivated(context: TemporaryScheduleOverride.Context, duration: TemporaryScheduleOverride.Duration) - func presetDeactivated(context: TemporaryScheduleOverride.Context) + +struct AlgorithmDisplayState { + var input: StoredDataAlgorithmInput? + var output: AlgorithmOutput? + + var activeInsulin: InsulinValue? { + guard let input, let value = output?.activeInsulin else { + return nil + } + return InsulinValue(startDate: input.predictionStart, value: value) + } + + var activeCarbs: CarbValue? { + guard let input, let value = output?.activeCarbs else { + return nil + } + return CarbValue(startDate: input.predictionStart, value: value) + } + + var asTuple: (algoInput: StoredDataAlgorithmInput?, algoOutput: AlgorithmOutput?) { + return (algoInput: input, algoOutput: output) + } +} + +protocol DeliveryDelegate: AnyObject { + var isSuspended: Bool { get } + var pumpInsulinType: InsulinType? { get } + var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? { get } + var isPumpConfigured: Bool { get } + + func enact(_ recommendation: AutomaticDoseRecommendation) async throws + func enactBolus(units: Double, activationType: BolusActivationType) async throws + func roundBasalRate(unitsPerHour: Double) -> Double + func roundBolusVolume(units: Double) -> Double } -final class LoopDataManager { - enum LoopUpdateContext: Int { - case insulin - case carbs - case glucose - case preferences - case loopFinished +extension PumpManagerStatus.BasalDeliveryState { + var currentTempBasal: DoseEntry? { + switch self { + case .tempBasal(let dose): + return dose + default: + return nil + } } +} - let loopLock = UnfairLock() +protocol DosingManagerDelegate { + func didMakeDosingDecision(_ decision: StoredDosingDecision) +} - static let LoopUpdateContextKey = "com.loudnate.Loop.LoopDataManager.LoopUpdateContext" +enum LoopUpdateContext: Int { + case insulin + case carbs + case glucose + case preferences + case forecast +} - private let carbStore: CarbStoreProtocol - - private let mealDetectionManager: MealDetectionManager +@MainActor +final class LoopDataManager: ObservableObject { + nonisolated static let LoopUpdateContextKey = "com.loudnate.Loop.LoopDataManager.LoopUpdateContext" - private let doseStore: DoseStoreProtocol + // Represents the current state of the loop algorithm for display + var displayState = AlgorithmDisplayState() - let dosingDecisionStore: DosingDecisionStoreProtocol + // Display state convenience accessors + var predictedGlucose: [PredictedGlucoseValue]? { + displayState.output?.predictedGlucose + } - private let glucoseStore: GlucoseStoreProtocol + var tempBasalRecommendation: TempBasalRecommendation? { + displayState.output?.recommendation?.automatic?.basalAdjustment + } - let latestStoredSettingsProvider: LatestStoredSettingsProvider + var automaticBolusRecommendation: Double? { + displayState.output?.recommendation?.automatic?.bolusUnits + } - weak var delegate: LoopDataManagerDelegate? + var automaticRecommendation: AutomaticDoseRecommendation? { + displayState.output?.recommendation?.automatic + } - private let logger = DiagnosticLog(category: "LoopDataManager") - private let widgetLog = DiagnosticLog(category: "LoopWidgets") + @Published private(set) var lastLoopCompleted: Date? + @Published private(set) var publishedMostRecentGlucoseDataDate: Date? + @Published private(set) var publishedMostRecentPumpDataDate: Date? + + var deliveryDelegate: DeliveryDelegate? + + let analyticsServicesManager: AnalyticsServicesManager? + let carbStore: CarbStoreProtocol + let doseStore: DoseStoreProtocol + let temporaryPresetsManager: TemporaryPresetsManager + let settingsProvider: SettingsProvider + let dosingDecisionStore: DosingDecisionStoreProtocol + let glucoseStore: GlucoseStoreProtocol + + let logger = DiagnosticLog(category: "LoopDataManager") - private let analyticsServicesManager: AnalyticsServicesManager + private let widgetLog = DiagnosticLog(category: "LoopWidgets") - private let trustedTimeOffset: () -> TimeInterval + private let trustedTimeOffset: () async -> TimeInterval private let now: () -> Date private let automaticDosingStatus: AutomaticDosingStatus - lazy private var cancellables = Set() - // References to registered notification center observers private var notificationObservers: [Any] = [] - - private var overrideIntentObserver: NSKeyValueObservation? = nil - var presetActivationObservers: [PresetActivationObserver] = [] + var activeInsulin: InsulinValue? { + displayState.activeInsulin + } + var activeCarbs: CarbValue? { + displayState.activeCarbs + } + + var latestGlucose: GlucoseSampleValue? { + displayState.input?.glucoseHistory.last + } + + var lastReservoirValue: ReservoirValue? { + doseStore.lastReservoirValue + } + + var carbAbsorptionModel: CarbAbsorptionModel - private var timeBasedDoseApplicationFactor: Double = 1.0 + private var lastManualBolusRecommendation: ManualBolusRecommendation? - private var insulinOnBoard: InsulinValue? + var usePositiveMomentumAndRCForManualBoluses: Bool - deinit { - for observer in notificationObservers { - NotificationCenter.default.removeObserver(observer) + var automationHistory: [AutomationHistoryEntry] { + didSet { + UserDefaults.standard.automationHistory = automationHistory } } + lazy private var cancellables = Set() + init( lastLoopCompleted: Date?, - basalDeliveryState: PumpManagerStatus.BasalDeliveryState?, - settings: LoopSettings, - overrideHistory: TemporaryScheduleOverrideHistory, - analyticsServicesManager: AnalyticsServicesManager, - localCacheDuration: TimeInterval = .days(1), + temporaryPresetsManager: TemporaryPresetsManager, + settingsProvider: SettingsProvider, doseStore: DoseStoreProtocol, glucoseStore: GlucoseStoreProtocol, carbStore: CarbStoreProtocol, dosingDecisionStore: DosingDecisionStoreProtocol, - latestStoredSettingsProvider: LatestStoredSettingsProvider, now: @escaping () -> Date = { Date() }, - pumpInsulinType: InsulinType?, automaticDosingStatus: AutomaticDosingStatus, - trustedTimeOffset: @escaping () -> TimeInterval + trustedTimeOffset: @escaping () async -> TimeInterval, + analyticsServicesManager: AnalyticsServicesManager?, + carbAbsorptionModel: CarbAbsorptionModel, + usePositiveMomentumAndRCForManualBoluses: Bool = true ) { - self.analyticsServicesManager = analyticsServicesManager - self.lockedLastLoopCompleted = Locked(lastLoopCompleted) - self.lockedBasalDeliveryState = Locked(basalDeliveryState) - self.lockedSettings = Locked(settings) - self.dosingEnabled = settings.dosingEnabled - - self.overrideHistory = overrideHistory - let absorptionTimes = LoopCoreConstants.defaultCarbAbsorptionTimes - - self.overrideHistory.relevantTimeWindow = absorptionTimes.slow * 2 - - self.carbStore = carbStore + self.lastLoopCompleted = lastLoopCompleted + self.temporaryPresetsManager = temporaryPresetsManager + self.settingsProvider = settingsProvider self.doseStore = doseStore self.glucoseStore = glucoseStore - + self.carbStore = carbStore self.dosingDecisionStore = dosingDecisionStore - self.now = now - - self.latestStoredSettingsProvider = latestStoredSettingsProvider - self.mealDetectionManager = MealDetectionManager( - carbRatioScheduleApplyingOverrideHistory: carbStore.carbRatioScheduleApplyingOverrideHistory, - insulinSensitivityScheduleApplyingOverrideHistory: carbStore.insulinSensitivityScheduleApplyingOverrideHistory, - maximumBolus: settings.maximumBolus - ) - - self.lockedPumpInsulinType = Locked(pumpInsulinType) - self.automaticDosingStatus = automaticDosingStatus - self.trustedTimeOffset = trustedTimeOffset - - overrideIntentObserver = UserDefaults.appGroup?.observe(\.intentExtensionOverrideToSet, options: [.new], changeHandler: {[weak self] (defaults, change) in - guard let name = change.newValue??.lowercased(), let appGroup = UserDefaults.appGroup else { - return - } - - guard let preset = self?.settings.overridePresets.first(where: {$0.name.lowercased() == name}) else { - self?.logger.error("Override Intent: Unable to find override named '%s'", String(describing: name)) - return - } - - self?.logger.default("Override Intent: setting override named '%s'", String(describing: name)) - self?.mutateSettings { settings in - if let oldPreset = settings.scheduleOverride { - if let observers = self?.presetActivationObservers { - for observer in observers { - observer.presetDeactivated(context: oldPreset.context) - } - } - } - settings.scheduleOverride = preset.createOverride(enactTrigger: .remote("Siri")) - if let observers = self?.presetActivationObservers { - for observer in observers { - observer.presetActivated(context: .preset(preset), duration: preset.duration) - } - } - } - // Remove the override from UserDefaults so we don't set it multiple times - appGroup.intentExtensionOverrideToSet = nil - }) + self.analyticsServicesManager = analyticsServicesManager + self.carbAbsorptionModel = carbAbsorptionModel + self.usePositiveMomentumAndRCForManualBoluses = usePositiveMomentumAndRCForManualBoluses + self.automationHistory = UserDefaults.standard.automationHistory + self.publishedMostRecentGlucoseDataDate = glucoseStore.latestGlucose?.startDate + self.publishedMostRecentPumpDataDate = mostRecentPumpDataDate // Required for device settings in stored dosing decisions UIDevice.current.isBatteryMonitoringEnabled = true @@ -165,13 +197,9 @@ final class LoopDataManager { object: self.carbStore, queue: nil ) { (note) -> Void in - self.dataAccessQueue.async { + Task { @MainActor in self.logger.default("Received notification of carb entries changing") - - self.carbEffect = nil - self.carbsOnBoard = nil - self.recentCarbEntries = nil - self.remoteRecommendationNeedsUpdating = true + await self.updateDisplayState() self.notify(forChange: .carbs) } }, @@ -180,12 +208,10 @@ final class LoopDataManager { object: self.glucoseStore, queue: nil ) { (note) in - self.dataAccessQueue.async { + Task { @MainActor in self.logger.default("Received notification of glucose samples changing") - - self.glucoseMomentumEffect = nil - self.remoteRecommendationNeedsUpdating = true - + self.restartGlucoseValueStalenessTimer() + await self.updateDisplayState() self.notify(forChange: .glucose) } }, @@ -194,551 +220,591 @@ final class LoopDataManager { object: self.doseStore, queue: OperationQueue.main ) { (note) in - self.dataAccessQueue.async { + Task { @MainActor in self.logger.default("Received notification of dosing changing") - - self.clearCachedInsulinEffects() - self.remoteRecommendationNeedsUpdating = true - + await self.updateDisplayState() self.notify(forChange: .insulin) } + }, + NotificationCenter.default.addObserver( + forName: .LoopDataUpdated, + object: nil, + queue: nil + ) { (note) in + let context = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopUpdateContext.RawValue + if case .preferences = LoopUpdateContext(rawValue: context) { + Task { @MainActor in + self.logger.default("Received notification of settings changing") + await self.updateDisplayState() + } + } } ] // Turn off preMeal when going into closed loop off mode // Cancel any active temp basal when going into closed loop off mode // The dispatch is necessary in case this is coming from a didSet already on the settings struct. - self.automaticDosingStatus.$automaticDosingEnabled + automaticDosingStatus.$automaticDosingEnabled .removeDuplicates() .dropFirst() - .receive(on: DispatchQueue.main) - .sink { if !$0 { - self.mutateSettings { settings in - settings.clearOverride(matching: .preMeal) + .sink { [weak self] enabled in + guard let self else { + return + } + if self.automationHistory.last?.enabled != enabled { + self.automationHistory.append(AutomationHistoryEntry(startDate: Date(), enabled: enabled)) + + // Clean up entries older than 36 hours; we should not be interpolating basal data before then. + let now = Date() + self.automationHistory = self.automationHistory.filter({ entry in + now.timeIntervalSince(entry.startDate) < .hours(36) + }) + } + if !enabled { + self.temporaryPresetsManager.clearOverride(matching: .preMeal) + Task { + try? await self.cancelActiveTempBasal(for: .automaticDosingDisabled) + } + } else { + Task { + await self.updateDisplayState() + } } - self.cancelActiveTempBasal(for: .automaticDosingDisabled) - } } + } .store(in: &cancellables) } - /// Loop-related settings + // MARK: - Calculation state + + fileprivate let dataAccessQueue: DispatchQueue = DispatchQueue(label: "com.loudnate.Naterade.LoopDataManager.dataAccessQueue", qos: .utility) + + + // MARK: - Background task management - private var lockedSettings: Locked + private var backgroundTask: UIBackgroundTaskIdentifier = .invalid - var settings: LoopSettings { - lockedSettings.value + private func startBackgroundTask() { + endBackgroundTask() + backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "PersistenceController save") { + self.endBackgroundTask() + } } - func mutateSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { - var oldValue: LoopSettings! - let newValue = lockedSettings.mutate { settings in - oldValue = settings - changes(&settings) + private func endBackgroundTask() { + if backgroundTask != .invalid { + UIApplication.shared.endBackgroundTask(backgroundTask) + backgroundTask = .invalid } + } - guard oldValue != newValue else { - return + func insulinModel(for type: InsulinType?) -> InsulinModel { + switch type { + case .fiasp: + return ExponentialInsulinModelPreset.fiasp + case .lyumjev: + return ExponentialInsulinModelPreset.lyumjev + case .afrezza: + return ExponentialInsulinModelPreset.afrezza + default: + return settings.defaultRapidActingModel?.presetForRapidActingInsulin?.model ?? ExponentialInsulinModelPreset.rapidActingAdult } + } - var invalidateCachedEffects = false + func fetchData( + for baseTime: Date = Date(), + disablingPreMeal: Bool = false, + ensureDosingCoverageStart: Date? = nil + ) async throws -> StoredDataAlgorithmInput { + // Need to fetch doses back as far as t - (DIA + DCA) for Dynamic carbs + let dosesInputHistory = CarbMath.maximumAbsorptionTimeInterval + InsulinMath.defaultInsulinActivityDuration - dosingEnabled = newValue.dosingEnabled + var dosesStart = baseTime.addingTimeInterval(-dosesInputHistory) - if newValue.preMealOverride != oldValue.preMealOverride { - // The prediction isn't actually invalid, but a target range change requires recomputing recommended doses - predictedGlucose = nil + // Ensure dosing data goes back before ensureDosingCoverageStart, if specified + if let ensureDosingCoverageStart { + dosesStart = min(ensureDosingCoverageStart, dosesStart) } - if newValue.scheduleOverride != oldValue.scheduleOverride { - overrideHistory.recordOverride(settings.scheduleOverride) + let doses = try await doseStore.getNormalizedDoseEntries( + start: dosesStart, + end: baseTime + ) - if let oldPreset = oldValue.scheduleOverride { - for observer in self.presetActivationObservers { - observer.presetDeactivated(context: oldPreset.context) - } + // Doses that were included because they cover dosesStart might have a start time earlier than dosesStart + // This moves the start time back to ensure basal covers + dosesStart = min(dosesStart, doses.map { $0.startDate }.min() ?? dosesStart) - } - if let newPreset = newValue.scheduleOverride { - for observer in self.presetActivationObservers { - observer.presetActivated(context: newPreset.context, duration: newPreset.duration) - } - } + // Doses with a start time before baseTime might still end after baseTime + let dosesEnd = max(baseTime, doses.map { $0.endDate }.max() ?? baseTime) - // Invalidate cached effects affected by the override - invalidateCachedEffects = true - - // Update the affected schedules - mealDetectionManager.carbRatioScheduleApplyingOverrideHistory = carbRatioScheduleApplyingOverrideHistory - mealDetectionManager.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivityScheduleApplyingOverrideHistory - } + let basal = try await settingsProvider.getBasalHistory(startDate: dosesStart, endDate: dosesEnd) - if newValue.insulinSensitivitySchedule != oldValue.insulinSensitivitySchedule { - carbStore.insulinSensitivitySchedule = newValue.insulinSensitivitySchedule - doseStore.insulinSensitivitySchedule = newValue.insulinSensitivitySchedule - mealDetectionManager.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivityScheduleApplyingOverrideHistory - invalidateCachedEffects = true - analyticsServicesManager.didChangeInsulinSensitivitySchedule() + guard !basal.isEmpty else { + throw LoopError.configurationError(.basalRateSchedule) } - if newValue.basalRateSchedule != oldValue.basalRateSchedule { - doseStore.basalProfile = newValue.basalRateSchedule + let forecastEndTime = baseTime.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration).dateCeiledToTimeInterval(GlucoseMath.defaultDelta) - if let newValue = newValue.basalRateSchedule, let oldValue = oldValue.basalRateSchedule, newValue.items != oldValue.items { - analyticsServicesManager.didChangeBasalRateSchedule() - } - } + let carbsStart = baseTime.addingTimeInterval(CarbMath.dateAdjustmentPast + .minutes(-1)) // additional minute to handle difference in seconds between carb entry and carb ratio - if newValue.carbRatioSchedule != oldValue.carbRatioSchedule { - carbStore.carbRatioSchedule = newValue.carbRatioSchedule - mealDetectionManager.carbRatioScheduleApplyingOverrideHistory = carbRatioScheduleApplyingOverrideHistory - invalidateCachedEffects = true - analyticsServicesManager.didChangeCarbRatioSchedule() + // Include future carbs in query, but filter out ones entered after basetime. The filtering is only applicable when running in a retrospective situation. + let carbEntries = try await carbStore.getCarbEntries( + start: carbsStart, + end: forecastEndTime + ).filter { + $0.userCreatedDate ?? $0.startDate < baseTime } - if newValue.defaultRapidActingModel != oldValue.defaultRapidActingModel { - if FeatureFlags.adultChildInsulinModelSelectionEnabled { - doseStore.insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: newValue.defaultRapidActingModel) - } else { - doseStore.insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: nil) - } - invalidateCachedEffects = true - analyticsServicesManager.didChangeInsulinModel() - } + let carbRatio = try await settingsProvider.getCarbRatioHistory( + startDate: carbsStart, + endDate: forecastEndTime + ) - if newValue.maximumBolus != oldValue.maximumBolus { - mealDetectionManager.maximumBolus = newValue.maximumBolus + guard !carbRatio.isEmpty else { + throw LoopError.configurationError(.carbRatioSchedule) } - if invalidateCachedEffects { - dataAccessQueue.async { - // Invalidate cached effects based on this schedule - self.carbEffect = nil - self.carbsOnBoard = nil - self.clearCachedInsulinEffects() - } - } + let glucose = try await glucoseStore.getGlucoseSamples(start: carbsStart, end: baseTime) - notify(forChange: .preferences) - analyticsServicesManager.didChangeLoopSettings(from: oldValue, to: newValue) - } + let dosesWithModel = doses.map { $0.simpleDose(with: insulinModel(for: $0.insulinType)) } - @Published private(set) var dosingEnabled: Bool + let recommendationInsulinModel = insulinModel(for: deliveryDelegate?.pumpInsulinType ?? .novolog) - let overrideHistory: TemporaryScheduleOverrideHistory + let recommendationEffectInterval = DateInterval( + start: baseTime, + duration: recommendationInsulinModel.effectDuration + ) + let neededSensitivityTimeline = LoopAlgorithm.timelineIntervalForSensitivity( + doses: dosesWithModel, + glucoseHistoryStart: glucose.first?.startDate ?? baseTime, + recommendationEffectInterval: recommendationEffectInterval + ) - // MARK: - Calculation state + let sensitivity = try await settingsProvider.getInsulinSensitivityHistory( + startDate: neededSensitivityTimeline.start, + endDate: neededSensitivityTimeline.end + ) - fileprivate let dataAccessQueue: DispatchQueue = DispatchQueue(label: "com.loudnate.Naterade.LoopDataManager.dataAccessQueue", qos: .utility) + let target = try await settingsProvider.getTargetRangeHistory(startDate: baseTime, endDate: forecastEndTime) - private var carbEffect: [GlucoseEffect]? { - didSet { - predictedGlucose = nil + let dosingLimits = try await settingsProvider.getDosingLimits(at: baseTime) - // Carb data may be back-dated, so re-calculate the retrospective glucose. - retrospectiveGlucoseDiscrepancies = nil + guard let maxBolus = dosingLimits.maxBolus else { + throw LoopError.configurationError(.maximumBolus) } - } - private var insulinEffect: [GlucoseEffect]? - - private var insulinEffectIncludingPendingInsulin: [GlucoseEffect]? { - didSet { - predictedGlucoseIncludingPendingInsulin = nil + guard let maxBasalRate = dosingLimits.maxBasalRate else { + throw LoopError.configurationError(.maximumBasalRatePerHour) } - } - private var glucoseMomentumEffect: [GlucoseEffect]? { - didSet { - predictedGlucose = nil + var overrides = temporaryPresetsManager.overrideHistory.getOverrideHistory(startDate: neededSensitivityTimeline.start, endDate: forecastEndTime) + + // Bug (https://tidepool.atlassian.net/browse/LOOP-4759) pre-meal is not recorded in override history + // So currently we handle automatic forecast by manually adding it in, and when meal bolusing, we do not do this. + // Eventually, when pre-meal is stored in override history, during meal bolusing we should scan for it and adjust the end time + if !disablingPreMeal, let preMeal = temporaryPresetsManager.preMealOverride { + overrides.append(preMeal) + overrides.sort { $0.startDate < $1.startDate } } - } - private var retrospectiveGlucoseEffect: [GlucoseEffect] = [] { - didSet { - predictedGlucose = nil + guard !sensitivity.isEmpty else { + throw LoopError.configurationError(.insulinSensitivitySchedule) } - } - /// When combining retrospective glucose discrepancies, extend the window slightly as a buffer. - private let retrospectiveCorrectionGroupingIntervalMultiplier = 1.01 + let sensitivityWithOverrides = overrides.applySensitivity(over: sensitivity) - private var retrospectiveGlucoseDiscrepancies: [GlucoseEffect]? { - didSet { - retrospectiveGlucoseDiscrepanciesSummed = retrospectiveGlucoseDiscrepancies?.combinedSums(of: LoopMath.retrospectiveCorrectionGroupingInterval * retrospectiveCorrectionGroupingIntervalMultiplier) + guard !basal.isEmpty else { + throw LoopError.configurationError(.basalRateSchedule) } - } - - private var retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange]? - - private var suspendInsulinDeliveryEffect: [GlucoseEffect] = [] + let basalWithOverrides = overrides.applyBasal(over: basal) - fileprivate var predictedGlucose: [PredictedGlucoseValue]? { - didSet { - recommendedAutomaticDose = nil - predictedGlucoseIncludingPendingInsulin = nil + guard !carbRatio.isEmpty else { + throw LoopError.configurationError(.carbRatioSchedule) } - } + let carbRatioWithOverrides = overrides.applyCarbRatio(over: carbRatio) - fileprivate var predictedGlucoseIncludingPendingInsulin: [PredictedGlucoseValue]? + guard !target.isEmpty else { + throw LoopError.configurationError(.glucoseTargetRangeSchedule) + } + let targetWithOverrides = overrides.applyTarget(over: target, at: baseTime) - private var recentCarbEntries: [StoredCarbEntry]? + // Create dosing strategy based on user setting + let applicationFactorStrategy: ApplicationFactorStrategy = UserDefaults.standard.glucoseBasedApplicationFactorEnabled + ? GlucoseBasedApplicationFactorStrategy() + : ConstantApplicationFactorStrategy() - fileprivate var recommendedAutomaticDose: (recommendation: AutomaticDoseRecommendation, date: Date)? + let correctionRange = target.closestPrior(to: baseTime)?.value - fileprivate var carbsOnBoard: CarbValue? + let effectiveBolusApplicationFactor: Double? - var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? { - get { - return lockedBasalDeliveryState.value - } - set { - self.logger.debug("Updating basalDeliveryState to %{public}@", String(describing: newValue)) - lockedBasalDeliveryState.value = newValue - } + if let latestGlucose = glucose.last { + effectiveBolusApplicationFactor = applicationFactorStrategy.calculateDosingFactor( + for: latestGlucose.quantity, + correctionRange: correctionRange! + ) + } else { + effectiveBolusApplicationFactor = nil + } + + return StoredDataAlgorithmInput( + glucoseHistory: glucose, + doses: dosesWithModel, + carbEntries: carbEntries, + predictionStart: baseTime, + basal: basalWithOverrides, + sensitivity: sensitivityWithOverrides, + carbRatio: carbRatioWithOverrides, + target: targetWithOverrides, + suspendThreshold: dosingLimits.suspendThreshold, + maxBolus: maxBolus, + maxBasalRate: maxBasalRate, + useIntegralRetrospectiveCorrection: UserDefaults.standard.integralRetrospectiveCorrectionEnabled, + includePositiveVelocityAndRC: true, + carbAbsorptionModel: carbAbsorptionModel, + recommendationInsulinModel: recommendationInsulinModel, + recommendationType: .manualBolus, + automaticBolusApplicationFactor: effectiveBolusApplicationFactor) } - private let lockedBasalDeliveryState: Locked - - var pumpInsulinType: InsulinType? { - get { - return lockedPumpInsulinType.value - } - set { - lockedPumpInsulinType.value = newValue - } + + func loopingReEnabled() async { + await updateDisplayState() + self.notify(forChange: .forecast) } - private let lockedPumpInsulinType: Locked - fileprivate var lastRequestedBolus: DoseEntry? + func updateDisplayState() async { + var newState = AlgorithmDisplayState() + do { + let midnight = Calendar.current.startOfDay(for: Date()) - /// The last date at which a loop completed, from prediction to dose (if dosing is enabled) - var lastLoopCompleted: Date? { - get { - return lockedLastLoopCompleted.value - } - set { - lockedLastLoopCompleted.value = newValue + var input = try await fetchData(for: now(), ensureDosingCoverageStart: midnight) + input.recommendationType = .manualBolus + newState.input = input + newState.output = LoopAlgorithm.run(input: input) + } catch { + let loopError = error as? LoopError ?? .unknownError(error) + logger.error("Error updating Loop state: %{public}@", String(describing: loopError)) } + displayState = newState + publishedMostRecentGlucoseDataDate = glucoseStore.latestGlucose?.startDate + publishedMostRecentPumpDataDate = mostRecentPumpDataDate + await updateRemoteRecommendation() } - private let lockedLastLoopCompleted: Locked - fileprivate var lastLoopError: LoopError? + /// Cancel the active temp basal if it was automatically issued + func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason) async throws { + guard case .tempBasal(let dose) = deliveryDelegate?.basalDeliveryState, (dose.automatic ?? true) else { return } + + logger.default("Cancelling active temp basal for reason: %{public}@", String(describing: reason)) - /// A timeline of average velocity of glucose change counteracting predicted insulin effects - fileprivate var insulinCounteractionEffects: [GlucoseEffectVelocity] = [] { - didSet { - carbEffect = nil - carbsOnBoard = nil - } - } + let recommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel) - // Confined to dataAccessQueue - private var lastIntegralRetrospectiveCorrectionEnabled: Bool? - private var cachedRetrospectiveCorrection: RetrospectiveCorrection? + var dosingDecision = StoredDosingDecision(reason: reason.rawValue) + dosingDecision.settings = StoredDosingDecision.Settings(settingsProvider.settings) + dosingDecision.automaticDoseRecommendation = recommendation - var retrospectiveCorrection: RetrospectiveCorrection { - let currentIntegralRetrospectiveCorrectionEnabled = UserDefaults.standard.integralRetrospectiveCorrectionEnabled - - if lastIntegralRetrospectiveCorrectionEnabled != currentIntegralRetrospectiveCorrectionEnabled || cachedRetrospectiveCorrection == nil { - lastIntegralRetrospectiveCorrectionEnabled = currentIntegralRetrospectiveCorrectionEnabled - if currentIntegralRetrospectiveCorrectionEnabled { - cachedRetrospectiveCorrection = IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) + do { + try await deliveryDelegate?.enact(recommendation) + } catch { + dosingDecision.appendError(error as? LoopError ?? .unknownError(error)) + if reason == .maximumBasalRateChanged { + throw CancelTempBasalFailedMaximumBasalRateChangedError(reason: error) } else { - cachedRetrospectiveCorrection = StandardRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) + throw error } } - - return cachedRetrospectiveCorrection! - } - - func clearCachedInsulinEffects() { - insulinEffect = nil - insulinEffectIncludingPendingInsulin = nil - predictedGlucose = nil + + await dosingDecisionStore.storeDosingDecision(dosingDecision) } - // MARK: - Background task management + func loop() async { + let loopBaseTime = now() - private var backgroundTask: UIBackgroundTaskIdentifier = .invalid + var dosingDecision = StoredDosingDecision( + date: loopBaseTime, + reason: "loop", + settings: StoredDosingDecision.Settings(settingsProvider.settings) + ) - private func startBackgroundTask() { - endBackgroundTask() - backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "PersistenceController save") { - self.endBackgroundTask() - } - } + do { + guard let deliveryDelegate else { + preconditionFailure("Unable to dose without dosing delegate.") + } - private func endBackgroundTask() { - if backgroundTask != .invalid { - UIApplication.shared.endBackgroundTask(backgroundTask) - backgroundTask = .invalid - } - } + logger.debug("Running Loop at %{public}@", String(describing: loopBaseTime)) + NotificationCenter.default.post(name: .LoopRunning, object: self) - private func loopDidComplete(date: Date, dosingDecision: StoredDosingDecision, duration: TimeInterval) { - logger.default("Loop completed successfully.") - lastLoopCompleted = date - analyticsServicesManager.loopDidSucceed(duration) - dosingDecisionStore.storeDosingDecision(dosingDecision) {} + var input = try await fetchData(for: loopBaseTime) - NotificationCenter.default.post(name: .LoopCompleted, object: self) - } + // Trim future basal + input.doses = input.doses.trimmed(to: loopBaseTime) - private func loopDidError(date: Date, error: LoopError, dosingDecision: StoredDosingDecision, duration: TimeInterval) { - logger.error("Loop did error: %{public}@", String(describing: error)) - lastLoopError = error - analyticsServicesManager.loopDidError(error: error) - var dosingDecisionWithError = dosingDecision - dosingDecisionWithError.appendError(error) - dosingDecisionStore.storeDosingDecision(dosingDecisionWithError) {} - } + let dosingStrategy = settingsProvider.settings.automaticDosingStrategy + input.recommendationType = dosingStrategy.recommendationType + + guard let latestGlucose = input.glucoseHistory.last else { + throw LoopError.missingDataError(.glucose) + } - // This is primarily for remote clients displaying a bolus recommendation and forecast - // Should be called after any significant change to forecast input data. + guard loopBaseTime.timeIntervalSince(latestGlucose.startDate) <= LoopAlgorithm.inputDataRecencyInterval else { + throw LoopError.glucoseTooOld(date: latestGlucose.startDate) + } + guard latestGlucose.startDate.timeIntervalSince(loopBaseTime) <= LoopAlgorithm.inputDataRecencyInterval else { + throw LoopError.invalidFutureGlucose(date: latestGlucose.startDate) + } - var remoteRecommendationNeedsUpdating: Bool = false + guard loopBaseTime.timeIntervalSince(doseStore.lastAddedPumpData) <= LoopAlgorithm.inputDataRecencyInterval else { + throw LoopError.pumpDataTooOld(date: doseStore.lastAddedPumpData) + } - func updateRemoteRecommendation() { - dataAccessQueue.async { - if self.remoteRecommendationNeedsUpdating { - var (dosingDecision, updateError) = self.update(for: .updateRemoteRecommendation) + var output = LoopAlgorithm.run(input: input) - if let error = updateError { - self.logger.error("Error updating manual bolus recommendation: %{public}@", String(describing: error)) - } else { - do { - if let predictedGlucoseIncludingPendingInsulin = self.predictedGlucoseIncludingPendingInsulin, - let manualBolusRecommendation = try self.recommendManualBolus(forPrediction: predictedGlucoseIncludingPendingInsulin, consideringPotentialCarbEntry: nil) - { - dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: manualBolusRecommendation, date: Date()) - self.logger.debug("Manual bolus rec = %{public}@", String(describing: dosingDecision.manualBolusRecommendation)) - self.dosingDecisionStore.storeDosingDecision(dosingDecision) {} - } - } catch { - self.logger.error("Error updating manual bolus recommendation: %{public}@", String(describing: error)) - } + switch output.recommendationResult { + case .success(let recommendation): + // Round delivery amounts to pump supported amounts, + // And determine if a change in dosing should be made. + + let algoRecommendation = recommendation.automatic! + logger.default("Algorithm recommendation: %{public}@", String(describing: algoRecommendation)) + + var recommendationToEnact = algoRecommendation + // Round bolus recommendation based on pump bolus precision + if let bolus = algoRecommendation.bolusUnits, bolus > 0 { + recommendationToEnact.bolusUnits = deliveryDelegate.roundBolusVolume(units: bolus) } - self.remoteRecommendationNeedsUpdating = false - } - } - } -} -// MARK: Background task management -extension LoopDataManager: PersistenceControllerDelegate { - func persistenceControllerWillSave(_ controller: PersistenceController) { - startBackgroundTask() - } + if var basal = algoRecommendation.basalAdjustment { + basal.unitsPerHour = deliveryDelegate.roundBasalRate(unitsPerHour: basal.unitsPerHour) - func persistenceControllerDidSave(_ controller: PersistenceController, error: PersistenceController.PersistenceControllerError?) { - endBackgroundTask() - } -} + let scheduledBasalRate = input.basal.closestPrior(to: loopBaseTime)!.value + let activeOverride = temporaryPresetsManager.overrideHistory.activeOverride(at: loopBaseTime) -// MARK: - Preferences -extension LoopDataManager { + let basalAdjustment = basal.adjustForCurrentDelivery( + at: loopBaseTime, + neutralBasalRate: scheduledBasalRate, + currentTempBasal: deliveryDelegate.basalDeliveryState?.currentTempBasal, + continuationInterval: .minutes(11), + neutralBasalRateMatchesPump: activeOverride == nil + ) - /// The basal rate schedule, applying recent overrides relative to the current moment in time. - var basalRateScheduleApplyingOverrideHistory: BasalRateSchedule? { - return doseStore.basalProfileApplyingOverrideHistory - } + recommendationToEnact.basalAdjustment = basalAdjustment + } + output.recommendationResult = .success(.init(automatic: recommendationToEnact)) - /// The carb ratio schedule, applying recent overrides relative to the current moment in time. - var carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule? { - return carbStore.carbRatioScheduleApplyingOverrideHistory - } + if recommendationToEnact != algoRecommendation { + logger.default("Recommendation changed to: %{public}@", String(describing: recommendationToEnact)) + } - /// The insulin sensitivity schedule, applying recent overrides relative to the current moment in time. - var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? { - return carbStore.insulinSensitivityScheduleApplyingOverrideHistory - } + dosingDecision.updateFrom(input: input, output: output) - /// Sets a new time zone for a the schedule-based settings - /// - /// - Parameter timeZone: The time zone - func setScheduleTimeZone(_ timeZone: TimeZone) { - self.mutateSettings { settings in - settings.basalRateSchedule?.timeZone = timeZone - settings.carbRatioSchedule?.timeZone = timeZone - settings.insulinSensitivitySchedule?.timeZone = timeZone - settings.glucoseTargetRangeSchedule?.timeZone = timeZone - } - } -} + if self.automaticDosingStatus.automaticDosingEnabled { + if deliveryDelegate.basalDeliveryState == .pumpInoperable { + throw LoopError.pumpInoperable + } + if deliveryDelegate.isSuspended { + throw LoopError.pumpSuspended + } -// MARK: - Intake -extension LoopDataManager { - /// Adds and stores glucose samples - /// - /// - Parameters: - /// - samples: The new glucose samples to store - /// - completion: A closure called once upon completion - /// - result: The stored glucose values - func addGlucoseSamples( - _ samples: [NewGlucoseSample], - completion: ((_ result: Swift.Result<[StoredGlucoseSample], Error>) -> Void)? = nil - ) { - glucoseStore.addGlucoseSamples(samples) { (result) in - self.dataAccessQueue.async { - switch result { - case .success(let samples): - if let endDate = samples.sorted(by: { $0.startDate < $1.startDate }).first?.startDate { - // Prune back any counteraction effects for recomputation - self.insulinCounteractionEffects = self.insulinCounteractionEffects.filter { $0.endDate < endDate } + if recommendationToEnact.hasDosingChange { + logger.default("Enacting: %{public}@", String(describing: recommendationToEnact)) + try await deliveryDelegate.enact(recommendationToEnact) } - completion?(.success(samples)) - case .failure(let error): - completion?(.failure(error)) + logger.default("loop() completed successfully.") + lastLoopCompleted = Date() + let duration = lastLoopCompleted!.timeIntervalSince(loopBaseTime) + + analyticsServicesManager?.loopDidSucceed(duration) + } else { + self.logger.default("Not adjusting dosing during open loop.") } + + await dosingDecisionStore.storeDosingDecision(dosingDecision) + NotificationCenter.default.post(name: .LoopCycleCompleted, object: self) + + case .failure(let error): + throw error } + } catch { + logger.error("loop() did error: %{public}@", String(describing: error)) + let loopError = error as? LoopError ?? .unknownError(error) + dosingDecision.appendError(loopError) + await dosingDecisionStore.storeDosingDecision(dosingDecision) + analyticsServicesManager?.loopDidError(error: loopError) + NotificationCenter.default.post(name: .LoopCycleCompleted, object: self) } - } - - /// Take actions to address how insulin is delivered when the CGM data is unreliable - /// - /// An active high temp basal (greater than the basal schedule) is cancelled when the CGM data is unreliable. - func receivedUnreliableCGMReading() { - guard case .tempBasal(let tempBasal) = basalDeliveryState, - let scheduledBasalRate = settings.basalRateSchedule?.value(at: now()), - tempBasal.unitsPerHour > scheduledBasalRate else - { - return - } - - // Cancel active high temp basal - cancelActiveTempBasal(for: .unreliableCGMData) + logger.default("Loop ended") } - private enum CancelActiveTempBasalReason: String { - case automaticDosingDisabled - case unreliableCGMData - case maximumBasalRateChanged - } - - /// Cancel the active temp basal if it was automatically issued - private func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason) { - guard case .tempBasal(let dose) = basalDeliveryState, (dose.automatic ?? true) else { return } + func recommendManualBolus( + manualGlucoseSample: NewGlucoseSample? = nil, + potentialCarbEntry: NewCarbEntry? = nil, + originalCarbEntry: StoredCarbEntry? = nil + ) async throws -> ManualBolusRecommendation? { - dataAccessQueue.async { - self.cancelActiveTempBasal(for: reason, completion: nil) - } - } - - private func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason, completion: ((Error?) -> Void)?) { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) + var input = try await self.fetchData(for: now(), disablingPreMeal: potentialCarbEntry != nil) + .addingGlucoseSample(sample: manualGlucoseSample?.asStoredGlucoseStample) + .removingCarbEntry(carbEntry: originalCarbEntry) + .addingCarbEntry(carbEntry: potentialCarbEntry?.asStoredCarbEntry) - let recommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel) - recommendedAutomaticDose = (recommendation: recommendation, date: now()) + input.includePositiveVelocityAndRC = usePositiveMomentumAndRCForManualBoluses + input.recommendationType = .manualBolus - var dosingDecision = StoredDosingDecision(reason: reason.rawValue) - dosingDecision.settings = StoredDosingDecision.Settings(latestStoredSettingsProvider.latestSettings) - dosingDecision.controllerStatus = UIDevice.current.controllerStatus - dosingDecision.automaticDoseRecommendation = recommendation - - let error = enactRecommendedAutomaticDose() + let output = LoopAlgorithm.run(input: input) - dosingDecision.pumpManagerStatus = delegate?.pumpManagerStatus - dosingDecision.cgmManagerStatus = delegate?.cgmManagerStatus - dosingDecision.lastReservoirValue = StoredDosingDecision.LastReservoirValue(doseStore.lastReservoirValue) + switch output.recommendationResult { + case .success(let prediction): + guard var manualBolusRecommendation = prediction.manual else { return nil } + if let roundedAmount = deliveryDelegate?.roundBolusVolume(units: manualBolusRecommendation.amount) { + manualBolusRecommendation.amount = roundedAmount + } + return manualBolusRecommendation + case .failure(let error): + throw error + } + } - if let error = error { - dosingDecision.appendError(error) + public func totalDeliveredToday() async -> InsulinValue? + { + guard let data = displayState.input else { + return nil } - self.dosingDecisionStore.storeDosingDecision(dosingDecision) {} - // Didn't actually run a loop, but this is similar to a loop() in that the automatic dosing - // was updated. - self.notify(forChange: .loopFinished) - completion?(error) + let now = data.predictionStart + let midnight = Calendar.current.startOfDay(for: now) + + let annotatedDoses = data.doses.annotated(with: data.basal, fillBasalGaps: true) + let trimmed = annotatedDoses.map { $0.trimmed(from: midnight, to: now)} + + return InsulinValue( + startDate: midnight, + value: trimmed.reduce(0.0) { $0 + $1.volume } + ) } + var iobValues: [InsulinValue] { + dosesRelativeToBasal.insulinOnBoardTimeline() + } - /// Adds and stores carb data, and recommends a bolus if needed - /// - /// - Parameters: - /// - carbEntry: The new carb value - /// - completion: A closure called once upon completion - /// - result: The bolus recommendation - func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry? = nil, completion: @escaping (_ result: Result) -> Void) { - let addCompletion: (CarbStoreResult) -> Void = { (result) in - self.dataAccessQueue.async { - switch result { - case .success(let storedCarbEntry): - // Remove the active pre-meal target override - self.mutateSettings { settings in - settings.clearOverride(matching: .preMeal) - } + var dosesRelativeToBasal: [BasalRelativeDose] { + displayState.output?.dosesRelativeToBasal ?? [] + } + + func updateRemoteRecommendation() async { + if lastManualBolusRecommendation == nil { + lastManualBolusRecommendation = displayState.output?.recommendation?.manual + } + + guard lastManualBolusRecommendation != displayState.output?.recommendation?.manual else { + // no change + return + } - self.carbEffect = nil - self.carbsOnBoard = nil - completion(.success(storedCarbEntry)) - case .failure(let error): - completion(.failure(error)) + lastManualBolusRecommendation = displayState.output?.recommendation?.manual + + if let output = displayState.output { + var dosingDecision = StoredDosingDecision(date: Date(), reason: "updateRemoteRecommendation") + dosingDecision.predictedGlucose = output.predictedGlucose + dosingDecision.insulinOnBoard = displayState.activeInsulin + dosingDecision.carbsOnBoard = displayState.activeCarbs + switch output.recommendationResult { + case .success(let recommendation): + dosingDecision.automaticDoseRecommendation = recommendation.automatic + if let recommendationDate = displayState.input?.predictionStart, let manualRec = recommendation.manual { + dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: manualRec, date: recommendationDate) + } + case .failure(let error): + if let loopError = error as? LoopError { + dosingDecision.errors.append(loopError.issue) + } else { + dosingDecision.errors.append(.init(id: "error", details: ["description": error.localizedDescription])) } } - } - if let replacingEntry = replacingEntry { - carbStore.replaceCarbEntry(replacingEntry, withEntry: carbEntry, completion: addCompletion) - } else { - carbStore.addCarbEntry(carbEntry, completion: addCompletion) + dosingDecision.controllerStatus = UIDevice.current.controllerStatus + self.logger.debug("Manual bolus rec = %{public}@", String(describing: dosingDecision.manualBolusRecommendation)) + await self.dosingDecisionStore.storeDosingDecision(dosingDecision) } } + + // MARK: - Glucose Staleness + + private var glucoseValueStalenessTimer: Timer? + + private func restartGlucoseValueStalenessTimer() { + stopGlucoseValueStalenessTimer() + startGlucoseValueStalenessTimerIfNeeded() + } - func deleteCarbEntry(_ oldEntry: StoredCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) { - carbStore.deleteCarbEntry(oldEntry) { result in - completion(result) + private func stopGlucoseValueStalenessTimer() { + glucoseValueStalenessTimer?.invalidate() + glucoseValueStalenessTimer = nil + } + + func startGlucoseValueStalenessTimerIfNeeded() { + guard let fireDate = glucoseValueStaleDate, + glucoseValueStalenessTimer == nil + else { return } + + glucoseValueStalenessTimer = Timer(fire: fireDate, interval: 0, repeats: false) { (_) in + Task { @MainActor in + self.notify(forChange: .glucose) + } } + RunLoop.main.add(glucoseValueStalenessTimer!, forMode: .default) } + private var glucoseValueStaleDate: Date? { + guard let latestGlucoseDataDate = glucoseStore.latestGlucose?.startDate else { return nil } + return latestGlucoseDataDate.addingTimeInterval(LoopAlgorithm.inputDataRecencyInterval) + } +} - /// Adds a bolus requested of the pump, but not confirmed. - /// - /// - Parameters: - /// - dose: The DoseEntry representing the requested bolus - /// - completion: A closure that is called after state has been updated - func addRequestedBolus(_ dose: DoseEntry, completion: (() -> Void)?) { - dataAccessQueue.async { - self.logger.debug("addRequestedBolus") - self.lastRequestedBolus = dose - self.notify(forChange: .insulin) +// MARK: - Background task management +extension LoopDataManager: PersistenceControllerDelegate { + func persistenceControllerWillSave(_ controller: PersistenceController) { + startBackgroundTask() + } - completion?() - } + func persistenceControllerDidSave(_ controller: PersistenceController, error: PersistenceController.PersistenceControllerError?) { + endBackgroundTask() } +} + - /// Notifies the manager that the bolus is confirmed, but not fully delivered. +// MARK: - Intake +extension LoopDataManager { + /// Adds and stores glucose samples /// /// - Parameters: - /// - completion: A closure that is called after state has been updated - func bolusConfirmed(completion: (() -> Void)?) { - self.dataAccessQueue.async { - self.logger.debug("bolusConfirmed") - self.lastRequestedBolus = nil - self.recommendedAutomaticDose = nil - self.clearCachedInsulinEffects() - self.notify(forChange: .insulin) - - completion?() - } + /// - samples: The new glucose samples to store + /// - completion: A closure called once upon completion + /// - result: The stored glucose values + func addGlucose(_ samples: [NewGlucoseSample]) async throws -> [StoredGlucoseSample] { + return try await glucoseStore.addGlucoseSamples(samples) } - /// Notifies the manager that the bolus failed. + /// Adds and stores carb data, and recommends a bolus if needed /// /// - Parameters: - /// - error: An error describing why the bolus request failed - /// - completion: A closure that is called after state has been updated - func bolusRequestFailed(_ error: Error, completion: (() -> Void)?) { - self.dataAccessQueue.async { - self.logger.debug("bolusRequestFailed") - self.lastRequestedBolus = nil - self.clearCachedInsulinEffects() - self.notify(forChange: .insulin) - - completion?() + /// - carbEntry: The new carb value + /// - completion: A closure called once upon completion + /// - result: The bolus recommendation + func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry? = nil) async throws -> StoredCarbEntry { + let storedCarbEntry: StoredCarbEntry + if let replacingEntry = replacingEntry { + storedCarbEntry = try await carbStore.replaceCarbEntry(replacingEntry, withEntry: carbEntry) + } else { + storedCarbEntry = try await carbStore.addCarbEntry(carbEntry) } + self.temporaryPresetsManager.clearOverride(matching: .preMeal) + return storedCarbEntry + } + + @discardableResult + func deleteCarbEntry(_ oldEntry: StoredCarbEntry) async throws -> Bool { + try await carbStore.deleteCarbEntry(oldEntry) } /// Logs a new external bolus insulin dose in the DoseStore and HealthKit @@ -747,62 +813,24 @@ extension LoopDataManager { /// - startDate: The date the dose was started at. /// - value: The number of Units in the dose. /// - insulinModel: The type of insulin model that should be used for the dose. - func addManuallyEnteredDose(startDate: Date, units: Double, insulinType: InsulinType? = nil) { + func addManuallyEnteredDose(startDate: Date, units: Double, insulinType: InsulinType? = nil) async { let syncIdentifier = Data(UUID().uuidString.utf8).hexadecimalString let dose = DoseEntry(type: .bolus, startDate: startDate, value: units, unit: .units, syncIdentifier: syncIdentifier, insulinType: insulinType, manuallyEntered: true) - doseStore.addDoses([dose], from: nil) { (error) in - if error == nil { - self.recommendedAutomaticDose = nil - self.clearCachedInsulinEffects() - self.notify(forChange: .insulin) - } - } - } - - /// Adds and stores a pump reservoir volume - /// - /// - Parameters: - /// - units: The reservoir volume, in units - /// - date: The date of the volume reading - /// - completion: A closure called once upon completion - /// - result: The current state of the reservoir values: - /// - newValue: The new stored value - /// - lastValue: The previous new stored value - /// - areStoredValuesContinuous: Whether the current recent state of the stored reservoir data is considered continuous and reliable for deriving insulin effects after addition of this new value. - func addReservoirValue(_ units: Double, at date: Date, completion: @escaping (_ result: Result<(newValue: ReservoirValue, lastValue: ReservoirValue?, areStoredValuesContinuous: Bool)>) -> Void) { - doseStore.addReservoirValue(units, at: date) { (newValue, previousValue, areStoredValuesContinuous, error) in - if let error = error { - completion(.failure(error)) - } else if let newValue = newValue { - self.dataAccessQueue.async { - self.clearCachedInsulinEffects() - - if let newDoseStartDate = previousValue?.startDate { - // Prune back any counteraction effects for recomputation, after the effect delay - self.insulinCounteractionEffects = self.insulinCounteractionEffects.filterDateRange(nil, newDoseStartDate.addingTimeInterval(.minutes(10))) - } - - completion(.success(( - newValue: newValue, - lastValue: previousValue, - areStoredValuesContinuous: areStoredValuesContinuous - ))) - } - } else { - assertionFailure() - } + do { + try await doseStore.addDoses([dose], from: nil) + self.notify(forChange: .insulin) + } catch { + logger.error("Error storing manual dose: %{public}@", error.localizedDescription) } } - func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) { + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) async { let dosingDecision = StoredDosingDecision(date: date, reason: bolusDosingDecision.reason.rawValue, - settings: StoredDosingDecision.Settings(latestStoredSettingsProvider.latestSettings), + settings: StoredDosingDecision.Settings(settingsProvider.settings), scheduleOverride: bolusDosingDecision.scheduleOverride, controllerStatus: UIDevice.current.controllerStatus, - pumpManagerStatus: delegate?.pumpManagerStatus, - cgmManagerStatus: delegate?.cgmManagerStatus, lastReservoirValue: StoredDosingDecision.LastReservoirValue(doseStore.lastReservoirValue), historicalGlucose: bolusDosingDecision.historicalGlucose, originalCarbEntry: bolusDosingDecision.originalCarbEntry, @@ -814,126 +842,9 @@ extension LoopDataManager { predictedGlucose: bolusDosingDecision.predictedGlucose, manualBolusRecommendation: bolusDosingDecision.manualBolusRecommendation, manualBolusRequested: bolusDosingDecision.manualBolusRequested) - dosingDecisionStore.storeDosingDecision(dosingDecision) {} - } - - // Actions - - /// Runs the "loop" - /// - /// Executes an analysis of the current data, and recommends an adjustment to the current - /// temporary basal rate. - /// - func loop() { - - if let lastLoopCompleted, Date().timeIntervalSince(lastLoopCompleted) < .minutes(2) { - print("Looping too fast!") - } - - let available = loopLock.withLockIfAvailable { - loopInternal() - return true - } - if available == nil { - print("Loop attempted while already looping!") - } - } - - func loopInternal() { - - dataAccessQueue.async { - - // If time was changed to future time, and a loop completed, then time was fixed, lastLoopCompleted will prevent looping - // until the future loop time passes. Fix that here. - if let lastLoopCompleted = self.lastLoopCompleted, Date() < lastLoopCompleted, self.trustedTimeOffset() == 0 { - self.logger.error("Detected future lastLoopCompleted. Restoring.") - self.lastLoopCompleted = Date() - } - - // Partial application factor assumes 5 minute intervals. If our looping intervals are shorter, then this will be adjusted - self.timeBasedDoseApplicationFactor = 1.0 - if let lastLoopCompleted = self.lastLoopCompleted { - let timeSinceLastLoop = max(0, Date().timeIntervalSince(lastLoopCompleted)) - self.timeBasedDoseApplicationFactor = min(1, timeSinceLastLoop/TimeInterval.minutes(5)) - self.logger.default("Looping with timeBasedDoseApplicationFactor = %{public}@", String(describing: self.timeBasedDoseApplicationFactor)) - } - - self.logger.default("Loop running") - NotificationCenter.default.post(name: .LoopRunning, object: self) - - self.lastLoopError = nil - let startDate = self.now() - - var (dosingDecision, error) = self.update(for: .loop) - - if error == nil, self.automaticDosingStatus.automaticDosingEnabled == true { - error = self.enactRecommendedAutomaticDose() - } else { - self.logger.default("Not adjusting dosing during open loop.") - } - - self.finishLoop(startDate: startDate, dosingDecision: dosingDecision, error: error) - } + await dosingDecisionStore.storeDosingDecision(dosingDecision) } - private func finishLoop(startDate: Date, dosingDecision: StoredDosingDecision, error: LoopError? = nil) { - let date = now() - let duration = date.timeIntervalSince(startDate) - - if let error = error { - loopDidError(date: date, error: error, dosingDecision: dosingDecision, duration: duration) - } else { - loopDidComplete(date: date, dosingDecision: dosingDecision, duration: duration) - } - - logger.default("Loop ended") - notify(forChange: .loopFinished) - - if FeatureFlags.missedMealNotifications { - let samplesStart = now().addingTimeInterval(-MissedMealSettings.maxRecency) - carbStore.getGlucoseEffects(start: samplesStart, end: now(), effectVelocities: insulinCounteractionEffects) {[weak self] result in - guard - let self = self, - case .success((_, let carbEffects)) = result - else { - if case .failure(let error) = result { - self?.logger.error("Failed to fetch glucose effects to check for missed meal: %{public}@", String(describing: error)) - } - return - } - - glucoseStore.getGlucoseSamples(start: samplesStart, end: now()) {[weak self] result in - guard - let self = self, - case .success(let glucoseSamples) = result - else { - if case .failure(let error) = result { - self?.logger.error("Failed to fetch glucose samples to check for missed meal: %{public}@", String(describing: error)) - } - return - } - - self.mealDetectionManager.generateMissedMealNotificationIfNeeded( - glucoseSamples: glucoseSamples, - insulinCounteractionEffects: self.insulinCounteractionEffects, - carbEffects: carbEffects, - pendingAutobolusUnits: self.recommendedAutomaticDose?.recommendation.bolusUnits, - bolusDurationEstimator: { [unowned self] bolusAmount in - return self.delegate?.loopDataManager(self, estimateBolusDuration: bolusAmount) - } - ) - } - } - } - - // 5 second delay to allow stores to cache data before it is read by widget - DispatchQueue.main.asyncAfter(deadline: .now() + 5) { - self.widgetLog.default("Refreshing widget. Reason: Loop completed") - WidgetCenter.shared.reloadAllTimelines() - } - - updateRemoteRecommendation() - } fileprivate enum UpdateReason: String { case loop @@ -941,1219 +852,83 @@ extension LoopDataManager { case updateRemoteRecommendation } - fileprivate func update(for reason: UpdateReason) -> (StoredDosingDecision, LoopError?) { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - - var dosingDecision = StoredDosingDecision(reason: reason.rawValue) - let latestSettings = latestStoredSettingsProvider.latestSettings - dosingDecision.settings = StoredDosingDecision.Settings(latestSettings) - dosingDecision.scheduleOverride = latestSettings.scheduleOverride - dosingDecision.controllerStatus = UIDevice.current.controllerStatus - dosingDecision.pumpManagerStatus = delegate?.pumpManagerStatus - if let pumpStatusHighlight = delegate?.pumpStatusHighlight { - dosingDecision.pumpStatusHighlight = StoredDosingDecision.StoredDeviceHighlight( - localizedMessage: pumpStatusHighlight.localizedMessage, - imageName: pumpStatusHighlight.imageName, - state: pumpStatusHighlight.state) - } - dosingDecision.cgmManagerStatus = delegate?.cgmManagerStatus - dosingDecision.lastReservoirValue = StoredDosingDecision.LastReservoirValue(doseStore.lastReservoirValue) - - let warnings = Locked<[LoopWarning]>([]) - - let updateGroup = DispatchGroup() - - let historicalGlucoseStartDate = Date(timeInterval: -LoopCoreConstants.dosingDecisionHistoricalGlucoseInterval, since: now()) - let inputDataRecencyStartDate = Date(timeInterval: -LoopCoreConstants.inputDataRecencyInterval, since: now()) - - // Fetch glucose effects as far back as we want to make retroactive analysis and historical glucose for dosing decision - var historicalGlucose: [HistoricalGlucoseValue]? - var latestGlucoseDate: Date? - updateGroup.enter() - glucoseStore.getGlucoseSamples(start: min(historicalGlucoseStartDate, inputDataRecencyStartDate), end: nil) { (result) in - switch result { - case .failure(let error): - self.logger.error("Failure getting glucose samples: %{public}@", String(describing: error)) - latestGlucoseDate = nil - warnings.append(.fetchDataWarning(.glucoseSamples(error: error))) - case .success(let samples): - historicalGlucose = samples.filter { $0.startDate >= historicalGlucoseStartDate }.map { HistoricalGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) } - latestGlucoseDate = samples.last?.startDate - } - updateGroup.leave() - } - _ = updateGroup.wait(timeout: .distantFuture) - - guard let lastGlucoseDate = latestGlucoseDate else { - dosingDecision.appendWarnings(warnings.value) - dosingDecision.appendError(.missingDataError(.glucose)) - return (dosingDecision, .missingDataError(.glucose)) - } - - let retrospectiveStart = lastGlucoseDate.addingTimeInterval(-type(of: retrospectiveCorrection).retrospectionInterval) - - let earliestEffectDate = Date(timeInterval: .hours(-24), since: now()) - let nextCounteractionEffectDate = insulinCounteractionEffects.last?.endDate ?? earliestEffectDate - let insulinEffectStartDate = nextCounteractionEffectDate.addingTimeInterval(.minutes(-5)) - - if glucoseMomentumEffect == nil { - updateGroup.enter() - glucoseStore.getRecentMomentumEffect(for: now()) { (result) -> Void in - switch result { - case .failure(let error): - self.logger.error("Failure getting recent momentum effect: %{public}@", String(describing: error)) - self.glucoseMomentumEffect = nil - warnings.append(.fetchDataWarning(.glucoseMomentumEffect(error: error))) - case .success(let effects): - self.glucoseMomentumEffect = effects - } - updateGroup.leave() - } - } - - if insulinEffect == nil || insulinEffect?.first?.startDate ?? .distantFuture > insulinEffectStartDate { - self.logger.debug("Recomputing insulin effects") - updateGroup.enter() - doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, basalDosingEnd: now()) { (result) -> Void in - switch result { - case .failure(let error): - self.logger.error("Could not fetch insulin effects: %{public}@", error.localizedDescription) - self.insulinEffect = nil - warnings.append(.fetchDataWarning(.insulinEffect(error: error))) - case .success(let effects): - self.insulinEffect = effects - } - - updateGroup.leave() - } - } - - if insulinEffectIncludingPendingInsulin == nil { - updateGroup.enter() - doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, basalDosingEnd: nil) { (result) -> Void in - switch result { - case .failure(let error): - self.logger.error("Could not fetch insulin effects including pending insulin: %{public}@", error.localizedDescription) - self.insulinEffectIncludingPendingInsulin = nil - warnings.append(.fetchDataWarning(.insulinEffectIncludingPendingInsulin(error: error))) - case .success(let effects): - self.insulinEffectIncludingPendingInsulin = effects - } - - updateGroup.leave() - } - } - - _ = updateGroup.wait(timeout: .distantFuture) - - if nextCounteractionEffectDate < lastGlucoseDate, let insulinEffect = insulinEffect { - updateGroup.enter() - self.logger.debug("Fetching counteraction effects after %{public}@", String(describing: nextCounteractionEffectDate)) - glucoseStore.getCounteractionEffects(start: nextCounteractionEffectDate, end: nil, to: insulinEffect) { (result) in - switch result { - case .failure(let error): - self.logger.error("Failure getting counteraction effects: %{public}@", String(describing: error)) - warnings.append(.fetchDataWarning(.insulinCounteractionEffect(error: error))) - case .success(let velocities): - self.insulinCounteractionEffects.append(contentsOf: velocities) - } - self.insulinCounteractionEffects = self.insulinCounteractionEffects.filterDateRange(earliestEffectDate, nil) - - updateGroup.leave() - } - - _ = updateGroup.wait(timeout: .distantFuture) - } - - if carbEffect == nil { - updateGroup.enter() - carbStore.getGlucoseEffects( - start: retrospectiveStart, end: nil, - effectVelocities: insulinCounteractionEffects - ) { (result) -> Void in - switch result { - case .failure(let error): - self.logger.error("%{public}@", String(describing: error)) - self.carbEffect = nil - self.recentCarbEntries = nil - warnings.append(.fetchDataWarning(.carbEffect(error: error))) - case .success(let (entries, effects)): - self.carbEffect = effects - self.recentCarbEntries = entries - } - - updateGroup.leave() - } - } - - if carbsOnBoard == nil { - updateGroup.enter() - carbStore.carbsOnBoard(at: now(), effectVelocities: insulinCounteractionEffects) { (result) in - switch result { - case .failure(let error): - switch error { - case .noData: - // when there is no data, carbs on board is set to 0 - self.carbsOnBoard = CarbValue(startDate: Date(), value: 0) - default: - self.carbsOnBoard = nil - warnings.append(.fetchDataWarning(.carbsOnBoard(error: error))) - } - case .success(let value): - self.carbsOnBoard = value - } - updateGroup.leave() - } - } - updateGroup.enter() - doseStore.insulinOnBoard(at: now()) { result in - switch result { - case .failure(let error): - warnings.append(.fetchDataWarning(.insulinOnBoard(error: error))) - case .success(let insulinValue): - self.insulinOnBoard = insulinValue - } - updateGroup.leave() - } - - _ = updateGroup.wait(timeout: .distantFuture) - - if retrospectiveGlucoseDiscrepancies == nil { - do { - try updateRetrospectiveGlucoseEffect() - } catch let error { - logger.error("%{public}@", String(describing: error)) - warnings.append(.fetchDataWarning(.retrospectiveGlucoseEffect(error: error))) - } - } - - do { - try updateSuspendInsulinDeliveryEffect() - } catch let error { - logger.error("%{public}@", String(describing: error)) - } - - dosingDecision.appendWarnings(warnings.value) - - dosingDecision.date = now() - dosingDecision.historicalGlucose = historicalGlucose - dosingDecision.carbsOnBoard = carbsOnBoard - dosingDecision.insulinOnBoard = self.insulinOnBoard - dosingDecision.glucoseTargetRangeSchedule = settings.effectiveGlucoseTargetRangeSchedule() - - // These will be updated by updatePredictedGlucoseAndRecommendedDose, if possible - dosingDecision.predictedGlucose = predictedGlucoseIncludingPendingInsulin - dosingDecision.automaticDoseRecommendation = recommendedAutomaticDose?.recommendation - - // If the glucose prediction hasn't changed, then nothing has changed, so just use pre-existing recommendations - guard predictedGlucose == nil else { - - // If we still have a bolus in progress, then warn (unlikely, but possible if device comms fail) - if lastRequestedBolus != nil, dosingDecision.automaticDoseRecommendation == nil, dosingDecision.manualBolusRecommendation == nil { - dosingDecision.appendWarning(.bolusInProgress) - } - - return (dosingDecision, nil) - } - - return updatePredictedGlucoseAndRecommendedDose(with: dosingDecision) - } - private func notify(forChange context: LoopUpdateContext) { NotificationCenter.default.post(name: .LoopDataUpdated, object: self, userInfo: [ - type(of: self).LoopUpdateContextKey: context.rawValue - ] - ) - } - - /// 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)) - - guard let basalRates = basalRateScheduleApplyingOverrideHistory else { - throw LoopError.configurationError(.basalRateSchedule) - } - - let pendingTempBasalInsulin: Double - let date = now() - - if let basalDeliveryState = basalDeliveryState, case .tempBasal(let lastTempBasal) = basalDeliveryState, lastTempBasal.endDate > date { - let normalBasalRate = basalRates.value(at: date) - let remainingTime = lastTempBasal.endDate.timeIntervalSince(date) - let remainingUnits = (lastTempBasal.unitsPerHour - normalBasalRate) * remainingTime.hours - - pendingTempBasalInsulin = max(0, remainingUnits) - } else { - pendingTempBasalInsulin = 0 - } - - let pendingBolusAmount: Double = lastRequestedBolus?.programmedUnits ?? 0 - - // All outstanding potential insulin delivery - return pendingTempBasalInsulin + pendingBolusAmount - } - - /// - Throws: - /// - LoopError.missingDataError - /// - LoopError.configurationError - /// - LoopError.glucoseTooOld - /// - LoopError.invalidFutureGlucose - /// - LoopError.pumpDataTooOld - fileprivate func predictGlucose( - startingAt startingGlucoseOverride: GlucoseValue? = nil, - using inputs: PredictionInputEffect, - historicalInsulinEffect insulinEffectOverride: [GlucoseEffect]? = nil, - insulinCounteractionEffects insulinCounteractionEffectsOverride: [GlucoseEffectVelocity]? = nil, - historicalCarbEffect carbEffectOverride: [GlucoseEffect]? = nil, - potentialBolus: DoseEntry? = nil, - potentialCarbEntry: NewCarbEntry? = nil, - replacingCarbEntry replacedCarbEntry: StoredCarbEntry? = nil, - includingPendingInsulin: Bool = false, - includingPositiveVelocityAndRC: Bool = true - ) throws -> [PredictedGlucoseValue] { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - - guard let glucose = startingGlucoseOverride ?? self.glucoseStore.latestGlucose else { - throw LoopError.missingDataError(.glucose) - } - - let pumpStatusDate = doseStore.lastAddedPumpData - let lastGlucoseDate = glucose.startDate - - guard now().timeIntervalSince(lastGlucoseDate) <= LoopCoreConstants.inputDataRecencyInterval else { - throw LoopError.glucoseTooOld(date: glucose.startDate) - } - - guard lastGlucoseDate.timeIntervalSince(now()) <= LoopCoreConstants.futureGlucoseDataInterval else { - throw LoopError.invalidFutureGlucose(date: lastGlucoseDate) - } - - guard now().timeIntervalSince(pumpStatusDate) <= LoopCoreConstants.inputDataRecencyInterval else { - throw LoopError.pumpDataTooOld(date: pumpStatusDate) - } - - var momentum: [GlucoseEffect] = [] - var retrospectiveGlucoseEffect = self.retrospectiveGlucoseEffect - var effects: [[GlucoseEffect]] = [] - - let insulinCounteractionEffects = insulinCounteractionEffectsOverride ?? self.insulinCounteractionEffects - if inputs.contains(.carbs) { - if let potentialCarbEntry = potentialCarbEntry { - let retrospectiveStart = lastGlucoseDate.addingTimeInterval(-type(of: retrospectiveCorrection).retrospectionInterval) - - if potentialCarbEntry.startDate > lastGlucoseDate || recentCarbEntries?.isEmpty != false, replacedCarbEntry == nil { - // The potential carb effect is independent and can be summed with the existing effect - if let carbEffect = carbEffectOverride ?? self.carbEffect { - effects.append(carbEffect) - } - - let potentialCarbEffect = try carbStore.glucoseEffects( - of: [potentialCarbEntry], - startingAt: retrospectiveStart, - endingAt: nil, - effectVelocities: insulinCounteractionEffects - ) - - effects.append(potentialCarbEffect) - } else { - var recentEntries = self.recentCarbEntries ?? [] - if let replacedCarbEntry = replacedCarbEntry, let index = recentEntries.firstIndex(of: replacedCarbEntry) { - recentEntries.remove(at: index) - } - - // If the entry is in the past or an entry is replaced, DCA and RC effects must be recomputed - var entries = recentEntries.map { NewCarbEntry(quantity: $0.quantity, startDate: $0.startDate, foodType: nil, absorptionTime: $0.absorptionTime) } - entries.append(potentialCarbEntry) - entries.sort(by: { $0.startDate > $1.startDate }) - - let potentialCarbEffect = try carbStore.glucoseEffects( - of: entries, - startingAt: retrospectiveStart, - endingAt: nil, - effectVelocities: insulinCounteractionEffects - ) - - effects.append(potentialCarbEffect) - - retrospectiveGlucoseEffect = computeRetrospectiveGlucoseEffect(startingAt: glucose, carbEffects: potentialCarbEffect) - } - } else if let carbEffect = carbEffectOverride ?? self.carbEffect { - effects.append(carbEffect) - } - } - - if inputs.contains(.insulin) { - let computationInsulinEffect: [GlucoseEffect]? - if insulinEffectOverride != nil { - computationInsulinEffect = insulinEffectOverride - } else { - computationInsulinEffect = includingPendingInsulin ? self.insulinEffectIncludingPendingInsulin : self.insulinEffect - } - - if let insulinEffect = computationInsulinEffect { - effects.append(insulinEffect) - } - - if let potentialBolus = potentialBolus { - guard let sensitivity = insulinSensitivityScheduleApplyingOverrideHistory else { - throw LoopError.configurationError(.insulinSensitivitySchedule) - } - - let earliestEffectDate = Date(timeInterval: .hours(-24), since: now()) - let nextEffectDate = insulinCounteractionEffects.last?.endDate ?? earliestEffectDate - let bolusEffect = [potentialBolus] - .glucoseEffects(insulinModelProvider: doseStore.insulinModelProvider, longestEffectDuration: doseStore.longestEffectDuration, insulinSensitivity: sensitivity) - .filterDateRange(nextEffectDate, nil) - effects.append(bolusEffect) - } - } - - if inputs.contains(.momentum), let momentumEffect = self.glucoseMomentumEffect { - if !includingPositiveVelocityAndRC, let netMomentum = momentumEffect.netEffect(), netMomentum.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { - momentum = [] - } else { - momentum = momentumEffect - } - } - - if inputs.contains(.retrospection) { - if !includingPositiveVelocityAndRC, let netRC = retrospectiveGlucoseEffect.netEffect(), netRC.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { - // positive RC is turned off - } else { - effects.append(retrospectiveGlucoseEffect) - } - } - - // Append effect of suspending insulin delivery when selected by the user on the Predicted Glucose screen (for information purposes only) - if inputs.contains(.suspend) { - effects.append(suspendInsulinDeliveryEffect) - } - - var prediction = LoopMath.predictGlucose(startingAt: glucose, momentum: momentum, effects: effects) - - // Dosing requires prediction entries at least as long as the insulin model duration. - // If our prediction is shorter than that, then extend it here. - let finalDate = glucose.startDate.addingTimeInterval(doseStore.longestEffectDuration) - if let last = prediction.last, last.startDate < finalDate { - prediction.append(PredictedGlucoseValue(startDate: finalDate, quantity: last.quantity)) - } - - return prediction - } - - fileprivate func predictGlucoseFromManualGlucose( - _ glucose: NewGlucoseSample, - potentialBolus: DoseEntry?, - potentialCarbEntry: NewCarbEntry?, - replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, - includingPendingInsulin: Bool, - considerPositiveVelocityAndRC: Bool - ) throws -> [PredictedGlucoseValue] { - let retrospectiveStart = glucose.date.addingTimeInterval(-type(of: retrospectiveCorrection).retrospectionInterval) - let earliestEffectDate = Date(timeInterval: .hours(-24), since: now()) - let nextEffectDate = insulinCounteractionEffects.last?.endDate ?? earliestEffectDate - let insulinEffectStartDate = nextEffectDate.addingTimeInterval(.minutes(-5)) - - let updateGroup = DispatchGroup() - let effectCalculationError = Locked(nil) - - var insulinEffect: [GlucoseEffect]? - let basalDosingEnd = includingPendingInsulin ? nil : now() - updateGroup.enter() - doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, basalDosingEnd: basalDosingEnd) { result in - switch result { - case .failure(let error): - effectCalculationError.mutate { $0 = error } - case .success(let effects): - insulinEffect = effects - } - - updateGroup.leave() - } - - updateGroup.wait() - - if let error = effectCalculationError.value { - throw error - } - - var insulinCounteractionEffects = self.insulinCounteractionEffects - if nextEffectDate < glucose.date, let insulinEffect = insulinEffect { - updateGroup.enter() - glucoseStore.getGlucoseSamples(start: nextEffectDate, end: nil) { result in - switch result { - case .failure(let error): - self.logger.error("Failure getting glucose samples: %{public}@", String(describing: error)) - case .success(let samples): - var samples = samples - let manualSample = StoredGlucoseSample(sample: glucose.quantitySample) - let insertionIndex = samples.partitioningIndex(where: { manualSample.startDate < $0.startDate }) - samples.insert(manualSample, at: insertionIndex) - let velocities = self.glucoseStore.counteractionEffects(for: samples, to: insulinEffect) - insulinCounteractionEffects.append(contentsOf: velocities) - } - insulinCounteractionEffects = insulinCounteractionEffects.filterDateRange(earliestEffectDate, nil) - - updateGroup.leave() - } - - updateGroup.wait() - } - - var carbEffect: [GlucoseEffect]? - updateGroup.enter() - carbStore.getGlucoseEffects( - start: retrospectiveStart, end: nil, - effectVelocities: insulinCounteractionEffects - ) { result in - switch result { - case .failure(let error): - effectCalculationError.mutate { $0 = error } - case .success(let (_, effects)): - carbEffect = effects - } - - updateGroup.leave() - } - - updateGroup.wait() - - if let error = effectCalculationError.value { - throw error - } - - return try predictGlucose( - startingAt: glucose.quantitySample, - using: [.insulin, .carbs], - historicalInsulinEffect: insulinEffect, - insulinCounteractionEffects: insulinCounteractionEffects, - historicalCarbEffect: carbEffect, - potentialBolus: potentialBolus, - potentialCarbEntry: potentialCarbEntry, - replacingCarbEntry: replacedCarbEntry, - includingPendingInsulin: true, - includingPositiveVelocityAndRC: considerPositiveVelocityAndRC - ) - } - - fileprivate func recommendBolusForManualGlucose(_ glucose: NewGlucoseSample, consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { - guard lastRequestedBolus == nil else { - // Don't recommend changes if a bolus was just requested. - // Sending additional pump commands is not going to be - // successful in any case. - return nil - } - - let pendingInsulin = try getPendingInsulin() - let shouldIncludePendingInsulin = pendingInsulin > 0 - let prediction = try predictGlucoseFromManualGlucose(glucose, potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, considerPositiveVelocityAndRC: considerPositiveVelocityAndRC) - return try recommendManualBolus(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry) - } - - /// - Throws: LoopError.missingDataError - fileprivate func recommendBolus(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { - guard lastRequestedBolus == nil else { - // Don't recommend changes if a bolus was just requested. - // Sending additional pump commands is not going to be - // successful in any case. - return nil - } - - let pendingInsulin = try getPendingInsulin() - let shouldIncludePendingInsulin = pendingInsulin > 0 - let prediction = try predictGlucose(using: .all, potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC) - return try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry) - } - - /// - Throws: - /// - LoopError.missingDataError - /// - LoopError.glucoseTooOld - /// - LoopError.invalidFutureGlucose - /// - LoopError.pumpDataTooOld - /// - LoopError.configurationError - fileprivate func recommendBolusValidatingDataRecency(forPrediction predictedGlucose: [Sample], - consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?) throws -> ManualBolusRecommendation? { - guard let glucose = glucoseStore.latestGlucose else { - throw LoopError.missingDataError(.glucose) - } - - let pumpStatusDate = doseStore.lastAddedPumpData - let lastGlucoseDate = glucose.startDate - - guard now().timeIntervalSince(lastGlucoseDate) <= LoopCoreConstants.inputDataRecencyInterval else { - throw LoopError.glucoseTooOld(date: glucose.startDate) - } - - guard lastGlucoseDate.timeIntervalSince(now()) <= LoopCoreConstants.inputDataRecencyInterval else { - throw LoopError.invalidFutureGlucose(date: lastGlucoseDate) - } - - guard now().timeIntervalSince(pumpStatusDate) <= LoopCoreConstants.inputDataRecencyInterval else { - throw LoopError.pumpDataTooOld(date: pumpStatusDate) - } - - guard glucoseMomentumEffect != nil else { - throw LoopError.missingDataError(.momentumEffect) - } - - guard carbEffect != nil else { - throw LoopError.missingDataError(.carbEffect) - } - - guard insulinEffect != nil else { - throw LoopError.missingDataError(.insulinEffect) - } - - return try recommendManualBolus(forPrediction: predictedGlucose, consideringPotentialCarbEntry: potentialCarbEntry) - } - - /// - Throws: LoopError.configurationError - private func recommendManualBolus(forPrediction predictedGlucose: [Sample], - consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?) throws -> ManualBolusRecommendation? { - guard let glucoseTargetRange = settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) else { - throw LoopError.configurationError(.glucoseTargetRangeSchedule) - } - guard let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory else { - throw LoopError.configurationError(.insulinSensitivitySchedule) - } - guard let maxBolus = settings.maximumBolus else { - throw LoopError.configurationError(.maximumBolus) - } - - guard lastRequestedBolus == nil - else { - // Don't recommend changes if a bolus was just requested. - // Sending additional pump commands is not going to be - // successful in any case. - return nil - } - - let volumeRounder = { (_ units: Double) in - return self.delegate?.roundBolusVolume(units: units) ?? units - } - - let model = doseStore.insulinModelProvider.model(for: pumpInsulinType) - - return predictedGlucose.recommendedManualBolus( - to: glucoseTargetRange, - at: now(), - suspendThreshold: settings.suspendThreshold?.quantity, - sensitivity: insulinSensitivity, - model: model, - pendingInsulin: 0, // Pending insulin is already reflected in the prediction - maxBolus: maxBolus, - volumeRounder: volumeRounder - ) - } - - /// Generates a correction effect based on how large the discrepancy is between the current glucose and its model predicted value. - /// - /// - Throws: LoopError.missingDataError - private func updateRetrospectiveGlucoseEffect() throws { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - - // Get carb effects, otherwise clear effect and throw error - guard let carbEffects = self.carbEffect else { - retrospectiveGlucoseDiscrepancies = nil - retrospectiveGlucoseEffect = [] - throw LoopError.missingDataError(.carbEffect) - } - - // Get most recent glucose, otherwise clear effect and throw error - guard let glucose = self.glucoseStore.latestGlucose else { - retrospectiveGlucoseEffect = [] - throw LoopError.missingDataError(.glucose) - } - - // Get timeline of glucose discrepancies - retrospectiveGlucoseDiscrepancies = insulinCounteractionEffects.subtracting(carbEffects, withUniformInterval: carbStore.delta) - - // Calculate retrospective correction - let insulinSensitivity = settings.insulinSensitivitySchedule!.quantity(at: glucose.startDate) - let basalRate = settings.basalRateSchedule!.value(at: glucose.startDate) - let correctionRange = settings.glucoseTargetRangeSchedule!.quantityRange(at: glucose.startDate) - - retrospectiveGlucoseEffect = retrospectiveCorrection.computeEffect( - startingAt: glucose, - retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed, - recencyInterval: LoopCoreConstants.inputDataRecencyInterval, - insulinSensitivity: insulinSensitivity, - basalRate: basalRate, - correctionRange: correctionRange, - retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval - ) - } - - private func computeRetrospectiveGlucoseEffect(startingAt glucose: GlucoseValue, carbEffects: [GlucoseEffect]) -> [GlucoseEffect] { - - let insulinSensitivity = settings.insulinSensitivitySchedule!.quantity(at: glucose.startDate) - let basalRate = settings.basalRateSchedule!.value(at: glucose.startDate) - let correctionRange = settings.glucoseTargetRangeSchedule!.quantityRange(at: glucose.startDate) - - let retrospectiveGlucoseDiscrepancies = insulinCounteractionEffects.subtracting(carbEffects, withUniformInterval: carbStore.delta) - let retrospectiveGlucoseDiscrepanciesSummed = retrospectiveGlucoseDiscrepancies.combinedSums(of: LoopMath.retrospectiveCorrectionGroupingInterval * retrospectiveCorrectionGroupingIntervalMultiplier) - return retrospectiveCorrection.computeEffect( - startingAt: glucose, - retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed, - recencyInterval: LoopCoreConstants.inputDataRecencyInterval, - insulinSensitivity: insulinSensitivity, - basalRate: basalRate, - correctionRange: correctionRange, - retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval - ) - } - - /// Generates a glucose prediction effect of suspending insulin delivery over duration of insulin action starting at current date - /// - /// - Throws: LoopError.configurationError - private func updateSuspendInsulinDeliveryEffect() throws { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - - // Get settings, otherwise clear effect and throw error - guard - let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory - else { - suspendInsulinDeliveryEffect = [] - throw LoopError.configurationError(.insulinSensitivitySchedule) - } - guard - let basalRateSchedule = basalRateScheduleApplyingOverrideHistory - else { - suspendInsulinDeliveryEffect = [] - throw LoopError.configurationError(.basalRateSchedule) - } - - let insulinModel = doseStore.insulinModelProvider.model(for: pumpInsulinType) - let insulinActionDuration = insulinModel.effectDuration - - let startSuspend = now() - let endSuspend = startSuspend.addingTimeInterval(insulinActionDuration) - - var suspendDoses: [DoseEntry] = [] - let basalItems = basalRateSchedule.between(start: startSuspend, end: endSuspend) - - // Iterate over basal entries during suspension of insulin delivery - for (index, basalItem) in basalItems.enumerated() { - var startSuspendDoseDate: Date - var endSuspendDoseDate: Date - - if index == 0 { - startSuspendDoseDate = startSuspend - } else { - startSuspendDoseDate = basalItem.startDate - } - - if index == basalItems.count - 1 { - endSuspendDoseDate = endSuspend - } else { - endSuspendDoseDate = basalItems[index + 1].startDate - } - - let suspendDose = DoseEntry(type: .tempBasal, startDate: startSuspendDoseDate, endDate: endSuspendDoseDate, value: -basalItem.value, unit: DoseUnit.unitsPerHour) - - suspendDoses.append(suspendDose) - } - - // Calculate predicted glucose effect of suspending insulin delivery - suspendInsulinDeliveryEffect = suspendDoses.glucoseEffects(insulinModelProvider: doseStore.insulinModelProvider, longestEffectDuration: doseStore.longestEffectDuration, insulinSensitivity: insulinSensitivity).filterDateRange(startSuspend, endSuspend) - } - - /// Runs the glucose prediction on the latest effect data. - /// - /// - Throws: - /// - LoopError.configurationError - /// - LoopError.glucoseTooOld - /// - LoopError.invalidFutureGlucose - /// - LoopError.missingDataError - /// - LoopError.pumpDataTooOld - private func updatePredictedGlucoseAndRecommendedDose(with dosingDecision: StoredDosingDecision) -> (StoredDosingDecision, LoopError?) { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - - var dosingDecision = dosingDecision - - self.logger.debug("Recomputing prediction and recommendations.") - - let startDate = now() - - guard let glucose = glucoseStore.latestGlucose else { - logger.error("Latest glucose missing") - dosingDecision.appendError(.missingDataError(.glucose)) - return (dosingDecision, .missingDataError(.glucose)) - } - - var errors = [LoopError]() - - if startDate.timeIntervalSince(glucose.startDate) > LoopCoreConstants.inputDataRecencyInterval { - errors.append(.glucoseTooOld(date: glucose.startDate)) - } - - if glucose.startDate.timeIntervalSince(startDate) > LoopCoreConstants.inputDataRecencyInterval { - errors.append(.invalidFutureGlucose(date: glucose.startDate)) - } - - let pumpStatusDate = doseStore.lastAddedPumpData - - if startDate.timeIntervalSince(pumpStatusDate) > LoopCoreConstants.inputDataRecencyInterval { - errors.append(.pumpDataTooOld(date: pumpStatusDate)) - } - - let glucoseTargetRange = settings.effectiveGlucoseTargetRangeSchedule() - if glucoseTargetRange == nil { - errors.append(.configurationError(.glucoseTargetRangeSchedule)) - } - - let basalRateSchedule = basalRateScheduleApplyingOverrideHistory - if basalRateSchedule == nil { - errors.append(.configurationError(.basalRateSchedule)) - } - - let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory - if insulinSensitivity == nil { - errors.append(.configurationError(.insulinSensitivitySchedule)) - } - - if carbRatioScheduleApplyingOverrideHistory == nil { - errors.append(.configurationError(.carbRatioSchedule)) - } - - let maxBasal = settings.maximumBasalRatePerHour - if maxBasal == nil { - errors.append(.configurationError(.maximumBasalRatePerHour)) - } - - let maxBolus = settings.maximumBolus - if maxBolus == nil { - errors.append(.configurationError(.maximumBolus)) - } - - if glucoseMomentumEffect == nil { - errors.append(.missingDataError(.momentumEffect)) - } - - if carbEffect == nil { - errors.append(.missingDataError(.carbEffect)) - } - - if insulinEffect == nil { - errors.append(.missingDataError(.insulinEffect)) - } - - if insulinEffectIncludingPendingInsulin == nil { - errors.append(.missingDataError(.insulinEffectIncludingPendingInsulin)) - } - - if self.insulinOnBoard == nil { - errors.append(.missingDataError(.activeInsulin)) - } - - dosingDecision.appendErrors(errors) - if let error = errors.first { - logger.error("%{public}@", String(describing: error)) - return (dosingDecision, error) - } - - var loopError: LoopError? - do { - let predictedGlucose = try predictGlucose(using: settings.enabledEffects) - self.predictedGlucose = predictedGlucose - let predictedGlucoseIncludingPendingInsulin = try predictGlucose(using: settings.enabledEffects, includingPendingInsulin: true) - self.predictedGlucoseIncludingPendingInsulin = predictedGlucoseIncludingPendingInsulin - - dosingDecision.predictedGlucose = predictedGlucose - - guard lastRequestedBolus == nil - else { - // Don't recommend changes if a bolus was just requested. - // Sending additional pump commands is not going to be - // successful in any case. - self.logger.debug("Not generating recommendations because bolus request is in progress.") - dosingDecision.appendWarning(.bolusInProgress) - return (dosingDecision, nil) - } - - let rateRounder = { (_ rate: Double) in - return self.delegate?.roundBasalRate(unitsPerHour: rate) ?? rate - } - - let lastTempBasal: DoseEntry? - - if case .some(.tempBasal(let dose)) = basalDeliveryState { - lastTempBasal = dose - } else { - lastTempBasal = nil - } - - let dosingRecommendation: AutomaticDoseRecommendation? - - // automaticDosingIOBLimit calculated from the user entered maxBolus - let automaticDosingIOBLimit = maxBolus! * 2.0 - let iobHeadroom = automaticDosingIOBLimit - self.insulinOnBoard!.value - - switch settings.automaticDosingStrategy { - case .automaticBolus: - let volumeRounder = { (_ units: Double) in - return self.delegate?.roundBolusVolume(units: units) ?? units - } - - // Create dosing strategy based on user setting - let applicationFactorStrategy: ApplicationFactorStrategy = UserDefaults.standard.glucoseBasedApplicationFactorEnabled - ? GlucoseBasedApplicationFactorStrategy() - : ConstantApplicationFactorStrategy() - - let correctionRangeSchedule = settings.effectiveGlucoseTargetRangeSchedule() - - let effectiveBolusApplicationFactor = applicationFactorStrategy.calculateDosingFactor( - for: glucose.quantity, - correctionRangeSchedule: correctionRangeSchedule!, - settings: settings - ) - - self.logger.debug(" *** Glucose: %{public}@, effectiveBolusApplicationFactor: %.2f", glucose.quantity.description, effectiveBolusApplicationFactor) - - // If a user customizes maxPartialApplicationFactor > 1; this respects maxBolus - let maxAutomaticBolus = min(iobHeadroom, maxBolus! * min(effectiveBolusApplicationFactor, 1.0)) - - dosingRecommendation = predictedGlucose.recommendedAutomaticDose( - to: glucoseTargetRange!, - at: predictedGlucose[0].startDate, - suspendThreshold: settings.suspendThreshold?.quantity, - sensitivity: insulinSensitivity!, - model: doseStore.insulinModelProvider.model(for: pumpInsulinType), - basalRates: basalRateSchedule!, - maxAutomaticBolus: maxAutomaticBolus, - partialApplicationFactor: effectiveBolusApplicationFactor * self.timeBasedDoseApplicationFactor, - lastTempBasal: lastTempBasal, - volumeRounder: volumeRounder, - rateRounder: rateRounder, - isBasalRateScheduleOverrideActive: settings.scheduleOverride?.isBasalRateScheduleOverriden(at: startDate) == true - ) - case .tempBasalOnly: - - let temp = predictedGlucose.recommendedTempBasal( - to: glucoseTargetRange!, - at: predictedGlucose[0].startDate, - suspendThreshold: settings.suspendThreshold?.quantity, - sensitivity: insulinSensitivity!, - model: doseStore.insulinModelProvider.model(for: pumpInsulinType), - basalRates: basalRateSchedule!, - maxBasalRate: maxBasal!, - additionalActiveInsulinClamp: iobHeadroom, - lastTempBasal: lastTempBasal, - rateRounder: rateRounder, - isBasalRateScheduleOverrideActive: settings.scheduleOverride?.isBasalRateScheduleOverriden(at: startDate) == true - ) - dosingRecommendation = AutomaticDoseRecommendation(basalAdjustment: temp) - } - - if let dosingRecommendation = dosingRecommendation { - self.logger.default("Recommending dose: %{public}@ at %{public}@", String(describing: dosingRecommendation), String(describing: startDate)) - recommendedAutomaticDose = (recommendation: dosingRecommendation, date: startDate) - } else { - self.logger.default("No dose recommended.") - recommendedAutomaticDose = nil - } - dosingDecision.automaticDoseRecommendation = recommendedAutomaticDose?.recommendation - } catch let error { - loopError = error as? LoopError ?? .unknownError(error) - if let loopError = loopError { - logger.error("Error attempting to predict glucose: %{public}@", String(describing: loopError)) - dosingDecision.appendError(loopError) - } - } - - return (dosingDecision, loopError) - } - - /// *This method should only be called from the `dataAccessQueue`* - private func enactRecommendedAutomaticDose() -> LoopError? { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - - guard let recommendedDose = self.recommendedAutomaticDose else { - return nil - } - - guard abs(recommendedDose.date.timeIntervalSince(now())) < TimeInterval(minutes: 5) else { - return LoopError.recommendationExpired(date: recommendedDose.date) - } - - if case .suspended = basalDeliveryState { - return LoopError.pumpSuspended - } - - let updateGroup = DispatchGroup() - updateGroup.enter() - var delegateError: LoopError? - - delegate?.loopDataManager(self, didRecommend: recommendedDose) { (error) in - delegateError = error - updateGroup.leave() - } - updateGroup.wait() - - if delegateError == nil { - self.recommendedAutomaticDose = nil - } - - return delegateError - } - - /// Ensures that the current temp basal is at or below the proposed max temp basal, and if not, cancel it before proceeding. - /// Calls the completion with `nil` if successful, or an `error` if canceling the active temp basal fails. - func maxTempBasalSavePreflight(unitsPerHour: Double?, completion: @escaping (_ error: Error?) -> Void) { - guard let unitsPerHour = unitsPerHour else { - completion(nil) - return - } - dataAccessQueue.async { - switch self.basalDeliveryState { - case .some(.tempBasal(let dose)): - if dose.unitsPerHour > unitsPerHour { - // Temp basal is higher than proposed rate, so should cancel - self.cancelActiveTempBasal(for: .maximumBasalRateChanged, completion: completion) - } else { - completion(nil) - } - default: - completion(nil) - } - } - } -} - -/// Describes a view into the loop state -protocol LoopState { - /// The last-calculated carbs on board - var carbsOnBoard: CarbValue? { get } - - /// The last-calculated insulin on board - var insulinOnBoard: InsulinValue? { get } - - /// An error in the current state of the loop, or one that happened during the last attempt to loop. - var error: LoopError? { get } - - /// A timeline of average velocity of glucose change counteracting predicted insulin effects - var insulinCounteractionEffects: [GlucoseEffectVelocity] { get } - - /// The calculated timeline of predicted glucose values - var predictedGlucose: [PredictedGlucoseValue]? { get } - - /// The calculated timeline of predicted glucose values, including the effects of pending insulin - var predictedGlucoseIncludingPendingInsulin: [PredictedGlucoseValue]? { get } - - /// The recommended temp basal based on predicted glucose - var recommendedAutomaticDose: (recommendation: AutomaticDoseRecommendation, date: Date)? { get } - - /// The difference in predicted vs actual glucose over a recent period - var retrospectiveGlucoseDiscrepancies: [GlucoseChange]? { get } - - /// The total corrective glucose effect from retrospective correction - var totalRetrospectiveCorrection: HKQuantity? { get } - - /// Calculates a new prediction from the current data using the specified effect inputs - /// - /// This method is intended for visualization purposes only, not dosing calculation. No validation of input data is done. - /// - /// - Parameter inputs: The effect inputs to include - /// - Parameter potentialBolus: A bolus under consideration for which to include effects in the prediction - /// - Parameter potentialCarbEntry: A carb entry under consideration for which to include effects in the prediction - /// - Parameter replacedCarbEntry: An existing carb entry replaced by `potentialCarbEntry` - /// - Parameter includingPendingInsulin: If `true`, the returned prediction will include the effects of scheduled but not yet delivered insulin - /// - Parameter considerPositiveVelocityAndRC: Positive velocity and positive retrospective correction will not be used if this is false. - /// - Returns: An timeline of predicted glucose values - /// - Throws: LoopError.missingDataError if prediction cannot be computed - func predictGlucose(using inputs: PredictionInputEffect, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, includingPendingInsulin: Bool, considerPositiveVelocityAndRC: Bool) throws -> [PredictedGlucoseValue] - - /// Calculates a new prediction from a manual glucose entry in the context of a meal entry - /// - /// - Parameter glucose: The unstored manual glucose entry - /// - Parameter potentialBolus: A bolus under consideration for which to include effects in the prediction - /// - Parameter potentialCarbEntry: A carb entry under consideration for which to include effects in the prediction - /// - Parameter replacedCarbEntry: An existing carb entry replaced by `potentialCarbEntry` - /// - Parameter includingPendingInsulin: If `true`, the returned prediction will include the effects of scheduled but not yet delivered insulin - /// - Parameter considerPositiveVelocityAndRC: Positive velocity and positive retrospective correction will not be used if this is false. - /// - Returns: A timeline of predicted glucose values - func predictGlucoseFromManualGlucose( - _ glucose: NewGlucoseSample, - potentialBolus: DoseEntry?, - potentialCarbEntry: NewCarbEntry?, - replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, - includingPendingInsulin: Bool, - considerPositiveVelocityAndRC: Bool - ) throws -> [PredictedGlucoseValue] - - /// Computes the recommended bolus for correcting a glucose prediction, optionally considering a potential carb entry. - /// - Parameter potentialCarbEntry: A carb entry under consideration for which to include effects in the prediction - /// - Parameter replacedCarbEntry: An existing carb entry replaced by `potentialCarbEntry` - /// - Parameter considerPositiveVelocityAndRC: Positive velocity and positive retrospective correction will not be used if this is false. - /// - Returns: A bolus recommendation, or `nil` if not applicable - /// - Throws: LoopError.missingDataError if recommendation cannot be computed - func recommendBolus(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? - - /// Computes the recommended bolus for correcting a glucose prediction derived from a manual glucose entry, optionally considering a potential carb entry. - /// - Parameter glucose: The unstored manual glucose entry - /// - Parameter potentialCarbEntry: A carb entry under consideration for which to include effects in the prediction - /// - Parameter replacedCarbEntry: An existing carb entry replaced by `potentialCarbEntry` - /// - Parameter considerPositiveVelocityAndRC: Positive velocity and positive retrospective correction will not be used if this is false. - /// - Returns: A bolus recommendation, or `nil` if not applicable - /// - Throws: LoopError.configurationError if recommendation cannot be computed - func recommendBolusForManualGlucose(_ glucose: NewGlucoseSample, consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? -} - -extension LoopState { - /// Calculates a new prediction from the current data using the specified effect inputs - /// - /// This method is intended for visualization purposes only, not dosing calculation. No validation of input data is done. - /// - /// - Parameter inputs: The effect inputs to include - /// - Parameter includingPendingInsulin: If `true`, the returned prediction will include the effects of scheduled but not yet delivered insulin - /// - Returns: An timeline of predicted glucose values - /// - Throws: LoopError.missingDataError if prediction cannot be computed - func predictGlucose(using inputs: PredictionInputEffect, includingPendingInsulin: Bool = false) throws -> [GlucoseValue] { - try predictGlucose(using: inputs, potentialBolus: nil, potentialCarbEntry: nil, replacingCarbEntry: nil, includingPendingInsulin: includingPendingInsulin, considerPositiveVelocityAndRC: true) - } -} - - -extension LoopDataManager { - private struct LoopStateView: LoopState { - - private let loopDataManager: LoopDataManager - private let updateError: LoopError? - - init(loopDataManager: LoopDataManager, updateError: LoopError?) { - self.loopDataManager = loopDataManager - self.updateError = updateError - } - - var carbsOnBoard: CarbValue? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.carbsOnBoard - } - - var insulinOnBoard: InsulinValue? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.insulinOnBoard - } + type(of: self).LoopUpdateContextKey: context.rawValue + ] + ) + } - var error: LoopError? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return updateError ?? loopDataManager.lastLoopError - } + /// Estimate glucose effects of suspending insulin delivery over duration of insulin action starting at the specified date + func insulinDeliveryEffect(at date: Date, insulinType: InsulinType) async throws -> [GlucoseEffect] { + let startSuspend = date + let insulinEffectDuration = insulinModel(for: insulinType).effectDuration + let endSuspend = startSuspend.addingTimeInterval(insulinEffectDuration) - var insulinCounteractionEffects: [GlucoseEffectVelocity] { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.insulinCounteractionEffects - } + var suspendDoses: [BasalRelativeDose] = [] - var predictedGlucose: [PredictedGlucoseValue]? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.predictedGlucose - } + let basal = try await settingsProvider.getBasalHistory(startDate: startSuspend, endDate: endSuspend) + let sensitivity = try await settingsProvider.getInsulinSensitivityHistory(startDate: startSuspend, endDate: endSuspend) - var predictedGlucoseIncludingPendingInsulin: [PredictedGlucoseValue]? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.predictedGlucoseIncludingPendingInsulin - } + // Iterate over basal entries during suspension of insulin delivery + for (index, basalItem) in basal.enumerated() { + var startSuspendDoseDate: Date + var endSuspendDoseDate: Date - var recommendedAutomaticDose: (recommendation: AutomaticDoseRecommendation, date: Date)? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - guard loopDataManager.lastRequestedBolus == nil else { - return nil + guard basalItem.endDate > startSuspend && basalItem.startDate < endSuspend else { + continue } - return loopDataManager.recommendedAutomaticDose - } - var retrospectiveGlucoseDiscrepancies: [GlucoseChange]? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.retrospectiveGlucoseDiscrepanciesSummed - } - - var totalRetrospectiveCorrection: HKQuantity? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.retrospectiveCorrection.totalGlucoseCorrectionEffect - } + if index == 0 { + startSuspendDoseDate = startSuspend + } else { + startSuspendDoseDate = basalItem.startDate + } - func predictGlucose(using inputs: PredictionInputEffect, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, includingPendingInsulin: Bool, considerPositiveVelocityAndRC: Bool) throws -> [PredictedGlucoseValue] { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return try loopDataManager.predictGlucose(using: inputs, potentialBolus: potentialBolus, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: includingPendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC) - } + if index == basal.count - 1 { + endSuspendDoseDate = endSuspend + } else { + endSuspendDoseDate = basal[index + 1].startDate + } - func predictGlucoseFromManualGlucose( - _ glucose: NewGlucoseSample, - potentialBolus: DoseEntry?, - potentialCarbEntry: NewCarbEntry?, - replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, - includingPendingInsulin: Bool, - considerPositiveVelocityAndRC: Bool - ) throws -> [PredictedGlucoseValue] { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return try loopDataManager.predictGlucoseFromManualGlucose(glucose, potentialBolus: potentialBolus, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: includingPendingInsulin, considerPositiveVelocityAndRC: considerPositiveVelocityAndRC) - } + let suspendDose = BasalRelativeDose( + type: .basal(scheduledRate: basalItem.value), + startDate: startSuspendDoseDate, + endDate: endSuspendDoseDate, + volume: 0 + ) - func recommendBolus(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return try loopDataManager.recommendBolus(consideringPotentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, considerPositiveVelocityAndRC: considerPositiveVelocityAndRC) + suspendDoses.append(suspendDose) } - func recommendBolusForManualGlucose(_ glucose: NewGlucoseSample, consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return try loopDataManager.recommendBolusForManualGlucose(glucose, consideringPotentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, considerPositiveVelocityAndRC: considerPositiveVelocityAndRC) - } + // Calculate predicted glucose effect of suspending insulin delivery + return suspendDoses.glucoseEffects( + insulinSensitivityHistory: sensitivity + ).filterDateRange(startSuspend, endSuspend) } - /// 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 { - let (_, updateError) = self.update(for: .getLoopState) - - handler(self, LoopStateView(loopDataManager: self, updateError: updateError)) - } - } - - func generateSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? { - + func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? { + var dosingDecision = BolusDosingDecision(for: .simpleBolus) - - var activeInsulin: Double? = nil - let semaphore = DispatchSemaphore(value: 0) - doseStore.insulinOnBoard(at: Date()) { (result) in - if case .success(let iobValue) = result { - activeInsulin = iobValue.value - dosingDecision.insulinOnBoard = iobValue - } - semaphore.signal() - } - semaphore.wait() - - guard let iob = activeInsulin, - let suspendThreshold = settings.suspendThreshold?.quantity, - let carbRatioSchedule = carbStore.carbRatioScheduleApplyingOverrideHistory, - let correctionRangeSchedule = settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: mealCarbs != nil), - let sensitivitySchedule = insulinSensitivityScheduleApplyingOverrideHistory + + guard let iob = displayState.activeInsulin?.value, + let suspendThreshold = settingsProvider.settings.suspendThreshold?.quantity, + let carbRatioSchedule = temporaryPresetsManager.carbRatioScheduleApplyingOverrideHistory, + let correctionRangeSchedule = temporaryPresetsManager.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: mealCarbs != nil), + let sensitivitySchedule = temporaryPresetsManager.insulinSensitivityScheduleApplyingOverrideHistory else { // Settings incomplete; should never get here; remove when therapy settings non-optional return nil } - - if let scheduleOverride = settings.scheduleOverride, !scheduleOverride.hasFinished() { - dosingDecision.scheduleOverride = settings.scheduleOverride + + if let scheduleOverride = temporaryPresetsManager.scheduleOverride, !scheduleOverride.hasFinished() { + dosingDecision.scheduleOverride = temporaryPresetsManager.scheduleOverride } dosingDecision.glucoseTargetRangeSchedule = correctionRangeSchedule - + var notice: BolusRecommendationNotice? = nil if let manualGlucose = manualGlucose { let glucoseValue = SimpleGlucoseValue(startDate: date, quantity: manualGlucose) @@ -2166,7 +941,7 @@ extension LoopDataManager { } } } - + let bolusAmount = SimpleBolusCalculator.recommendedInsulin( mealCarbs: mealCarbs, manualGlucose: manualGlucose, @@ -2175,169 +950,111 @@ extension LoopDataManager { correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule, at: date) - - dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: bolusAmount.doubleValue(for: .internationalUnit()), pendingInsulin: 0, notice: notice), + + dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: bolusAmount.doubleValue(for: .internationalUnit()), notice: notice), date: Date()) - + return dosingDecision } + + } -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) { - getLoopState { (manager, state) in - - var entries: [String] = [ - "## LoopDataManager", - "settings: \(String(reflecting: manager.settings))", - - "insulinCounteractionEffects: [", - "* GlucoseEffectVelocity(start, end, mg/dL/min)", - manager.insulinCounteractionEffects.reduce(into: "", { (entries, entry) in - entries.append("* \(entry.startDate), \(entry.endDate), \(entry.quantity.doubleValue(for: GlucoseEffectVelocity.unit))\n") - }), - "]", - - "insulinEffect: [", - "* GlucoseEffect(start, mg/dL)", - (manager.insulinEffect ?? []).reduce(into: "", { (entries, entry) in - entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") - }), - "]", - - "carbEffect: [", - "* GlucoseEffect(start, mg/dL)", - (manager.carbEffect ?? []).reduce(into: "", { (entries, entry) in - entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") - }), - "]", - - "predictedGlucose: [", - "* PredictedGlucoseValue(start, mg/dL)", - (state.predictedGlucoseIncludingPendingInsulin ?? []).reduce(into: "", { (entries, entry) in - entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") - }), - "]", - - "integralRetrospectiveCorrectionEnabled: \(UserDefaults.standard.integralRetrospectiveCorrectionEnabled)", - - "retrospectiveGlucoseDiscrepancies: [", - "* GlucoseEffect(start, mg/dL)", - (manager.retrospectiveGlucoseDiscrepancies ?? []).reduce(into: "", { (entries, entry) in - entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") - }), - "]", - - "retrospectiveGlucoseDiscrepanciesSummed: [", - "* GlucoseChange(start, end, mg/dL)", - (manager.retrospectiveGlucoseDiscrepanciesSummed ?? []).reduce(into: "", { (entries, entry) in - entries.append("* \(entry.startDate), \(entry.endDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") - }), - "]", - - "glucoseMomentumEffect: \(manager.glucoseMomentumEffect ?? [])", - "retrospectiveGlucoseEffect: \(manager.retrospectiveGlucoseEffect)", - "recommendedAutomaticDose: \(String(describing: state.recommendedAutomaticDose))", - "lastBolus: \(String(describing: manager.lastRequestedBolus))", - "lastLoopCompleted: \(String(describing: manager.lastLoopCompleted))", - "basalDeliveryState: \(String(describing: manager.basalDeliveryState))", - "carbsOnBoard: \(String(describing: state.carbsOnBoard))", - "insulinOnBoard: \(String(describing: manager.insulinOnBoard))", - "error: \(String(describing: state.error))", - "overrideInUserDefaults: \(String(describing: UserDefaults.appGroup?.intentExtensionOverrideToSet))", - "glucoseBasedApplicationFactorEnabled: \(UserDefaults.standard.glucoseBasedApplicationFactorEnabled)", - "", - String(reflecting: self.retrospectiveCorrection), - "", - ] +extension NewCarbEntry { + var asStoredCarbEntry: StoredCarbEntry { + StoredCarbEntry( + startDate: startDate, + quantity: quantity, + foodType: foodType, + absorptionTime: absorptionTime, + userCreatedDate: date + ) + } +} - self.glucoseStore.generateDiagnosticReport { (report) in - entries.append(report) - entries.append("") - - self.carbStore.generateDiagnosticReport { (report) in - entries.append(report) - entries.append("") - - self.doseStore.generateDiagnosticReport { (report) in - entries.append(report) - entries.append("") - - self.mealDetectionManager.generateDiagnosticReport { report in - entries.append(report) - entries.append("") - - UNUserNotificationCenter.current().generateDiagnosticReport { (report) in - entries.append(report) - entries.append("") - - UIDevice.current.generateDiagnosticReport { (report) in - entries.append(report) - entries.append("") - - completion(entries.joined(separator: "\n")) - } - } - } - } - } - } - } +extension NewGlucoseSample { + var asStoredGlucoseStample: StoredGlucoseSample { + StoredGlucoseSample( + syncIdentifier: syncIdentifier, + syncVersion: syncVersion, + startDate: date, + quantity: quantity, + condition: condition, + trend: trend, + trendRate: trendRate, + isDisplayOnly: isDisplayOnly, + wasUserEntered: wasUserEntered, + device: device + ) } } -extension Notification.Name { - static let LoopDataUpdated = Notification.Name(rawValue: "com.loopkit.Loop.LoopDataUpdated") - static let LoopRunning = Notification.Name(rawValue: "com.loopkit.Loop.LoopRunning") - static let LoopCompleted = Notification.Name(rawValue: "com.loopkit.Loop.LoopCompleted") -} +extension StoredDataAlgorithmInput { -protocol LoopDataManagerDelegate: AnyObject { + func addingDose(dose: InsulinDoseType?) -> StoredDataAlgorithmInput { + var rval = self + if let dose { + rval.doses = doses + [dose] + } + return rval + } - /// Informs the delegate that an immediate basal change is recommended - /// - /// - Parameters: - /// - manager: The manager - /// - basal: The new recommended basal - /// - completion: A closure called once on completion. Will be passed a non-null error if acting on the recommendation fails. - /// - result: The enacted basal - func loopDataManager(_ manager: LoopDataManager, didRecommend automaticDose: (recommendation: AutomaticDoseRecommendation, date: Date), completion: @escaping (LoopError?) -> Void) -> Void + func addingGlucoseSample(sample: GlucoseType?) -> StoredDataAlgorithmInput { + var rval = self + if let sample { + rval.glucoseHistory.append(sample) + } + return rval + } - /// Asks the delegate to round a recommended basal rate to a supported rate - /// - /// - Parameters: - /// - rate: The recommended rate in U/hr - /// - Returns: a supported rate of delivery in Units/hr. The rate returned should not be larger than the passed in rate. - func roundBasalRate(unitsPerHour: Double) -> Double - - /// Asks the delegate to estimate the duration to deliver the bolus. - /// - /// - Parameters: - /// - bolusUnits: size of the bolus in U - /// - Returns: the estimated time it will take to deliver bolus - func loopDataManager(_ manager: LoopDataManager, estimateBolusDuration bolusUnits: Double) -> TimeInterval? - - /// Asks the delegate to round a recommended bolus volume to a supported volume - /// - /// - Parameters: - /// - units: The recommended bolus in U - /// - Returns: a supported bolus volume in U. The volume returned should be the nearest deliverable volume. - func roundBolusVolume(units: Double) -> Double + func addingCarbEntry(carbEntry: CarbType?) -> StoredDataAlgorithmInput { + var rval = self + if let carbEntry { + rval.carbEntries = carbEntries + [carbEntry] + } + return rval + } + + func removingCarbEntry(carbEntry: CarbType?) -> StoredDataAlgorithmInput { + guard let carbEntry else { + return self + } + var rval = self + var currentEntries = self.carbEntries + if let index = currentEntries.firstIndex(of: carbEntry) { + currentEntries.remove(at: index) + } + rval.carbEntries = currentEntries + return rval + } - /// The pump manager status, if one exists. - var pumpManagerStatus: PumpManagerStatus? { get } + func predictGlucose(effectsOptions: AlgorithmEffectsOptions = .all) throws -> [PredictedGlucoseValue] { + let prediction = LoopAlgorithm.generatePrediction( + start: predictionStart, + glucoseHistory: glucoseHistory, + doses: doses, + carbEntries: carbEntries, + basal: basal, + sensitivity: sensitivity, + carbRatio: carbRatio, + algorithmEffectsOptions: effectsOptions, + useIntegralRetrospectiveCorrection: self.useIntegralRetrospectiveCorrection, + useMidAbsorptionISF: true, + carbAbsorptionModel: self.carbAbsorptionModel.model + ) + return prediction.glucose + } +} - /// The pump status highlight, if one exists. - var pumpStatusHighlight: DeviceStatusHighlight? { get } +extension Notification.Name { + static let LoopDataUpdated = Notification.Name(rawValue: "com.loopkit.Loop.LoopDataUpdated") + static let LoopRunning = Notification.Name(rawValue: "com.loopkit.Loop.LoopRunning") + static let LoopCycleCompleted = Notification.Name(rawValue: "com.loopkit.Loop.LoopCycleCompleted") +} - /// The cgm manager status, if one exists. - var cgmManagerStatus: CGMManagerStatus? { get } +protocol BolusDurationEstimator: AnyObject { + func estimateBolusDuration(bolusUnits: Double) -> TimeInterval? } private extension TemporaryScheduleOverride { @@ -2376,111 +1093,12 @@ private extension StoredDosingDecision.Settings { } } -// MARK: - Simulated Core Data - -extension LoopDataManager { - func generateSimulatedHistoricalCoreData(completion: @escaping (Error?) -> Void) { - guard FeatureFlags.simulatedCoreDataEnabled else { - fatalError("\(#function) should be invoked only when simulated core data is enabled") - } - - guard let glucoseStore = glucoseStore as? GlucoseStore, let carbStore = carbStore as? CarbStore, let doseStore = doseStore as? DoseStore, let dosingDecisionStore = dosingDecisionStore as? DosingDecisionStore else { - fatalError("Mock stores should not be used to generate simulated core data") - } - - glucoseStore.generateSimulatedHistoricalGlucoseObjects() { error in - guard error == nil else { - completion(error) - return - } - carbStore.generateSimulatedHistoricalCarbObjects() { error in - guard error == nil else { - completion(error) - return - } - dosingDecisionStore.generateSimulatedHistoricalDosingDecisionObjects() { error in - guard error == nil else { - completion(error) - return - } - doseStore.generateSimulatedHistoricalPumpEvents(completion: completion) - } - } - } - } - - func purgeHistoricalCoreData(completion: @escaping (Error?) -> Void) { - guard FeatureFlags.simulatedCoreDataEnabled else { - fatalError("\(#function) should be invoked only when simulated core data is enabled") - } - - guard let glucoseStore = glucoseStore as? GlucoseStore, let carbStore = carbStore as? CarbStore, let doseStore = doseStore as? DoseStore, let dosingDecisionStore = dosingDecisionStore as? DosingDecisionStore else { - fatalError("Mock stores should not be used to generate simulated core data") - } - - doseStore.purgeHistoricalPumpEvents() { error in - guard error == nil else { - completion(error) - return - } - dosingDecisionStore.purgeHistoricalDosingDecisionObjects() { error in - guard error == nil else { - completion(error) - return - } - carbStore.purgeHistoricalCarbObjects() { error in - guard error == nil else { - completion(error) - return - } - glucoseStore.purgeHistoricalGlucoseObjects(completion: completion) - } - } - } - } -} - -extension LoopDataManager { - public var therapySettings: TherapySettings { - get { - let settings = settings - return TherapySettings(glucoseTargetRangeSchedule: settings.glucoseTargetRangeSchedule, - correctionRangeOverrides: CorrectionRangeOverrides(preMeal: settings.preMealTargetRange, workout: settings.legacyWorkoutTargetRange), - overridePresets: settings.overridePresets, - maximumBasalRatePerHour: settings.maximumBasalRatePerHour, - maximumBolus: settings.maximumBolus, - suspendThreshold: settings.suspendThreshold, - insulinSensitivitySchedule: settings.insulinSensitivitySchedule, - carbRatioSchedule: settings.carbRatioSchedule, - basalRateSchedule: settings.basalRateSchedule, - defaultRapidActingModel: settings.defaultRapidActingModel) - } - - set { - mutateSettings { settings in - settings.defaultRapidActingModel = newValue.defaultRapidActingModel - settings.insulinSensitivitySchedule = newValue.insulinSensitivitySchedule - settings.carbRatioSchedule = newValue.carbRatioSchedule - settings.basalRateSchedule = newValue.basalRateSchedule - settings.glucoseTargetRangeSchedule = newValue.glucoseTargetRangeSchedule - settings.preMealTargetRange = newValue.correctionRangeOverrides?.preMeal - settings.legacyWorkoutTargetRange = newValue.correctionRangeOverrides?.workout - settings.suspendThreshold = newValue.suspendThreshold - settings.maximumBolus = newValue.maximumBolus - settings.maximumBasalRatePerHour = newValue.maximumBasalRatePerHour - settings.overridePresets = newValue.overridePresets ?? [] - } - } - } -} - extension LoopDataManager: ServicesManagerDelegate { - //Overrides - + // Remote Overrides func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws { - guard let preset = settings.overridePresets.first(where: { $0.name == name }) else { + guard let preset = settingsProvider.settings.overridePresets.first(where: { $0.name == name }) else { throw EnactOverrideError.unknownPreset(name) } @@ -2489,19 +1107,16 @@ extension LoopDataManager: ServicesManagerDelegate { if let duration { remoteOverride.duration = duration } - - await enactOverride(remoteOverride) + + temporaryPresetsManager.scheduleOverride = remoteOverride } func cancelCurrentOverride() async throws { - await enactOverride(nil) - } - - func enactOverride(_ override: TemporaryScheduleOverride?) async { - mutateSettings { settings in settings.scheduleOverride = override } + temporaryPresetsManager.scheduleOverride = nil } + enum EnactOverrideError: LocalizedError { case unknownPreset(String) @@ -2518,7 +1133,7 @@ extension LoopDataManager: ServicesManagerDelegate { func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { - let absorptionTime = absorptionTime ?? carbStore.defaultAbsorptionTimes.medium + let absorptionTime = absorptionTime ?? LoopCoreConstants.defaultCarbAbsorptionTimes.medium if absorptionTime < LoopConstants.minCarbAbsorptionTime || absorptionTime > LoopConstants.maxCarbAbsorptionTime { throw CarbActionError.invalidAbsorptionTime(absorptionTime) } @@ -2542,7 +1157,7 @@ extension LoopDataManager: ServicesManagerDelegate { let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) let candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate ?? Date(), foodType: foodType, absorptionTime: absorptionTime) - let _ = try await devliverCarbEntry(candidateCarbEntry) + let _ = try await carbStore.addCarbEntry(candidateCarbEntry) } enum CarbActionError: LocalizedError { @@ -2579,19 +1194,322 @@ extension LoopDataManager: ServicesManagerDelegate { return formatter }() } +} + +extension LoopDataManager: SimpleBolusViewModelDelegate { + + func insulinOnBoard(at date: Date) async -> InsulinValue? { + displayState.activeInsulin + } + + var maximumBolus: Double? { + settingsProvider.settings.maximumBolus + } - //Can't add this concurrency wrapper method to LoopKit due to the minimum iOS version - func devliverCarbEntry(_ carbEntry: NewCarbEntry) async throws -> StoredCarbEntry { - return try await withCheckedThrowingContinuation { continuation in - carbStore.addCarbEntry(carbEntry) { result in - switch result { - case .success(let storedCarbEntry): - continuation.resume(returning: storedCarbEntry) - case .failure(let error): - continuation.resume(throwing: error) - } - } + var suspendThreshold: HKQuantity? { + settingsProvider.settings.suspendThreshold?.quantity + } + + func enactBolus(units: Double, activationType: BolusActivationType) async throws { + try await deliveryDelegate?.enactBolus(units: units, activationType: activationType) + } + +} + +extension LoopDataManager: BolusEntryViewModelDelegate { + func saveGlucose(sample: LoopKit.NewGlucoseSample) async throws -> LoopKit.StoredGlucoseSample { + let storedSamples = try await addGlucose([sample]) + return storedSamples.first! + } + + var preMealOverride: TemporaryScheduleOverride? { + temporaryPresetsManager.preMealOverride + } + + var mostRecentGlucoseDataDate: Date? { + displayState.input?.glucoseHistory.last?.startDate + } + + var mostRecentPumpDataDate: Date? { + return doseStore.lastAddedPumpData + } + + func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool) -> GlucoseRangeSchedule? { + temporaryPresetsManager.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: presumingMealEntry) + } + + func generatePrediction(input: StoredDataAlgorithmInput) throws -> [PredictedGlucoseValue] { + try input.predictGlucose() + } +} + + +extension LoopDataManager: CarbEntryViewModelDelegate { + func scheduleOverrideEnabled(at date: Date) -> Bool { + temporaryPresetsManager.scheduleOverrideEnabled(at: date) + } + + var defaultAbsorptionTimes: DefaultAbsorptionTimes { + LoopCoreConstants.defaultCarbAbsorptionTimes + } + func getGlucoseSamples(start: Date?, end: Date?) async throws -> [StoredGlucoseSample] { + try await glucoseStore.getGlucoseSamples(start: start, end: end) + } +} + +extension LoopDataManager: FavoriteFoodInsightsViewModelDelegate { + func selectedFavoriteFoodLastEaten(_ favoriteFood: StoredFavoriteFood) async throws -> Date? { + try await carbStore.getCarbEntries(start: nil, end: nil, dateAscending: false, fetchLimit: 1, with: favoriteFood.id).first?.startDate + } + + + func getFavoriteFoodCarbEntries(_ favoriteFood: StoredFavoriteFood) async throws -> [LoopKit.StoredCarbEntry] { + try await carbStore.getCarbEntries(start: nil, end: nil, dateAscending: false, fetchLimit: nil, with: favoriteFood.id) + } + + func getHistoricalChartsData(start: Date, end: Date) async throws -> HistoricalChartsData { + // Need to get insulin data from any active doses that might affect this time range + var dosesStart = start.addingTimeInterval(-InsulinMath.defaultInsulinActivityDuration) + let doses = try await doseStore.getNormalizedDoseEntries( + start: dosesStart, + end: end + ).map { $0.simpleDose(with: insulinModel(for: $0.insulinType)) } + + dosesStart = doses.map { $0.startDate }.min() ?? dosesStart + + let basal = try await settingsProvider.getBasalHistory(startDate: dosesStart, endDate: end) + + let carbEntries = try await carbStore.getCarbEntries(start: start, end: end) + + let carbRatio = try await settingsProvider.getCarbRatioHistory(startDate: start, endDate: end) + + let glucose = try await glucoseStore.getGlucoseSamples(start: start, end: end) + + let sensitivityStart = min(start, dosesStart) + + let sensitivity = try await settingsProvider.getInsulinSensitivityHistory(startDate: sensitivityStart, endDate: end) + + let overrides = temporaryPresetsManager.overrideHistory.getOverrideHistory(startDate: sensitivityStart, endDate: end) + + guard !sensitivity.isEmpty else { + throw LoopError.configurationError(.insulinSensitivitySchedule) + } + + let sensitivityWithOverrides = overrides.applySensitivity(over: sensitivity) + + guard !basal.isEmpty else { + throw LoopError.configurationError(.basalRateSchedule) + } + let basalWithOverrides = overrides.applyBasal(over: basal) + + guard !carbRatio.isEmpty else { + throw LoopError.configurationError(.carbRatioSchedule) } + let carbRatioWithOverrides = overrides.applyCarbRatio(over: carbRatio) + + let carbModel: CarbAbsorptionModel = FeatureFlags.nonlinearCarbModelEnabled ? .piecewiseLinear : .linear + + // Overlay basal history on basal doses, splitting doses to get amount delivered relative to basal + let annotatedDoses = doses.annotated(with: basalWithOverrides) + + let insulinEffects = annotatedDoses.glucoseEffects( + insulinSensitivityHistory: sensitivityWithOverrides, + from: start.addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval).dateFlooredToTimeInterval(GlucoseMath.defaultDelta), + to: nil) + + // ICE + let insulinCounteractionEffects = glucose.counteractionEffects(to: insulinEffects) + + // Carb Effects + let carbStatus = carbEntries.map( + to: insulinCounteractionEffects, + carbRatio: carbRatioWithOverrides, + insulinSensitivity: sensitivityWithOverrides + ) + + let carbEffects = carbStatus.dynamicGlucoseEffects( + from: end, + to: end.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration), + carbRatios: carbRatioWithOverrides, + insulinSensitivities: sensitivityWithOverrides, + absorptionModel: carbModel.model + ) + + let carbAbsorptionReview = CarbAbsorptionReview( + carbEntries: carbEntries, + carbStatuses: carbStatus, + effectsVelocities: insulinCounteractionEffects, + carbEffects: carbEffects + ) + + let trimmedDoses = annotatedDoses.filterDateRange(start, end) + let trimmedIOBValues = annotatedDoses.insulinOnBoardTimeline().filterDateRange(start, end) + + let historicalChartsData = HistoricalChartsData( + glucoseValues: glucose, + carbEntries: carbEntries, + doses: trimmedDoses, + iobValues: trimmedIOBValues, + carbAbsorptionReview: carbAbsorptionReview + ) + + return historicalChartsData + } +} + +extension LoopDataManager: ManualDoseViewModelDelegate { + var pumpInsulinType: InsulinType? { + deliveryDelegate?.pumpInsulinType + } + + var settings: StoredSettings { + settingsProvider.settings + } + + var scheduleOverride: TemporaryScheduleOverride? { + temporaryPresetsManager.scheduleOverride } + func insulinActivityDuration(for type: InsulinType?) -> TimeInterval { + return insulinModel(for: type).effectDuration + } + + var algorithmDisplayState: AlgorithmDisplayState { + get async { return displayState } + } + +} + +extension AutomaticDosingStrategy { + var recommendationType: DoseRecommendationType { + switch self { + case .tempBasalOnly: + return .tempBasal + case .automaticBolus: + return .automaticBolus + } + } +} + +extension AutomaticDoseRecommendation { + public var hasDosingChange: Bool { + return basalAdjustment != nil || bolusUnits != nil + } +} + +extension StoredDosingDecision { + mutating func updateFrom(input: StoredDataAlgorithmInput, output: AlgorithmOutput) { + self.historicalGlucose = input.glucoseHistory.map { HistoricalGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) } + switch output.recommendationResult { + case .success(let recommendation): + self.automaticDoseRecommendation = recommendation.automatic + case .failure(let error): + self.appendError(error as? LoopError ?? .unknownError(error)) + } + if let activeInsulin = output.activeInsulin { + self.insulinOnBoard = InsulinValue(startDate: input.predictionStart, value: activeInsulin) + } + if let activeCarbs = output.activeCarbs { + self.carbsOnBoard = CarbValue(startDate: input.predictionStart, value: activeCarbs) + } + self.predictedGlucose = output.predictedGlucose + } +} + +enum CancelActiveTempBasalReason: String { + case automaticDosingDisabled + case unreliableCGMData + case maximumBasalRateChanged +} + +extension LoopDataManager : AlgorithmDisplayStateProvider { + var algorithmState: AlgorithmDisplayState { + return displayState + } +} + +extension LoopDataManager: DiagnosticReportGenerator { + func generateDiagnosticReport() async -> String { + let (algoInput, algoOutput) = displayState.asTuple + + var loopError: Error? + var doseRecommendation: LoopAlgorithmDoseRecommendation? + + if let algoOutput { + switch algoOutput.recommendationResult { + case .success(let recommendation): + doseRecommendation = recommendation + case .failure(let error): + loopError = error + } + } + + let entries: [String] = [ + "## LoopDataManager", + "settings: \(String(reflecting: settingsProvider.settings))", + + "insulinCounteractionEffects: [", + "* GlucoseEffectVelocity(start, end, mg/dL/min)", + (algoOutput?.effects.insulinCounteraction ?? []).reduce(into: "", { (entries, entry) in + entries.append("* \(entry.startDate), \(entry.endDate), \(entry.quantity.doubleValue(for: GlucoseEffectVelocity.unit))\n") + }), + "]", + + "insulinEffect: [", + "* GlucoseEffect(start, mg/dL)", + (algoOutput?.effects.insulin ?? []).reduce(into: "", { (entries, entry) in + entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") + }), + "]", + + "carbEffect: [", + "* GlucoseEffect(start, mg/dL)", + (algoOutput?.effects.carbs ?? []).reduce(into: "", { (entries, entry) in + entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") + }), + "]", + + "predictedGlucose: [", + "* PredictedGlucoseValue(start, mg/dL)", + (algoOutput?.predictedGlucose ?? []).reduce(into: "", { (entries, entry) in + entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") + }), + "]", + + "integralRetrospectiveCorrectionEnabled: \(UserDefaults.standard.integralRetrospectiveCorrectionEnabled)", + + "retrospectiveCorrection: [", + "* GlucoseEffect(start, mg/dL)", + (algoOutput?.effects.retrospectiveCorrection ?? []).reduce(into: "", { (entries, entry) in + entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") + }), + "]", + + "glucoseMomentumEffect: \(algoOutput?.effects.momentum ?? [])", + "recommendedAutomaticDose: \(String(describing: doseRecommendation))", + "lastLoopCompleted: \(String(describing: lastLoopCompleted))", + "carbsOnBoard: \(String(describing: algoOutput?.activeCarbs))", + "insulinOnBoard: \(String(describing: algoOutput?.activeInsulin))", + "error: \(String(describing: loopError))", + "overrideInUserDefaults: \(String(describing: UserDefaults.appGroup?.intentExtensionOverrideToSet))", + "glucoseBasedApplicationFactorEnabled: \(UserDefaults.standard.glucoseBasedApplicationFactorEnabled)", + "integralRetrospectiveCorrectionEanbled: \(String(describing: algoInput?.useIntegralRetrospectiveCorrection))", + "" + ] + return entries.joined(separator: "\n") + + } +} + +extension LoopDataManager: LoopControl {} + +extension LoopDataManager: AutomationHistoryProvider { + func automationHistory(from start: Date, to end: Date) async throws -> [AbsoluteScheduleValue] { + return automationHistory.toTimeline(from: start, to: end) + } +} + +extension CarbMath { + public static let dateAdjustmentPast: TimeInterval = .hours(-12) + public static let dateAdjustmentFuture: TimeInterval = .hours(1) } diff --git a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift index a3922a873a..e014a4332d 100644 --- a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift +++ b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift @@ -11,21 +11,29 @@ import HealthKit import OSLog import LoopCore import LoopKit +import Combine +import LoopAlgorithm enum MissedMealStatus: Equatable { case hasMissedMeal(startTime: Date, carbAmount: Double) case noMissedMeal } +protocol BolusStateProvider { + var bolusState: PumpManagerStatus.BolusState? { get } +} + +protocol AlgorithmDisplayStateProvider { + var algorithmState: AlgorithmDisplayState { get async } +} + +@MainActor class MealDetectionManager { private let log = OSLog(category: "MealDetectionManager") + // All math for meal detection occurs in mg/dL, with settings being converted if in mmol/L private let unit = HKUnit.milligramsPerDeciliter - public var carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule? - public var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? - public var maximumBolus: Double? - /// The last missed meal notification that was sent /// Internal for unit testing var lastMissedMealNotification: MissedMealNotification? = UserDefaults.standard.lastMissedMealNotification { @@ -40,46 +48,84 @@ class MealDetectionManager { /// Timeline from the most recent detection of an missed meal private var lastDetectedMissedMealTimeline: [(date: Date, unexpectedDeviation: Double?, mealThreshold: Double?, rateOfChangeThreshold: Double?)] = [] - - /// Allows for controlling uses of the system date in unit testing - internal var test_currentDate: Date? - - /// Current date. Will return the unit-test configured date if set, or the current date otherwise. - internal var currentDate: Date { - test_currentDate ?? Date() - } - internal func currentDate(timeIntervalSinceNow: TimeInterval = 0) -> Date { - return currentDate.addingTimeInterval(timeIntervalSinceNow) - } - - public init( - carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule?, - insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule?, - maximumBolus: Double?, - test_currentDate: Date? = nil + private var algorithmStateProvider: AlgorithmDisplayStateProvider + private var settingsProvider: SettingsWithOverridesProvider + private var bolusStateProvider: BolusStateProvider + + private lazy var cancellables = Set() + + // For testing only + var test_currentDate: Date? + + init( + algorithmStateProvider: AlgorithmDisplayStateProvider, + settingsProvider: SettingsWithOverridesProvider, + bolusStateProvider: BolusStateProvider ) { - self.carbRatioScheduleApplyingOverrideHistory = carbRatioScheduleApplyingOverrideHistory - self.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivityScheduleApplyingOverrideHistory - self.maximumBolus = maximumBolus - self.test_currentDate = test_currentDate + self.algorithmStateProvider = algorithmStateProvider + self.settingsProvider = settingsProvider + self.bolusStateProvider = bolusStateProvider + + if FeatureFlags.missedMealNotifications { + NotificationCenter.default.publisher(for: .LoopCycleCompleted) + .sink { [weak self] _ in + Task { await self?.run() } + } + .store(in: &cancellables) + } } - + + func run() async { + let algoState = await algorithmStateProvider.algorithmState + guard let input = algoState.input, let output = algoState.output else { + self.log.debug("Skipping run with missing algorithm input/output") + return + } + + let date = test_currentDate ?? Date() + let samplesStart = date.addingTimeInterval(-MissedMealSettings.maxRecency) + + guard let sensitivitySchedule = settingsProvider.insulinSensitivityScheduleApplyingOverrideHistory, + let carbRatioSchedule = settingsProvider.carbRatioSchedule, + let maxBolus = settingsProvider.maximumBolus else + { + return + } + + generateMissedMealNotificationIfNeeded( + at: date, + glucoseSamples: input.glucoseHistory, + insulinCounteractionEffects: output.effects.insulinCounteraction, + carbEffects: output.effects.carbs, + sensitivitySchedule: sensitivitySchedule, + carbRatioSchedule: carbRatioSchedule, + maxBolus: maxBolus + ) + } + // MARK: Meal Detection - func hasMissedMeal(glucoseSamples: [some GlucoseSampleValue], insulinCounteractionEffects: [GlucoseEffectVelocity], carbEffects: [GlucoseEffect], completion: @escaping (MissedMealStatus) -> Void) { + func hasMissedMeal( + at date: Date, + glucoseSamples: [some GlucoseSampleValue], + insulinCounteractionEffects: [GlucoseEffectVelocity], + carbEffects: [GlucoseEffect], + sensitivitySchedule: InsulinSensitivitySchedule, + carbRatioSchedule: CarbRatioSchedule + ) -> MissedMealStatus + { let delta = TimeInterval(minutes: 5) - let intervalStart = currentDate(timeIntervalSinceNow: -MissedMealSettings.maxRecency) - let intervalEnd = currentDate(timeIntervalSinceNow: -MissedMealSettings.minRecency) - let now = self.currentDate - + let intervalStart = date.addingTimeInterval(-MissedMealSettings.maxRecency) + let intervalEnd = date.addingTimeInterval(-MissedMealSettings.minRecency) + let now = date + let filteredGlucoseValues = glucoseSamples.filter { intervalStart <= $0.startDate && $0.startDate <= now } /// Only try to detect if there's a missed meal if there are no calibration/user-entered BGs, /// since these can cause large jumps guard !filteredGlucoseValues.containsUserEntered() else { - completion(.noMissedMeal) - return + return .noMissedMeal } let filteredCarbEffects = carbEffects.filterDateRange(intervalStart, now) @@ -155,9 +201,16 @@ class MealDetectionManager { /// Find the threshold based on a minimum of `missedMealGlucoseRiseThreshold` of change per minute let minutesAgo = now.timeIntervalSince(pastTime).minutes let rateThreshold = MissedMealSettings.glucoseRiseThreshold * minutesAgo - + + let carbRatio = carbRatioSchedule.value(at: pastTime) + let insulinSensitivity = sensitivitySchedule.value(for: unit, at: pastTime) + /// Find the total effect we'd expect to see for a meal with `carbThreshold`-worth of carbs that started at `pastTime` - guard let mealThreshold = self.effectThreshold(mealStart: pastTime, carbsInGrams: MissedMealSettings.minCarbThreshold) else { + guard let mealThreshold = self.effectThreshold( + carbRatio: carbRatio, + insulinSensitivity: insulinSensitivity, + carbsInGrams: MissedMealSettings.minCarbThreshold + ) else { continue } @@ -175,24 +228,30 @@ class MealDetectionManager { let mealTimeTooRecent = now.timeIntervalSince(mealTime) < MissedMealSettings.minRecency guard !mealTimeTooRecent else { - completion(.noMissedMeal) - return + return .noMissedMeal } self.lastDetectedMissedMealTimeline = missedMealTimeline.reversed() - - let carbAmount = self.determineCarbs(mealtime: mealTime, unexpectedDeviation: unexpectedDeviation) - completion(.hasMissedMeal(startTime: mealTime, carbAmount: carbAmount ?? MissedMealSettings.minCarbThreshold)) + + let carbRatio = carbRatioSchedule.value(at: mealTime) + let insulinSensitivity = sensitivitySchedule.value(for: unit, at: mealTime) + + let carbAmount = self.determineCarbs( + carbRatio: carbRatio, + insulinSensitivity: insulinSensitivity, + unexpectedDeviation: unexpectedDeviation + ) + return .hasMissedMeal(startTime: mealTime, carbAmount: carbAmount ?? MissedMealSettings.minCarbThreshold) } - private func determineCarbs(mealtime: Date, unexpectedDeviation: Double) -> Double? { + private func determineCarbs(carbRatio: Double, insulinSensitivity: Double, unexpectedDeviation: Double) -> Double? { var mealCarbs: Double? = nil /// Search `carbAmount`s from `minCarbThreshold` to `maxCarbThreshold` in 5-gram increments, /// seeing if the deviation is at least `carbAmount` of carbs for carbAmount in stride(from: MissedMealSettings.minCarbThreshold, through: MissedMealSettings.maxCarbThreshold, by: 5) { if - let modeledCarbEffect = effectThreshold(mealStart: mealtime, carbsInGrams: carbAmount), + let modeledCarbEffect = effectThreshold(carbRatio: carbRatio, insulinSensitivity: insulinSensitivity, carbsInGrams: carbAmount), unexpectedDeviation >= modeledCarbEffect { mealCarbs = carbAmount @@ -202,14 +261,14 @@ class MealDetectionManager { return mealCarbs } - private func effectThreshold(mealStart: Date, carbsInGrams: Double) -> Double? { - guard - let carbRatio = carbRatioScheduleApplyingOverrideHistory?.value(at: mealStart), - let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory?.value(for: unit, at: mealStart) - else { - return nil - } - + + /// Calculates effect threshold. + /// + /// - Parameters: + /// - carbRatio: Carb ratio in grams per unit in effect at the start of the meal. + /// - insulinSensitivity: Insulin sensitivity in mg/dL/U in effect at the start of the meal. + /// - carbsInGrams: Carbohydrate amount for the meal in grams + private func effectThreshold(carbRatio: Double, insulinSensitivity: Double, carbsInGrams: Double) -> Double? { return carbsInGrams / carbRatio * insulinSensitivity } @@ -220,28 +279,41 @@ class MealDetectionManager { /// - Parameters: /// - insulinCounteractionEffects: the current insulin counteraction effects that have been observed /// - carbEffects: the effects of any active carb entries. Must include effects from `currentDate() - MissedMealSettings.maxRecency` until `currentDate()`. - /// - pendingAutobolusUnits: any autobolus units that are still being delivered. Used to delay the missed meal notification to avoid notifying during an autobolus. - /// - bolusDurationEstimator: estimator of bolus duration that takes the units of the bolus as an input. Used to delay the missed meal notification to avoid notifying during an autobolus. func generateMissedMealNotificationIfNeeded( + at date: Date, glucoseSamples: [some GlucoseSampleValue], insulinCounteractionEffects: [GlucoseEffectVelocity], carbEffects: [GlucoseEffect], - pendingAutobolusUnits: Double? = nil, - bolusDurationEstimator: @escaping (Double) -> TimeInterval? + sensitivitySchedule: InsulinSensitivitySchedule, + carbRatioSchedule: CarbRatioSchedule, + maxBolus: Double ) { - hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: insulinCounteractionEffects, carbEffects: carbEffects) {[weak self] status in - self?.manageMealNotifications(for: status, pendingAutobolusUnits: pendingAutobolusUnits, bolusDurationEstimator: bolusDurationEstimator) - } + let status = hasMissedMeal( + at: date, + glucoseSamples: glucoseSamples, + insulinCounteractionEffects: insulinCounteractionEffects, + carbEffects: carbEffects, + sensitivitySchedule: sensitivitySchedule, + carbRatioSchedule: carbRatioSchedule + ) + + manageMealNotifications( + at: date, + for: status + ) } // Internal for unit testing - func manageMealNotifications(for status: MissedMealStatus, pendingAutobolusUnits: Double? = nil, bolusDurationEstimator getBolusDuration: (Double) -> TimeInterval?) { + func manageMealNotifications( + at date: Date, + for status: MissedMealStatus + ) { // We should remove expired notifications regardless of whether or not there was a meal NotificationManager.removeExpiredMealNotifications() // Figure out if we should deliver a notification - let now = self.currentDate + let now = date let notificationTimeTooRecent = now.timeIntervalSince(lastMissedMealNotification?.deliveryTime ?? .distantPast) < (MissedMealSettings.maxRecency - MissedMealSettings.minRecency) guard @@ -253,24 +325,17 @@ class MealDetectionManager { return } - var clampedCarbAmount = carbAmount - if - let maxBolus = maximumBolus, - let currentCarbRatio = carbRatioScheduleApplyingOverrideHistory?.quantity(at: now).doubleValue(for: .gram()) - { - let maxAllowedCarbAutofill = maxBolus * currentCarbRatio - clampedCarbAmount = min(clampedCarbAmount, maxAllowedCarbAutofill) - } - + let currentCarbRatio = settingsProvider.carbRatioSchedule!.quantity(at: now).doubleValue(for: .gram()) + let maxAllowedCarbAutofill = settingsProvider.maximumBolus! * currentCarbRatio + let clampedCarbAmount = min(carbAmount, maxAllowedCarbAutofill) + log.debug("Delivering a missed meal notification") /// Coordinate the missed meal notification time with any pending autoboluses that `update` may have started /// so that the user doesn't have to cancel the current autobolus to bolus in response to the missed meal notification - if - let pendingAutobolusUnits, - pendingAutobolusUnits > 0, - let estimatedBolusDuration = getBolusDuration(pendingAutobolusUnits), - estimatedBolusDuration < MissedMealSettings.maxNotificationDelay + if let estimatedBolusDuration = bolusStateProvider.bolusTimeRemaining(at: now), + estimatedBolusDuration < MissedMealSettings.maxNotificationDelay, + estimatedBolusDuration > 0 { NotificationManager.sendMissedMealNotification(mealStart: startTime, amountInGrams: clampedCarbAmount, delay: estimatedBolusDuration) lastMissedMealNotification = MissedMealNotification(deliveryTime: now.advanced(by: estimatedBolusDuration), @@ -286,23 +351,25 @@ class MealDetectionManager { /// Generates a diagnostic report about the current state /// /// - parameter completionHandler: A closure called once the report has been generated. The closure takes a single argument of the report string. - func generateDiagnosticReport(_ completionHandler: @escaping (_ report: String) -> Void) { - let report = [ - "## MealDetectionManager", - "", - "* lastMissedMealNotificationTime: \(String(describing: lastMissedMealNotification?.deliveryTime))", - "* lastMissedMealCarbEstimate: \(String(describing: lastMissedMealNotification?.carbAmount))", - "* lastEvaluatedMissedMealTimeline:", - lastEvaluatedMissedMealTimeline.reduce(into: "", { (entries, entry) in - entries.append(" * date: \(entry.date), unexpectedDeviation: \(entry.unexpectedDeviation ?? -1), meal-based threshold: \(entry.mealThreshold ?? -1), change-based threshold: \(entry.rateOfChangeThreshold ?? -1) \n") - }), - "* lastDetectedMissedMealTimeline:", - lastDetectedMissedMealTimeline.reduce(into: "", { (entries, entry) in - entries.append(" * date: \(entry.date), unexpectedDeviation: \(entry.unexpectedDeviation ?? -1), meal-based threshold: \(entry.mealThreshold ?? -1), change-based threshold: \(entry.rateOfChangeThreshold ?? -1) \n") - }) - ] - - completionHandler(report.joined(separator: "\n")) + func generateDiagnosticReport() async -> String { + await withCheckedContinuation { continuation in + let report = [ + "## MealDetectionManager", + "", + "* lastMissedMealNotificationTime: \(String(describing: lastMissedMealNotification?.deliveryTime))", + "* lastMissedMealCarbEstimate: \(String(describing: lastMissedMealNotification?.carbAmount))", + "* lastEvaluatedMissedMealTimeline:", + lastEvaluatedMissedMealTimeline.reduce(into: "", { (entries, entry) in + entries.append(" * date: \(entry.date), unexpectedDeviation: \(entry.unexpectedDeviation ?? -1), meal-based threshold: \(entry.mealThreshold ?? -1), change-based threshold: \(entry.rateOfChangeThreshold ?? -1) \n") + }), + "* lastDetectedMissedMealTimeline:", + lastDetectedMissedMealTimeline.reduce(into: "", { (entries, entry) in + entries.append(" * date: \(entry.date), unexpectedDeviation: \(entry.unexpectedDeviation ?? -1), meal-based threshold: \(entry.mealThreshold ?? -1), change-based threshold: \(entry.rateOfChangeThreshold ?? -1) \n") + }) + ] + + continuation.resume(returning: report.joined(separator: "\n")) + } } } @@ -313,3 +380,37 @@ fileprivate extension BidirectionalCollection where Element: GlucoseSampleValue, return containsCalibrations() || filter({ $0.wasUserEntered }).count != 0 } } + +extension BolusStateProvider { + func bolusTimeRemaining(at date: Date = Date()) -> TimeInterval? { + guard case .inProgress(let dose) = bolusState else { + return nil + } + return max(0, dose.endDate.timeIntervalSince(date)) + } +} + +extension GlucoseEffectVelocity { + /// The integration of the velocity span from `start` to `end` + public func effect(from start: Date, to end: Date) -> GlucoseEffect? { + guard + start <= end, + startDate <= start, + end <= endDate + else { + return nil + } + + let duration = end.timeIntervalSince(start) + let velocityPerSecond = quantity.doubleValue(for: GlucoseEffectVelocity.perSecondUnit) + + return GlucoseEffect( + startDate: end, + quantity: HKQuantity( + unit: .milligramsPerDeciliter, + doubleValue: velocityPerSecond * duration + ) + ) + } +} + diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index 996d147047..b91ab70614 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -19,6 +19,7 @@ enum NotificationManager { } } +@MainActor extension NotificationManager { private static var notificationCategories: Set { var categories = [UNNotificationCategory]() @@ -115,7 +116,6 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } - @MainActor static func sendRemoteBolusNotification(amount: Double) { let notification = UNMutableNotificationContent() let quantityFormatter = QuantityFormatter(for: .internationalUnit()) @@ -138,7 +138,6 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } - @MainActor static func sendRemoteBolusFailureNotification(for error: Error, amountInUnits: Double) { let notification = UNMutableNotificationContent() let quantityFormatter = QuantityFormatter(for: .internationalUnit()) @@ -159,7 +158,6 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } - @MainActor static func sendRemoteCarbEntryNotification(amountInGrams: Double) { let notification = UNMutableNotificationContent() @@ -180,7 +178,6 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } - @MainActor static func sendRemoteCarbEntryFailureNotification(for error: Error, amountInGrams: Double) { let notification = UNMutableNotificationContent() diff --git a/Loop/Managers/OnboardingManager.swift b/Loop/Managers/OnboardingManager.swift index b9f6c8c232..6435e126ed 100644 --- a/Loop/Managers/OnboardingManager.swift +++ b/Loop/Managers/OnboardingManager.swift @@ -11,6 +11,7 @@ import HealthKit import LoopKit import LoopKitUI +@MainActor class OnboardingManager { private let pluginManager: PluginManager private let bluetoothProvider: BluetoothProvider @@ -18,6 +19,7 @@ class OnboardingManager { private let statefulPluginManager: StatefulPluginManager private let servicesManager: ServicesManager private let loopDataManager: LoopDataManager + private let settingsManager: SettingsManager private let supportManager: SupportManager private weak var windowProvider: WindowProvider? private let userDefaults: UserDefaults @@ -43,6 +45,7 @@ class OnboardingManager { init(pluginManager: PluginManager, bluetoothProvider: BluetoothProvider, deviceDataManager: DeviceDataManager, + settingsManager: SettingsManager, statefulPluginManager: StatefulPluginManager, servicesManager: ServicesManager, loopDataManager: LoopDataManager, @@ -53,6 +56,7 @@ class OnboardingManager { self.pluginManager = pluginManager self.bluetoothProvider = bluetoothProvider self.deviceDataManager = deviceDataManager + self.settingsManager = settingsManager self.statefulPluginManager = statefulPluginManager self.servicesManager = servicesManager self.loopDataManager = loopDataManager @@ -62,9 +66,9 @@ class OnboardingManager { self.isSuspended = userDefaults.onboardingManagerIsSuspended - self.isComplete = userDefaults.onboardingManagerIsComplete && loopDataManager.therapySettings.isComplete + self.isComplete = userDefaults.onboardingManagerIsComplete && settingsManager.therapySettings.isComplete if !isComplete { - if loopDataManager.therapySettings.isComplete { + if settingsManager.therapySettings.isComplete { self.completedOnboardingIdentifiers = userDefaults.onboardingManagerCompletedOnboardingIdentifiers } if let activeOnboardingRawValue = userDefaults.onboardingManagerActiveOnboardingRawValue { @@ -143,7 +147,7 @@ class OnboardingManager { } private func displayOnboarding(_ onboarding: OnboardingUI, resuming: Bool) -> Bool { - var onboardingViewController = onboarding.onboardingViewController(onboardingProvider: self, displayGlucosePreference: deviceDataManager.displayGlucosePreference, colorPalette: .default) + var onboardingViewController = onboarding.onboardingViewController(onboardingProvider: self, displayGlucosePreference: deviceDataManager.displayGlucosePreference, colorPalette: .default, adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled) onboardingViewController.cgmManagerOnboardingDelegate = deviceDataManager onboardingViewController.pumpManagerOnboardingDelegate = deviceDataManager onboardingViewController.serviceOnboardingDelegate = servicesManager @@ -255,12 +259,12 @@ extension OnboardingManager: OnboardingDelegate { func onboarding(_ onboarding: OnboardingUI, hasNewTherapySettings therapySettings: TherapySettings) { guard onboarding.pluginIdentifier == activeOnboarding?.pluginIdentifier else { return } - loopDataManager.therapySettings = therapySettings + settingsManager.therapySettings = therapySettings } func onboarding(_ onboarding: OnboardingUI, hasNewDosingEnabled dosingEnabled: Bool) { guard onboarding.pluginIdentifier == activeOnboarding?.pluginIdentifier else { return } - loopDataManager.mutateSettings { settings in + settingsManager.mutateLoopSettings { settings in settings.dosingEnabled = dosingEnabled } } @@ -395,6 +399,11 @@ extension OnboardingManager: PumpManagerProvider { guard let pumpManager = deviceDataManager.pumpManager else { return deviceDataManager.setupPumpManager(withIdentifier: identifier, initialSettings: settings, prefersToSkipUserInteraction: prefersToSkipUserInteraction) } + + guard let pumpManager = pumpManager as? PumpManagerUI else { + return .failure(OnboardingError.invalidState) + } + guard pumpManager.pluginIdentifier == identifier else { return .failure(OnboardingError.invalidState) } @@ -420,36 +429,32 @@ extension OnboardingManager: ServiceProvider { var activeServices: [Service] { servicesManager.activeServices } var availableServices: [ServiceDescriptor] { servicesManager.availableServices } - - func onboardService(withIdentifier identifier: String) -> Swift.Result, Error> { - guard let service = activeServices.first(where: { $0.pluginIdentifier == identifier }) else { - return servicesManager.setupService(withIdentifier: identifier) - } - - if service.isOnboarded { - return .success(.createdAndOnboarded(service)) - } - - guard let serviceUI = service as? ServiceUI else { - return .failure(OnboardingError.invalidState) - } - - return .success(.userInteractionRequired(serviceUI.settingsViewController(colorPalette: .default))) - } } // MARK: - TherapySettingsProvider extension OnboardingManager: TherapySettingsProvider { var onboardingTherapySettings: TherapySettings { - return loopDataManager.therapySettings + return settingsManager.therapySettings + } +} + +// MARK: - PluginHost + +extension OnboardingManager: PluginHost { + nonisolated var hostIdentifier: String { + return Bundle.main.hostIdentifier + } + + nonisolated var hostVersion: String { + return Bundle.main.hostVersion } } // MARK: - OnboardingProvider extension OnboardingManager: OnboardingProvider { - var allowDebugFeatures: Bool { FeatureFlags.allowDebugFeatures } // NOTE: DEBUG FEATURES - DEBUG AND TEST ONLY + nonisolated var allowDebugFeatures: Bool { FeatureFlags.allowDebugFeatures } // NOTE: DEBUG FEATURES - DEBUG AND TEST ONLY } // MARK: - SupportProvider diff --git a/Loop/Managers/RemoteDataServicesManager.swift b/Loop/Managers/RemoteDataServicesManager.swift index bf21376bc3..97fafd4d34 100644 --- a/Loop/Managers/RemoteDataServicesManager.swift +++ b/Loop/Managers/RemoteDataServicesManager.swift @@ -8,7 +8,9 @@ import os.log import Foundation +import LoopAlgorithm import LoopKit +import UIKit enum RemoteDataType: String, CaseIterable { case alert = "Alert" @@ -37,6 +39,11 @@ struct UploadTaskKey: Hashable { } } +protocol AutomationHistoryProvider { + func automationHistory(from start: Date, to end: Date) async throws -> [AbsoluteScheduleValue] +} + +@MainActor final class RemoteDataServicesManager { public typealias RawState = [String: Any] @@ -51,6 +58,7 @@ final class RemoteDataServicesManager { private var unlockedRemoteDataServices = [RemoteDataService]() func addService(_ remoteDataService: RemoteDataService) { + remoteDataService.remoteDataServiceDelegate = self lock.withLock { unlockedRemoteDataServices.append(remoteDataService) } @@ -58,6 +66,7 @@ final class RemoteDataServicesManager { } func restoreService(_ remoteDataService: RemoteDataService) { + remoteDataService.remoteDataServiceDelegate = self lock.withLock { unlockedRemoteDataServices.append(remoteDataService) } @@ -126,7 +135,7 @@ final class RemoteDataServicesManager { private let doseStore: DoseStore - private let dosingDecisionStore: DosingDecisionStore + private let dosingDecisionStore: DosingDecisionStoreProtocol private let glucoseStore: GlucoseStore @@ -134,20 +143,27 @@ final class RemoteDataServicesManager { private let insulinDeliveryStore: InsulinDeliveryStore - private let settingsStore: SettingsStore + private let settingsProvider: SettingsProvider private let overrideHistory: TemporaryScheduleOverrideHistory + private let deviceLog: PersistentDeviceLog + + private let automationHistoryProvider: AutomationHistoryProvider + + init( alertStore: AlertStore, carbStore: CarbStore, doseStore: DoseStore, - dosingDecisionStore: DosingDecisionStore, + dosingDecisionStore: DosingDecisionStoreProtocol, glucoseStore: GlucoseStore, cgmEventStore: CgmEventStore, - settingsStore: SettingsStore, + settingsProvider: SettingsProvider, overrideHistory: TemporaryScheduleOverrideHistory, - insulinDeliveryStore: InsulinDeliveryStore + insulinDeliveryStore: InsulinDeliveryStore, + deviceLog: PersistentDeviceLog, + automationHistoryProvider: AutomationHistoryProvider ) { self.alertStore = alertStore self.carbStore = carbStore @@ -156,9 +172,11 @@ final class RemoteDataServicesManager { self.glucoseStore = glucoseStore self.cgmEventStore = cgmEventStore self.insulinDeliveryStore = insulinDeliveryStore - self.settingsStore = settingsStore + self.settingsProvider = settingsProvider self.overrideHistory = overrideHistory self.lockedFailedUploads = Locked([]) + self.deviceLog = deviceLog + self.automationHistoryProvider = automationHistoryProvider } private func uploadExistingData(to remoteDataService: RemoteDataService) { @@ -179,7 +197,21 @@ final class RemoteDataServicesManager { } } + func triggerAllUploads() { + Task { + for type in RemoteDataType.allCases { + await performUpload(for: type) + } + } + } + func triggerUpload(for triggeringType: RemoteDataType) { + Task { + await performUpload(for: triggeringType) + } + } + + func performUpload(for triggeringType: RemoteDataType) { let uploadTypes = [triggeringType] + failedUploads.map { $0.remoteDataType } log.debug("RemoteDataType %{public}@ triggering uploads for: %{public}@", triggeringType.rawValue, String(describing: uploadTypes.map { $0.debugDescription})) @@ -208,16 +240,16 @@ final class RemoteDataServicesManager { } } - func triggerUpload(for triggeringType: RemoteDataType, completion: @escaping () -> Void) { - triggerUpload(for: triggeringType) + func performUpload(for triggeringType: RemoteDataType, completion: @escaping () -> Void) { + performUpload(for: triggeringType) self.uploadGroup.notify(queue: DispatchQueue.main) { completion() } } - func triggerUpload(for triggeringType: RemoteDataType) async { + func performUpload(for triggeringType: RemoteDataType) async { return await withCheckedContinuation { continuation in - triggerUpload(for: triggeringType) { + performUpload(for: triggeringType) { continuation.resume(returning: ()) } } @@ -240,14 +272,14 @@ extension RemoteDataServicesManager { self.log.error("Error querying alert data: %{public}@", String(describing: error)) semaphore.signal() case .success(let queryAnchor, let data): - remoteDataService.uploadAlertData(data) { result in - switch result { - case .failure(let error): - self.log.error("Error synchronizing alert data: %{public}@", String(describing: error)) - self.uploadFailed(key) - case .success: + Task { + do { + try await remoteDataService.uploadAlertData(data) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .alert, queryAnchor) self.uploadSucceeded(key) + } catch { + self.log.error("Error synchronizing alert data: %{public}@", String(describing: error)) + self.uploadFailed(key) } semaphore.signal() } @@ -276,15 +308,15 @@ extension RemoteDataServicesManager { self.log.error("Error querying carb data: %{public}@", String(describing: error)) semaphore.signal() case .success(let queryAnchor, let created, let updated, let deleted): - remoteDataService.uploadCarbData(created: created, updated: updated, deleted: deleted) { result in - switch result { - case .failure(let error): - self.log.error("Error synchronizing carb data: %{public}@", String(describing: error)) - self.uploadFailed(key) - case .success: + Task { + do { + try await remoteDataService.uploadCarbData(created: created, updated: updated, deleted: deleted) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .carb, queryAnchor) continueUpload = queryAnchor != previousQueryAnchor self.uploadSucceeded(key) + } catch { + self.log.error("Error synchronizing carb data: %{public}@", String(describing: error)) + self.uploadFailed(key) } semaphore.signal() } @@ -318,15 +350,15 @@ extension RemoteDataServicesManager { self.log.error("Error querying dose data: %{public}@", String(describing: error)) semaphore.signal() case .success(let queryAnchor, let created, let deleted): - remoteDataService.uploadDoseData(created: created, deleted: deleted) { result in - switch result { - case .failure(let error): - self.log.error("Error synchronizing dose data: %{public}@", String(describing: error)) - self.uploadFailed(key) - case .success: - UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .dose, queryAnchor) + Task { + do { continueUpload = queryAnchor != previousQueryAnchor + try await remoteDataService.uploadDoseData(created: created, deleted: deleted) + UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .dose, queryAnchor) self.uploadSucceeded(key) + } catch { + self.log.error("Error synchronizing dose data: %{public}@", String(describing: error)) + self.uploadFailed(key) } semaphore.signal() } @@ -360,15 +392,16 @@ extension RemoteDataServicesManager { self.log.error("Error querying dosing decision data: %{public}@", String(describing: error)) semaphore.signal() case .success(let queryAnchor, let data): - remoteDataService.uploadDosingDecisionData(data) { result in - switch result { - case .failure(let error): - self.log.error("Error synchronizing dosing decision data: %{public}@", String(describing: error)) - self.uploadFailed(key) - case .success: + Task { + do { + try await remoteDataService.uploadDosingDecisionData(data) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .dosingDecision, queryAnchor) continueUpload = queryAnchor != previousQueryAnchor self.uploadSucceeded(key) + + } catch { + self.log.error("Error synchronizing dosing decision data: %{public}@", String(describing: error)) + self.uploadFailed(key) } semaphore.signal() } @@ -387,11 +420,8 @@ extension RemoteDataServicesManager { extension RemoteDataServicesManager { private func uploadGlucoseData(to remoteDataService: RemoteDataService) { - - if delegate?.shouldSyncToRemoteService == false { - return - } - + guard delegate?.shouldSyncGlucoseToRemoteService != false else { return } + uploadGroup.enter() let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .glucose) @@ -401,24 +431,22 @@ extension RemoteDataServicesManager { let previousQueryAnchor = UserDefaults.appGroup?.getQueryAnchor(for: remoteDataService, withRemoteDataType: .glucose) ?? GlucoseStore.QueryAnchor() var continueUpload = false - self.glucoseStore.executeGlucoseQuery(fromQueryAnchor: previousQueryAnchor, limit: remoteDataService.glucoseDataLimit ?? Int.max) { result in - switch result { - case .failure(let error): + Task { + do { + let (queryAnchor, data) = try await self.glucoseStore.executeGlucoseQuery(fromQueryAnchor: previousQueryAnchor, limit: remoteDataService.glucoseDataLimit ?? Int.max) + do { + try await remoteDataService.uploadGlucoseData(data) + UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .glucose, queryAnchor) + continueUpload = queryAnchor != previousQueryAnchor + await self.uploadSucceeded(key) + } catch { + self.log.error("Error synchronizing glucose data: %{public}@", String(describing: error)) + await self.uploadFailed(key) + } + semaphore.signal() + } catch { self.log.error("Error querying glucose data: %{public}@", String(describing: error)) semaphore.signal() - case .success(let queryAnchor, let data): - remoteDataService.uploadGlucoseData(data) { result in - switch result { - case .failure(let error): - self.log.error("Error synchronizing glucose data: %{public}@", String(describing: error)) - self.uploadFailed(key) - case .success: - UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .glucose, queryAnchor) - continueUpload = queryAnchor != previousQueryAnchor - self.uploadSucceeded(key) - } - semaphore.signal() - } } } @@ -442,25 +470,22 @@ extension RemoteDataServicesManager { let semaphore = DispatchSemaphore(value: 0) let previousQueryAnchor = UserDefaults.appGroup?.getQueryAnchor(for: remoteDataService, withRemoteDataType: .pumpEvent) ?? DoseStore.QueryAnchor() var continueUpload = false - - self.doseStore.executePumpEventQuery(fromQueryAnchor: previousQueryAnchor, limit: remoteDataService.pumpEventDataLimit ?? Int.max) { result in - switch result { - case .failure(let error): + Task { + do { + let (queryAnchor, data) = try await self.doseStore.executePumpEventQuery(fromQueryAnchor: previousQueryAnchor, limit: remoteDataService.pumpEventDataLimit ?? Int.max) + do { + try await remoteDataService.uploadPumpEventData(data) + UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .pumpEvent, queryAnchor) + continueUpload = queryAnchor != previousQueryAnchor + self.uploadSucceeded(key) + } catch { + self.log.error("Error synchronizing pump event data: %{public}@", String(describing: error)) + self.uploadFailed(key) + } + semaphore.signal() + } catch { self.log.error("Error querying pump event data: %{public}@", String(describing: error)) semaphore.signal() - case .success(let queryAnchor, let data): - remoteDataService.uploadPumpEventData(data) { result in - switch result { - case .failure(let error): - self.log.error("Error synchronizing pump event data: %{public}@", String(describing: error)) - self.uploadFailed(key) - case .success: - UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .pumpEvent, queryAnchor) - continueUpload = queryAnchor != previousQueryAnchor - self.uploadSucceeded(key) - } - semaphore.signal() - } } } @@ -485,21 +510,21 @@ extension RemoteDataServicesManager { let previousQueryAnchor = UserDefaults.appGroup?.getQueryAnchor(for: remoteDataService, withRemoteDataType: .settings) ?? SettingsStore.QueryAnchor() var continueUpload = false - self.settingsStore.executeSettingsQuery(fromQueryAnchor: previousQueryAnchor, limit: remoteDataService.settingsDataLimit ?? Int.max) { result in + self.settingsProvider.executeSettingsQuery(fromQueryAnchor: previousQueryAnchor, limit: remoteDataService.settingsDataLimit ?? Int.max) { result in switch result { case .failure(let error): self.log.error("Error querying settings data: %{public}@", String(describing: error)) semaphore.signal() case .success(let queryAnchor, let data): - remoteDataService.uploadSettingsData(data) { result in - switch result { - case .failure(let error): - self.log.error("Error synchronizing settings data: %{public}@", String(describing: error)) - self.uploadFailed(key) - case .success: + Task { + do { + try await remoteDataService.uploadSettingsData(data) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .settings, queryAnchor) continueUpload = queryAnchor != previousQueryAnchor self.uploadSucceeded(key) + } catch { + self.log.error("Error synchronizing settings data: %{public}@", String(describing: error)) + self.uploadFailed(key) } semaphore.signal() } @@ -529,14 +554,14 @@ extension RemoteDataServicesManager { let (overrides, deletedOverrides, newAnchor) = self.overrideHistory.queryByAnchor(queryAnchor) - remoteDataService.uploadTemporaryOverrideData(updated: overrides, deleted: deletedOverrides) { result in - switch result { - case .failure(let error): - self.log.error("Error synchronizing temporary override data: %{public}@", String(describing: error)) - self.uploadFailed(key) - case .success: + Task { + do { + try await remoteDataService.uploadTemporaryOverrideData(updated: overrides, deleted: deletedOverrides) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .overrides, newAnchor) self.uploadSucceeded(key) + } catch { + self.log.error("Error synchronizing temporary override data: %{public}@", String(describing: error)) + self.uploadFailed(key) } semaphore.signal() } @@ -564,15 +589,15 @@ extension RemoteDataServicesManager { self.log.error("Error querying cgm event data: %{public}@", String(describing: error)) semaphore.signal() case .success(let queryAnchor, let data): - remoteDataService.uploadCgmEventData(data) { result in - switch result { - case .failure(let error): - self.log.error("Error synchronizing cgm event data: %{public}@", String(describing: error)) - self.uploadFailed(key) - case .success: + Task { + do { + try await remoteDataService.uploadCgmEventData(data) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .cgmEvent, queryAnchor) continueUpload = queryAnchor != previousQueryAnchor self.uploadSucceeded(key) + } catch { + self.log.error("Error synchronizing pump event data: %{public}@", String(describing: error)) + self.uploadFailed(key) } semaphore.signal() } @@ -589,6 +614,21 @@ extension RemoteDataServicesManager { } } +// RemoteDataServiceDelegate +extension RemoteDataServicesManager: RemoteDataServiceDelegate { + func automationHistory(from start: Date, to end: Date) async throws -> [AbsoluteScheduleValue] { + try await automationHistoryProvider.automationHistory(from: start, to: end) + } + + func getBasalHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + try await settingsProvider.getBasalHistory(startDate: startDate, endDate: endDate) + } + + func fetchDeviceLogs(startDate: Date, endDate: Date) async throws -> [StoredDeviceLogEntry] { + return try await deviceLog.fetch(startDate: startDate, endDate: endDate) + } +} + //Remote Commands extension RemoteDataServicesManager { @@ -618,8 +658,10 @@ extension RemoteDataServicesManager { } } +extension RemoteDataServicesManager: UploadEventListener { } + protocol RemoteDataServicesManagerDelegate: AnyObject { - var shouldSyncToRemoteService: Bool {get} + var shouldSyncGlucoseToRemoteService: Bool { get } } diff --git a/Loop/Managers/ResetLoopManager.swift b/Loop/Managers/ResetLoopManager.swift index fe3f1710a1..b14829edfa 100644 --- a/Loop/Managers/ResetLoopManager.swift +++ b/Loop/Managers/ResetLoopManager.swift @@ -102,12 +102,14 @@ class ResetLoopManager { private func resetLoopUserDefaults() { // Store values to persist let allowDebugFeatures = UserDefaults.appGroup?.allowDebugFeatures + let defaultEnvironment = UserDefaults.appGroup?.defaultEnvironment // Wipe away whole domain UserDefaults.appGroup?.removePersistentDomain(forName: Bundle.main.appGroupSuiteName) // Restore values to persist UserDefaults.appGroup?.allowDebugFeatures = allowDebugFeatures ?? false + UserDefaults.appGroup?.defaultEnvironment = defaultEnvironment } private func resetLoopDocuments() { diff --git a/Loop/Managers/Service.swift b/Loop/Managers/Service.swift index 9f4b2f0eee..6a6cc25764 100644 --- a/Loop/Managers/Service.swift +++ b/Loop/Managers/Service.swift @@ -12,21 +12,16 @@ import MockKit let staticServices: [Service.Type] = [MockService.self] -let staticServicesByIdentifier: [String: Service.Type] = staticServices.reduce(into: [:]) { (map, Type) in - map[Type.pluginIdentifier] = Type -} - -let availableStaticServices = staticServices.map { (Type) -> ServiceDescriptor in - return ServiceDescriptor(identifier: Type.pluginIdentifier, localizedTitle: Type.localizedTitle) -} +let staticServicesByIdentifier: [String: Service.Type] = [ + MockService.serviceIdentifier: MockService.self +] -func ServiceFromRawValue(_ rawValue: [String: Any]) -> Service? { - guard let serviceIdentifier = rawValue["serviceIdentifier"] as? String, - let rawState = rawValue["state"] as? Service.RawStateValue, - let ServiceType = staticServicesByIdentifier[serviceIdentifier] - else { - return nil +var availableStaticServices: [ServiceDescriptor] { + if FeatureFlags.allowSimulators { + return [ + ServiceDescriptor(identifier: MockService.serviceIdentifier, localizedTitle: MockService.localizedTitle) + ] + } else { + return [] } - - return ServiceType.init(rawState: rawState) } diff --git a/Loop/Managers/ServicesManager.swift b/Loop/Managers/ServicesManager.swift index 2393ceb073..5fbc5b7f41 100644 --- a/Loop/Managers/ServicesManager.swift +++ b/Loop/Managers/ServicesManager.swift @@ -12,6 +12,7 @@ import LoopKitUI import LoopCore import Combine +@MainActor class ServicesManager { private let pluginManager: PluginManager @@ -86,7 +87,7 @@ class ServicesManager { return .failure(UnknownServiceIdentifierError()) } - let result = serviceUIType.setupViewController(colorPalette: .default, pluginHost: self) + let result = serviceUIType.setupViewController(colorPalette: .default, pluginHost: self, allowDebugFeatures: FeatureFlags.allowDebugFeatures) if case .createdAndOnboarded(let serviceUI) = result { serviceOnboarding(didCreateService: serviceUI) serviceOnboarding(didOnboardService: serviceUI) @@ -121,6 +122,10 @@ class ServicesManager { return servicesLock.withLock { services } } + public func getServices() -> [Service] { + return servicesLock.withLock { services } + } + public func addActiveService(_ service: Service) { servicesLock.withLock { service.serviceDelegate = self @@ -213,10 +218,10 @@ class ServicesManager { private func beginBackgroundTask(name: String) async -> UIBackgroundTaskIdentifier? { var backgroundTask: UIBackgroundTaskIdentifier? - backgroundTask = await UIApplication.shared.beginBackgroundTask(withName: name) { + backgroundTask = UIApplication.shared.beginBackgroundTask(withName: name) { guard let backgroundTask = backgroundTask else {return} Task { - await UIApplication.shared.endBackgroundTask(backgroundTask) + UIApplication.shared.endBackgroundTask(backgroundTask) } self.log.error("Background Task Expired: %{public}@", name) @@ -227,7 +232,7 @@ class ServicesManager { private func endBackgroundTask(_ backgroundTask: UIBackgroundTaskIdentifier?) async { guard let backgroundTask else {return} - await UIApplication.shared.endBackgroundTask(backgroundTask) + UIApplication.shared.endBackgroundTask(backgroundTask) } } @@ -254,25 +259,20 @@ extension ServicesManager: StatefulPluggableDelegate { } } -// MARK: - ServiceDelegate - -extension ServicesManager: ServiceDelegate { - var hostIdentifier: String { - return "com.loopkit.Loop" +// MARK: - PluginHost +extension ServicesManager: PluginHost { + nonisolated var hostIdentifier: String { + return Bundle.main.hostIdentifier } - var hostVersion: String { - var semanticVersion = Bundle.main.shortVersionString - - while semanticVersion.split(separator: ".").count < 3 { - semanticVersion += ".0" - } + nonisolated var hostVersion: String { + return Bundle.main.hostVersion + } +} - semanticVersion += "+\(Bundle.main.version)" +// MARK: - ServiceDelegate - return semanticVersion - } - +extension ServicesManager: ServiceDelegate { func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws { var duration: TemporaryScheduleOverride.Duration? = nil @@ -294,7 +294,7 @@ extension ServicesManager: ServiceDelegate { } try await servicesManagerDelegate?.enactOverride(name: name, duration: duration, remoteAddress: remoteAddress) - await remoteDataServicesManager.triggerUpload(for: .overrides) + await remoteDataServicesManager.performUpload(for: .overrides) } enum OverrideActionError: LocalizedError { @@ -314,17 +314,17 @@ extension ServicesManager: ServiceDelegate { func cancelRemoteOverride() async throws { try await servicesManagerDelegate?.cancelCurrentOverride() - await remoteDataServicesManager.triggerUpload(for: .overrides) + await remoteDataServicesManager.performUpload(for: .overrides) } func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { do { try await servicesManagerDelegate?.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) - await NotificationManager.sendRemoteCarbEntryNotification(amountInGrams: amountInGrams) - await remoteDataServicesManager.triggerUpload(for: .carb) + NotificationManager.sendRemoteCarbEntryNotification(amountInGrams: amountInGrams) + await remoteDataServicesManager.performUpload(for: .carb) analyticsServicesManager.didAddCarbs(source: "Remote", amount: amountInGrams) } catch { - await NotificationManager.sendRemoteCarbEntryFailureNotification(for: error, amountInGrams: amountInGrams) + NotificationManager.sendRemoteCarbEntryFailureNotification(for: error, amountInGrams: amountInGrams) throw error } } @@ -345,11 +345,11 @@ extension ServicesManager: ServiceDelegate { } try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits) - await NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) - await remoteDataServicesManager.triggerUpload(for: .dose) + NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) + await remoteDataServicesManager.performUpload(for: .dose) analyticsServicesManager.didBolus(source: "Remote", units: amountInUnits) } catch { - await NotificationManager.sendRemoteBolusFailureNotification(for: error, amountInUnits: amountInUnits) + NotificationManager.sendRemoteBolusFailureNotification(for: error, amountInUnits: amountInUnits) throw error } } @@ -375,14 +375,20 @@ extension ServicesManager: ServiceDelegate { extension ServicesManager: AlertIssuer { func issueAlert(_ alert: Alert) { - alertManager.issueAlert(alert) + Task { @MainActor in + alertManager.issueAlert(alert) + } } func retractAlert(identifier: Alert.Identifier) { - alertManager.retractAlert(identifier: identifier) + Task { @MainActor in + alertManager.retractAlert(identifier: identifier) + } } } +extension ServicesManager: ActiveServicesProvider { } + // MARK: - ServiceOnboardingDelegate extension ServicesManager: ServiceOnboardingDelegate { diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift index e3fdb60bf7..4da04fc75c 100644 --- a/Loop/Managers/SettingsManager.swift +++ b/Loop/Managers/SettingsManager.swift @@ -15,6 +15,7 @@ import Combine import LoopCore import LoopKitUI import os.log +import LoopAlgorithm protocol DeviceStatusProvider { @@ -22,19 +23,22 @@ protocol DeviceStatusProvider { var cgmManagerStatus: CGMManagerStatus? { get } } +@MainActor class SettingsManager { let settingsStore: SettingsStore var remoteDataServicesManager: RemoteDataServicesManager? + var analyticsServicesManager: AnalyticsServicesManager? + var deviceStatusProvider: DeviceStatusProvider? var alertMuter: AlertMuter var displayGlucosePreference: DisplayGlucosePreference? - public var latestSettings: StoredSettings + public var settings: StoredSettings private var remoteNotificationRegistrationResult: Swift.Result? @@ -42,18 +46,26 @@ class SettingsManager { private let log = OSLog(category: "SettingsManager") - init(cacheStore: PersistenceController, expireAfter: TimeInterval, alertMuter: AlertMuter) + private var loopSettingsLock = UnfairLock() + + @Published private(set) var dosingEnabled: Bool + + init(cacheStore: PersistenceController, expireAfter: TimeInterval, alertMuter: AlertMuter, analyticsServicesManager: AnalyticsServicesManager? = nil) { + self.analyticsServicesManager = analyticsServicesManager + settingsStore = SettingsStore(store: cacheStore, expireAfter: expireAfter) self.alertMuter = alertMuter if let storedSettings = settingsStore.latestSettings { - latestSettings = storedSettings + settings = storedSettings } else { - log.default("SettingsStore has no latestSettings: initializing empty StoredSettings.") - latestSettings = StoredSettings() + log.default("SettingsStore has no settings: initializing empty StoredSettings.") + settings = StoredSettings() } + dosingEnabled = settings.dosingEnabled + settingsStore.delegate = self // Migrate old settings from UserDefaults @@ -69,20 +81,9 @@ class SettingsManager { UserDefaults.appGroup?.removeLegacyLoopSettings() } - NotificationCenter.default - .publisher(for: .LoopDataUpdated) - .receive(on: DispatchQueue.main) - .sink { [weak self] note in - let context = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopDataManager.LoopUpdateContext.RawValue - if case .preferences = LoopDataManager.LoopUpdateContext(rawValue: context), let loopDataManager = note.object as? LoopDataManager { - self?.storeSettings(newLoopSettings: loopDataManager.settings) - } - } - .store(in: &cancellables) - self.alertMuter.$configuration .sink { [weak self] alertMuterConfiguration in - guard var notificationSettings = self?.latestSettings.notificationSettings else { return } + guard var notificationSettings = self?.settings.notificationSettings else { return } let newTemporaryMuteAlertsSetting = NotificationSettings.TemporaryMuteAlertSetting(enabled: alertMuterConfiguration.shouldMute, duration: alertMuterConfiguration.duration) if notificationSettings.temporaryMuteAlertsSetting != newTemporaryMuteAlertsSetting { notificationSettings.temporaryMuteAlertsSetting = newTemporaryMuteAlertsSetting @@ -95,21 +96,19 @@ class SettingsManager { var loopSettings: LoopSettings { get { return LoopSettings( - dosingEnabled: latestSettings.dosingEnabled, - glucoseTargetRangeSchedule: latestSettings.glucoseTargetRangeSchedule, - insulinSensitivitySchedule: latestSettings.insulinSensitivitySchedule, - basalRateSchedule: latestSettings.basalRateSchedule, - carbRatioSchedule: latestSettings.carbRatioSchedule, - preMealTargetRange: latestSettings.preMealTargetRange, - legacyWorkoutTargetRange: latestSettings.workoutTargetRange, - overridePresets: latestSettings.overridePresets, - scheduleOverride: latestSettings.scheduleOverride, - preMealOverride: latestSettings.preMealOverride, - maximumBasalRatePerHour: latestSettings.maximumBasalRatePerHour, - maximumBolus: latestSettings.maximumBolus, - suspendThreshold: latestSettings.suspendThreshold, - automaticDosingStrategy: latestSettings.automaticDosingStrategy, - defaultRapidActingModel: latestSettings.defaultRapidActingModel?.presetForRapidActingInsulin) + dosingEnabled: settings.dosingEnabled, + glucoseTargetRangeSchedule: settings.glucoseTargetRangeSchedule, + insulinSensitivitySchedule: settings.insulinSensitivitySchedule, + basalRateSchedule: settings.basalRateSchedule, + carbRatioSchedule: settings.carbRatioSchedule, + preMealTargetRange: settings.preMealTargetRange, + legacyWorkoutTargetRange: settings.workoutTargetRange, + overridePresets: settings.overridePresets, + maximumBasalRatePerHour: settings.maximumBasalRatePerHour, + maximumBolus: settings.maximumBolus, + suspendThreshold: settings.suspendThreshold, + automaticDosingStrategy: settings.automaticDosingStrategy, + defaultRapidActingModel: settings.defaultRapidActingModel?.presetForRapidActingInsulin) } } @@ -124,8 +123,6 @@ class SettingsManager { preMealTargetRange: newLoopSettings.preMealTargetRange, workoutTargetRange: newLoopSettings.legacyWorkoutTargetRange, overridePresets: newLoopSettings.overridePresets, - scheduleOverride: newLoopSettings.scheduleOverride, - preMealOverride: newLoopSettings.preMealOverride, maximumBasalRatePerHour: newLoopSettings.maximumBasalRatePerHour, maximumBolus: newLoopSettings.maximumBolus, suspendThreshold: newLoopSettings.suspendThreshold, @@ -153,40 +150,98 @@ class SettingsManager { let mergedSettings = mergeSettings(newLoopSettings: newLoopSettings, notificationSettings: notificationSettings, deviceToken: deviceTokenStr) - if latestSettings == mergedSettings { + guard settings != mergedSettings else { // Skipping unchanged settings store return } - latestSettings = mergedSettings + settings = mergedSettings if remoteNotificationRegistrationResult == nil && FeatureFlags.remoteCommandsEnabled { // remote notification registration not finished return } - if latestSettings.insulinSensitivitySchedule == nil { + if settings.insulinSensitivitySchedule == nil { log.default("Saving settings with no ISF schedule.") } - settingsStore.storeSettings(latestSettings) { error in + settingsStore.storeSettings(settings) { error in if let error = error { self.log.error("Error storing settings: %{public}@", error.localizedDescription) } } } + /// Sets a new time zone for a the schedule-based settings + /// + /// - Parameter timeZone: The time zone + func setScheduleTimeZone(_ timeZone: TimeZone) { + self.mutateLoopSettings { settings in + settings.basalRateSchedule?.timeZone = timeZone + settings.carbRatioSchedule?.timeZone = timeZone + settings.insulinSensitivitySchedule?.timeZone = timeZone + settings.glucoseTargetRangeSchedule?.timeZone = timeZone + } + } + + private func notify(forChange context: LoopUpdateContext) { + NotificationCenter.default.post(name: .LoopDataUpdated, + object: self, + userInfo: [ + LoopDataManager.LoopUpdateContextKey: context.rawValue + ] + ) + } + + func mutateLoopSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { + loopSettingsLock.withLock { + let oldValue = loopSettings + var newValue = oldValue + changes(&newValue) + + guard oldValue != newValue else { + return + } + + storeSettings(newLoopSettings: newValue) + + if newValue.insulinSensitivitySchedule != oldValue.insulinSensitivitySchedule { + analyticsServicesManager?.didChangeInsulinSensitivitySchedule() + } + + if newValue.basalRateSchedule != oldValue.basalRateSchedule { + if let newValue = newValue.basalRateSchedule, let oldValue = oldValue.basalRateSchedule, newValue.items != oldValue.items { + analyticsServicesManager?.didChangeBasalRateSchedule() + } + } + + if newValue.carbRatioSchedule != oldValue.carbRatioSchedule { + analyticsServicesManager?.didChangeCarbRatioSchedule() + } + + if newValue.defaultRapidActingModel != oldValue.defaultRapidActingModel { + analyticsServicesManager?.didChangeInsulinModel() + } + + if newValue.dosingEnabled != oldValue.dosingEnabled { + self.dosingEnabled = newValue.dosingEnabled + } + } + notify(forChange: .preferences) + } + func storeSettingsCheckingNotificationPermissions() { UNUserNotificationCenter.current().getNotificationSettings() { notificationSettings in DispatchQueue.main.async { - guard let latestSettings = self.settingsStore.latestSettings else { + guard let settings = self.settingsStore.latestSettings else { return } let temporaryMuteAlertSetting = NotificationSettings.TemporaryMuteAlertSetting(enabled: self.alertMuter.configuration.shouldMute, duration: self.alertMuter.configuration.duration) let notificationSettings = NotificationSettings(notificationSettings, temporaryMuteAlertsSetting: temporaryMuteAlertSetting) - if notificationSettings != latestSettings.notificationSettings + if notificationSettings != settings.notificationSettings { self.storeSettings(notificationSettings: notificationSettings) } @@ -206,6 +261,80 @@ class SettingsManager { func purgeHistoricalSettingsObjects(completion: @escaping (Error?) -> Void) { settingsStore.purgeHistoricalSettingsObjects(completion: completion) } + + // MARK: Historical queries + + func getBasalHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + try await settingsStore.getBasalHistory(startDate: startDate, endDate: endDate) + } + + func getCarbRatioHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + try await settingsStore.getCarbRatioHistory(startDate: startDate, endDate: endDate) + } + + func getInsulinSensitivityHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + try await settingsStore.getInsulinSensitivityHistory(startDate: startDate, endDate: endDate) + } + + func getTargetRangeHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue>] { + try await settingsStore.getTargetRangeHistory(startDate: startDate, endDate: endDate) + } + + func getDosingLimits(at date: Date) async throws -> DosingLimits { + try await settingsStore.getDosingLimits(at: date) + } + +} + +extension SettingsManager { + public var therapySettings: TherapySettings { + get { + let settings = self.settings + return TherapySettings(glucoseTargetRangeSchedule: settings.glucoseTargetRangeSchedule, + correctionRangeOverrides: CorrectionRangeOverrides(preMeal: settings.preMealTargetRange, workout: settings.workoutTargetRange), + overridePresets: settings.overridePresets, + maximumBasalRatePerHour: settings.maximumBasalRatePerHour, + maximumBolus: settings.maximumBolus, + suspendThreshold: settings.suspendThreshold, + insulinSensitivitySchedule: settings.insulinSensitivitySchedule, + carbRatioSchedule: settings.carbRatioSchedule, + basalRateSchedule: settings.basalRateSchedule, + defaultRapidActingModel: settings.defaultRapidActingModel?.presetForRapidActingInsulin) + } + + set { + mutateLoopSettings { settings in + settings.defaultRapidActingModel = newValue.defaultRapidActingModel + settings.insulinSensitivitySchedule = newValue.insulinSensitivitySchedule + settings.carbRatioSchedule = newValue.carbRatioSchedule + settings.basalRateSchedule = newValue.basalRateSchedule + settings.glucoseTargetRangeSchedule = newValue.glucoseTargetRangeSchedule + settings.preMealTargetRange = newValue.correctionRangeOverrides?.preMeal + settings.legacyWorkoutTargetRange = newValue.correctionRangeOverrides?.workout + settings.suspendThreshold = newValue.suspendThreshold + settings.maximumBolus = newValue.maximumBolus + settings.maximumBasalRatePerHour = newValue.maximumBasalRatePerHour + settings.overridePresets = newValue.overridePresets ?? [] + } + } + } +} + +protocol SettingsProvider { + var settings: StoredSettings { get } + + func getBasalHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] + func getCarbRatioHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] + func getInsulinSensitivityHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] + func getTargetRangeHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue>] + func getDosingLimits(at date: Date) async throws -> DosingLimits + func executeSettingsQuery(fromQueryAnchor queryAnchor: SettingsStore.QueryAnchor?, limit: Int, completion: @escaping (SettingsStore.SettingsQueryResult) -> Void) +} + +extension SettingsManager: SettingsProvider { + func executeSettingsQuery(fromQueryAnchor queryAnchor: SettingsStore.QueryAnchor?, limit: Int, completion: @escaping (SettingsStore.SettingsQueryResult) -> Void) { + settingsStore.executeSettingsQuery(fromQueryAnchor: queryAnchor, limit: limit, completion: completion) + } } // MARK: - SettingsStoreDelegate @@ -247,3 +376,5 @@ private extension NotificationSettings { ) } } + + diff --git a/Loop/Managers/StatefulPluginManager.swift b/Loop/Managers/StatefulPluginManager.swift index 22fc035b0c..9dfa3f0ede 100644 --- a/Loop/Managers/StatefulPluginManager.swift +++ b/Loop/Managers/StatefulPluginManager.swift @@ -11,12 +11,12 @@ import LoopKitUI import LoopCore import Combine +@MainActor class StatefulPluginManager: StatefulPluggableProvider { private let pluginManager: PluginManager private let servicesManager: ServicesManager - private var statefulPlugins = [StatefulPluggable]() private let statefulPluginLock = UnfairLock() @@ -123,3 +123,5 @@ extension StatefulPluginManager: StatefulPluggableDelegate { removeActiveStatefulPlugin(plugin) } } + +extension StatefulPluginManager: ActiveStatefulPluginsProvider { } diff --git a/Loop/Managers/StatusChartsManager.swift b/Loop/Managers/StatusChartsManager.swift index 79ec51ad62..f047a27575 100644 --- a/Loop/Managers/StatusChartsManager.swift +++ b/Loop/Managers/StatusChartsManager.swift @@ -10,6 +10,7 @@ import LoopKit import LoopUI import LoopKitUI import SwiftCharts +import LoopAlgorithm class StatusChartsManager: ChartsManager { @@ -115,7 +116,7 @@ extension StatusChartsManager { extension StatusChartsManager { - func setDoseEntries(_ doseEntries: [DoseEntry]) { + func setDoseEntries(_ doseEntries: [BasalRelativeDose]) { dose.doseEntries = doseEntries invalidateChart(atIndex: ChartIndex.dose.rawValue) } diff --git a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift index a7ffef2e5e..a565cb703f 100644 --- a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift @@ -10,47 +10,21 @@ import LoopKit import HealthKit protocol CarbStoreProtocol: AnyObject { - - var preferredUnit: HKUnit! { get } - - var delegate: CarbStoreDelegate? { get set } - - // MARK: Settings - var carbRatioSchedule: CarbRatioSchedule? { get set } - - var insulinSensitivitySchedule: InsulinSensitivitySchedule? { get set } - - var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? { get } - - var carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule? { get } - - var maximumAbsorptionTimeInterval: TimeInterval { get } - - var delta: TimeInterval { get } - - var defaultAbsorptionTimes: CarbStore.DefaultAbsorptionTimes { get } - - // MARK: Data Management - func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: NewCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) - - func addCarbEntry(_ entry: NewCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) - - func getCarbStatus(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (_ result: CarbStoreResult<[CarbStatus]>) -> Void) - - func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) - - // MARK: COB & Effect Generation - func getGlucoseEffects(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity], completion: @escaping(_ result: CarbStoreResult<(entries: [StoredCarbEntry], effects: [GlucoseEffect])>) -> Void) - - func glucoseEffects(of samples: [Sample], startingAt start: Date, endingAt end: Date?, effectVelocities: [GlucoseEffectVelocity]) throws -> [GlucoseEffect] - - func getCarbsOnBoardValues(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (_ result: CarbStoreResult<[CarbValue]>) -> Void) - - func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (_ result: CarbStoreResult) -> Void) - - func getTotalCarbs(since start: Date, completion: @escaping (_ result: CarbStoreResult) -> Void) - - func deleteCarbEntry(_ entry: StoredCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) + + func getCarbEntries(start: Date?, end: Date?, dateAscending: Bool, fetchLimit: Int?, with favoriteFoodID: String?) async throws -> [StoredCarbEntry] + + func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: NewCarbEntry) async throws -> StoredCarbEntry + + func addCarbEntry(_ entry: NewCarbEntry) async throws -> StoredCarbEntry + + func deleteCarbEntry(_ oldEntry: StoredCarbEntry) async throws -> Bool + +} + +extension CarbStoreProtocol { + func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] { + try await getCarbEntries(start: start, end: end, dateAscending: true, fetchLimit: nil, with: nil) + } } extension CarbStore: CarbStoreProtocol { } diff --git a/Loop/Managers/Store Protocols/DoseStoreProtocol.swift b/Loop/Managers/Store Protocols/DoseStoreProtocol.swift index dd21ea2a1f..0d0d11d6a9 100644 --- a/Loop/Managers/Store Protocols/DoseStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/DoseStoreProtocol.swift @@ -8,54 +8,16 @@ import LoopKit import HealthKit +import LoopAlgorithm protocol DoseStoreProtocol: AnyObject { - // MARK: settings - var basalProfile: LoopKit.BasalRateSchedule? { get set } + func getNormalizedDoseEntries(start: Date, end: Date?) async throws -> [DoseEntry] - var insulinModelProvider: InsulinModelProvider { get set } - - var longestEffectDuration: TimeInterval { get set } + func addDoses(_ doses: [DoseEntry], from device: HKDevice?) async throws - var insulinSensitivitySchedule: LoopKit.InsulinSensitivitySchedule? { get set } - - var basalProfileApplyingOverrideHistory: BasalRateSchedule? { get } - - // MARK: store information - var lastReservoirValue: LoopKit.ReservoirValue? { get } - - var lastAddedPumpData: Date { get } - - var delegate: DoseStoreDelegate? { get set } - - var device: HKDevice? { get set } - - var pumpRecordsBasalProfileStartEvents: Bool { get set } - - var pumpEventQueryAfterDate: Date { get } - - // MARK: dose management - func addPumpEvents(_ events: [NewPumpEvent], lastReconciliation: Date?, replacePendingEvents: Bool, completion: @escaping (_ error: DoseStore.DoseStoreError?) -> Void) + var lastReservoirValue: ReservoirValue? { get } - func addReservoirValue(_ unitVolume: Double, at date: Date, completion: @escaping (_ value: ReservoirValue?, _ previousValue: ReservoirValue?, _ areStoredValuesContinuous: Bool, _ error: DoseStore.DoseStoreError?) -> Void) - - func getNormalizedDoseEntries(start: Date, end: Date?, completion: @escaping (_ result: DoseStoreResult<[DoseEntry]>) -> Void) - - func executePumpEventQuery(fromQueryAnchor queryAnchor: DoseStore.QueryAnchor?, limit: Int, completion: @escaping (DoseStore.PumpEventQueryResult) -> Void) - - func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) - - func addDoses(_ doses: [DoseEntry], from device: HKDevice?, completion: @escaping (_ error: Error?) -> Void) - - // MARK: IOB and insulin effect - func insulinOnBoard(at date: Date, completion: @escaping (_ result: DoseStoreResult) -> Void) - - func getGlucoseEffects(start: Date, end: Date?, basalDosingEnd: Date?, completion: @escaping (_ result: DoseStoreResult<[GlucoseEffect]>) -> Void) - - func getInsulinOnBoardValues(start: Date, end: Date? , basalDosingEnd: Date?, completion: @escaping (_ result: DoseStoreResult<[InsulinValue]>) -> Void) - - func getTotalUnitsDelivered(since startDate: Date, completion: @escaping (_ result: DoseStoreResult) -> Void) - + var lastAddedPumpData: Date { get } } -extension DoseStore: DoseStoreProtocol { } +extension DoseStore: DoseStoreProtocol {} diff --git a/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift b/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift index 6ff38926f9..79ba9ca090 100644 --- a/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift @@ -8,8 +8,12 @@ import LoopKit -protocol DosingDecisionStoreProtocol: AnyObject { - func storeDosingDecision(_ dosingDecision: StoredDosingDecision, completion: @escaping () -> Void) +protocol DosingDecisionStoreProtocol: CriticalEventLog { + var delegate: DosingDecisionStoreDelegate? { get set } + + func storeDosingDecision(_ dosingDecision: StoredDosingDecision) async + + func executeDosingDecisionQuery(fromQueryAnchor queryAnchor: DosingDecisionStore.QueryAnchor?, limit: Int, completion: @escaping (DosingDecisionStore.DosingDecisionQueryResult) -> Void) } extension DosingDecisionStore: DosingDecisionStoreProtocol { } diff --git a/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift b/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift index adde73c4c7..0f15a19f56 100644 --- a/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift @@ -8,32 +8,12 @@ import LoopKit import HealthKit +import LoopAlgorithm protocol GlucoseStoreProtocol: AnyObject { - var latestGlucose: GlucoseSampleValue? { get } - - var delegate: GlucoseStoreDelegate? { get set } - - var managedDataInterval: TimeInterval? { get set } - - // MARK: Sample Management - func addGlucoseSamples(_ samples: [NewGlucoseSample], completion: @escaping (_ result: Result<[StoredGlucoseSample], Error>) -> Void) - - func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (_ result: Result<[StoredGlucoseSample], Error>) -> Void) - - func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) - - func purgeAllGlucoseSamples(healthKitPredicate: NSPredicate, completion: @escaping (Error?) -> Void) - - func executeGlucoseQuery(fromQueryAnchor queryAnchor: GlucoseStore.QueryAnchor?, limit: Int, completion: @escaping (GlucoseStore.GlucoseQueryResult) -> Void) - - // MARK: Effect Calculation - func getRecentMomentumEffect(for date: Date?, _ completion: @escaping (_ result: Result<[GlucoseEffect], Error>) -> Void) - - func getCounteractionEffects(start: Date, end: Date?, to effects: [GlucoseEffect], _ completion: @escaping (_ effects: Result<[GlucoseEffectVelocity], Error>) -> Void) - - func counteractionEffects(for samples: [Sample], to effects: [GlucoseEffect]) -> [GlucoseEffectVelocity] + func getGlucoseSamples(start: Date?, end: Date?) async throws -> [StoredGlucoseSample] + func addGlucoseSamples(_ samples: [NewGlucoseSample]) async throws -> [StoredGlucoseSample] } extension GlucoseStore: GlucoseStoreProtocol { } diff --git a/Loop/Managers/Store Protocols/LatestStoredSettingsProvider.swift b/Loop/Managers/Store Protocols/LatestStoredSettingsProvider.swift index 72ead59cbc..f220ce00d6 100644 --- a/Loop/Managers/Store Protocols/LatestStoredSettingsProvider.swift +++ b/Loop/Managers/Store Protocols/LatestStoredSettingsProvider.swift @@ -9,7 +9,7 @@ import LoopKit protocol LatestStoredSettingsProvider: AnyObject { - var latestSettings: StoredSettings { get } + var settings: StoredSettings { get } } extension SettingsManager: LatestStoredSettingsProvider { } diff --git a/Loop/Managers/SupportManager.swift b/Loop/Managers/SupportManager.swift index 58cddddf74..5e44909a8d 100644 --- a/Loop/Managers/SupportManager.swift +++ b/Loop/Managers/SupportManager.swift @@ -17,9 +17,10 @@ public protocol DeviceSupportDelegate { var pumpManagerStatus: LoopKit.PumpManagerStatus? { get } var cgmManagerStatus: LoopKit.CGMManagerStatus? { get } - func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) + func generateDiagnosticReport() async -> String } +@MainActor public final class SupportManager { private lazy var log = DiagnosticLog(category: "SupportManager") @@ -38,24 +39,17 @@ public final class SupportManager { private let alertIssuer: AlertIssuer private let deviceSupportDelegate: DeviceSupportDelegate private let pluginManager: PluginManager - private let staticSupportTypes: [SupportUI.Type] - private let staticSupportTypesByIdentifier: [String: SupportUI.Type] lazy private var cancellables = Set() init(pluginManager: PluginManager, deviceSupportDelegate: DeviceSupportDelegate, servicesManager: ServicesManager? = nil, - staticSupportTypes: [SupportUI.Type]? = nil, alertIssuer: AlertIssuer) { self.alertIssuer = alertIssuer self.deviceSupportDelegate = deviceSupportDelegate self.pluginManager = pluginManager - self.staticSupportTypes = [] - staticSupportTypesByIdentifier = self.staticSupportTypes.reduce(into: [:]) { (map, type) in - map[type.pluginIdentifier] = type - } restoreState() @@ -86,8 +80,7 @@ public final class SupportManager { let availablePluginSupports = [SupportUI]() let availableDeviceSupports = deviceSupportDelegate.availableSupports let availableServiceSupports = servicesManager?.availableSupports ?? [SupportUI]() - let staticSupports = self.staticSupportTypes.map { $0.init(rawState: [:]) }.compactMap { $0 } - let allSupports = availablePluginSupports + availableDeviceSupports + availableServiceSupports + staticSupports + let allSupports = availablePluginSupports + availableDeviceSupports + availableServiceSupports allSupports.forEach { addSupport($0) } @@ -99,7 +92,7 @@ public final class SupportManager { } .store(in: &cancellables) - NotificationCenter.default.publisher(for: .LoopCompleted) + NotificationCenter.default.publisher(for: .LoopCycleCompleted) .sink { [weak self] _ in self?.performCheck() } @@ -242,8 +235,8 @@ extension SupportManager: SupportUIDelegate { return Bundle.main.localizedNameAndVersion } - public func generateIssueReport(completion: @escaping (String) -> Void) { - deviceSupportDelegate.generateDiagnosticReport(completion) + public func generateIssueReport() async -> String { + await deviceSupportDelegate.generateDiagnosticReport() } public func issueAlert(_ alert: LoopKit.Alert) { @@ -283,7 +276,7 @@ extension SupportManager { private func supportTypeFromRawValue(_ rawValue: [String: Any]) -> SupportUI.Type? { guard let supportIdentifier = rawValue["supportIdentifier"] as? String, - let supportType = pluginManager.getSupportUITypeByIdentifier(supportIdentifier) ?? staticSupportTypesByIdentifier[supportIdentifier] + let supportType = pluginManager.getSupportUITypeByIdentifier(supportIdentifier) else { return nil } @@ -331,11 +324,10 @@ fileprivate extension UserDefaults { extension SupportUI { var rawValue: RawStateValue { return [ - "supportIdentifier": Self.pluginIdentifier, + "supportIdentifier": pluginIdentifier, "state": rawState ] } - } extension Bundle { diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift new file mode 100644 index 0000000000..2edbdecb12 --- /dev/null +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -0,0 +1,287 @@ +// +// TemporaryPresetsManager.swift +// Loop +// +// Created by Pete Schwamb on 11/1/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import os.log +import LoopCore +import HealthKit + +protocol PresetActivationObserver: AnyObject { + func presetActivated(context: TemporaryScheduleOverride.Context, duration: TemporaryScheduleOverride.Duration) + func presetDeactivated(context: TemporaryScheduleOverride.Context) +} + +class TemporaryPresetsManager { + + private let log = OSLog(category: "TemporaryPresetsManager") + + private var settingsProvider: SettingsProvider + + var overrideHistory = UserDefaults.appGroup?.overrideHistory ?? TemporaryScheduleOverrideHistory.init() + + private var presetActivationObservers: [PresetActivationObserver] = [] + + private var overrideIntentObserver: NSKeyValueObservation? = nil + + init(settingsProvider: SettingsProvider) { + self.settingsProvider = settingsProvider + + self.overrideHistory.relevantTimeWindow = LoopCoreConstants.defaultCarbAbsorptionTimes.slow * 2 + + scheduleOverride = overrideHistory.activeOverride(at: Date()) + + if scheduleOverride?.context == .preMeal { + preMealOverride = scheduleOverride + scheduleOverride = nil + } + + overrideIntentObserver = UserDefaults.appGroup?.observe( + \.intentExtensionOverrideToSet, + options: [.new], + changeHandler: + { [weak self] (defaults, change) in + self?.handleIntentOverrideAction(default: defaults, change: change) + } + ) + } + + private func handleIntentOverrideAction(default: UserDefaults, change: NSKeyValueObservedChange) { + guard let name = change.newValue??.lowercased(), + let appGroup = UserDefaults.appGroup else + { + return + } + + guard let preset = settingsProvider.settings.overridePresets.first(where: {$0.name.lowercased() == name}) else + { + log.error("Override Intent: Unable to find override named '%s'", String(describing: name)) + return + } + + log.default("Override Intent: setting override named '%s'", String(describing: name)) + scheduleOverride = preset.createOverride(enactTrigger: .remote("Siri")) + + // Remove the override from UserDefaults so we don't set it multiple times + appGroup.intentExtensionOverrideToSet = nil + } + + public func addTemporaryPresetObserver(_ observer: PresetActivationObserver) { + presetActivationObservers.append(observer) + } + + public var scheduleOverride: TemporaryScheduleOverride? { + didSet { + guard oldValue != scheduleOverride else { + return + } + + if scheduleOverride != nil { + preMealOverride = nil + } + + if let newValue = scheduleOverride, newValue.context == .preMeal { + preconditionFailure("The `scheduleOverride` field should not be used for a pre-meal target range override; use `preMealOverride` instead") + } + + if scheduleOverride != oldValue { + overrideHistory.recordOverride(scheduleOverride) + + if let oldPreset = oldValue { + for observer in self.presetActivationObservers { + observer.presetDeactivated(context: oldPreset.context) + } + } + if let newPreset = scheduleOverride { + for observer in self.presetActivationObservers { + observer.presetActivated(context: newPreset.context, duration: newPreset.duration) + } + } + } + + notify(forChange: .preferences) + } + } + + public var preMealOverride: TemporaryScheduleOverride? { + didSet { + guard oldValue != preMealOverride else { + return + } + + if let newValue = preMealOverride, newValue.context != .preMeal || newValue.settings.insulinNeedsScaleFactor != nil { + preconditionFailure("The `preMealOverride` field should be used only for a pre-meal target range override") + } + + if preMealOverride != nil { + scheduleOverride = nil + } + + overrideHistory.recordOverride(preMealOverride) + + notify(forChange: .preferences) + } + } + + public var isScheduleOverrideInfiniteWorkout: Bool { + guard let scheduleOverride = scheduleOverride else { return false } + return scheduleOverride.context == .legacyWorkout && scheduleOverride.duration.isInfinite + } + + public func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool = false) -> GlucoseRangeSchedule? { + + guard let glucoseTargetRangeSchedule = settingsProvider.settings.glucoseTargetRangeSchedule else { + return nil + } + + let preMealOverride = presumingMealEntry ? nil : self.preMealOverride + + let currentEffectiveOverride: TemporaryScheduleOverride? + switch (preMealOverride, scheduleOverride) { + case (let preMealOverride?, nil): + currentEffectiveOverride = preMealOverride + case (nil, let scheduleOverride?): + currentEffectiveOverride = scheduleOverride + case (let preMealOverride?, let scheduleOverride?): + currentEffectiveOverride = preMealOverride.scheduledEndDate > Date() + ? preMealOverride + : scheduleOverride + case (nil, nil): + currentEffectiveOverride = nil + } + + if let effectiveOverride = currentEffectiveOverride { + return glucoseTargetRangeSchedule.applyingOverride(effectiveOverride) + } else { + return glucoseTargetRangeSchedule + } + } + + public func scheduleOverrideEnabled(at date: Date = Date()) -> Bool { + return scheduleOverride?.isActive(at: date) == true + } + + public func nonPreMealOverrideEnabled(at date: Date = Date()) -> Bool { + return scheduleOverride?.isActive(at: date) == true + } + + public func preMealTargetEnabled(at date: Date = Date()) -> Bool { + return preMealOverride?.isActive(at: date) == true + } + + public func futureOverrideEnabled(relativeTo date: Date = Date()) -> Bool { + guard let scheduleOverride = scheduleOverride else { return false } + return scheduleOverride.startDate > date + } + + public func enablePreMealOverride(at date: Date = Date(), for duration: TimeInterval) { + preMealOverride = makePreMealOverride(beginningAt: date, for: duration) + } + + private func makePreMealOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { + guard let preMealTargetRange = settingsProvider.settings.preMealTargetRange else { + return nil + } + return TemporaryScheduleOverride( + context: .preMeal, + settings: TemporaryScheduleOverrideSettings(targetRange: preMealTargetRange), + startDate: date, + duration: .finite(duration), + enactTrigger: .local, + syncIdentifier: UUID() + ) + } + + public func enableLegacyWorkoutOverride(at date: Date = Date(), for duration: TimeInterval) { + scheduleOverride = legacyWorkoutOverride(beginningAt: date, for: duration) + preMealOverride = nil + } + + public func legacyWorkoutOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { + guard let legacyWorkoutTargetRange = settingsProvider.settings.workoutTargetRange else { + return nil + } + + return TemporaryScheduleOverride( + context: .legacyWorkout, + settings: TemporaryScheduleOverrideSettings(targetRange: legacyWorkoutTargetRange), + startDate: date, + duration: duration.isInfinite ? .indefinite : .finite(duration), + enactTrigger: .local, + syncIdentifier: UUID() + ) + } + + public func clearOverride(matching context: TemporaryScheduleOverride.Context? = nil) { + if context == .preMeal { + preMealOverride = nil + return + } + + guard let scheduleOverride = scheduleOverride else { return } + + if let context = context { + if scheduleOverride.context == context { + self.scheduleOverride = nil + } + } else { + self.scheduleOverride = nil + } + } + + public var basalRateScheduleApplyingOverrideHistory: BasalRateSchedule? { + if let basalSchedule = settingsProvider.settings.basalRateSchedule { + return overrideHistory.resolvingRecentBasalSchedule(basalSchedule) + } else { + return nil + } + } + + /// The insulin sensitivity schedule, applying recent overrides relative to the current moment in time. + public var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? { + if let insulinSensitivitySchedule = settingsProvider.settings.insulinSensitivitySchedule { + return overrideHistory.resolvingRecentInsulinSensitivitySchedule(insulinSensitivitySchedule) + } else { + return nil + } + } + + public var carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule? { + if let carbRatioSchedule = carbRatioSchedule { + return overrideHistory.resolvingRecentCarbRatioSchedule(carbRatioSchedule) + } else { + return nil + } + } + + private func notify(forChange context: LoopUpdateContext) { + NotificationCenter.default.post(name: .LoopDataUpdated, + object: self, + userInfo: [ + LoopDataManager.LoopUpdateContextKey: context.rawValue + ] + ) + } + +} + +public protocol SettingsWithOverridesProvider { + var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? { get } + var carbRatioSchedule: CarbRatioSchedule? { get } + var maximumBolus: Double? { get } +} + +extension TemporaryPresetsManager : SettingsWithOverridesProvider { + var carbRatioSchedule: LoopKit.CarbRatioSchedule? { + settingsProvider.settings.carbRatioSchedule + } + + var maximumBolus: Double? { + settingsProvider.settings.maximumBolus + } +} diff --git a/Loop/Managers/TestingScenariosManager.swift b/Loop/Managers/TestingScenariosManager.swift index b71e357433..0bc469e864 100644 --- a/Loop/Managers/TestingScenariosManager.swift +++ b/Loop/Managers/TestingScenariosManager.swift @@ -14,30 +14,83 @@ protocol TestingScenariosManagerDelegate: AnyObject { func testingScenariosManager(_ manager: TestingScenariosManager, didUpdateScenarioURLs scenarioURLs: [URL]) } -protocol TestingScenariosManager: AnyObject { - var delegate: TestingScenariosManagerDelegate? { get set } - var activeScenarioURL: URL? { get } - var scenarioURLs: [URL] { get } - var supportManager: SupportManager { get } - func loadScenario(from url: URL, completion: @escaping (Error?) -> Void) - func loadScenario(from url: URL, advancedByLoopIterations iterations: Int, completion: @escaping (Error?) -> Void) - func loadScenario(from url: URL, rewoundByLoopIterations iterations: Int, completion: @escaping (Error?) -> Void) - func stepActiveScenarioBackward(completion: @escaping (Error?) -> Void) - func stepActiveScenarioForward(completion: @escaping (Error?) -> Void) -} +@MainActor +final class TestingScenariosManager: DirectoryObserver { -/// Describes the requirements necessary to implement TestingScenariosManager -protocol TestingScenariosManagerRequirements: TestingScenariosManager { - var deviceManager: DeviceDataManager { get } - var activeScenarioURL: URL? { get set } - var activeScenario: TestingScenario? { get set } - var log: DiagnosticLog { get } - func fetchScenario(from url: URL, completion: @escaping (Result) -> Void) -} + unowned let deviceManager: DeviceDataManager + unowned let supportManager: SupportManager + unowned let pluginManager: PluginManager + unowned let carbStore: CarbStore + unowned let settingsManager: SettingsManager + + let log = DiagnosticLog(category: "LocalTestingScenariosManager") + + private let fileManager = FileManager.default + private let scenariosSource: URL + private var directoryObservationToken: DirectoryObservationToken? + + private(set) var scenarioURLs: [URL] = [] + var activeScenarioURL: URL? + var activeScenario: TestingScenario? + + weak var delegate: TestingScenariosManagerDelegate? { + didSet { + delegate?.testingScenariosManager(self, didUpdateScenarioURLs: scenarioURLs) + } + } + + init( + deviceManager: DeviceDataManager, + supportManager: SupportManager, + pluginManager: PluginManager, + carbStore: CarbStore, + settingsManager: SettingsManager + ) { + guard FeatureFlags.scenariosEnabled else { + fatalError("\(#function) should be invoked only when scenarios are enabled") + } -// MARK: - TestingScenarioManager requirement implementations + self.deviceManager = deviceManager + self.supportManager = supportManager + self.pluginManager = pluginManager + self.carbStore = carbStore + self.settingsManager = settingsManager + self.scenariosSource = Bundle.main.bundleURL.appendingPathComponent("Scenarios") + + log.debug("Loading testing scenarios from %{public}@", scenariosSource.path) + if !fileManager.fileExists(atPath: scenariosSource.path) { + do { + try fileManager.createDirectory(at: scenariosSource, withIntermediateDirectories: false) + } catch { + log.error("%{public}@", String(describing: error)) + } + } + + directoryObservationToken = observeDirectory(at: scenariosSource) { [weak self] in + self?.reloadScenarioURLs() + } + reloadScenarioURLs() + } + + func fetchScenario(from url: URL, completion: (Result) -> Void) { + let result = Result(catching: { try TestingScenario(source: url) }) + completion(result) + } + + private func reloadScenarioURLs() { + do { + let scenarioURLs = try fileManager.contentsOfDirectory(at: scenariosSource, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "json" } + self.scenarioURLs = scenarioURLs + delegate?.testingScenariosManager(self, didUpdateScenarioURLs: scenarioURLs) + log.debug("Reloaded scenario URLs") + } catch { + log.error("%{public}@", String(describing: error)) + } + } +} -extension TestingScenariosManagerRequirements { +extension TestingScenariosManager { func loadScenario(from url: URL, completion: @escaping (Error?) -> Void) { loadScenario( from: url, @@ -110,7 +163,7 @@ private enum ScenarioLoadingError: LocalizedError { } } -extension TestingScenariosManagerRequirements { +extension TestingScenariosManager { private func loadScenario( from url: URL, loadingVia load: @escaping ( @@ -126,7 +179,7 @@ extension TestingScenariosManagerRequirements { load(scenario) { error in if error == nil { self.activeScenarioURL = url - self.log.debug("@{public}%", successLogMessage) + self.log.debug("%{public}@", successLogMessage) } completion(error) } @@ -156,19 +209,9 @@ extension TestingScenariosManagerRequirements { } private func stepForward(_ scenario: TestingScenario, completion: @escaping (TestingScenario) -> Void) { - deviceManager.loopManager.getLoopState { _, state in - var scenario = scenario - guard let recommendedDose = state.recommendedAutomaticDose?.recommendation else { - scenario.stepForward(by: .minutes(5)) - completion(scenario) - return - } - - if let basalAdjustment = recommendedDose.basalAdjustment { - scenario.stepForward(unitsPerHour: basalAdjustment.unitsPerHour, duration: basalAdjustment.duration) - } - completion(scenario) - } + var scenario = scenario + scenario.stepForward(by: .minutes(5)) + completion(scenario) } private func loadScenario(_ scenario: TestingScenario, rewoundByLoopIterations iterations: Int, completion: @escaping (Error?) -> Void) { @@ -181,81 +224,83 @@ extension TestingScenariosManagerRequirements { } private func loadScenario(_ scenario: TestingScenario, completion: @escaping (Error?) -> Void) { - guard FeatureFlags.scenariosEnabled else { - fatalError("\(#function) should be invoked only when scenarios are enabled") - } - func bail(with error: Error) { activeScenarioURL = nil log.error("%{public}@", String(describing: error)) completion(error) } - let instance = scenario.instantiate() - - var testingCGMManager: TestingCGMManager? - var testingPumpManager: TestingPumpManager? - - if instance.hasCGMData { - if let cgmManager = deviceManager.cgmManager as? TestingCGMManager { - if instance.shouldReloadManager?.cgm == true { - testingCGMManager = reloadCGMManager(withIdentifier: cgmManager.pluginIdentifier) - } else { - testingCGMManager = cgmManager - } - } else { - bail(with: ScenarioLoadingError.noTestingCGMManagerEnabled) - return - } - } - - if instance.hasPumpData { - if let pumpManager = deviceManager.pumpManager as? TestingPumpManager { - if instance.shouldReloadManager?.pump == true { - testingPumpManager = reloadPumpManager(withIdentifier: pumpManager.pluginIdentifier) - } else { - testingPumpManager = pumpManager - } - } else { - bail(with: ScenarioLoadingError.noTestingPumpManagerEnabled) - return - } + guard FeatureFlags.scenariosEnabled else { + fatalError("\(#function) should be invoked only when scenarios are enabled") } - wipeExistingData { error in - guard error == nil else { - bail(with: error!) - return - } - - self.deviceManager.carbStore.addCarbEntries(instance.carbEntries) { result in - switch result { - case .success(_): - testingPumpManager?.reservoirFillFraction = 1.0 - testingPumpManager?.injectPumpEvents(instance.pumpEvents) - testingCGMManager?.injectGlucoseSamples(instance.pastGlucoseSamples, futureSamples: instance.futureGlucoseSamples) - self.activeScenario = scenario - completion(nil) - case .failure(let error): - bail(with: error) + Task { [weak self] in + do { + try await self?.wipeExistingData() + let instance = scenario.instantiate() + + let _: Void = try await withCheckedThrowingContinuation { continuation in + self?.carbStore.addNewCarbEntries(entries: instance.carbEntries, completion: { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + }) } - } - } - - instance.deviceActions.forEach { [testingCGMManager, testingPumpManager] action in - if testingCGMManager?.pluginIdentifier == action.managerIdentifier { - testingCGMManager?.trigger(action: action) - } else if testingPumpManager?.pluginIdentifier == action.managerIdentifier { - testingPumpManager?.trigger(action: action) + + var testingCGMManager: TestingCGMManager? + var testingPumpManager: TestingPumpManager? + + if instance.hasCGMData { + if let cgmManager = self?.deviceManager.cgmManager as? TestingCGMManager { + if instance.shouldReloadManager?.cgm == true { + testingCGMManager = await self?.reloadCGMManager(withIdentifier: cgmManager.pluginIdentifier) + } else { + testingCGMManager = cgmManager + } + } else { + bail(with: ScenarioLoadingError.noTestingCGMManagerEnabled) + return + } + } + + if instance.hasPumpData { + if let pumpManager = self?.deviceManager.pumpManager as? TestingPumpManager { + if instance.shouldReloadManager?.pump == true { + testingPumpManager = self?.reloadPumpManager(withIdentifier: pumpManager.pluginIdentifier) + } else { + testingPumpManager = pumpManager + } + } else { + bail(with: ScenarioLoadingError.noTestingPumpManagerEnabled) + return + } + } + + testingPumpManager?.reservoirFillFraction = 1.0 + testingPumpManager?.injectPumpEvents(instance.pumpEvents) + testingCGMManager?.injectGlucoseSamples(instance.pastGlucoseSamples, futureSamples: instance.futureGlucoseSamples) + + self?.activeScenario = scenario + + instance.deviceActions.forEach { [testingCGMManager, testingPumpManager] action in + testingCGMManager?.trigger(action: action) + testingPumpManager?.trigger(action: action) + } + + completion(nil) + } catch { + bail(with: error) } } } private func reloadPumpManager(withIdentifier pumpManagerIdentifier: String) -> TestingPumpManager { deviceManager.pumpManager = nil - guard let maximumBasalRate = deviceManager.loopManager.settings.maximumBasalRatePerHour, - let maxBolus = deviceManager.loopManager.settings.maximumBolus, - let basalSchedule = deviceManager.loopManager.settings.basalRateSchedule else + guard let maximumBasalRate = settingsManager.settings.maximumBasalRatePerHour, + let maxBolus = settingsManager.settings.maximumBolus, + let basalSchedule = settingsManager.settings.basalRateSchedule else { fatalError("Failed to reload pump manager. Missing initial settings") } @@ -278,96 +323,66 @@ extension TestingScenariosManagerRequirements { } } - private func reloadCGMManager(withIdentifier cgmManagerIdentifier: String) -> TestingCGMManager { - deviceManager.cgmManager = nil - let result = deviceManager.setupCGMManager(withIdentifier: cgmManagerIdentifier, prefersToSkipUserInteraction: true) - switch result { - case .success(let setupUIResult): - switch setupUIResult { - case .createdAndOnboarded(let cgmManager): - return cgmManager as! TestingCGMManager - default: - fatalError("Failed to reload CGM manager. UI interaction required for setup") + private func reloadCGMManager(withIdentifier cgmManagerIdentifier: String) async -> TestingCGMManager { + await withCheckedContinuation { continuation in + self.deviceManager.cgmManager?.delete() { [weak self] in + let result = self?.deviceManager.setupCGMManager(withIdentifier: cgmManagerIdentifier, prefersToSkipUserInteraction: true) + switch result { + case .success(let setupUIResult): + switch setupUIResult { + case .createdAndOnboarded(let cgmManager): + let cgmManager = cgmManager as! TestingCGMManager + cgmManager.autoStartTrace = false + continuation.resume(returning: cgmManager) + default: + fatalError("Failed to reload CGM manager. UI interaction required for setup") + } + default: + fatalError("Failed to reload CGM manager. Setup failed") + } } - default: - fatalError("Failed to reload CGM manager. Setup failed") } } - private func wipeExistingData(completion: @escaping (Error?) -> Void) { + private func wipeExistingData() async throws { guard FeatureFlags.scenariosEnabled else { fatalError("\(#function) should be invoked only when scenarios are enabled") } - deviceManager.deleteTestingPumpData { error in - guard error == nil else { - completion(error!) - return - } - - self.deviceManager.deleteTestingCGMData { error in - guard error == nil else { - completion(error!) - return - } - - self.deviceManager.carbStore.deleteAllCarbEntries() { error in - guard error == nil else { - completion(error!) - return - } - - self.deviceManager.alertManager.alertStore.purge(before: Date(), completion: completion) - } - } + try await deviceManager.deleteTestingPumpData() + + try await deviceManager.deleteTestingCGMData() + + try await carbStore.deleteAllCarbEntries() + + await withCheckedContinuation { [weak alertStore = deviceManager.alertManager.alertStore] continuation in + alertStore?.purge(before: Date(), completion: { _ in + continuation.resume() + }) } } } private extension CarbStore { - /// Errors if adding any individual entry errors. - func addCarbEntries(_ entries: [NewCarbEntry], completion: @escaping (CarbStoreResult<[StoredCarbEntry]>) -> Void) { - addCarbEntries(entries[...], completion: completion) - } - - private func addCarbEntries(_ entries: ArraySlice, completion: @escaping (CarbStoreResult<[StoredCarbEntry]>) -> Void) { - guard let entry = entries.first else { - completion(.success([])) - return - } - - addCarbEntry(entry) { individualResult in - switch individualResult { - case .success(let entry): - let remainder = entries.dropFirst() - self.addCarbEntries(remainder) { collectiveResult in - switch collectiveResult { - case .success(let entries): - completion(.success([entry] + entries)) - case .failure(let error): - completion(.failure(error)) - } - } - case .failure(let error): - completion(.failure(error)) - } - } - } /// Errors if getting carb entries errors, or if deleting any individual entry errors. - func deleteAllCarbEntries(completion: @escaping (CarbStoreError?) -> Void) { - getCarbEntries() { result in - switch result { - case .success(let entries): - self.deleteCarbEntries(entries[...], completion: completion) - case .failure(let error): - completion(error) + func deleteAllCarbEntries() async throws { + try await withCheckedThrowingContinuation { continuation in + getCarbEntries() { result in + switch result { + case .success(let entries): + self.deleteCarbEntries(entries[...], completion: { _ in + continuation.resume() + }) + case .failure(let error): + continuation.resume(throwing: error) + } } } } - private func deleteCarbEntries(_ entries: ArraySlice, completion: @escaping (CarbStoreError?) -> Void) { + private func deleteCarbEntries(_ entries: ArraySlice, completion: @escaping (Error?) -> Void) { guard let entry = entries.first else { completion(nil) return diff --git a/Loop/Managers/TrustedTimeChecker.swift b/Loop/Managers/TrustedTimeChecker.swift index 4d627b9f8f..ee2704a09f 100644 --- a/Loop/Managers/TrustedTimeChecker.swift +++ b/Loop/Managers/TrustedTimeChecker.swift @@ -9,8 +9,9 @@ import LoopKit import TrueTime import UIKit +import Combine -fileprivate extension UserDefaults { +extension UserDefaults { private enum Key: String { case detectedSystemTimeOffset = "com.loopkit.Loop.DetectedSystemTimeOffset" } @@ -25,7 +26,12 @@ fileprivate extension UserDefaults { } } -class TrustedTimeChecker { +protocol TrustedTimeChecker { + var detectedSystemTimeOffset: TimeInterval { get } +} + +@MainActor +class LoopTrustedTimeChecker: TrustedTimeChecker { private let acceptableTimeDelta = TimeInterval.seconds(120) // For NTP time checking @@ -33,9 +39,15 @@ class TrustedTimeChecker { private weak var alertManager: AlertManager? private lazy var log = DiagnosticLog(category: "TrustedTimeChecker") + lazy private var cancellables = Set() + + nonisolated var detectedSystemTimeOffset: TimeInterval { - didSet { - UserDefaults.standard.detectedSystemTimeOffset = detectedSystemTimeOffset + get { + UserDefaults.standard.detectedSystemTimeOffset ?? 0 + } + set { + UserDefaults.standard.detectedSystemTimeOffset = newValue } } @@ -48,11 +60,23 @@ class TrustedTimeChecker { #endif ntpClient.start() self.alertManager = alertManager - self.detectedSystemTimeOffset = UserDefaults.standard.detectedSystemTimeOffset ?? 0 - NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, - object: nil, queue: nil) { [weak self] _ in self?.checkTrustedTime() } - NotificationCenter.default.addObserver(forName: .LoopRunning, - object: nil, queue: nil) { [weak self] _ in self?.checkTrustedTime() } + + NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification) + .sink { [weak self] _ in + Task { + self?.checkTrustedTime() + } + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .LoopRunning) + .sink { [weak self] _ in + Task { + self?.checkTrustedTime() + } + } + .store(in: &cancellables) + checkTrustedTime() } diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index bac60b71dc..c73af7aeea 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -12,19 +12,41 @@ import WatchConnectivity import LoopKit import LoopCore +@MainActor final class WatchDataManager: NSObject { private unowned let deviceManager: DeviceDataManager - - init(deviceManager: DeviceDataManager, healthStore: HKHealthStore) { + private unowned let settingsManager: SettingsManager + private unowned let loopDataManager: LoopDataManager + private unowned let carbStore: CarbStore + private unowned let glucoseStore: GlucoseStore + private unowned let analyticsServicesManager: AnalyticsServicesManager? + private unowned let temporaryPresetsManager: TemporaryPresetsManager + + init( + deviceManager: DeviceDataManager, + settingsManager: SettingsManager, + loopDataManager: LoopDataManager, + carbStore: CarbStore, + glucoseStore: GlucoseStore, + analyticsServicesManager: AnalyticsServicesManager?, + temporaryPresetsManager: TemporaryPresetsManager, + healthStore: HKHealthStore + ) { self.deviceManager = deviceManager + self.settingsManager = settingsManager + self.loopDataManager = loopDataManager + self.carbStore = carbStore + self.glucoseStore = glucoseStore + self.analyticsServicesManager = analyticsServicesManager + self.temporaryPresetsManager = temporaryPresetsManager self.sleepStore = SleepStore(healthStore: healthStore) self.lastBedtimeQuery = UserDefaults.appGroup?.lastBedtimeQuery ?? .distantPast self.bedtime = UserDefaults.appGroup?.bedtime super.init() - NotificationCenter.default.addObserver(self, selector: #selector(updateWatch(_:)), name: .LoopDataUpdated, object: deviceManager.loopManager) + NotificationCenter.default.addObserver(self, selector: #selector(updateWatch(_:)), name: .LoopDataUpdated, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(sendSupportedBolusVolumesIfNeeded), name: .PumpManagerChanged, object: deviceManager) watchSession?.delegate = self @@ -41,7 +63,7 @@ final class WatchDataManager: NSObject { } }() - private var lastSentSettings: LoopSettings? + private var lastSentUserInfo: LoopSettingsUserInfo? private var lastSentBolusVolumes: [Double]? private var contextDosingDecisions: [Date: BolusDosingDecision] { @@ -100,8 +122,8 @@ final class WatchDataManager: NSObject { @objc private func updateWatch(_ notification: Notification) { guard - let rawUpdateContext = notification.userInfo?[LoopDataManager.LoopUpdateContextKey] as? LoopDataManager.LoopUpdateContext.RawValue, - let updateContext = LoopDataManager.LoopUpdateContext(rawValue: rawUpdateContext) + let rawUpdateContext = notification.userInfo?[LoopDataManager.LoopUpdateContextKey] as? LoopUpdateContext.RawValue, + let updateContext = LoopUpdateContext(rawValue: rawUpdateContext) else { return } @@ -120,7 +142,10 @@ final class WatchDataManager: NSObject { private lazy var minTrendUnit = HKUnit.milligramsPerDeciliter private func sendSettingsIfNeeded() { - let settings = deviceManager.loopManager.settings + let userInfo = LoopSettingsUserInfo( + loopSettings: settingsManager.loopSettings, + scheduleOverride: temporaryPresetsManager.scheduleOverride, + preMealOverride: temporaryPresetsManager.preMealOverride) guard let session = watchSession, session.isPaired, session.isWatchAppInstalled else { return @@ -131,12 +156,11 @@ final class WatchDataManager: NSObject { return } - guard settings != lastSentSettings else { - log.default("Skipping settings transfer due to no changes") + guard userInfo != lastSentUserInfo else { return } - lastSentSettings = settings + lastSentUserInfo = userInfo // clear any old pending settings transfers for transfer in session.outstandingUserInfoTransfers { @@ -146,9 +170,9 @@ final class WatchDataManager: NSObject { } } - let userInfo = LoopSettingsUserInfo(settings: settings).rawValue - log.default("Transferring LoopSettingsUserInfo: %{public}@", userInfo) - session.transferUserInfo(userInfo) + let rawUserInfo = userInfo.rawValue + log.default("Transferring LoopSettingsUserInfo: %{public}@", rawUserInfo) + session.transferUserInfo(rawUserInfo) } @objc private func sendSupportedBolusVolumesIfNeeded() { @@ -167,7 +191,6 @@ final class WatchDataManager: NSObject { } guard volumes != lastSentBolusVolumes else { - log.default("Skipping bolus volumes transfer due to no changes") return } @@ -182,12 +205,15 @@ final class WatchDataManager: NSObject { return } + log.default("*** sendWatchContextIfNeeded") + guard case .activated = session.activationState else { session.activate() return } - createWatchContext { (context) in + Task { + let context = await createWatchContext() self.sendWatchContext(context) } } @@ -231,131 +257,116 @@ final class WatchDataManager: NSObject { } } - private func createWatchContext(recommendingBolusFor potentialCarbEntry: NewCarbEntry? = nil, _ completion: @escaping (_ context: WatchContext) -> Void) { + @MainActor + private func createWatchContext(recommendingBolusFor potentialCarbEntry: NewCarbEntry? = nil) async -> WatchContext { var dosingDecision = BolusDosingDecision(for: .watchBolus) - let loopManager = deviceManager.loopManager! - - let glucose = deviceManager.glucoseStore.latestGlucose - let reservoir = deviceManager.doseStore.lastReservoirValue + let glucose = loopDataManager.latestGlucose + let reservoir = loopDataManager.lastReservoirValue let basalDeliveryState = deviceManager.pumpManager?.status.basalDeliveryState - loopManager.getLoopState { (manager, state) in - let updateGroup = DispatchGroup() + let (_, algoOutput) = loopDataManager.displayState.asTuple - let carbsOnBoard = state.carbsOnBoard + let carbsOnBoard = loopDataManager.activeCarbs - let context = WatchContext(glucose: glucose, glucoseUnit: self.deviceManager.preferredGlucoseUnit) - context.reservoir = reservoir?.unitVolume - context.loopLastRunDate = manager.lastLoopCompleted - context.cob = carbsOnBoard?.quantity.doubleValue(for: HKUnit.gram()) + let context = WatchContext(glucose: glucose, glucoseUnit: self.deviceManager.displayGlucosePreference.unit) + context.reservoir = reservoir?.unitVolume + context.loopLastRunDate = loopDataManager.lastLoopCompleted + context.cob = carbsOnBoard?.quantity.doubleValue(for: HKUnit.gram()) - if let glucoseDisplay = self.deviceManager.glucoseDisplay(for: glucose) { - context.glucoseTrend = glucoseDisplay.trendType - context.glucoseTrendRate = glucoseDisplay.trendRate - } + if let glucoseDisplay = self.deviceManager.glucoseDisplay(for: glucose) { + context.glucoseTrend = glucoseDisplay.trendType + context.glucoseTrendRate = glucoseDisplay.trendRate + } - dosingDecision.carbsOnBoard = carbsOnBoard + dosingDecision.carbsOnBoard = carbsOnBoard - context.cgmManagerState = self.deviceManager.cgmManager?.rawValue - - let settings = self.deviceManager.loopManager.settings + context.cgmManagerState = self.deviceManager.cgmManager?.rawValue - context.isClosedLoop = settings.dosingEnabled + let settings = self.settingsManager.loopSettings - context.potentialCarbEntry = potentialCarbEntry - if let recommendedBolus = try? state.recommendBolus(consideringPotentialCarbEntry: potentialCarbEntry, replacingCarbEntry: nil, considerPositiveVelocityAndRC: FeatureFlags.usePositiveMomentumAndRCForManualBoluses) - { - context.recommendedBolusDose = recommendedBolus.amount - dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: recommendedBolus, - date: Date()) - } + context.isClosedLoop = settings.dosingEnabled - var historicalGlucose: [HistoricalGlucoseValue]? - if let glucose = glucose { - updateGroup.enter() - let historicalGlucoseStartDate = Date(timeIntervalSinceNow: -LoopCoreConstants.dosingDecisionHistoricalGlucoseInterval) - self.deviceManager.glucoseStore.getGlucoseSamples(start: min(historicalGlucoseStartDate, glucose.startDate), end: nil) { (result) in - var sample: StoredGlucoseSample? - switch result { - case .failure(let error): - self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) - sample = nil - case .success(let samples): - sample = samples.last - historicalGlucose = samples.filter { $0.startDate >= historicalGlucoseStartDate }.map { HistoricalGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) } - } - context.glucose = sample?.quantity - context.glucoseDate = sample?.startDate - context.glucoseIsDisplayOnly = sample?.isDisplayOnly - context.glucoseWasUserEntered = sample?.wasUserEntered - context.glucoseSyncIdentifier = sample?.syncIdentifier - updateGroup.leave() - } - } + context.potentialCarbEntry = potentialCarbEntry - var insulinOnBoard: InsulinValue? - updateGroup.enter() - self.deviceManager.doseStore.insulinOnBoard(at: Date()) { (result) in - switch result { - case .success(let iobValue): - context.iob = iobValue.value - insulinOnBoard = iobValue - case .failure: - context.iob = nil - } - updateGroup.leave() - } + if let recommendedBolus = try? await loopDataManager.recommendManualBolus( + manualGlucoseSample: nil, + potentialCarbEntry: potentialCarbEntry, + originalCarbEntry: nil + ) { + context.recommendedBolusDose = recommendedBolus.amount + dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate( + recommendation: recommendedBolus, + date: Date()) + } - _ = updateGroup.wait(timeout: .distantFuture) + var historicalGlucose: [HistoricalGlucoseValue]? - dosingDecision.historicalGlucose = historicalGlucose - dosingDecision.insulinOnBoard = insulinOnBoard + if let glucose = glucose { + var sample: StoredGlucoseSample? - if let basalDeliveryState = basalDeliveryState, - let basalSchedule = manager.basalRateScheduleApplyingOverrideHistory, - let netBasal = basalDeliveryState.getNetBasal(basalSchedule: basalSchedule, settings: manager.settings) - { - context.lastNetTempBasalDose = netBasal.rate + let historicalGlucoseStartDate = Date(timeIntervalSinceNow: -LoopCoreConstants.dosingDecisionHistoricalGlucoseInterval) + if let input = loopDataManager.displayState.input { + let start = min(historicalGlucoseStartDate, glucose.startDate) + let samples = input.glucoseHistory.filterDateRange(start, nil) + sample = samples.last + historicalGlucose = samples.filter { $0.startDate >= historicalGlucoseStartDate }.map { HistoricalGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) } } + context.glucose = sample?.quantity + context.glucoseDate = sample?.startDate + context.glucoseIsDisplayOnly = sample?.isDisplayOnly + context.glucoseWasUserEntered = sample?.wasUserEntered + context.glucoseSyncIdentifier = sample?.syncIdentifier + } - if let predictedGlucose = state.predictedGlucoseIncludingPendingInsulin { - // Drop the first element in predictedGlucose because it is the current glucose - let filteredPredictedGlucose = predictedGlucose.dropFirst() - if filteredPredictedGlucose.count > 0 { - context.predictedGlucose = WatchPredictedGlucose(values: Array(filteredPredictedGlucose)) - } - } + context.iob = loopDataManager.activeInsulin?.value - dosingDecision.predictedGlucose = state.predictedGlucoseIncludingPendingInsulin ?? state.predictedGlucose + dosingDecision.historicalGlucose = historicalGlucose + dosingDecision.insulinOnBoard = loopDataManager.activeInsulin - var preMealOverride = settings.preMealOverride - if preMealOverride?.hasFinished() == true { - preMealOverride = nil - } + if let basalDeliveryState = basalDeliveryState, + let basalSchedule = self.temporaryPresetsManager.basalRateScheduleApplyingOverrideHistory, + let netBasal = basalDeliveryState.getNetBasal(basalSchedule: basalSchedule, maximumBasalRatePerHour: self.settingsManager.settings.maximumBasalRatePerHour) + { + context.lastNetTempBasalDose = netBasal.rate + } - var scheduleOverride = settings.scheduleOverride - if scheduleOverride?.hasFinished() == true { - scheduleOverride = nil + if let predictedGlucose = algoOutput?.predictedGlucose { + // Drop the first element in predictedGlucose because it is the current glucose + let filteredPredictedGlucose = predictedGlucose.dropFirst() + if filteredPredictedGlucose.count > 0 { + context.predictedGlucose = WatchPredictedGlucose(values: Array(filteredPredictedGlucose)) } + } - dosingDecision.scheduleOverride = scheduleOverride + dosingDecision.predictedGlucose = algoOutput?.predictedGlucose - if scheduleOverride != nil || preMealOverride != nil { - dosingDecision.glucoseTargetRangeSchedule = settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) - } else { - dosingDecision.glucoseTargetRangeSchedule = settings.glucoseTargetRangeSchedule - } + var preMealOverride = self.temporaryPresetsManager.preMealOverride + if preMealOverride?.hasFinished() == true { + preMealOverride = nil + } - // Remove any expired context dosing decisions and add new - self.contextDosingDecisions = self.contextDosingDecisions.filter { (date, _) in date.timeIntervalSinceNow > self.contextDosingDecisionExpirationDuration } - self.contextDosingDecisions[context.creationDate] = dosingDecision + var scheduleOverride = self.temporaryPresetsManager.scheduleOverride + if scheduleOverride?.hasFinished() == true { + scheduleOverride = nil + } + + dosingDecision.scheduleOverride = scheduleOverride - completion(context) + if scheduleOverride != nil || preMealOverride != nil { + dosingDecision.glucoseTargetRangeSchedule = self.temporaryPresetsManager.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) + } else { + dosingDecision.glucoseTargetRangeSchedule = settings.glucoseTargetRangeSchedule } + + // Remove any expired context dosing decisions and add new + self.contextDosingDecisions = self.contextDosingDecisions.filter { (date, _) in date.timeIntervalSinceNow > self.contextDosingDecisionExpirationDuration } + self.contextDosingDecisions[context.creationDate] = dosingDecision + + return context } - private func addCarbEntryAndBolusFromWatchMessage(_ message: [String: Any]) { + private func addCarbEntryAndBolusFromWatchMessage(_ message: [String: Any]) async throws { guard let bolus = SetBolusUserInfo(rawValue: message as SetBolusUserInfo.RawValue) else { log.error("Could not enact bolus from from unknown message: %{public}@", String(describing: message)) return @@ -374,43 +385,30 @@ final class WatchDataManager: NSObject { dosingDecision = BolusDosingDecision(for: .watchBolus) // The user saved without waiting for recommendation (no bolus) } - func enactBolus() { - dosingDecision.manualBolusRequested = bolus.value - deviceManager.loopManager.storeManualBolusDosingDecision(dosingDecision, withDate: bolus.startDate) - - guard bolus.value > 0 else { - // Ensure active carbs is updated in the absence of a bolus - sendWatchContextIfNeeded() - return - } - - deviceManager.enactBolus(units: bolus.value, activationType: bolus.activationType) { (error) in - if error == nil { - self.deviceManager.analyticsServicesManager.didBolus(source: "Watch", units: bolus.value) - } - - // When we've successfully started the bolus, send a new context with our new prediction - self.sendWatchContextIfNeeded() - - self.deviceManager.loopManager.updateRemoteRecommendation() - } - } - if let carbEntry = bolus.carbEntry { - deviceManager.loopManager.addCarbEntry(carbEntry) { (result) in - switch result { - case .success(let storedCarbEntry): - dosingDecision.carbEntry = storedCarbEntry - self.deviceManager.analyticsServicesManager.didAddCarbs(source: "Watch", amount: storedCarbEntry.quantity.doubleValue(for: .gram())) - enactBolus() - case .failure(let error): - self.log.error("%{public}@", String(describing: error)) - } - } + let storedCarbEntry = try await loopDataManager.addCarbEntry(carbEntry) + dosingDecision.carbEntry = storedCarbEntry + self.analyticsServicesManager?.didAddCarbs(source: "Watch", amount: storedCarbEntry.quantity.doubleValue(for: .gram())) } else { dosingDecision.carbEntry = nil - enactBolus() } + + dosingDecision.manualBolusRequested = bolus.value + await loopDataManager.storeManualBolusDosingDecision(dosingDecision, withDate: bolus.startDate) + + guard bolus.value > 0 else { + // Ensure active carbs is updated in the absence of a bolus + sendWatchContextIfNeeded() + return + } + + do { + try await deviceManager.enactBolus(units: bolus.value, activationType: bolus.activationType) + self.analyticsServicesManager?.didBolus(source: "Watch", units: bolus.value) + } catch { } + + // When we've started the bolus, send a new context with our new prediction + self.sendWatchContextIfNeeded() } } @@ -420,7 +418,8 @@ extension WatchDataManager: WCSessionDelegate { switch message["name"] as? String { case PotentialCarbEntryUserInfo.name?: if let potentialCarbEntry = PotentialCarbEntryUserInfo(rawValue: message)?.carbEntry { - self.createWatchContext(recommendingBolusFor: potentialCarbEntry) { (context) in + Task { @MainActor in + let context = await createWatchContext(recommendingBolusFor: potentialCarbEntry) replyHandler(context.rawValue) } } else { @@ -429,31 +428,31 @@ extension WatchDataManager: WCSessionDelegate { } case SetBolusUserInfo.name?: // Add carbs if applicable; start the bolus and reply when it's successfully requested - addCarbEntryAndBolusFromWatchMessage(message) - + Task { @MainActor in + try await addCarbEntryAndBolusFromWatchMessage(message) + } // Reply immediately replyHandler([:]) + case LoopSettingsUserInfo.name?: - if let watchSettings = LoopSettingsUserInfo(rawValue: message)?.settings { + if let userInfo = LoopSettingsUserInfo(rawValue: message) { // So far we only support watch changes of temporary schedule overrides - var loopSettings = deviceManager.loopManager.settings - loopSettings.preMealOverride = watchSettings.preMealOverride - loopSettings.scheduleOverride = watchSettings.scheduleOverride + temporaryPresetsManager.preMealOverride = userInfo.preMealOverride + temporaryPresetsManager.scheduleOverride = userInfo.scheduleOverride // Prevent re-sending these updated settings back to the watch - lastSentSettings = loopSettings - deviceManager.loopManager.mutateSettings { settings in - settings = loopSettings - } + lastSentUserInfo?.preMealOverride = userInfo.preMealOverride + lastSentUserInfo?.scheduleOverride = userInfo.scheduleOverride } // Since target range affects recommended bolus, send back a new one - createWatchContext { (context) in + Task { @MainActor in + let context = await createWatchContext() replyHandler(context.rawValue) } case CarbBackfillRequestUserInfo.name?: if let userInfo = CarbBackfillRequestUserInfo(rawValue: message) { - deviceManager.carbStore.getSyncCarbObjects(start: userInfo.startDate) { (result) in + carbStore.getSyncCarbObjects(start: userInfo.startDate) { (result) in switch result { case .failure(let error): self.log.error("%{public}@", String(describing: error)) @@ -467,21 +466,21 @@ extension WatchDataManager: WCSessionDelegate { } case GlucoseBackfillRequestUserInfo.name?: if let userInfo = GlucoseBackfillRequestUserInfo(rawValue: message) { - deviceManager.glucoseStore.getSyncGlucoseSamples(start: userInfo.startDate.addingTimeInterval(1)) { (result) in - switch result { - case .failure(let error): + Task { + do { + let samples = try await glucoseStore.getSyncGlucoseSamples(start: userInfo.startDate.addingTimeInterval(1)) + replyHandler(WatchHistoricalGlucose(samples: samples).rawValue) + } catch { self.log.error("Failure getting sync glucose objects: %{public}@", String(describing: error)) replyHandler([:]) - case .success(let samples): - replyHandler(WatchHistoricalGlucose(samples: samples).rawValue) } } } else { replyHandler([:]) } case WatchContextRequestUserInfo.name?: - self.createWatchContext { (context) in - // Send back the updated prediction and recommended bolus + Task { @MainActor in + let context = await createWatchContext() replyHandler(context.rawValue) } default: @@ -517,12 +516,12 @@ extension WatchDataManager: WCSessionDelegate { // This might be useless, as userInfoTransfer.userInfo seems to be nil when error is non-nil. switch userInfoTransfer.userInfo["name"] as? String { case nil: - lastSentSettings = nil + lastSentUserInfo = nil sendSettingsIfNeeded() lastSentBolusVolumes = nil sendSupportedBolusVolumesIfNeeded() case LoopSettingsUserInfo.name: - lastSentSettings = nil + lastSentUserInfo = nil sendSettingsIfNeeded() case SupportedBolusVolumesUserInfo.name: lastSentBolusVolumes = nil @@ -538,7 +537,7 @@ extension WatchDataManager: WCSessionDelegate { } func sessionDidDeactivate(_ session: WCSession) { - lastSentSettings = nil + lastSentUserInfo = nil watchSession = WCSession.default watchSession?.delegate = self watchSession?.activate() @@ -555,7 +554,7 @@ extension WatchDataManager { override var debugDescription: String { var items = [ "## WatchDataManager", - "lastSentSettings: \(String(describing: lastSentSettings))", + "lastSentUserInfo: \(String(describing: lastSentUserInfo))", "lastComplicationContext: \(String(describing: lastComplicationContext))", "lastBedtimeQuery: \(String(describing: lastBedtimeQuery))", "bedtime: \(String(describing: bedtime))", diff --git a/Loop/Models/ApplicationFactorStrategy.swift b/Loop/Models/ApplicationFactorStrategy.swift index bf67935c4e..d3244ec1c2 100644 --- a/Loop/Models/ApplicationFactorStrategy.swift +++ b/Loop/Models/ApplicationFactorStrategy.swift @@ -14,7 +14,6 @@ import LoopCore protocol ApplicationFactorStrategy { func calculateDosingFactor( for glucose: HKQuantity, - correctionRangeSchedule: GlucoseRangeSchedule, - settings: LoopSettings + correctionRange: ClosedRange ) -> Double } diff --git a/Loop/Models/AutomaticDosingStatus.swift b/Loop/Models/AutomaticDosingStatus.swift index ae3930c122..c5b66e955c 100644 --- a/Loop/Models/AutomaticDosingStatus.swift +++ b/Loop/Models/AutomaticDosingStatus.swift @@ -8,12 +8,12 @@ import Foundation -class AutomaticDosingStatus { - @Published var automaticDosingEnabled: Bool - @Published var isAutomaticDosingAllowed: Bool +public class AutomaticDosingStatus: ObservableObject { + @Published public var automaticDosingEnabled: Bool + @Published public var isAutomaticDosingAllowed: Bool - init(automaticDosingEnabled: Bool, - isAutomaticDosingAllowed: Bool) + public init(automaticDosingEnabled: Bool, + isAutomaticDosingAllowed: Bool) { self.automaticDosingEnabled = automaticDosingEnabled self.isAutomaticDosingAllowed = isAutomaticDosingAllowed diff --git a/Loop/Models/AutomationHistoryEntry.swift b/Loop/Models/AutomationHistoryEntry.swift new file mode 100644 index 0000000000..8d55541924 --- /dev/null +++ b/Loop/Models/AutomationHistoryEntry.swift @@ -0,0 +1,49 @@ +// +// AutomationHistoryEntry.swift +// Loop +// +// Created by Pete Schwamb on 9/19/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopAlgorithm + +struct AutomationHistoryEntry: Codable { + var startDate: Date + var enabled: Bool +} + +extension Array where Element == AutomationHistoryEntry { + func toTimeline(from start: Date, to end: Date) -> [AbsoluteScheduleValue] { + guard !isEmpty else { + return [] + } + + var out = [AbsoluteScheduleValue]() + + var iter = makeIterator() + + var prev = iter.next()! + + func addItem(start: Date, end: Date, enabled: Bool) { + out.append(AbsoluteScheduleValue(startDate: start, endDate: end, value: enabled)) + } + + while let cur = iter.next() { + guard cur.enabled != prev.enabled else { + continue + } + if cur.startDate > start { + addItem(start: Swift.max(prev.startDate, start), end: Swift.min(cur.startDate, end), enabled: prev.enabled) + } + prev = cur + } + + if prev.startDate < end { + addItem(start: prev.startDate, end: end, enabled: prev.enabled) + } + + return out + } +} diff --git a/Loop/Models/BolusDosingDecision.swift b/Loop/Models/BolusDosingDecision.swift index 9d63905858..4d3002d2ba 100644 --- a/Loop/Models/BolusDosingDecision.swift +++ b/Loop/Models/BolusDosingDecision.swift @@ -7,6 +7,7 @@ // import LoopKit +import LoopAlgorithm struct BolusDosingDecision { enum Reason: String { diff --git a/Loop/Models/ConstantApplicationFactorStrategy.swift b/Loop/Models/ConstantApplicationFactorStrategy.swift index e13c40c42e..82ab6ebad6 100644 --- a/Loop/Models/ConstantApplicationFactorStrategy.swift +++ b/Loop/Models/ConstantApplicationFactorStrategy.swift @@ -10,14 +10,14 @@ import Foundation import HealthKit import LoopKit import LoopCore +import LoopAlgorithm struct ConstantApplicationFactorStrategy: ApplicationFactorStrategy { func calculateDosingFactor( for glucose: HKQuantity, - correctionRangeSchedule: GlucoseRangeSchedule, - settings: LoopSettings + correctionRange: ClosedRange ) -> Double { // The original strategy uses a constant dosing factor. - return LoopConstants.bolusPartialApplicationFactor + return LoopAlgorithm.defaultBolusPartialApplicationFactor } } diff --git a/Loop/Models/CrashRecoveryManager.swift b/Loop/Models/CrashRecoveryManager.swift index e0f0e6f260..2e2a249e9c 100644 --- a/Loop/Models/CrashRecoveryManager.swift +++ b/Loop/Models/CrashRecoveryManager.swift @@ -8,6 +8,7 @@ import Foundation import LoopKit +import LoopAlgorithm class CrashRecoveryManager { diff --git a/Loop/Models/Deeplink.swift b/Loop/Models/Deeplink.swift new file mode 100644 index 0000000000..b3ccbb4855 --- /dev/null +++ b/Loop/Models/Deeplink.swift @@ -0,0 +1,24 @@ +// +// Deeplink.swift +// Loop +// +// Created by Noah Brauner on 8/9/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation + +enum Deeplink: String, CaseIterable { + case carbEntry = "carb-entry" + case bolus = "manual-bolus" + case preMeal = "pre-meal-preset" + case customPresets = "custom-presets" + + init?(url: URL?) { + guard let url, let host = url.host, let deeplink = Deeplink.allCases.first(where: { $0.rawValue == host }) else { + return nil + } + + self = deeplink + } +} diff --git a/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift b/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift index 41caa3d773..7f03337011 100644 --- a/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift +++ b/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift @@ -21,12 +21,10 @@ struct GlucoseBasedApplicationFactorStrategy: ApplicationFactorStrategy { func calculateDosingFactor( for glucose: HKQuantity, - correctionRangeSchedule: GlucoseRangeSchedule, - settings: LoopSettings + correctionRange: ClosedRange ) -> Double { // Calculate current glucose and lower bound target let currentGlucose = glucose.doubleValue(for: .milligramsPerDeciliter) - let correctionRange = correctionRangeSchedule.quantityRange(at: Date()) let lowerBoundTarget = correctionRange.lowerBound.doubleValue(for: .milligramsPerDeciliter) // Calculate minimum glucose sliding scale and scaling fraction diff --git a/Loop/Models/GlucoseEffectVelocity.swift b/Loop/Models/GlucoseEffectVelocity.swift index 9557f2fd50..6680073769 100644 --- a/Loop/Models/GlucoseEffectVelocity.swift +++ b/Loop/Models/GlucoseEffectVelocity.swift @@ -7,6 +7,7 @@ import HealthKit import LoopKit +import LoopAlgorithm extension GlucoseEffectVelocity: RawRepresentable { diff --git a/Loop/Models/LoopConstants.swift b/Loop/Models/LoopConstants.swift index fb69c8275f..bd1296c12f 100644 --- a/Loop/Models/LoopConstants.swift +++ b/Loop/Models/LoopConstants.swift @@ -49,9 +49,6 @@ enum LoopConstants { static let retrospectiveCorrectionEnabled = true - // Percentage of recommended dose to apply as bolus when using automatic bolus dosing strategy - static let bolusPartialApplicationFactor = 0.4 - /// Loop completion aging category limits static let completionFreshLimit = TimeInterval(minutes: 6) static let completionAgingLimit = TimeInterval(minutes: 16) diff --git a/Loop/Models/LoopError.swift b/Loop/Models/LoopError.swift index 015d5cc05c..23e71dd7d4 100644 --- a/Loop/Models/LoopError.swift +++ b/Loop/Models/LoopError.swift @@ -17,7 +17,7 @@ enum ConfigurationErrorDetail: String, Codable { case insulinSensitivitySchedule case maximumBasalRatePerHour case maximumBolus - + func localized() -> String { switch self { case .pumpManager: @@ -45,7 +45,7 @@ enum MissingDataErrorDetail: String, Codable { case insulinEffect case activeInsulin case insulinEffectIncludingPendingInsulin - + var localizedDetail: String { switch self { case .glucose: @@ -99,12 +99,18 @@ enum LoopError: Error { // Recommendation Expired case recommendationExpired(date: Date) + // Pump Failure + case pumpInoperable + // Pump Suspended case pumpSuspended // Pump Manager Error case pumpManagerError(PumpManagerError) + // Loop State loop in progress + case loopInProgress + // Some other error case unknownError(Error) } @@ -130,10 +136,14 @@ extension LoopError { return "pumpDataTooOld" case .recommendationExpired: return "recommendationExpired" + case .pumpInoperable: + return "pumpInoperable" case .pumpSuspended: return "pumpSuspended" case .pumpManagerError: return "pumpManagerError" + case .loopInProgress: + return "loopInProgress" case .unknownError: return "unknownError" } @@ -200,12 +210,16 @@ extension LoopError: LocalizedError { case .recommendationExpired(let date): let minutes = formatter.string(from: -date.timeIntervalSinceNow) ?? "" return String(format: NSLocalizedString("Recommendation expired: %1$@ old", comment: "The error message when a recommendation has expired. (1: age of recommendation in minutes)"), minutes) + case .pumpInoperable: + return NSLocalizedString("Pump Inoperable. Automatic dosing is disabled.", comment: "The error message displayed for LoopError.pumpInoperable errors.") case .pumpSuspended: - return NSLocalizedString("Pump Suspended. Automatic dosing is disabled.", comment: "The error message displayed for pumpSuspended errors.") + return NSLocalizedString("Pump Suspended. Automatic dosing is disabled.", comment: "The error message displayed for LoopError.pumpSuspended errors.") case .pumpManagerError(let pumpManagerError): return String(format: NSLocalizedString("Pump Manager Error: %1$@", comment: "The error message displayed for pump manager errors. (1: pump manager error)"), pumpManagerError.errorDescription!) + case .loopInProgress: + return NSLocalizedString("Loop is already looping.", comment: "The error message displayed for LoopError.loopInProgress errors.") case .unknownError(let error): - return String(format: NSLocalizedString("Unknown Error: %1$@", comment: "The error message displayed for unknown errors. (1: unknown error)"), error.localizedDescription) + return String(format: NSLocalizedString("Unknown Error: %1$@", comment: "The error message displayed for unknown LoopError errors. (1: unknown error)"), error.localizedDescription) } } } diff --git a/Loop/Models/ManualBolusRecommendation.swift b/Loop/Models/ManualBolusRecommendation.swift index c1ad01125a..1753813e2c 100644 --- a/Loop/Models/ManualBolusRecommendation.swift +++ b/Loop/Models/ManualBolusRecommendation.swift @@ -9,6 +9,7 @@ import Foundation import LoopKit import HealthKit +import LoopAlgorithm extension BolusRecommendationNotice { @@ -37,39 +38,3 @@ extension BolusRecommendationNotice { } } -extension BolusRecommendationNotice: Equatable { - public static func ==(lhs: BolusRecommendationNotice, rhs: BolusRecommendationNotice) -> Bool { - switch (lhs, rhs) { - case (.glucoseBelowSuspendThreshold, .glucoseBelowSuspendThreshold): - return true - - case (.currentGlucoseBelowTarget, .currentGlucoseBelowTarget): - return true - - case (let .predictedGlucoseBelowTarget(minGlucose1), let .predictedGlucoseBelowTarget(minGlucose2)): - // GlucoseValue is not equatable - return - minGlucose1.startDate == minGlucose2.startDate && - minGlucose1.endDate == minGlucose2.endDate && - minGlucose1.quantity == minGlucose2.quantity - - case (.predictedGlucoseInRange, .predictedGlucoseInRange): - return true - - default: - return false - } - } -} - - -extension ManualBolusRecommendation: Comparable { - public static func ==(lhs: ManualBolusRecommendation, rhs: ManualBolusRecommendation) -> Bool { - return lhs.amount == rhs.amount - } - - public static func <(lhs: ManualBolusRecommendation, rhs: ManualBolusRecommendation) -> Bool { - return lhs.amount < rhs.amount - } -} - diff --git a/Loop/Models/NetBasal.swift b/Loop/Models/NetBasal.swift index ff11e9e064..02a349a602 100644 --- a/Loop/Models/NetBasal.swift +++ b/Loop/Models/NetBasal.swift @@ -8,6 +8,7 @@ import Foundation import LoopKit +import LoopAlgorithm /// Max basal should generally be set, but in those cases where it isn't just use 3.0U/hr as a default top of scale, so we can show *something*. fileprivate let defaultMaxBasalForScale = 3.0 diff --git a/Loop/Models/PredictionInputEffect.swift b/Loop/Models/PredictionInputEffect.swift index 45fb5ea0c7..175afd3c1b 100644 --- a/Loop/Models/PredictionInputEffect.swift +++ b/Loop/Models/PredictionInputEffect.swift @@ -8,7 +8,8 @@ import Foundation import HealthKit - +import LoopKit +import LoopAlgorithm struct PredictionInputEffect: OptionSet { let rawValue: Int @@ -55,3 +56,22 @@ struct PredictionInputEffect: OptionSet { } } } + +extension PredictionInputEffect { + var algorithmEffectOptions: AlgorithmEffectsOptions { + var rval = [AlgorithmEffectsOptions]() + if self.contains(.carbs) { + rval.append(.carbs) + } + if self.contains(.insulin) { + rval.append(.insulin) + } + if self.contains(.momentum) { + rval.append(.momentum) + } + if self.contains(.retrospection) { + rval.append(.retrospection) + } + return AlgorithmEffectsOptions(rval) + } +} diff --git a/Loop/Models/SimpleInsulinDose.swift b/Loop/Models/SimpleInsulinDose.swift new file mode 100644 index 0000000000..6235d69768 --- /dev/null +++ b/Loop/Models/SimpleInsulinDose.swift @@ -0,0 +1,86 @@ +// +// SimpleInsulinDose.swift +// Loop +// +// Created by Pete Schwamb on 2/23/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import LoopAlgorithm + +// Implements the bare minimum of InsulinDose, including a slot for InsulinModel +// We could use DoseEntry, but we need to dynamically lookup user's preferred +// fast acting insulin model in settings. So until that is removed, we need this. +struct SimpleInsulinDose: InsulinDose { + var deliveryType: InsulinDeliveryType + var startDate: Date + var endDate: Date + var volume: Double + var insulinModel: InsulinModel +} + +extension DoseEntry { + public var deliveryType: InsulinDeliveryType { + switch self.type { + case .bolus: + return .bolus + default: + return .basal + } + } + + public var volume: Double { + return deliveredUnits ?? programmedUnits + } + + func simpleDose(with model: InsulinModel) -> SimpleInsulinDose { + SimpleInsulinDose( + deliveryType: deliveryType, + startDate: startDate, + endDate: endDate, + volume: volume, + insulinModel: model + ) + } +} + +extension Array where Element == SimpleInsulinDose { + func trimmed(to end: Date? = nil) -> [SimpleInsulinDose] { + return self.compactMap { (dose) -> SimpleInsulinDose? in + if let end, dose.startDate > end { + return nil + } + if dose.deliveryType == .bolus { + return dose + } + return dose.trimmed(to: end) + } + } +} + +extension SimpleInsulinDose { + public func trimmed(from start: Date? = nil, to end: Date? = nil, syncIdentifier: String? = nil) -> SimpleInsulinDose { + + let originalDuration = endDate.timeIntervalSince(startDate) + + let startDate = max(start ?? .distantPast, self.startDate) + let endDate = max(startDate, min(end ?? .distantFuture, self.endDate)) + + var trimmedVolume: Double = volume + + if originalDuration > .ulpOfOne && (startDate > self.startDate || endDate < self.endDate) { + trimmedVolume = volume * (endDate.timeIntervalSince(startDate) / originalDuration) + } + + return SimpleInsulinDose( + deliveryType: self.deliveryType, + startDate: startDate, + endDate: endDate, + volume: trimmedVolume, + insulinModel: insulinModel + ) + } +} + diff --git a/Loop/Models/StoredDataAlgorithmInput.swift b/Loop/Models/StoredDataAlgorithmInput.swift new file mode 100644 index 0000000000..ae33304c3c --- /dev/null +++ b/Loop/Models/StoredDataAlgorithmInput.swift @@ -0,0 +1,56 @@ +// +// StoredDataAlgorithmInput.swift +// Loop +// +// Created by Pete Schwamb on 2/23/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import HealthKit +import LoopAlgorithm + +struct StoredDataAlgorithmInput: AlgorithmInput { + typealias CarbType = StoredCarbEntry + + typealias GlucoseType = StoredGlucoseSample + + typealias InsulinDoseType = SimpleInsulinDose + + var glucoseHistory: [StoredGlucoseSample] + + var doses: [SimpleInsulinDose] + + var carbEntries: [StoredCarbEntry] + + var predictionStart: Date + + var basal: [AbsoluteScheduleValue] + + var sensitivity: [AbsoluteScheduleValue] + + var carbRatio: [AbsoluteScheduleValue] + + var target: GlucoseRangeTimeline + + var suspendThreshold: HKQuantity? + + var maxBolus: Double + + var maxBasalRate: Double + + var useIntegralRetrospectiveCorrection: Bool + + var includePositiveVelocityAndRC: Bool + + var carbAbsorptionModel: CarbAbsorptionModel + + var recommendationInsulinModel: InsulinModel + + var recommendationType: DoseRecommendationType + + var automaticBolusApplicationFactor: Double? + + let useMidAbsorptionISF: Bool = true +} diff --git a/Loop/Models/WatchContext+LoopKit.swift b/Loop/Models/WatchContext+LoopKit.swift index a9adf41da4..2235ec7b92 100644 --- a/Loop/Models/WatchContext+LoopKit.swift +++ b/Loop/Models/WatchContext+LoopKit.swift @@ -9,12 +9,14 @@ import Foundation import HealthKit import LoopKit +import LoopAlgorithm extension WatchContext { convenience init(glucose: GlucoseSampleValue?, glucoseUnit: HKUnit?) { self.init() self.glucose = glucose?.quantity + self.glucoseCondition = glucose?.condition self.glucoseDate = glucose?.startDate self.glucoseIsDisplayOnly = glucose?.isDisplayOnly self.glucoseWasUserEntered = glucose?.wasUserEntered diff --git a/Loop/Plugins/PluginManager.swift b/Loop/Plugins/PluginManager.swift index a254d26872..3128ec4c61 100644 --- a/Loop/Plugins/PluginManager.swift +++ b/Loop/Plugins/PluginManager.swift @@ -27,6 +27,12 @@ class PluginManager { log.debug("Found loop plugin: %{public}@", pluginURL.absoluteString) bundles.append(bundle) } + + // extensions are always instantiated + if bundle.isLoopExtension { + log.debug("Found loop extension: %{public}@", pluginURL.absoluteString) + _ = try? bundle.loadAndInstantiateExtension() + } } } } catch let error { @@ -36,8 +42,6 @@ class PluginManager { self.pluginBundles = bundles } - - func getPumpManagerTypeByIdentifier(_ identifier: String) -> PumpManagerUI.Type? { for bundle in pluginBundles { if let name = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.pumpManagerIdentifier.rawValue) as? String, name == identifier { @@ -248,4 +252,14 @@ extension Bundle { var isLoopExtension: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.extensionIdentifier.rawValue) as? String != nil } var isSimulator: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.pluginIsSimulator.rawValue) as? Bool == true } + + fileprivate func loadAndInstantiateExtension() throws -> NSObject? { + try loadAndReturnError() + + guard let principalClass = principalClass as? NSObject.Type else { + return nil + } + + return principalClass.init() + } } diff --git a/Loop/View Controllers/CarbAbsorptionViewController.swift b/Loop/View Controllers/CarbAbsorptionViewController.swift index fc770192e9..be8327bba8 100644 --- a/Loop/View Controllers/CarbAbsorptionViewController.swift +++ b/Loop/View Controllers/CarbAbsorptionViewController.swift @@ -13,6 +13,7 @@ import LoopKit import LoopKitUI import LoopUI import os.log +import LoopAlgorithm private extension RefreshContext { @@ -30,6 +31,10 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif var automaticDosingStatus: AutomaticDosingStatus! + var loopDataManager: LoopDataManager! + var carbStore: CarbStore! + var analyticsServicesManager: AnalyticsServicesManager! + override func viewDidLoad() { super.viewDidLoad() @@ -40,10 +45,10 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif let notificationCenter = NotificationCenter.default notificationObservers += [ - notificationCenter.addObserver(forName: .LoopDataUpdated, object: deviceManager.loopManager, queue: nil) { [weak self] note in - let context = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopDataManager.LoopUpdateContext.RawValue + notificationCenter.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { [weak self] note in + let context = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopUpdateContext.RawValue DispatchQueue.main.async { - switch LoopDataManager.LoopUpdateContext(rawValue: context) { + switch LoopUpdateContext(rawValue: context) { case .carbs?: self?.refreshContext.formUnion([.carbs, .glucose]) case .glucose?: @@ -53,7 +58,9 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif } self?.refreshContext.update(with: .status) - self?.reloadData(animated: true) + Task { @MainActor in + await self?.reloadData(animated: true) + } } }, ] @@ -72,7 +79,9 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif tableView.rowHeight = UITableView.automaticDimension - reloadData(animated: false) + Task { @MainActor in + await reloadData(animated: false) + } } override func didReceiveMemoryWarning() { @@ -114,7 +123,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif refreshContext = RefreshContext.all } - override func reloadData(animated: Bool = false) { + override func reloadData(animated: Bool = false) async { guard active && !reloading && !self.refreshContext.isEmpty else { return } var currentContext = self.refreshContext var retryContext: Set = [] @@ -139,113 +148,74 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif charts.updateEndDate(chartStartDate.addingTimeInterval(.hours(totalHours+1))) // When there is no data, this allows presenting current hour + 1 let midnight = Calendar.current.startOfDay(for: Date()) - let listStart = min(midnight, chartStartDate, Date(timeIntervalSinceNow: -deviceManager.carbStore.maximumAbsorptionTimeInterval)) + let listStart = min(midnight, chartStartDate, Date(timeIntervalSinceNow: -CarbMath.maximumAbsorptionTimeInterval)) + let listEnd = Date().addingTimeInterval(CarbMath.dateAdjustmentFuture) - let reloadGroup = DispatchGroup() let shouldUpdateGlucose = currentContext.contains(.glucose) let shouldUpdateCarbs = currentContext.contains(.carbs) var carbEffects: [GlucoseEffect]? var carbStatuses: [CarbStatus]? var carbsOnBoard: CarbValue? - var carbTotal: CarbValue? var insulinCounteractionEffects: [GlucoseEffectVelocity]? - // TODO: Don't always assume currentContext.contains(.status) - reloadGroup.enter() - deviceManager.loopManager.getLoopState { (manager, state) in - if shouldUpdateGlucose || shouldUpdateCarbs { - let allInsulinCounteractionEffects = state.insulinCounteractionEffects - insulinCounteractionEffects = allInsulinCounteractionEffects.filterDateRange(chartStartDate, nil) - - reloadGroup.enter() - self.deviceManager.carbStore.getCarbStatus(start: listStart, end: nil, effectVelocities: allInsulinCounteractionEffects) { (result) in - switch result { - case .success(let status): - carbStatuses = status - carbsOnBoard = status.getClampedCarbsOnBoard() - case .failure(let error): - self.log.error("CarbStore failed to get carbStatus: %{public}@", String(describing: error)) - retryContext.update(with: .carbs) - } - - reloadGroup.leave() - } - - reloadGroup.enter() - self.deviceManager.carbStore.getGlucoseEffects(start: chartStartDate, end: nil, effectVelocities: insulinCounteractionEffects!) { (result) in - switch result { - case .success((_, let effects)): - carbEffects = effects - case .failure(let error): - carbEffects = [] - self.log.error("CarbStore failed to get glucoseEffects: %{public}@", String(describing: error)) - retryContext.update(with: .carbs) - } - reloadGroup.leave() - } + if shouldUpdateGlucose || shouldUpdateCarbs { + do { + let review = try await loopDataManager.fetchCarbAbsorptionReview(start: listStart, end: listEnd) + insulinCounteractionEffects = review.effectsVelocities.filterDateRange(chartStartDate, nil) + carbStatuses = review.carbStatuses + carbsOnBoard = loopDataManager.activeCarbs + carbEffects = review.carbEffects + } catch { + log.error("Failed to get carb absorption review: %{public}@", String(describing: error)) + retryContext.update(with: .carbs) } - - reloadGroup.leave() } if shouldUpdateCarbs { - reloadGroup.enter() - deviceManager.carbStore.getTotalCarbs(since: midnight) { (result) in - switch result { - case .success(let total): - carbTotal = total - case .failure(let error): - self.log.error("CarbStore failed to get total carbs: %{public}@", String(describing: error)) - retryContext.update(with: .carbs) - } - - reloadGroup.leave() + do { + self.carbTotal = try await carbStore.getTotalCarbs(since: midnight) + } catch { + log.error("CarbStore failed to get total carbs: %{public}@", String(describing: error)) + retryContext.update(with: .carbs) } } - reloadGroup.notify(queue: .main) { - if let carbEffects = carbEffects { - self.carbEffectChart.setCarbEffects(carbEffects) - self.charts.invalidateChart(atIndex: 0) - } - - if let insulinCounteractionEffects = insulinCounteractionEffects { - self.carbEffectChart.setInsulinCounteractionEffects(insulinCounteractionEffects) - self.charts.invalidateChart(atIndex: 0) - } - - self.charts.prerender() + if let carbEffects = carbEffects { + carbEffectChart.setCarbEffects(carbEffects) + charts.invalidateChart(atIndex: 0) + } - for case let cell as ChartTableViewCell in self.tableView.visibleCells { - cell.reloadChart() - } + if let insulinCounteractionEffects = insulinCounteractionEffects { + carbEffectChart.setInsulinCounteractionEffects(insulinCounteractionEffects) + charts.invalidateChart(atIndex: 0) + } - if shouldUpdateCarbs || shouldUpdateGlucose { - // Change to descending order for display - self.carbStatuses = carbStatuses?.reversed() ?? [] + charts.prerender() - if shouldUpdateCarbs { - self.carbTotal = carbTotal - } + for case let cell as ChartTableViewCell in self.tableView.visibleCells { + cell.reloadChart() + } - self.carbsOnBoard = carbsOnBoard + if shouldUpdateCarbs || shouldUpdateGlucose { + // Change to descending order for display + self.carbStatuses = carbStatuses?.reversed() ?? [] + self.carbsOnBoard = carbsOnBoard - self.tableView.reloadSections(IndexSet(integer: Section.entries.rawValue), with: .fade) - } + tableView.reloadSections(IndexSet(integer: Section.entries.rawValue), with: .fade) + } - if let cell = self.tableView.cellForRow(at: IndexPath(row: 0, section: Section.totals.rawValue)) as? HeaderValuesTableViewCell { - self.updateCell(cell) - } + if let cell = tableView.cellForRow(at: IndexPath(row: 0, section: Section.totals.rawValue)) as? HeaderValuesTableViewCell { + updateCell(cell) + } - self.reloading = false - let reloadNow = !self.refreshContext.isEmpty - self.refreshContext.formUnion(retryContext) + reloading = false + let reloadNow = !refreshContext.isEmpty + refreshContext.formUnion(retryContext) - // Trigger a reload if new context exists. - if reloadNow { - self.reloadData() - } + // Trigger a reload if new context exists. + if reloadNow { + await reloadData() } } @@ -265,11 +235,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif static let count = 1 } - private lazy var carbFormatter: NumberFormatter = { - let formatter = NumberFormatter() - formatter.numberStyle = .none - return formatter - }() + private lazy var carbFormatter: QuantityFormatter = QuantityFormatter(for: .gram()) private lazy var absorptionFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() @@ -331,7 +297,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif // Entry value let status = carbStatuses[indexPath.row] - let carbText = carbFormatter.string(from: status.entry.quantity.doubleValue(for: unit), unit: unit.unitString) + let carbText = carbFormatter.string(from: status.entry.quantity) if let carbText = carbText, let foodType = status.entry.foodType { cell.valueLabel?.text = String( @@ -358,9 +324,9 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif if let absorption = status.absorption { // Absorbed value let observedProgress = Float(absorption.observedProgress.doubleValue(for: .percent())) - let observedCarbs = max(0, absorption.observed.doubleValue(for: unit)) + let observedCarbs = absorption.observed - if let observedCarbsText = carbFormatter.string(from: observedCarbs, unit: unit.unitString) { + if let observedCarbsText = carbFormatter.string(from: observedCarbs) { cell.observedValueText = String( format: NSLocalizedString("%@ absorbed", comment: "Formats absorbed carb value"), observedCarbsText @@ -376,7 +342,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif } cell.observedProgress = observedProgress - cell.clampedProgress = Float(absorption.clampedProgress.doubleValue(for: .percent())) + cell.clampedProgress = Float(absorption.observedProgress.doubleValue(for: .percent())) cell.observedDateText = absorptionFormatter.string(from: absorption.estimatedDate.duration) // Absorbed time @@ -407,7 +373,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif format: NSLocalizedString("at %@", comment: "Format fragment for a specific time"), timeFormatter.string(from: carbsOnBoard.startDate) ) - cell.COBValueLabel.text = carbFormatter.string(from: carbsOnBoard.quantity.doubleValue(for: unit)) + cell.COBValueLabel.text = carbFormatter.string(from: carbsOnBoard.quantity, includeUnit: false) // Warn the user if the carbsOnBoard value isn't recent let textColor: UIColor @@ -423,7 +389,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif cell.COBDateLabel.textColor = textColor } else { cell.COBDateLabel.text = nil - cell.COBValueLabel.text = carbFormatter.string(from: 0.0) + cell.COBValueLabel.text = carbFormatter.string(from: HKQuantity(unit: .gram(), doubleValue: 0), includeUnit: false) } if let carbTotal = carbTotal { @@ -431,10 +397,10 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif format: NSLocalizedString("since %@", comment: "Format fragment for a start time"), timeFormatter.string(from: carbTotal.startDate) ) - cell.totalValueLabel.text = carbFormatter.string(from: carbTotal.quantity.doubleValue(for: unit)) + cell.totalValueLabel.text = carbFormatter.string(from: carbTotal.quantity, includeUnit: false) } else { cell.totalDateLabel.text = nil - cell.totalValueLabel.text = carbFormatter.string(from: 0.0) + cell.totalValueLabel.text = carbFormatter.string(from: HKQuantity(unit: .gram(), doubleValue: 0), includeUnit: false) } } @@ -450,16 +416,13 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif public override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { if editingStyle == .delete { let status = carbStatuses[indexPath.row] - deviceManager.loopManager.deleteCarbEntry(status.entry) { (result) -> Void in - DispatchQueue.main.async { - switch result { - case .success: - self.isEditing = false - break // Notification will trigger update - case .failure(let error): - self.refreshContext.update(with: .carbs) - self.present(UIAlertController(with: error), animated: true) - } + Task { @MainActor in + do { + try await loopDataManager.deleteCarbEntry(status.entry) + self.isEditing = false + } catch { + self.refreshContext.update(with: .carbs) + self.present(UIAlertController(with: error), animated: true) } } } @@ -481,7 +444,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { switch Section(rawValue: indexPath.section)! { case .charts: - return indexPath + return nil case .totals: return nil case .entries: @@ -490,21 +453,39 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard indexPath.row < carbStatuses.count else { return } tableView.deselectRow(at: indexPath, animated: true) - - let originalCarbEntry = carbStatuses[indexPath.row].entry - - let viewModel = CarbEntryViewModel(delegate: deviceManager, originalCarbEntry: originalCarbEntry) - let carbEntryView = CarbEntryView(viewModel: viewModel) - .environmentObject(deviceManager.displayGlucosePreference) - .environment(\.dismissAction, carbEditWasCanceled) - let hostingController = UIHostingController(rootView: carbEntryView) - hostingController.title = "Edit Carb Entry" - hostingController.navigationItem.largeTitleDisplayMode = .never - let leftBarButton = UIBarButtonItem(title: "Back", style: .plain, target: self, action: #selector(carbEditWasCanceled)) - hostingController.navigationItem.backBarButtonItem = leftBarButton - navigationController?.pushViewController(hostingController, animated: true) + + switch Section(rawValue: indexPath.section)! { + case .entries: + guard indexPath.row < carbStatuses.count else { return } + + let originalCarbEntry = carbStatuses[indexPath.row].entry + + let viewModel = createCarbEntryViewModel(originalCarbEntry: originalCarbEntry) + let carbEntryView = CarbEntryView(viewModel: viewModel) + .environmentObject(deviceManager.displayGlucosePreference) + .environment(\.dismissAction, carbEditWasCanceled) + let hostingController = DismissibleHostingController(rootView: carbEntryView) + hostingController.title = "Edit Carb Entry" + hostingController.navigationItem.largeTitleDisplayMode = .never + let leftBarButton = UIBarButtonItem(title: "Back", style: .plain, target: self, action: #selector(carbEditWasCanceled)) + hostingController.navigationItem.backBarButtonItem = leftBarButton + navigationController?.pushViewController(hostingController, animated: true) + default: + return + } + } + + private func createCarbEntryViewModel(originalCarbEntry: StoredCarbEntry? = nil) -> CarbEntryViewModel { + let viewModel: CarbEntryViewModel + if let originalCarbEntry { + viewModel = CarbEntryViewModel(delegate: loopDataManager, originalCarbEntry: originalCarbEntry) + } else { + viewModel = CarbEntryViewModel(delegate: loopDataManager) + } + viewModel.analyticsServicesManager = analyticsServicesManager + viewModel.deliveryDelegate = deviceManager + return viewModel } @objc func carbEditWasCanceled() { @@ -514,14 +495,15 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif // MARK: - Navigation @IBAction func presentCarbEntryScreen() { if FeatureFlags.simpleBolusCalculatorEnabled && !automaticDosingStatus.automaticDosingEnabled { - let viewModel = SimpleBolusViewModel(delegate: deviceManager, displayMealEntry: true) - let bolusEntryView = SimpleBolusView(viewModel: viewModel).environmentObject(DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter)) + let displayGlucosePreference = DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) + let viewModel = SimpleBolusViewModel(delegate: loopDataManager, displayMealEntry: true, displayGlucosePreference: displayGlucosePreference) + let bolusEntryView = SimpleBolusView(viewModel: viewModel).environmentObject(displayGlucosePreference) let hostingController = DismissibleHostingController(rootView: bolusEntryView, isModalInPresentation: false) let navigationWrapper = UINavigationController(rootViewController: hostingController) hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: navigationWrapper, action: #selector(dismissWithAnimation)) present(navigationWrapper, animated: true) } else { - let viewModel = CarbEntryViewModel(delegate: deviceManager) + let viewModel = createCarbEntryViewModel() let carbEntryView = CarbEntryView(viewModel: viewModel) .environmentObject(deviceManager.displayGlucosePreference) let hostingController = DismissibleHostingController(rootView: carbEntryView, isModalInPresentation: false) diff --git a/Loop/View Controllers/CommandResponseViewController.swift b/Loop/View Controllers/CommandResponseViewController.swift index e14c41c8a4..2bd93cc09f 100644 --- a/Loop/View Controllers/CommandResponseViewController.swift +++ b/Loop/View Controllers/CommandResponseViewController.swift @@ -13,21 +13,20 @@ import LoopKitUI extension CommandResponseViewController { typealias T = CommandResponseViewController - static func generateDiagnosticReport(deviceManager: DeviceDataManager) -> T { + static func generateDiagnosticReport(reportGenerator: DiagnosticReportGenerator) -> T { let date = Date() let vc = T(command: { (completionHandler) in - deviceManager.generateDiagnosticReport { (report) in - DispatchQueue.main.async { - completionHandler([ - "Use the Share button above to save this diagnostic report to aid investigating your problem. Issues can be filed at https://github.com/LoopKit/Loop/issues.", - "Generated: \(date)", - "", - report, - "", - ].joined(separator: "\n\n")) - } + Task { @MainActor in + let report = await reportGenerator.generateDiagnosticReport() + // TODO: https://tidepool.atlassian.net/browse/LOOP-4771 + completionHandler([ + "Use the Share button above to save this diagnostic report to aid investigating your problem. Issues can be filed at https://github.com/LoopKit/Loop/issues.", + "Generated: \(date)", + "", + report, + "", + ].joined(separator: "\n\n")) } - return NSLocalizedString("Loading...", comment: "The loading message for the diagnostic report screen") }) vc.fileName = "Loop Report \(ISO8601DateFormatter.string(from: date, timeZone: .current, formatOptions: [.withSpaceBetweenDateAndTime, .withInternetDateTime])).md" diff --git a/Loop/View Controllers/InsulinDeliveryTableViewController.swift b/Loop/View Controllers/InsulinDeliveryTableViewController.swift index c340f8f536..e2e3ea9488 100644 --- a/Loop/View Controllers/InsulinDeliveryTableViewController.swift +++ b/Loop/View Controllers/InsulinDeliveryTableViewController.swift @@ -47,13 +47,8 @@ public final class InsulinDeliveryTableViewController: UITableViewController { public var enableEntryDeletion: Bool = true - var deviceManager: DeviceDataManager? { - didSet { - doseStore = deviceManager?.doseStore - } - } - - public var doseStore: DoseStore? { + var loopDataManager: LoopDataManager! + var doseStore: DoseStore! { didSet { if let doseStore = doseStore { doseStoreObserver = NotificationCenter.default.addObserver(forName: nil, object: doseStore, queue: OperationQueue.main, using: { [weak self] (note) -> Void in @@ -61,7 +56,9 @@ public final class InsulinDeliveryTableViewController: UITableViewController { switch note.name { case DoseStore.valuesDidChange: if self?.isViewLoaded == true { - self?.reloadData() + Task { @MainActor in + await self?.reloadData() + } } default: break @@ -159,13 +156,13 @@ public final class InsulinDeliveryTableViewController: UITableViewController { } @objc func didTapEnterDoseButton(sender: AnyObject){ - guard let deviceManager = deviceManager else { + guard let loopDataManager = loopDataManager else { return } tableView.endEditing(true) - let viewModel = ManualEntryDoseViewModel(delegate: deviceManager) + let viewModel = ManualEntryDoseViewModel(delegate: loopDataManager) let bolusEntryView = ManualEntryDoseView(viewModel: viewModel) let hostingController = DismissibleHostingController(rootView: bolusEntryView, isModalInPresentation: false) let navigationWrapper = UINavigationController(rootViewController: hostingController) @@ -185,7 +182,9 @@ public final class InsulinDeliveryTableViewController: UITableViewController { private var state = State.unknown { didSet { if isViewLoaded { - reloadData() + Task { @MainActor in + await reloadData() + } } } } @@ -201,6 +200,11 @@ public final class InsulinDeliveryTableViewController: UITableViewController { case history([PersistedPumpEvent]) case manualEntryDoses([DoseEntry]) } + + fileprivate enum HistorySection: Int { + case today + case yesterday + } // Not thread-safe private var values = Values.reservoir([]) { @@ -222,7 +226,7 @@ public final class InsulinDeliveryTableViewController: UITableViewController { } } - private func reloadData() { + private func reloadData() async { let sinceDate = Date().addingTimeInterval(-InsulinDeliveryTableViewController.historicDataDisplayTimeInterval) switch state { case .unknown: @@ -240,56 +244,35 @@ public final class InsulinDeliveryTableViewController: UITableViewController { self.tableView.tableHeaderView?.isHidden = false self.tableView.tableFooterView = nil - switch DataSourceSegment(rawValue: dataSourceSegmentedControl.selectedSegmentIndex)! { - case .reservoir: - doseStore?.getReservoirValues(since: sinceDate) { (result) in - DispatchQueue.main.async { () -> Void in - switch result { - case .failure(let error): - self.state = .unavailable(error) - case .success(let reservoirValues): - self.values = .reservoir(reservoirValues) - self.tableView.reloadData() - } - } - - self.updateTimelyStats(nil) - self.updateTotal() - } - case .history: - doseStore?.getPumpEventValues(since: sinceDate) { (result) in - DispatchQueue.main.async { () -> Void in - switch result { - case .failure(let error): - self.state = .unavailable(error) - case .success(let pumpEventValues): - self.values = .history(pumpEventValues) - self.tableView.reloadData() - } - } + guard let doseStore else { + return + } - self.updateTimelyStats(nil) - self.updateTotal() + do { + switch DataSourceSegment(rawValue: dataSourceSegmentedControl.selectedSegmentIndex)! { + case .reservoir: + self.values = .reservoir(try await doseStore.getReservoirValues(since: sinceDate, limit: nil)) + case .history: + self.values = .history(try await self.getPumpEvents(since: sinceDate)) + case .manualEntryDose: + self.values = .manualEntryDoses(try await doseStore.getManuallyEnteredDoses(since: sinceDate)) } - case .manualEntryDose: - doseStore?.getManuallyEnteredDoses(since: sinceDate) { (result) in - DispatchQueue.main.async { () -> Void in - switch result { - case .failure(let error): - self.state = .unavailable(error) - case .success(let values): - self.values = .manualEntryDoses(values) - self.tableView.reloadData() - } - } - } - + self.tableView.reloadData() self.updateTimelyStats(nil) self.updateTotal() + } catch { + self.state = .unavailable(error) } } } + private func getPumpEvents(since sinceDate: Date) async throws -> [PersistedPumpEvent] { + let events = try await doseStore.getPumpEventValues(since: sinceDate) + return events.filter { event in + return event.dose != nil + } + } + @objc func updateTimelyStats(_: Timer?) { updateIOB() } @@ -311,38 +294,38 @@ public final class InsulinDeliveryTableViewController: UITableViewController { return formatter }() + + private lazy var dateFormatter: DateFormatter = { + let formatter = DateFormatter() + + formatter.dateStyle = .short + formatter.timeStyle = .none + formatter.doesRelativeDateFormatting = true + + return formatter + }() private func updateIOB() { if case .display = state { - doseStore?.insulinOnBoard(at: Date()) { (result) -> Void in - DispatchQueue.main.async { - switch result { - case .failure: - self.iobValueLabel.text = "…" - self.iobDateLabel.text = nil - case .success(let iob): - self.iobValueLabel.text = self.iobNumberFormatter.string(from: iob.value) - self.iobDateLabel.text = String(format: NSLocalizedString("com.loudnate.InsulinKit.IOBDateLabel", value: "at %1$@", comment: "The format string describing the date of an IOB value. The first format argument is the localized date."), self.timeFormatter.string(from: iob.startDate)) - } - } + if let activeInsulin = loopDataManager.activeInsulin { + self.iobValueLabel.text = self.iobNumberFormatter.string(from: activeInsulin.value) + self.iobDateLabel.text = String(format: NSLocalizedString("com.loudnate.InsulinKit.IOBDateLabel", value: "at %1$@", comment: "The format string describing the date of an IOB value. The first format argument is the localized date."), self.timeFormatter.string(from: activeInsulin.startDate)) + } else { + self.iobValueLabel.text = "…" + self.iobDateLabel.text = nil } } } private func updateTotal() { - if case .display = state { - let midnight = Calendar.current.startOfDay(for: Date()) - - doseStore?.getTotalUnitsDelivered(since: midnight) { (result) in - DispatchQueue.main.async { - switch result { - case .failure: - self.totalValueLabel.text = "…" - self.totalDateLabel.text = nil - case .success(let result): - self.totalValueLabel.text = NumberFormatter.localizedString(from: NSNumber(value: result.value), number: .none) - self.totalDateLabel.text = String(format: NSLocalizedString("com.loudnate.InsulinKit.totalDateLabel", value: "since %1$@", comment: "The format string describing the starting date of a total value. The first format argument is the localized date."), DateFormatter.localizedString(from: result.startDate, dateStyle: .none, timeStyle: .short)) - } + Task { @MainActor in + if case .display = state { + if let result = await loopDataManager.totalDeliveredToday() { + self.totalValueLabel.text = NumberFormatter.localizedString(from: NSNumber(value: result.value), number: .none) + self.totalDateLabel.text = String(format: NSLocalizedString("com.loudnate.InsulinKit.totalDateLabel", value: "since %1$@", comment: "The format string describing the starting date of a total value. The first format argument is the localized date."), DateFormatter.localizedString(from: result.startDate, dateStyle: .none, timeStyle: .short)) + } else { + self.totalValueLabel.text = "…" + self.totalDateLabel.text = nil } } } @@ -357,7 +340,9 @@ public final class InsulinDeliveryTableViewController: UITableViewController { } @IBAction func selectedSegmentChanged(_ sender: Any) { - reloadData() + Task { @MainActor in + await reloadData() + } } @IBAction func confirmDeletion(_ sender: Any) { @@ -377,37 +362,35 @@ public final class InsulinDeliveryTableViewController: UITableViewController { } let sheet = UIAlertController(deleteAllConfirmationMessage: confirmMessage) { - self.deleteAllObjects() + Task { + await self.deleteAllObjects() + } } present(sheet, animated: true) } private var deletionPending = false - private func deleteAllObjects() { + private func deleteAllObjects() async { guard !deletionPending else { return } deletionPending = true - let completion = { (_: DoseStore.DoseStoreError?) -> Void in - DispatchQueue.main.async { - self.deletionPending = false - self.setEditing(false, animated: true) - } - } - let sinceDate = Date().addingTimeInterval(-InsulinDeliveryTableViewController.historicDataDisplayTimeInterval) switch DataSourceSegment(rawValue: dataSourceSegmentedControl.selectedSegmentIndex)! { case .reservoir: - doseStore?.deleteAllReservoirValues(completion) + try? await doseStore?.deleteAllReservoirValues() case .history: - doseStore?.deleteAllPumpEvents(completion) + try? await doseStore?.deleteAllPumpEvents() case .manualEntryDose: - doseStore?.deleteAllManuallyEnteredDoses(since: sinceDate, completion) + try? await doseStore?.deleteAllManuallyEnteredDoses(since: sinceDate) } + self.deletionPending = false + self.setEditing(false, animated: true) + } // MARK: - Table view data source @@ -417,7 +400,10 @@ public final class InsulinDeliveryTableViewController: UITableViewController { case .unknown, .unavailable: return 0 case .display: - return 1 + switch self.values { + case .history(let pumpEvents): return pumpEvents.pumpEventsBeforeToday.isEmpty ? 1 : 2 + default: return 1 + } } } @@ -425,13 +411,37 @@ public final class InsulinDeliveryTableViewController: UITableViewController { switch values { case .reservoir(let values): return values.count - case .history(let values): - return values.count + case .history(let pumpEvents): + switch HistorySection(rawValue: section) { + case .today: return pumpEvents.pumpEventsFromToday.count + case .yesterday: return pumpEvents.pumpEventsBeforeToday.count + case .none: return 0 + } case .manualEntryDoses(let values): return values.count } } + public override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch state { + case .display: + switch self.values { + case .history(let pumpEvents): + switch HistorySection(rawValue: section) { + case .today: + guard let firstValue = pumpEvents.pumpEventsFromToday.first else { return nil } + return dateFormatter.string(from: firstValue.date).uppercased() + case .yesterday: + guard let firstValue = pumpEvents.pumpEventsBeforeToday.first else { return nil } + return dateFormatter.string(from: firstValue.date).uppercased() + case .none: return nil + } + default: return nil + } + default: return nil + } + } + public override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: ReuseIdentifier, for: indexPath) @@ -447,18 +457,18 @@ public final class InsulinDeliveryTableViewController: UITableViewController { cell.detailTextLabel?.text = time cell.accessoryType = .none cell.selectionStyle = .none - case .history(let values): - let entry = values[indexPath.row] - let time = timeFormatter.string(from: entry.date) + case .history(let pumpEvents): + let pumpEvent = pumpEvents.pumpEventForIndexPath(indexPath) + let time = timeFormatter.string(from: pumpEvent.date) - if let attributedText = entry.localizedAttributedDescription { + if let attributedText = pumpEvent.localizedAttributedDescription { cell.textLabel?.attributedText = attributedText } else { cell.textLabel?.text = NSLocalizedString("Unknown", comment: "The default description to use when an entry has no dose description") } cell.detailTextLabel?.text = time - cell.accessoryType = entry.isUploaded ? .checkmark : .none + cell.accessoryType = pumpEvent.isUploaded ? .checkmark : .none cell.selectionStyle = .default case .manualEntryDoses(let values): let entry = values[indexPath.row] @@ -495,22 +505,25 @@ public final class InsulinDeliveryTableViewController: UITableViewController { if let error = error { DispatchQueue.main.async { self.present(UIAlertController(with: error), animated: true) - self.reloadData() + Task { @MainActor in + await self.reloadData() + } } } } - case .history(let historyValues): - var historyValues = historyValues - let value = historyValues.remove(at: indexPath.row) - self.values = .history(historyValues) + case .history(let pumpEvents): + let pumpEvent = pumpEvents.pumpEventForIndexPath(indexPath) + self.values = .history(pumpEvents.filter { $0.dose != pumpEvent.dose }) tableView.deleteRows(at: [indexPath], with: .automatic) - doseStore?.deletePumpEvent(value) { (error) -> Void in + doseStore?.deletePumpEvent(pumpEvent) { (error) -> Void in if let error = error { DispatchQueue.main.async { self.present(UIAlertController(with: error), animated: true) - self.reloadData() + Task { @MainActor in + await self.reloadData() + } } } } @@ -524,7 +537,9 @@ public final class InsulinDeliveryTableViewController: UITableViewController { if let error = error { DispatchQueue.main.async { self.present(UIAlertController(with: error), animated: true) - self.reloadData() + Task { @MainActor in + await self.reloadData() + } } } } @@ -533,23 +548,23 @@ public final class InsulinDeliveryTableViewController: UITableViewController { } public override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if case .display = state, case .history(let history) = values { - let entry = history[indexPath.row] + if case .display = state, case .history(let pumpEvents) = values { + let pumpEvent = pumpEvents.pumpEventForIndexPath(indexPath) let vc = CommandResponseViewController(command: { (completionHandler) -> String in var description = [String]() - description.append(self.timeFormatter.string(from: entry.date)) + description.append(self.timeFormatter.string(from: pumpEvent.date)) - if let title = entry.title { + if let title = pumpEvent.title { description.append(title) } - if let dose = entry.dose { + if let dose = pumpEvent.dose { description.append(String(describing: dose)) } - if let raw = entry.raw { + if let raw = pumpEvent.raw { description.append(raw.hexadecimalString) } @@ -664,3 +679,25 @@ extension PersistedPumpEvent { } extension InsulinDeliveryTableViewController: IdentifiableClass { } + +fileprivate extension Array where Element == PersistedPumpEvent { + var pumpEventsFromToday: [PersistedPumpEvent] { + let startOfDay = Calendar.current.startOfDay(for: Date()) + return self.filter({ $0.date >= startOfDay}) + } + + var pumpEventsBeforeToday: [PersistedPumpEvent] { + let startOfDay = Calendar.current.startOfDay(for: Date()) + return self.filter({ $0.date < startOfDay}) + } + + func pumpEventForIndexPath(_ indexPath: IndexPath) -> PersistedPumpEvent { + let filterPumpEvents: [PersistedPumpEvent] + if InsulinDeliveryTableViewController.HistorySection(rawValue: indexPath.section) == .today { + filterPumpEvents = self.pumpEventsFromToday + } else { + filterPumpEvents = self.pumpEventsBeforeToday + } + return filterPumpEvents[indexPath.row] + } +} diff --git a/Loop/View Controllers/PredictionTableViewController.swift b/Loop/View Controllers/PredictionTableViewController.swift index a460e52aaf..79ab21a35f 100644 --- a/Loop/View Controllers/PredictionTableViewController.swift +++ b/Loop/View Controllers/PredictionTableViewController.swift @@ -13,6 +13,7 @@ import LoopKitUI import LoopUI import UIKit import os.log +import LoopAlgorithm private extension RefreshContext { @@ -23,6 +24,9 @@ private extension RefreshContext { class PredictionTableViewController: LoopChartsTableViewController, IdentifiableClass { private let log = OSLog(category: "PredictionTableViewController") + var settingsManager: SettingsManager! + var loopDataManager: LoopDataManager! + override func viewDidLoad() { super.viewDidLoad() @@ -34,10 +38,10 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable let notificationCenter = NotificationCenter.default notificationObservers += [ - notificationCenter.addObserver(forName: .LoopDataUpdated, object: deviceManager.loopManager, queue: nil) { [weak self] note in - let context = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopDataManager.LoopUpdateContext.RawValue + notificationCenter.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { [weak self] note in + let context = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopUpdateContext.RawValue DispatchQueue.main.async { - switch LoopDataManager.LoopUpdateContext(rawValue: context) { + switch LoopUpdateContext(rawValue: context) { case .preferences?: self?.refreshContext.formUnion([.status, .targets]) case .glucose?: @@ -46,7 +50,9 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable break } - self?.reloadData(animated: true) + Task { + await self?.reloadData(animated: true) + } } }, ] @@ -98,7 +104,7 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable refreshContext = RefreshContext.all } - override func reloadData(animated: Bool = false) { + override func reloadData(animated: Bool = false) async { guard active && visible && !refreshContext.isEmpty else { return } refreshContext.remove(.size(.zero)) @@ -108,84 +114,69 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable let date = Date(timeIntervalSinceNow: -TimeInterval(hours: 1)) chartStartDate = calendar.nextDate(after: date, matching: components, matchingPolicy: .strict, direction: .backward) ?? date - let reloadGroup = DispatchGroup() var glucoseSamples: [StoredGlucoseSample]? var totalRetrospectiveCorrection: HKQuantity? - if self.refreshContext.remove(.glucose) != nil { - reloadGroup.enter() - deviceManager.glucoseStore.getGlucoseSamples(start: self.chartStartDate, end: nil) { (result) -> Void in - switch result { - case .failure(let error): - self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) - glucoseSamples = nil - case .success(let samples): - glucoseSamples = samples - } - reloadGroup.leave() - } - } - // For now, do this every time _ = self.refreshContext.remove(.status) - reloadGroup.enter() - deviceManager.loopManager.getLoopState { (manager, state) in - self.retrospectiveGlucoseDiscrepancies = state.retrospectiveGlucoseDiscrepancies - totalRetrospectiveCorrection = state.totalRetrospectiveCorrection - self.glucoseChart.setPredictedGlucoseValues(state.predictedGlucoseIncludingPendingInsulin ?? []) - - do { - let glucose = try state.predictGlucose(using: self.selectedInputs, includingPendingInsulin: true) - self.glucoseChart.setAlternatePredictedGlucoseValues(glucose) - } catch { - self.refreshContext.update(with: .status) - self.glucoseChart.setAlternatePredictedGlucoseValues([]) - } + let (algoInput, algoOutput) = await loopDataManager.algorithmDisplayState.asTuple - if let lastPoint = self.glucoseChart.alternatePredictedGlucosePoints?.last?.y { - self.eventualGlucoseDescription = String(describing: lastPoint) - } else { - self.eventualGlucoseDescription = nil - } + if self.refreshContext.remove(.glucose) != nil, let algoInput { + glucoseSamples = algoInput.glucoseHistory.filterDateRange(self.chartStartDate, nil) + } - if self.refreshContext.remove(.targets) != nil { - self.glucoseChart.targetGlucoseSchedule = manager.settings.glucoseTargetRangeSchedule - } + self.retrospectiveGlucoseDiscrepancies = algoOutput?.effects.retrospectiveGlucoseDiscrepancies + totalRetrospectiveCorrection = algoOutput?.effects.totalRetrospectiveCorrectionEffect + + self.glucoseChart.setPredictedGlucoseValues(algoOutput?.predictedGlucose ?? []) - reloadGroup.leave() + do { + let glucose = try algoInput?.predictGlucose(effectsOptions: self.selectedInputs.algorithmEffectOptions) ?? [] + self.glucoseChart.setAlternatePredictedGlucoseValues(glucose) + } catch { + self.refreshContext.update(with: .status) + self.glucoseChart.setAlternatePredictedGlucoseValues([]) } - reloadGroup.notify(queue: .main) { - if let glucoseSamples = glucoseSamples { - self.glucoseChart.setGlucoseValues(glucoseSamples) - } - self.charts.invalidateChart(atIndex: 0) + if let lastPoint = self.glucoseChart.alternatePredictedGlucosePoints?.last?.y { + self.eventualGlucoseDescription = String(describing: lastPoint) + } else { + self.eventualGlucoseDescription = nil + } - if let totalRetrospectiveCorrection = totalRetrospectiveCorrection { - self.totalRetrospectiveCorrection = totalRetrospectiveCorrection - } + if self.refreshContext.remove(.targets) != nil { + self.glucoseChart.targetGlucoseSchedule = self.settingsManager.settings.glucoseTargetRangeSchedule + } - self.charts.prerender() + if let glucoseSamples = glucoseSamples { + self.glucoseChart.setGlucoseValues(glucoseSamples) + } + self.charts.invalidateChart(atIndex: 0) - self.tableView.beginUpdates() - for cell in self.tableView.visibleCells { - switch cell { - case let cell as ChartTableViewCell: - cell.reloadChart() + if let totalRetrospectiveCorrection = totalRetrospectiveCorrection { + self.totalRetrospectiveCorrection = totalRetrospectiveCorrection + } - if let indexPath = self.tableView.indexPath(for: cell) { - self.tableView(self.tableView, updateTitleFor: cell, at: indexPath) - } - case let cell as PredictionInputEffectTableViewCell: - if let indexPath = self.tableView.indexPath(for: cell) { - self.tableView(self.tableView, updateTextFor: cell, at: indexPath) - } - default: - break + self.charts.prerender() + + self.tableView.beginUpdates() + for cell in self.tableView.visibleCells { + switch cell { + case let cell as ChartTableViewCell: + cell.reloadChart() + + if let indexPath = self.tableView.indexPath(for: cell) { + self.tableView(self.tableView, updateTitleFor: cell, at: indexPath) } + case let cell as PredictionInputEffectTableViewCell: + if let indexPath = self.tableView.indexPath(for: cell) { + self.tableView(self.tableView, updateTextFor: cell, at: indexPath) + } + default: + break } - self.tableView.endUpdates() } + self.tableView.endUpdates() } // MARK: - UITableViewDataSource @@ -197,7 +188,9 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable private var eventualGlucoseDescription: String? - private var availableInputs: [PredictionInputEffect] = [.carbs, .insulin, .momentum, .retrospection, .suspend] + // Removed .suspend from this list; LoopAlgorithm needs updates to support this. Also review + // for better ways to support desired use cases. https://github.com/LoopKit/Loop/pull/2026 + private var availableInputs: [PredictionInputEffect] = [.carbs, .insulin, .momentum, .retrospection] private var selectedInputs = PredictionInputEffect.all @@ -263,7 +256,7 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable if input == .retrospection, let lastDiscrepancy = retrospectiveGlucoseDiscrepancies?.last, - let currentGlucose = deviceManager.glucoseStore.latestGlucose + let currentGlucose = loopDataManager.latestGlucose { let formatter = QuantityFormatter(for: glucoseChart.glucoseUnit) let predicted = HKQuantity(unit: glucoseChart.glucoseUnit, doubleValue: currentGlucose.quantity.doubleValue(for: glucoseChart.glucoseUnit) - lastDiscrepancy.quantity.doubleValue(for: glucoseChart.glucoseUnit)) @@ -326,6 +319,9 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable tableView.deselectRow(at: indexPath, animated: true) refreshContext.update(with: .status) - reloadData() + + Task { + await reloadData() + } } } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 6a4aadfcdd..9fe356cccf 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -19,17 +19,24 @@ import SwiftCharts import os.log import Combine import WidgetKit - +import LoopAlgorithm private extension RefreshContext { static let all: Set = [.status, .glucose, .insulin, .carbs, .targets] } +@MainActor final class StatusTableViewController: LoopChartsTableViewController { private let log = OSLog(category: "StatusTableViewController") lazy var carbFormatter: QuantityFormatter = QuantityFormatter(for: .gram()) + + lazy var insulinFormatter: QuantityFormatter = { + let formatter = QuantityFormatter(for: .internationalUnit()) + formatter.numberFormatter.maximumFractionDigits = 2 + return formatter + }() var onboardingManager: OnboardingManager! @@ -39,10 +46,31 @@ final class StatusTableViewController: LoopChartsTableViewController { var alertPermissionsChecker: AlertPermissionsChecker! + var settingsManager: SettingsManager! + + var temporaryPresetsManager: TemporaryPresetsManager! + + var loopManager: LoopDataManager! + var alertMuter: AlertMuter! var supportManager: SupportManager! + var diagnosticReportGenerator: DiagnosticReportGenerator! + + var analyticsServicesManager: AnalyticsServicesManager? + + var servicesManager: ServicesManager! + + var simulatedData: SimulatedData! + + var carbStore: CarbStore! + + var doseStore: DoseStore! + + var criticalEventLogExportManager: CriticalEventLogExportManager! + + lazy private var cancellables = Set() override func viewDidLoad() { @@ -67,10 +95,10 @@ final class StatusTableViewController: LoopChartsTableViewController { let notificationCenter = NotificationCenter.default notificationObservers += [ - notificationCenter.addObserver(forName: .LoopDataUpdated, object: deviceManager.loopManager, queue: nil) { [weak self] note in - let rawContext = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopDataManager.LoopUpdateContext.RawValue - let context = LoopDataManager.LoopUpdateContext(rawValue: rawContext) - DispatchQueue.main.async { + notificationCenter.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { note in + let rawContext = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopUpdateContext.RawValue + let context = LoopUpdateContext(rawValue: rawContext) + Task { @MainActor [weak self] in switch context { case .none, .insulin?: self?.refreshContext.formUnion([.status, .insulin]) @@ -80,56 +108,84 @@ final class StatusTableViewController: LoopChartsTableViewController { self?.refreshContext.update(with: .carbs) case .glucose?: self?.refreshContext.formUnion([.glucose, .carbs]) - case .loopFinished?: - self?.refreshContext.update(with: .insulin) + default: + break } self?.hudView?.loopCompletionHUD.loopInProgress = false self?.log.debug("[reloadData] from notification with context %{public}@", String(describing: context)) - self?.reloadData(animated: true) + await self?.reloadData(animated: true) } - + WidgetCenter.shared.reloadAllTimelines() }, - notificationCenter.addObserver(forName: .LoopRunning, object: deviceManager.loopManager, queue: nil) { [weak self] _ in - DispatchQueue.main.async { + notificationCenter.addObserver(forName: .LoopRunning, object: nil, queue: nil) { _ in + Task { @MainActor [weak self] in self?.hudView?.loopCompletionHUD.loopInProgress = true } }, - notificationCenter.addObserver(forName: .PumpManagerChanged, object: deviceManager, queue: nil) { [weak self] (notification: Notification) in - DispatchQueue.main.async { + notificationCenter.addObserver(forName: .LoopCycleCompleted, object: nil, queue: nil) { _ in + Task { @MainActor [weak self] in + self?.hudView?.loopCompletionHUD.loopInProgress = false + } + }, + notificationCenter.addObserver(forName: .PumpManagerChanged, object: deviceManager, queue: nil) { (notification: Notification) in + Task { @MainActor [weak self] in self?.registerPumpManager() self?.configurePumpManagerHUDViews() self?.updateToolbarItems() } }, - notificationCenter.addObserver(forName: .CGMManagerChanged, object: deviceManager, queue: nil) { [weak self] (notification: Notification) in - DispatchQueue.main.async { + notificationCenter.addObserver(forName: .CGMManagerChanged, object: deviceManager, queue: nil) { (notification: Notification) in + Task { @MainActor [weak self] in self?.registerCGMManager() self?.configureCGMManagerHUDViews() self?.updateToolbarItems() } }, - notificationCenter.addObserver(forName: .PumpEventsAdded, object: deviceManager, queue: nil) { [weak self] (notification: Notification) in - DispatchQueue.main.async { + notificationCenter.addObserver(forName: .PumpEventsAdded, object: deviceManager, queue: nil) { (notification: Notification) in + Task { @MainActor [weak self] in self?.refreshContext.update(with: .insulin) - self?.reloadData(animated: true) + await self?.reloadData(animated: true) } }, ] automaticDosingStatus.$automaticDosingEnabled .receive(on: DispatchQueue.main) + .dropFirst() .sink { self.automaticDosingStatusChanged($0) } .store(in: &cancellables) alertMuter.$configuration .removeDuplicates() - .receive(on: RunLoop.main) .dropFirst() .sink { _ in - self.refreshContext.update(with: .status) - self.reloadData(animated: true) + Task { @MainActor in + self.refreshContext.update(with: .status) + await self.reloadData(animated: true) + } + } + .store(in: &cancellables) + + loopManager.$lastLoopCompleted + .receive(on: DispatchQueue.main) + .sink { [weak self] lastLoopCompleted in + self?.hudView?.loopCompletionHUD.lastLoopCompleted = lastLoopCompleted + } + .store(in: &cancellables) + + loopManager.$publishedMostRecentGlucoseDataDate + .receive(on: DispatchQueue.main) + .sink { [weak self] mostRecentGlucoseDataDate in + self?.hudView?.loopCompletionHUD.mostRecentGlucoseDataDate = mostRecentGlucoseDataDate + } + .store(in: &cancellables) + + loopManager.$publishedMostRecentPumpDataDate + .receive(on: DispatchQueue.main) + .sink { [weak self] mostRecentPumpDataDate in + self?.hudView?.loopCompletionHUD.mostRecentPumpDataDate = mostRecentPumpDataDate } .store(in: &cancellables) @@ -172,11 +228,12 @@ final class StatusTableViewController: LoopChartsTableViewController { onboardingManager.$isComplete .merge(with: onboardingManager.$isSuspended) - .receive(on: RunLoop.main) .sink { [weak self] _ in - self?.refreshContext.update(with: .status) - self?.reloadData(animated: true) - self?.updateToolbarItems() + Task { @MainActor in + self?.refreshContext.update(with: .status) + await self?.reloadData(animated: true) + self?.updateToolbarItems() + } } .store(in: &cancellables) } @@ -187,15 +244,15 @@ final class StatusTableViewController: LoopChartsTableViewController { if !appearedOnce { appearedOnce = true - DispatchQueue.main.async { + Task { @MainActor in self.log.debug("[reloadData] after HealthKit authorization") - self.reloadData() + await self.reloadData() } } onscreen = true - deviceManager.analyticsServicesManager.didDisplayStatusScreen() + analyticsServicesManager?.didDisplayStatusScreen() deviceManager.checkDeliveryUncertaintyState() } @@ -233,6 +290,7 @@ final class StatusTableViewController: LoopChartsTableViewController { var onscreen: Bool = false { didSet { updateHUDActive() + loopManager.startGlucoseValueStalenessTimerIfNeeded() } } @@ -249,8 +307,10 @@ final class StatusTableViewController: LoopChartsTableViewController { default: break } - refreshContext.update(with: .status) - reloadData(animated: true) + Task { @MainActor in + refreshContext.update(with: .status) + await reloadData(animated: true) + } } } } @@ -259,7 +319,9 @@ final class StatusTableViewController: LoopChartsTableViewController { private func updateBolusProgress() { if let cell = tableView.cellForRow(at: IndexPath(row: StatusRow.status.rawValue, section: Section.status.rawValue)) as? BolusProgressTableViewCell { - cell.deliveredUnits = bolusProgressReporter?.progress.deliveredUnits + if case let .bolusing(_, total) = cell.configuration { + cell.configuration = .bolusing(delivered: bolusProgressReporter?.progress.deliveredUnits, ofTotalVolume: total) + } } } @@ -273,6 +335,10 @@ final class StatusTableViewController: LoopChartsTableViewController { let bolus = UIBarButtonItem(image: UIImage(named: "bolus"), style: .plain, target: self, action: #selector(presentBolusScreen)) let settings = UIBarButtonItem(image: UIImage(named: "settings"), style: .plain, target: self, action: #selector(onSettingsTapped)) + carbs.accessibilityIdentifier = "statusTableViewControllerCarbsButton" + bolus.accessibilityIdentifier = "statusTableViewControllerBolusButton" + settings.accessibilityIdentifier = "statusTableViewControllerSettingsButton" + let preMeal = createPreMealButtonItem(selected: false, isEnabled: true) let workout = createWorkoutButtonItem(selected: false, isEnabled: true) toolbarItems = [ @@ -307,9 +373,11 @@ final class StatusTableViewController: LoopChartsTableViewController { public var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? = nil { didSet { if oldValue != basalDeliveryState { - log.debug("New basalDeliveryState: %@", String(describing: basalDeliveryState)) - refreshContext.update(with: .status) - reloadData(animated: true) + Task { @MainActor in + log.debug("New basalDeliveryState: %@", String(describing: basalDeliveryState)) + refreshContext.update(with: .status) + await reloadData(animated: true) + } } } } @@ -359,7 +427,7 @@ final class StatusTableViewController: LoopChartsTableViewController { let availableWidth = (refreshContext.newSize ?? tableView.bounds.size).width - charts.fixedHorizontalMargin let totalHours = floor(Double(availableWidth / LoopConstants.minimumChartWidthPerHour)) - let futureHours = ceil(deviceManager.doseStore.longestEffectDuration.hours) + let futureHours = ceil(doseStore.longestEffectDuration.hours) let historyHours = max(LoopConstants.statusChartMinimumHistoryDisplay.hours, totalHours - futureHours) let date = Date(timeIntervalSinceNow: -TimeInterval(hours: historyHours)) @@ -372,10 +440,12 @@ final class StatusTableViewController: LoopChartsTableViewController { charts.updateEndDate(charts.maxEndDate) } - override func reloadData(animated: Bool = false) { + override func reloadData(animated: Bool = false) async { dispatchPrecondition(condition: .onQueue(.main)) // This should be kept up to date immediately - hudView?.loopCompletionHUD.lastLoopCompleted = deviceManager.loopManager.lastLoopCompleted + hudView?.loopCompletionHUD.lastLoopCompleted = loopManager.lastLoopCompleted + hudView?.loopCompletionHUD.mostRecentGlucoseDataDate = loopManager.mostRecentGlucoseDataDate + hudView?.loopCompletionHUD.mostRecentPumpDataDate = loopManager.mostRecentPumpDataDate guard !reloading && !deviceManager.authorizationRequired else { return @@ -402,15 +472,13 @@ final class StatusTableViewController: LoopChartsTableViewController { log.debug("Reloading data with context: %@", String(describing: refreshContext)) let currentContext = refreshContext - var retryContext: Set = [] refreshContext = [] reloading = true - let reloadGroup = DispatchGroup() var glucoseSamples: [StoredGlucoseSample]? var predictedGlucoseValues: [GlucoseValue]? var iobValues: [InsulinValue]? - var doseEntries: [DoseEntry]? + var doseEntries: [BasalRelativeDose]? var totalDelivery: Double? var cobValues: [CarbValue]? var carbsOnBoard: HKQuantity? @@ -418,231 +486,170 @@ final class StatusTableViewController: LoopChartsTableViewController { let basalDeliveryState = self.basalDeliveryState let automaticDosingEnabled = automaticDosingStatus.automaticDosingEnabled - // TODO: Don't always assume currentContext.contains(.status) - reloadGroup.enter() - deviceManager.loopManager.getLoopState { (manager, state) -> Void in - predictedGlucoseValues = state.predictedGlucoseIncludingPendingInsulin ?? [] - - // Retry this refresh again if predicted glucose isn't available - if state.predictedGlucose == nil { - retryContext.update(with: .status) - } - - /// Update the status HUDs immediately - let lastLoopError = state.error + let state = await loopManager.algorithmDisplayState + predictedGlucoseValues = state.output?.predictedGlucose ?? [] - // Net basal rate HUD - let netBasal: NetBasal? - if let basalSchedule = manager.basalRateScheduleApplyingOverrideHistory { - netBasal = basalDeliveryState?.getNetBasal(basalSchedule: basalSchedule, settings: manager.settings) - } else { - netBasal = nil - } - self.log.debug("Update net basal to %{public}@", String(describing: netBasal)) + /// Update the status HUDs immediately + let lastLoopError: Error? + if let output = state.output, case .failure(let error) = output.recommendationResult { + lastLoopError = error + } else { + lastLoopError = nil + } - DispatchQueue.main.async { - self.lastLoopError = lastLoopError + // Net basal rate HUD + let netBasal: NetBasal? + if let basalSchedule = temporaryPresetsManager.basalRateScheduleApplyingOverrideHistory { + netBasal = basalDeliveryState?.getNetBasal(basalSchedule: basalSchedule, maximumBasalRatePerHour: settingsManager.settings.maximumBasalRatePerHour) + } else { + netBasal = nil + } + self.log.debug("Update net basal to %{public}@", String(describing: netBasal)) - if let netBasal = netBasal { - self.hudView?.pumpStatusHUD.basalRateHUD.setNetBasalRate(netBasal.rate, percent: netBasal.percent, at: netBasal.start) - } - } + self.lastLoopError = lastLoopError - if currentContext.contains(.carbs) { - reloadGroup.enter() - self.deviceManager.carbStore.getCarbsOnBoardValues(start: startDate, end: nil, effectVelocities: state.insulinCounteractionEffects) { (result) in - switch result { - case .failure(let error): - self.log.error("CarbStore failed to get carbs on board values: %{public}@", String(describing: error)) - retryContext.update(with: .carbs) - cobValues = [] - case .success(let values): - cobValues = values - } - reloadGroup.leave() - } - } - // always check for cob - carbsOnBoard = state.carbsOnBoard?.quantity + if let netBasal = netBasal { + self.hudView?.pumpStatusHUD.basalRateHUD.setNetBasalRate(netBasal.rate, percent: netBasal.percent, at: netBasal.start) + } - reloadGroup.leave() + if currentContext.contains(.carbs) { + cobValues = await loopManager.dynamicCarbsOnBoard(from: startDate) } + // always check for cob + carbsOnBoard = loopManager.activeCarbs?.quantity + if currentContext.contains(.glucose) { - reloadGroup.enter() - deviceManager.glucoseStore.getGlucoseSamples(start: startDate, end: nil) { (result) -> Void in - switch result { - case .failure(let error): - self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) - glucoseSamples = nil - case .success(let samples): - glucoseSamples = samples - } - reloadGroup.leave() + do { + glucoseSamples = try await loopManager.glucoseStore.getGlucoseSamples(start: startDate, end: nil) + } catch { + self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) + glucoseSamples = nil } } if currentContext.contains(.insulin) { - reloadGroup.enter() - deviceManager.doseStore.getInsulinOnBoardValues(start: startDate, end: nil, basalDosingEnd: nil) { (result) -> Void in - switch result { - case .failure(let error): - self.log.error("DoseStore failed to get insulin on board values: %{public}@", String(describing: error)) - retryContext.update(with: .insulin) - iobValues = [] - case .success(let values): - iobValues = values - } - reloadGroup.leave() - } - - reloadGroup.enter() - deviceManager.doseStore.getNormalizedDoseEntries(start: startDate, end: nil) { (result) -> Void in - switch result { - case .failure(let error): - self.log.error("DoseStore failed to get normalized dose entries: %{public}@", String(describing: error)) - retryContext.update(with: .insulin) - doseEntries = [] - case .success(let doses): - doseEntries = doses - } - reloadGroup.leave() - } + doseEntries = loopManager.dosesRelativeToBasal.trimmed(from: startDate) - reloadGroup.enter() - deviceManager.doseStore.getTotalUnitsDelivered(since: Calendar.current.startOfDay(for: Date())) { (result) in - switch result { - case .failure: - retryContext.update(with: .insulin) - totalDelivery = nil - case .success(let total): - totalDelivery = total.value - } - - reloadGroup.leave() - } + iobValues = loopManager.iobValues.filterDateRange(startDate, nil) + totalDelivery = await loopManager.totalDeliveredToday()?.value } updatePresetModeAvailability(automaticDosingEnabled: automaticDosingEnabled) - if deviceManager.loopManager.settings.preMealTargetRange == nil { + if settingsManager.settings.preMealTargetRange == nil { preMealMode = nil } else { - preMealMode = deviceManager.loopManager.settings.preMealTargetEnabled() + preMealMode = temporaryPresetsManager.preMealTargetEnabled() } - if !FeatureFlags.sensitivityOverridesEnabled, deviceManager.loopManager.settings.legacyWorkoutTargetRange == nil { + if !FeatureFlags.sensitivityOverridesEnabled, settingsManager.settings.workoutTargetRange == nil { workoutMode = nil } else { - workoutMode = deviceManager.loopManager.settings.nonPreMealOverrideEnabled() + workoutMode = temporaryPresetsManager.nonPreMealOverrideEnabled() } - reloadGroup.notify(queue: .main) { - /// Update the chart data + /// Update the chart data - // Glucose - if let glucoseSamples = glucoseSamples { - self.statusCharts.setGlucoseValues(glucoseSamples) - } - if (automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled), let predictedGlucoseValues = predictedGlucoseValues { - self.statusCharts.setPredictedGlucoseValues(predictedGlucoseValues) - } else { - self.statusCharts.setPredictedGlucoseValues([]) - } - if !FeatureFlags.predictedGlucoseChartClampEnabled, - let lastPoint = self.statusCharts.glucose.predictedGlucosePoints.last?.y - { - self.eventualGlucoseDescription = String(describing: lastPoint) - } else { - // if the predicted glucose values are clamped, the eventually glucose description should not be displayed, since it may not align with what is being charted. - self.eventualGlucoseDescription = nil - } - if currentContext.contains(.targets) { - self.statusCharts.targetGlucoseSchedule = self.deviceManager.loopManager.settings.glucoseTargetRangeSchedule - self.statusCharts.preMealOverride = self.deviceManager.loopManager.settings.preMealOverride - self.statusCharts.scheduleOverride = self.deviceManager.loopManager.settings.scheduleOverride - } - if self.statusCharts.scheduleOverride?.hasFinished() == true { - self.statusCharts.scheduleOverride = nil - } + // Glucose + if let glucoseSamples = glucoseSamples { + self.statusCharts.setGlucoseValues(glucoseSamples) + } + if (automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled), let predictedGlucoseValues = predictedGlucoseValues { + self.statusCharts.setPredictedGlucoseValues(predictedGlucoseValues) + } else { + self.statusCharts.setPredictedGlucoseValues([]) + } + if !FeatureFlags.predictedGlucoseChartClampEnabled, + let lastPoint = self.statusCharts.glucose.predictedGlucosePoints.last?.y + { + self.eventualGlucoseDescription = String(describing: lastPoint) + } else { + // if the predicted glucose values are clamped, the eventually glucose description should not be displayed, since it may not align with what is being charted. + self.eventualGlucoseDescription = nil + } + if currentContext.contains(.targets) { + self.statusCharts.targetGlucoseSchedule = settingsManager.settings.glucoseTargetRangeSchedule + self.statusCharts.preMealOverride = temporaryPresetsManager.preMealOverride + self.statusCharts.scheduleOverride = temporaryPresetsManager.scheduleOverride + } + if self.statusCharts.scheduleOverride?.hasFinished() == true { + self.statusCharts.scheduleOverride = nil + } - let charts = self.statusCharts + let charts = self.statusCharts - // Active Insulin - if let iobValues = iobValues { - charts.setIOBValues(iobValues) - } + // Active Insulin + if let iobValues = iobValues { + charts.setIOBValues(iobValues) + } - // Show the larger of the value either before or after the current date - if let maxValue = charts.iob.iobPoints.allElementsAdjacent(to: Date()).max(by: { - return $0.y.scalar < $1.y.scalar - }) { - self.currentIOBDescription = String(describing: maxValue.y) - } else { - self.currentIOBDescription = nil - } + // Show the larger of the value either before or after the current date + if let activeInsulin = loopManager.activeInsulin { + self.currentIOBDescription = insulinFormatter.string(from: activeInsulin.quantity, includeUnit: true) + } else { + self.currentIOBDescription = nil + } - // Insulin Delivery - if let doseEntries = doseEntries { - charts.setDoseEntries(doseEntries) - } - if let totalDelivery = totalDelivery { - self.totalDelivery = totalDelivery - } + // Insulin Delivery + if let doseEntries = doseEntries { + charts.setDoseEntries(doseEntries) + } + if let totalDelivery = totalDelivery { + self.totalDelivery = totalDelivery + } - // Active Carbohydrates - if let cobValues = cobValues { - charts.setCOBValues(cobValues) - } - if let index = charts.cob.cobPoints.closestIndex(priorTo: Date()) { - self.currentCOBDescription = String(describing: charts.cob.cobPoints[index].y) - } else if let carbsOnBoard = carbsOnBoard { - self.currentCOBDescription = self.carbFormatter.string(from: carbsOnBoard) - } else { - self.currentCOBDescription = nil - } + // Active Carbohydrates + if let cobValues = cobValues { + charts.setCOBValues(cobValues) + } + if let index = charts.cob.cobPoints.closestIndex(priorTo: Date()) { + self.currentCOBDescription = String(describing: charts.cob.cobPoints[index].y) + } else if let carbsOnBoard = carbsOnBoard { + self.currentCOBDescription = self.carbFormatter.string(from: carbsOnBoard) + } else { + self.currentCOBDescription = nil + } - self.tableView.beginUpdates() - if let hudView = self.hudView { - // CGM Status - if let glucose = self.deviceManager.glucoseStore.latestGlucose { - let unit = self.statusCharts.glucose.glucoseUnit - hudView.cgmStatusHUD.setGlucoseQuantity(glucose.quantity.doubleValue(for: unit), - at: glucose.startDate, - unit: unit, - staleGlucoseAge: LoopCoreConstants.inputDataRecencyInterval, - glucoseDisplay: self.deviceManager.glucoseDisplay(for: glucose), - wasUserEntered: glucose.wasUserEntered, - isDisplayOnly: glucose.isDisplayOnly) - } - hudView.cgmStatusHUD.presentStatusHighlight(self.deviceManager.cgmStatusHighlight) - hudView.cgmStatusHUD.presentStatusBadge(self.deviceManager.cgmStatusBadge) - hudView.cgmStatusHUD.lifecycleProgress = self.deviceManager.cgmLifecycleProgress - - // Pump Status - hudView.pumpStatusHUD.presentStatusHighlight(self.deviceManager.pumpStatusHighlight) - hudView.pumpStatusHUD.presentStatusBadge(self.deviceManager.pumpStatusBadge) - hudView.pumpStatusHUD.lifecycleProgress = self.deviceManager.pumpLifecycleProgress + self.tableView.beginUpdates() + if let hudView = self.hudView { + // CGM Status + if let glucose = self.loopManager.latestGlucose { + let unit = self.statusCharts.glucose.glucoseUnit + hudView.cgmStatusHUD.setGlucoseQuantity(glucose.quantity.doubleValue(for: unit), + at: glucose.startDate, + unit: unit, + glucoseDisplay: self.deviceManager.glucoseDisplay(for: glucose), + wasUserEntered: glucose.wasUserEntered, + isDisplayOnly: glucose.isDisplayOnly, + isGlucoseValueStale: self.deviceManager.isGlucoseValueStale) } + hudView.cgmStatusHUD.presentStatusHighlight(self.deviceManager.cgmStatusHighlight) + hudView.cgmStatusHUD.presentStatusBadge(self.deviceManager.cgmStatusBadge) + hudView.cgmStatusHUD.lifecycleProgress = self.deviceManager.cgmLifecycleProgress + + // Pump Status + hudView.pumpStatusHUD.presentStatusHighlight(self.deviceManager.pumpStatusHighlight) + hudView.pumpStatusHUD.presentStatusBadge(self.deviceManager.pumpStatusBadge) + hudView.pumpStatusHUD.lifecycleProgress = self.deviceManager.pumpLifecycleProgress + } - // Show/hide the table view rows - let statusRowMode = self.determineStatusRowMode() + // Show/hide the table view rows + let statusRowMode = self.determineStatusRowMode() - self.updateBannerAndHUDandStatusRows(statusRowMode: statusRowMode, newSize: currentContext.newSize, animated: animated) + updateBannerAndHUDandStatusRows(statusRowMode: statusRowMode, newSize: currentContext.newSize, animated: animated) - self.redrawCharts() + redrawCharts() - self.tableView.endUpdates() + tableView.endUpdates() - self.reloading = false - let reloadNow = !self.refreshContext.isEmpty - self.refreshContext.formUnion(retryContext) + reloading = false + let reloadNow = !self.refreshContext.isEmpty - // Trigger a reload if new context exists. - if reloadNow { - self.log.debug("[reloadData] due to context change during previous reload") - self.reloadData() - } + // Trigger a reload if new context exists. + if reloadNow { + log.debug("[reloadData] due to context change during previous reload") + await reloadData() } } @@ -690,6 +697,7 @@ final class StatusTableViewController: LoopChartsTableViewController { case enactingBolus case bolusing(dose: DoseEntry) case cancelingBolus + case canceledBolus(dose: DoseEntry) case pumpSuspended(resuming: Bool) case onboardingSuspended case recommendManualGlucoseEntry @@ -706,6 +714,8 @@ final class StatusTableViewController: LoopChartsTableViewController { private var statusRowMode = StatusRowMode.hidden + private var canceledDose: DoseEntry? = nil + private func determineStatusRowMode() -> StatusRowMode { let statusRowMode: StatusRowMode @@ -713,21 +723,24 @@ final class StatusTableViewController: LoopChartsTableViewController { statusRowMode = .enactingBolus } else if case .canceling = bolusState { statusRowMode = .cancelingBolus + } else if let canceledDose { + statusRowMode = .canceledBolus(dose: canceledDose) } else if case .suspended = basalDeliveryState { statusRowMode = .pumpSuspended(resuming: false) } else if case .resuming = basalDeliveryState { statusRowMode = .pumpSuspended(resuming: true) - } else if case .inProgress(let dose) = bolusState, dose.endDate.timeIntervalSinceNow > 0 { + } else if case .inProgress(let dose) = bolusState, bolusProgressReporter?.progress.isComplete == false { + // the isComplete check should be tested on DIY statusRowMode = .bolusing(dose: dose) } else if !onboardingManager.isComplete, deviceManager.pumpManager?.isOnboarded == true { statusRowMode = .onboardingSuspended } else if onboardingManager.isComplete, deviceManager.isGlucoseValueStale { statusRowMode = .recommendManualGlucoseEntry - } else if let scheduleOverride = deviceManager.loopManager.settings.scheduleOverride, + } else if let scheduleOverride = temporaryPresetsManager.scheduleOverride, !scheduleOverride.hasFinished() { statusRowMode = .scheduleOverrideEnabled(scheduleOverride) - } else if let premealOverride = deviceManager.loopManager.settings.preMealOverride, + } else if let premealOverride = temporaryPresetsManager.preMealOverride, !premealOverride.hasFinished() { statusRowMode = .scheduleOverrideEnabled(premealOverride) @@ -767,8 +780,9 @@ final class StatusTableViewController: LoopChartsTableViewController { let hudIsVisible = self.shouldShowHUD let statusIsVisible = self.shouldShowStatus - + hudView?.cgmStatusHUD?.isVisible = hudIsVisible + hudView?.cgmStatusHUD.isGlucoseValueStale = deviceManager.isGlucoseValueStale tableView.beginUpdates() @@ -788,16 +802,29 @@ final class StatusTableViewController: LoopChartsTableViewController { switch (statusWasVisible, statusIsVisible) { case (true, true): switch (oldStatusRowMode, self.statusRowMode) { + case (.pumpSuspended(resuming: let wasResuming), .pumpSuspended(resuming: let isResuming)): + if isResuming != wasResuming { + tableView.reloadRows(at: [statusIndexPath], with: animated ? .fade : .none) + } case (.enactingBolus, .enactingBolus): break case (.bolusing(let oldDose), .bolusing(let newDose)): if oldDose.syncIdentifier != newDose.syncIdentifier { tableView.reloadRows(at: [statusIndexPath], with: animated ? .fade : .none) } - case (.pumpSuspended(resuming: let wasResuming), .pumpSuspended(resuming: let isResuming)): - if isResuming != wasResuming { + case (.canceledBolus(let oldDose), .canceledBolus(let newDose)): + if oldDose != newDose { tableView.reloadRows(at: [statusIndexPath], with: animated ? .fade : .none) } + // these updates cause flickering and/or confusion. + case (.cancelingBolus, .cancelingBolus): + break + case (.cancelingBolus, .bolusing(_)): + break + case (.canceledBolus(_), .cancelingBolus): + break + case (.canceledBolus(_), .bolusing(_)): + break default: tableView.reloadRows(at: [statusIndexPath], with: animated ? .fade : .none) } @@ -837,14 +864,14 @@ final class StatusTableViewController: LoopChartsTableViewController { } private lazy var preMealModeAllowed: Bool = { onboardingManager.isComplete && - (automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) - && deviceManager.loopManager.settings.preMealTargetRange != nil + (automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) + && settingsManager.settings.preMealTargetRange != nil }() private func updatePresetModeAvailability(automaticDosingEnabled: Bool) { preMealModeAllowed = onboardingManager.isComplete && - (automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) - && deviceManager.loopManager.settings.preMealTargetRange != nil + (automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) + && settingsManager.settings.preMealTargetRange != nil workoutModeAllowed = onboardingManager.isComplete && workoutMode != nil updateToolbarItems() } @@ -882,7 +909,14 @@ final class StatusTableViewController: LoopChartsTableViewController { } private class AlertPermissionsDisabledWarningCell: UITableViewCell { + + var alert: AlertPermissionsChecker.UnsafeNotificationPermissionAlert? + override func updateConfiguration(using state: UICellConfigurationState) { + guard let alert else { + return + } + super.updateConfiguration(using: state) let adjustViewForNarrowDisplay = bounds.width < 350 @@ -890,14 +924,14 @@ final class StatusTableViewController: LoopChartsTableViewController { var contentConfig = defaultContentConfiguration().updated(for: state) let titleImageAttachment = NSTextAttachment() titleImageAttachment.image = UIImage(systemName: "exclamationmark.triangle.fill")?.withTintColor(.white) - let title = NSMutableAttributedString(string: NSLocalizedString(" Safety Notifications are OFF", comment: "Warning text for when Notifications or Critical Alerts Permissions is disabled")) + let title = NSMutableAttributedString(string: alert.bannerTitle) let titleWithImage = NSMutableAttributedString(attachment: titleImageAttachment) titleWithImage.append(title) contentConfig.attributedText = titleWithImage contentConfig.textProperties.color = .white contentConfig.textProperties.font = .systemFont(ofSize: adjustViewForNarrowDisplay ? 16 : 18, weight: .bold) contentConfig.textProperties.adjustsFontSizeToFitWidth = true - contentConfig.secondaryText = NSLocalizedString("Fix now by turning Notifications, Critical Alerts and Time Sensitive Notifications ON.", comment: "Secondary text for alerts disabled warning, which appears on the main status screen.") + contentConfig.secondaryText = alert.bannerBody contentConfig.secondaryTextProperties.color = .white contentConfig.secondaryTextProperties.font = .systemFont(ofSize: adjustViewForNarrowDisplay ? 13 : 15) contentConfiguration = contentConfig @@ -930,7 +964,7 @@ final class StatusTableViewController: LoopChartsTableViewController { let adjustViewForNarrowDisplay = bounds.width < 350 var contentConfig = defaultContentConfiguration().updated(for: state) - let title = NSMutableAttributedString(string: NSLocalizedString("All Alerts Muted", comment: "Warning text for when alerts are muted")) + let title = NSMutableAttributedString(string: NSLocalizedString("All App Sounds Muted", comment: "Warning text for when alerts are muted")) let image = UIImage(systemName: "speaker.slash.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 25, weight: .thin, scale: .large)) contentConfig.image = image contentConfig.imageProperties.tintColor = .white @@ -966,7 +1000,8 @@ final class StatusTableViewController: LoopChartsTableViewController { switch Section(rawValue: indexPath.section)! { case .alertWarning: if alertPermissionsChecker.showWarning { - let cell = tableView.dequeueReusableCell(withIdentifier: AlertPermissionsDisabledWarningCell.className, for: indexPath) as! AlertPermissionsDisabledWarningCell + var cell = tableView.dequeueReusableCell(withIdentifier: AlertPermissionsDisabledWarningCell.className, for: indexPath) as! AlertPermissionsDisabledWarningCell + cell.alert = AlertPermissionsChecker.UnsafeNotificationPermissionAlert(permissions: alertPermissionsChecker.notificationCenterSettings) return cell } else { let cell = tableView.dequeueReusableCell(withIdentifier: MuteAlertsWarningCell.className, for: indexPath) as! MuteAlertsWarningCell @@ -977,6 +1012,7 @@ final class StatusTableViewController: LoopChartsTableViewController { case .hud: let cell = tableView.dequeueReusableCell(withIdentifier: HUDViewTableViewCell.className, for: indexPath) as! HUDViewTableViewCell hudView = cell.hudView + cell.hudView.loopCompletionHUD.loopStatusColors = .loopStatus return cell case .charts: @@ -1015,7 +1051,6 @@ final class StatusTableViewController: LoopChartsTableViewController { return cell case .status: - func getTitleSubtitleCell() -> TitleSubtitleTableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: TitleSubtitleTableViewCell.className, for: indexPath) as! TitleSubtitleTableViewCell cell.selectionStyle = .none @@ -1070,29 +1105,27 @@ final class StatusTableViewController: LoopChartsTableViewController { return cell case .enactingBolus: - let cell = getTitleSubtitleCell() - cell.titleLabel.text = NSLocalizedString("Starting Bolus", comment: "The title of the cell indicating a bolus is being sent") - - let indicatorView = UIActivityIndicatorView(style: .default) - indicatorView.startAnimating() - cell.accessoryView = indicatorView - return cell + let progressCell = tableView.dequeueReusableCell(withIdentifier: BolusProgressTableViewCell.className, for: indexPath) as! BolusProgressTableViewCell + progressCell.selectionStyle = .none + progressCell.configuration = .starting + return progressCell case .bolusing(let dose): let progressCell = tableView.dequeueReusableCell(withIdentifier: BolusProgressTableViewCell.className, for: indexPath) as! BolusProgressTableViewCell progressCell.selectionStyle = .none - progressCell.totalUnits = dose.programmedUnits + progressCell.configuration = .bolusing(delivered: bolusProgressReporter?.progress.deliveredUnits, ofTotalVolume: dose.programmedUnits) progressCell.tintColor = .insulinTintColor - progressCell.deliveredUnits = bolusProgressReporter?.progress.deliveredUnits - progressCell.backgroundColor = .secondarySystemBackground return progressCell case .cancelingBolus: - let cell = getTitleSubtitleCell() - cell.titleLabel.text = NSLocalizedString("Canceling Bolus", comment: "The title of the cell indicating a bolus is being canceled") - - let indicatorView = UIActivityIndicatorView(style: .default) - indicatorView.startAnimating() - cell.accessoryView = indicatorView - return cell + let progressCell = tableView.dequeueReusableCell(withIdentifier: BolusProgressTableViewCell.className, for: indexPath) as! BolusProgressTableViewCell + progressCell.selectionStyle = .none + progressCell.configuration = .canceling + progressCell.activityIndicator.startAnimating() + return progressCell + case .canceledBolus(let dose): + let progressCell = tableView.dequeueReusableCell(withIdentifier: BolusProgressTableViewCell.className, for: indexPath) as! BolusProgressTableViewCell + progressCell.selectionStyle = .none + progressCell.configuration = .canceled(delivered: dose.deliveredUnits ?? 0, ofTotalVolume: dose.programmedUnits) + return progressCell case .pumpSuspended(let resuming): let cell = getTitleSubtitleCell() cell.titleLabel.text = NSLocalizedString("Insulin Suspended", comment: "The title of the cell indicating the pump is suspended") @@ -1213,7 +1246,7 @@ final class StatusTableViewController: LoopChartsTableViewController { case .pumpSuspended(let resuming) where !resuming: updateBannerAndHUDandStatusRows(statusRowMode: .pumpSuspended(resuming: true) , newSize: nil, animated: true) deviceManager.pumpManager?.resumeDelivery() { (error) in - DispatchQueue.main.async { + Task { @MainActor in if let error = error { let alert = UIAlertController(with: error, title: NSLocalizedString("Failed to Resume Insulin Delivery", comment: "The alert title for a resume error")) self.present(alert, animated: true, completion: nil) @@ -1224,7 +1257,7 @@ final class StatusTableViewController: LoopChartsTableViewController { self.updateBannerAndHUDandStatusRows(statusRowMode: self.determineStatusRowMode(), newSize: nil, animated: true) self.refreshContext.update(with: .insulin) self.log.debug("[reloadData] after manually resuming suspend") - self.reloadData() + await self.reloadData() } } } @@ -1238,20 +1271,32 @@ final class StatusTableViewController: LoopChartsTableViewController { vc.delegate = self show(vc, sender: tableView.cellForRow(at: indexPath)) } - case .bolusing: + case .bolusing(var dose): + bolusState = .canceling updateBannerAndHUDandStatusRows(statusRowMode: .cancelingBolus, newSize: nil, animated: true) - deviceManager.pumpManager?.cancelBolus() { (result) in - DispatchQueue.main.async { - switch result { - case .success: - // show user confirmation and actual delivery amount? - break - case .failure(let error): - self.presentErrorCancelingBolus(error) - if case .inProgress(let dose) = self.bolusState { - self.updateBannerAndHUDandStatusRows(statusRowMode: .bolusing(dose: dose), newSize: nil, animated: true) - } else { - self.updateBannerAndHUDandStatusRows(statusRowMode: .hidden, newSize: nil, animated: true) + Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC) + dose.deliveredUnits = bolusProgressReporter?.progress.deliveredUnits + self.canceledDose = dose + deviceManager.pumpManager?.cancelBolus() { (result) in + DispatchQueue.main.async { + switch result { + case .success: + self.updateBannerAndHUDandStatusRows(statusRowMode: .canceledBolus(dose: dose), newSize: nil, animated: true) + self.bolusState = .noBolus + Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC * 10) + self.canceledDose = nil + self.updateBannerAndHUDandStatusRows(statusRowMode: self.determineStatusRowMode(), newSize: nil, animated: true) + } + case .failure(let error): + self.canceledDose = nil + self.presentErrorCancelingBolus(error) + if case .inProgress(let dose) = self.bolusState { + self.updateBannerAndHUDandStatusRows(statusRowMode: .bolusing(dose: dose), newSize: nil, animated: true) + } else { + self.updateBannerAndHUDandStatusRows(statusRowMode: .hidden, newSize: nil, animated: true) + } } } } @@ -1279,10 +1324,10 @@ final class StatusTableViewController: LoopChartsTableViewController { } private func presentUnmuteAlertConfirmation() { - let title = NSLocalizedString("Unmute Alerts?", comment: "The alert title for unmute alert confirmation") - let body = NSLocalizedString("Tap Unmute to resume sound for your alerts and alarms.", comment: "The alert body for unmute alert confirmation") + let title = NSLocalizedString("Unmute All App Sounds?", comment: "The alert title for unmute all app sounds confirmation") + let body = NSLocalizedString("Tap Unmute to resume all app sounds for your alerts.", comment: "The alert body for unmute alert confirmation") let action = UIAlertAction( - title: NSLocalizedString("Unmute", comment: "The title of the action used to unmute alerts"), + title: NSLocalizedString("Unmute", comment: "The title of the action used to unmute app sounds"), style: .default) { _ in self.alertMuter.unmuteAlerts() } @@ -1328,22 +1373,28 @@ final class StatusTableViewController: LoopChartsTableViewController { vc.isOnboardingComplete = onboardingManager.isComplete vc.automaticDosingStatus = automaticDosingStatus vc.deviceManager = deviceManager + vc.loopDataManager = loopManager + vc.analyticsServicesManager = analyticsServicesManager + vc.carbStore = carbStore vc.hidesBottomBarWhenPushed = true case let vc as InsulinDeliveryTableViewController: - vc.deviceManager = deviceManager + vc.loopDataManager = loopManager + vc.doseStore = doseStore vc.hidesBottomBarWhenPushed = true vc.enableEntryDeletion = FeatureFlags.entryDeletionEnabled vc.headerValueLabelColor = .insulinTintColor case let vc as OverrideSelectionViewController: - if deviceManager.loopManager.settings.futureOverrideEnabled() { - vc.scheduledOverride = deviceManager.loopManager.settings.scheduleOverride + if temporaryPresetsManager.futureOverrideEnabled() { + vc.scheduledOverride = temporaryPresetsManager.scheduleOverride } - vc.presets = deviceManager.loopManager.settings.overridePresets + vc.presets = loopManager.settings.overridePresets vc.glucoseUnit = statusCharts.glucose.glucoseUnit - vc.overrideHistory = deviceManager.loopManager.overrideHistory.getEvents() + vc.overrideHistory = temporaryPresetsManager.overrideHistory.getEvents() vc.delegate = self case let vc as PredictionTableViewController: vc.deviceManager = deviceManager + vc.settingsManager = settingsManager + vc.loopDataManager = loopManager default: break } @@ -1360,7 +1411,7 @@ final class StatusTableViewController: LoopChartsTableViewController { func presentCarbEntryScreen(_ activity: NSUserActivity?) { let navigationWrapper: UINavigationController if FeatureFlags.simpleBolusCalculatorEnabled && !automaticDosingStatus.automaticDosingEnabled { - let viewModel = SimpleBolusViewModel(delegate: deviceManager, displayMealEntry: true) + let viewModel = SimpleBolusViewModel(delegate: loopManager, displayMealEntry: true, displayGlucosePreference: deviceManager.displayGlucosePreference) if let activity = activity { viewModel.restoreUserActivityState(activity) } @@ -1370,7 +1421,9 @@ final class StatusTableViewController: LoopChartsTableViewController { hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: navigationWrapper, action: #selector(dismissWithAnimation)) present(navigationWrapper, animated: true) } else { - let viewModel = CarbEntryViewModel(delegate: deviceManager) + let viewModel = CarbEntryViewModel(delegate: loopManager) + viewModel.deliveryDelegate = deviceManager + viewModel.analyticsServicesManager = loopManager.analyticsServicesManager if let activity { viewModel.restoreUserActivityState(activity) } @@ -1379,7 +1432,7 @@ final class StatusTableViewController: LoopChartsTableViewController { let hostingController = DismissibleHostingController(rootView: carbEntryView, isModalInPresentation: false) present(hostingController, animated: true) } - deviceManager.analyticsServicesManager.didDisplayCarbEntryScreen() + analyticsServicesManager?.didDisplayCarbEntryScreen() } @IBAction func presentBolusScreen() { @@ -1391,24 +1444,21 @@ final class StatusTableViewController: LoopChartsTableViewController { if FeatureFlags.simpleBolusCalculatorEnabled && !automaticDosingStatus.automaticDosingEnabled { SimpleBolusView( viewModel: SimpleBolusViewModel( - delegate: deviceManager, - displayMealEntry: false + delegate: loopManager, + displayMealEntry: false, + displayGlucosePreference: deviceManager.displayGlucosePreference ) ) .environmentObject(deviceManager.displayGlucosePreference) } else { let viewModel: BolusEntryViewModel = { let viewModel = BolusEntryViewModel( - delegate: deviceManager, + delegate: loopManager, screenWidth: UIScreen.main.bounds.width, isManualGlucoseEntryEnabled: enableManualGlucoseEntry ) - - Task { @MainActor in - await viewModel.generateRecommendationAndStartObserving() - } - - viewModel.analyticsServicesManager = deviceManager.analyticsServicesManager + viewModel.deliveryDelegate = deviceManager + viewModel.analyticsServicesManager = analyticsServicesManager return viewModel }() @@ -1420,15 +1470,16 @@ final class StatusTableViewController: LoopChartsTableViewController { func presentBolusEntryView(enableManualGlucoseEntry: Bool = false) { let hostingController = DismissibleHostingController( - content: bolusEntryView( + rootView: bolusEntryView( enableManualGlucoseEntry: enableManualGlucoseEntry - ) + ), + isModalInPresentation: false ) let navigationWrapper = UINavigationController(rootViewController: hostingController) hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: navigationWrapper, action: #selector(dismissWithAnimation)) present(navigationWrapper, animated: true) - deviceManager.analyticsServicesManager.didDisplayBolusScreen() + analyticsServicesManager?.didDisplayBolusScreen() } private func createPreMealButtonItem(selected: Bool, isEnabled: Bool) -> UIBarButtonItem { @@ -1444,6 +1495,7 @@ final class StatusTableViewController: LoopChartsTableViewController { item.tintColor = UIColor.carbTintColor item.isEnabled = isEnabled + item.accessibilityIdentifier = isEnabled ? "statusTableViewPreMealButtonEnabled" : "statusTableViewPreMealButtonDisabled" return item } @@ -1466,25 +1518,17 @@ final class StatusTableViewController: LoopChartsTableViewController { } @IBAction func premealButtonTapped(_ sender: UIBarButtonItem) { - togglePreMealMode(confirm: false) + togglePreMealMode() } - func togglePreMealMode(confirm: Bool = true) { + func togglePreMealMode() { if preMealMode == true { - if confirm { - let alert = UIAlertController(title: "Disable Pre-Meal Preset?", message: "This will remove any currently applied pre-meal preset.", preferredStyle: .alert) - alert.addCancelAction() - alert.addAction(UIAlertAction(title: "Disable", style: .destructive, handler: { [weak self] _ in - self?.deviceManager.loopManager.mutateSettings { settings in - settings.clearOverride(matching: .preMeal) - } - })) - present(alert, animated: true) - } else { - deviceManager.loopManager.mutateSettings { settings in - settings.clearOverride(matching: .preMeal) - } - } + let alert = UIAlertController(title: "Disable Pre-Meal Preset?", message: "This will remove any currently applied pre-meal preset.", preferredStyle: .alert) + alert.addCancelAction() + alert.addAction(UIAlertAction(title: "Disable", style: .destructive, handler: { [weak self] _ in + self?.temporaryPresetsManager.preMealOverride = nil + })) + present(alert, animated: true) } else { presentPreMealModeAlertController() } @@ -1496,41 +1540,26 @@ final class StatusTableViewController: LoopChartsTableViewController { guard self.workoutMode != true else { // allow cell animation when switching between presets - self.deviceManager.loopManager.mutateSettings { settings in - settings.clearOverride() - } + self.temporaryPresetsManager.scheduleOverride = nil DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - self.deviceManager.loopManager.mutateSettings { settings in - settings.enablePreMealOverride(at: startDate, for: duration) - } + self.temporaryPresetsManager.enablePreMealOverride(at: startDate, for: duration) } return } - - self.deviceManager.loopManager.mutateSettings { settings in - settings.enablePreMealOverride(at: startDate, for: duration) - } + self.temporaryPresetsManager.enablePreMealOverride(at: startDate, for: duration) }) present(vc, animated: true, completion: nil) } - func presentCustomPresets(confirm: Bool = true) { + func presentCustomPresets() { if workoutMode == true { - if confirm { - let alert = UIAlertController(title: "Disable Preset?", message: "This will remove any currently applied preset.", preferredStyle: .alert) - alert.addCancelAction() - alert.addAction(UIAlertAction(title: "Disable", style: .destructive, handler: { [weak self] _ in - self?.deviceManager.loopManager.mutateSettings { settings in - settings.clearOverride() - } - })) - present(alert, animated: true) - } else { - deviceManager.loopManager.mutateSettings { settings in - settings.clearOverride() - } - } + let alert = UIAlertController(title: "Disable Preset?", message: "This will remove any currently applied preset.", preferredStyle: .alert) + alert.addCancelAction() + alert.addAction(UIAlertAction(title: "Disable", style: .destructive, handler: { [weak self] _ in + self?.temporaryPresetsManager.scheduleOverride = nil + })) + present(alert, animated: true) } else { if FeatureFlags.sensitivityOverridesEnabled { performSegue(withIdentifier: OverrideSelectionViewController.className, sender: toolbarItems![6]) @@ -1546,27 +1575,21 @@ final class StatusTableViewController: LoopChartsTableViewController { guard self.preMealMode != true else { // allow cell animation when switching between presets - self.deviceManager.loopManager.mutateSettings { settings in - settings.clearOverride(matching: .preMeal) - } + self.temporaryPresetsManager.clearOverride(matching: .preMeal) DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - self.deviceManager.loopManager.mutateSettings { settings in - settings.enableLegacyWorkoutOverride(at: startDate, for: duration) - } + self.temporaryPresetsManager.enableLegacyWorkoutOverride(at: startDate, for: duration) } return } - self.deviceManager.loopManager.mutateSettings { settings in - settings.enableLegacyWorkoutOverride(at: startDate, for: duration) - } + self.temporaryPresetsManager.enableLegacyWorkoutOverride(at: startDate, for: duration) }) present(vc, animated: true, completion: nil) } @IBAction func toggleWorkoutMode(_ sender: UIBarButtonItem) { - presentCustomPresets(confirm: false) + presentCustomPresets() } @IBAction func onSettingsTapped(_ sender: UIBarButtonItem) { @@ -1576,16 +1599,16 @@ final class StatusTableViewController: LoopChartsTableViewController { private func presentSettings() { let deletePumpDataFunc: () -> PumpManagerViewModel.DeleteTestingDataFunc? = { [weak self] in (self?.deviceManager.pumpManager is TestingPumpManager) ? { - [weak self] in self?.deviceManager.deleteTestingPumpData() - } : nil + Task { [weak self] in try? await self?.deviceManager.deleteTestingPumpData() + }} : nil } let deleteCGMDataFunc: () -> CGMManagerViewModel.DeleteTestingDataFunc? = { [weak self] in (self?.deviceManager.cgmManager is TestingCGMManager) ? { - [weak self] in self?.deviceManager.deleteTestingCGMData() - } : nil + Task { [weak self] in try? await self?.deviceManager.deleteTestingCGMData() + }} : nil } let pumpViewModel = PumpManagerViewModel( - image: { [weak self] in self?.deviceManager.pumpManager?.smallImage }, + image: { [weak self] in (self?.deviceManager.pumpManager as? PumpManagerUI)?.smallImage }, name: { [weak self] in self?.deviceManager.pumpManager?.localizedTitle ?? "" }, isSetUp: { [weak self] in self?.deviceManager.pumpManager?.isOnboarded == true }, availableDevices: deviceManager.availablePumpManagers, @@ -1610,8 +1633,8 @@ final class StatusTableViewController: LoopChartsTableViewController { self?.addCGMManager(withIdentifier: $0.identifier) }) let servicesViewModel = ServicesViewModel(showServices: FeatureFlags.includeServicesInSettingsEnabled, - availableServices: { [weak self] in self?.deviceManager.servicesManager.availableServices ?? [] }, - activeServices: { [weak self] in self?.deviceManager.servicesManager.activeServices ?? [] }, + availableServices: { [weak self] in self?.servicesManager.availableServices ?? [] }, + activeServices: { [weak self] in self?.servicesManager.activeServices ?? [] }, delegate: self) let versionUpdateViewModel = VersionUpdateViewModel(supportManager: supportManager, guidanceColors: .default) let viewModel = SettingsViewModel(alertPermissionsChecker: alertPermissionsChecker, @@ -1620,29 +1643,36 @@ final class StatusTableViewController: LoopChartsTableViewController { pumpManagerSettingsViewModel: pumpViewModel, cgmManagerSettingsViewModel: cgmViewModel, servicesViewModel: servicesViewModel, - criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: deviceManager.criticalEventLogExportManager), - therapySettings: { [weak self] in self?.deviceManager.loopManager.therapySettings ?? TherapySettings() }, + criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: criticalEventLogExportManager), + therapySettings: { [weak self] in self?.settingsManager.therapySettings ?? TherapySettings() }, sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, - initialDosingEnabled: deviceManager.loopManager.settings.dosingEnabled, - isClosedLoopAllowed: automaticDosingStatus.$isAutomaticDosingAllowed, - automaticDosingStrategy: deviceManager.loopManager.settings.automaticDosingStrategy, + initialDosingEnabled: self.settingsManager.settings.dosingEnabled, automaticDosingStatus: self.automaticDosingStatus, + automaticDosingStrategy: self.settingsManager.settings.automaticDosingStrategy, + lastLoopCompletion: loopManager.$lastLoopCompleted, + mostRecentGlucoseDataDate: loopManager.$publishedMostRecentGlucoseDataDate, + mostRecentPumpDataDate: loopManager.$publishedMostRecentPumpDataDate, availableSupports: supportManager.availableSupports, isOnboardingComplete: onboardingManager.isComplete, therapySettingsViewModelDelegate: deviceManager, - delegate: self) + delegate: self + ) + viewModel.favoriteFoodInsightsDelegate = loopManager let hostingController = DismissibleHostingController( rootView: SettingsView(viewModel: viewModel, localizedAppNameAndVersion: supportManager.localizedAppNameAndVersion) .environmentObject(deviceManager.displayGlucosePreference) - .environment(\.appName, Bundle.main.bundleDisplayName), + .environment(\.appName, Bundle.main.bundleDisplayName) + .environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice) + .environment(\.loopStatusColorPalette, .loopStatus), isModalInPresentation: false) present(hostingController, animated: true) } private func onPumpTapped() { - guard var settingsViewController = deviceManager.pumpManager?.settingsViewController(bluetoothProvider: deviceManager.bluetoothProvider, colorPalette: .default, allowDebugFeatures: FeatureFlags.allowDebugFeatures, allowedInsulinTypes: deviceManager.allowedInsulinTypes) else { - // assert? + guard let pumpManager = deviceManager.pumpManager as? PumpManagerUI else { return } + + var settingsViewController = pumpManager.settingsViewController(bluetoothProvider: deviceManager.bluetoothProvider, colorPalette: .default, allowDebugFeatures: FeatureFlags.allowDebugFeatures, allowedInsulinTypes: deviceManager.allowedInsulinTypes) settingsViewController.pumpManagerOnboardingDelegate = deviceManager settingsViewController.completionDelegate = self show(settingsViewController, sender: self) @@ -1661,9 +1691,17 @@ final class StatusTableViewController: LoopChartsTableViewController { } private func automaticDosingStatusChanged(_ automaticDosingEnabled: Bool) { + log.debug("automaticDosingStatusChanged -> %{public}@", String(describing: automaticDosingEnabled)) updatePresetModeAvailability(automaticDosingEnabled: automaticDosingEnabled) hudView?.loopCompletionHUD.loopIconClosed = automaticDosingEnabled hudView?.loopCompletionHUD.closedLoopDisallowedLocalizedDescription = deviceManager.closedLoopDisallowedLocalizedDescription + + if automaticDosingEnabled { + Task { + log.debug("Triggering loop() from automatic dosing flag") + await loopManager.loop() + } + } } // MARK: - HUDs @@ -1690,7 +1728,9 @@ final class StatusTableViewController: LoopChartsTableViewController { // when HUD view is initialized, update loop completion HUD (e.g., icon and last loop completed) hudView.loopCompletionHUD.stateColors = .loopStatus hudView.loopCompletionHUD.loopIconClosed = automaticDosingStatus.automaticDosingEnabled - hudView.loopCompletionHUD.lastLoopCompleted = deviceManager.loopManager.lastLoopCompleted + hudView.loopCompletionHUD.lastLoopCompleted = loopManager.lastLoopCompleted + hudView.loopCompletionHUD.mostRecentGlucoseDataDate = loopManager.mostRecentGlucoseDataDate + hudView.loopCompletionHUD.mostRecentPumpDataDate = loopManager.mostRecentPumpDataDate hudView.cgmStatusHUD.stateColors = .cgmStatus hudView.cgmStatusHUD.tintColor = .label @@ -1698,8 +1738,10 @@ final class StatusTableViewController: LoopChartsTableViewController { hudView.pumpStatusHUD.tintColor = .insulinTintColor refreshContext.update(with: .status) - log.debug("[reloadData] after hudView loaded") - reloadData() + Task { @MainActor in + log.debug("[reloadData] after hudView loaded") + await reloadData() + } } } @@ -1761,7 +1803,9 @@ final class StatusTableViewController: LoopChartsTableViewController { if let error = error { let alertController = UIAlertController(with: error) let manualLoopAction = UIAlertAction(title: NSLocalizedString("Retry", comment: "The button text for attempting a manual loop"), style: .default, handler: { _ in - self.deviceManager.refreshDeviceData() + Task { + await self.deviceManager.refreshDeviceData() + } }) alertController.addAction(manualLoopAction) present(alertController, animated: true) @@ -1832,8 +1876,7 @@ final class StatusTableViewController: LoopChartsTableViewController { present(alert, animated: true, completion: nil) } } - - + // MARK: - Debug Scenarios and Simulated Core Data var lastOrientation: UIDeviceOrientation? @@ -1855,9 +1898,11 @@ final class StatusTableViewController: LoopChartsTableViewController { } else { rotateTimer?.invalidate() rotateTimer = Timer.scheduledTimer(withTimeInterval: rotateTimerTimeout, repeats: false) { [weak self] _ in - self?.rotateCount = 0 - self?.rotateTimer?.invalidate() - self?.rotateTimer = nil + Task { @MainActor [weak self] in + self?.rotateCount = 0 + self?.rotateTimer?.invalidate() + self?.rotateTimer = nil + } } rotateCount += 1 } @@ -1884,14 +1929,14 @@ final class StatusTableViewController: LoopChartsTableViewController { }) } actionSheet.addAction(UIAlertAction(title: "Remove Exports Directory", style: .default) { _ in - if let error = self.deviceManager.removeExportsDirectory() { + if let error = self.criticalEventLogExportManager.removeExportsDirectory() { self.presentError(error) } }) if FeatureFlags.mockTherapySettingsEnabled { actionSheet.addAction(UIAlertAction(title: "Mock Therapy Settings", style: .default) { _ in let therapySettings = TherapySettings.mockTherapySettings - self.deviceManager.loopManager.mutateSettings { settings in + self.settingsManager.mutateLoopSettings { settings in settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule settings.preMealTargetRange = therapySettings.correctionRangeOverrides?.preMeal settings.legacyWorkoutTargetRange = therapySettings.correctionRangeOverrides?.workout @@ -1911,6 +1956,12 @@ final class StatusTableViewController: LoopChartsTableViewController { actionSheet.addAction(UIAlertAction(title: "Delete CGM Manager", style: .destructive) { _ in self.deviceManager.cgmManager?.delete() { } }) + + actionSheet.addAction(UIAlertAction(title: "Delete Pump Manager", style: .destructive) { _ in + self.deviceManager.pumpManager?.prepareForDeactivation(){ [weak self] _ in + self?.deviceManager.pumpManager?.notifyDelegateOfDeactivation() { } + } + }) actionSheet.addCancelAction() present(actionSheet, animated: true) @@ -1965,7 +2016,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } presentActivityIndicator(title: "Simulated Core Data", message: "Generating simulated historical...") { dismissActivityIndicator in - self.deviceManager.purgeHistoricalCoreData() { error in + self.simulatedData.purgeHistoricalCoreData() { error in DispatchQueue.main.async { if let error = error { dismissActivityIndicator() @@ -1973,7 +2024,7 @@ final class StatusTableViewController: LoopChartsTableViewController { return } - self.deviceManager.generateSimulatedHistoricalCoreData() { error in + self.simulatedData.generateSimulatedHistoricalCoreData() { error in DispatchQueue.main.async { dismissActivityIndicator() if let error = error { @@ -1992,7 +2043,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } presentActivityIndicator(title: "Simulated Core Data", message: "Purging historical...") { dismissActivityIndicator in - self.deviceManager.purgeHistoricalCoreData() { error in + self.simulatedData.purgeHistoricalCoreData() { error in DispatchQueue.main.async { dismissActivityIndicator() if let error = error { @@ -2058,21 +2109,22 @@ extension StatusTableViewController: CompletionDelegate { extension StatusTableViewController: PumpManagerStatusObserver { func pumpManager(_ pumpManager: PumpManager, didUpdate status: PumpManagerStatus, oldStatus: PumpManagerStatus) { - dispatchPrecondition(condition: .onQueue(.main)) log.default("PumpManager:%{public}@ did update status", String(describing: type(of: pumpManager))) + Task { @MainActor in - basalDeliveryState = status.basalDeliveryState - bolusState = status.bolusState + basalDeliveryState = status.basalDeliveryState + bolusState = status.bolusState - refreshContext.update(with: .status) - reloadData(animated: true) + refreshContext.update(with: .status) + await self.reloadData(animated: true) + } } } extension StatusTableViewController: CGMManagerStatusObserver { func cgmManager(_ manager: CGMManager, didUpdate status: CGMManagerStatus) { refreshContext.update(with: .status) - reloadData(animated: true) + Task { await reloadData(animated: true) } } } @@ -2086,7 +2138,7 @@ extension StatusTableViewController: DoseProgressObserver { self.bolusProgressReporter = nil DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: { self.bolusState = .noBolus - self.reloadData(animated: true) + Task { await self.reloadData(animated: true) } }) } } @@ -2094,15 +2146,13 @@ extension StatusTableViewController: DoseProgressObserver { extension StatusTableViewController: OverrideSelectionViewControllerDelegate { func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didUpdatePresets presets: [TemporaryScheduleOverridePreset]) { - deviceManager.loopManager.mutateSettings { settings in + settingsManager.mutateLoopSettings { settings in settings.overridePresets = presets } } func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didConfirmOverride override: TemporaryScheduleOverride) { - deviceManager.loopManager.mutateSettings { settings in - settings.scheduleOverride = override - } + temporaryPresetsManager.scheduleOverride = override } func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didConfirmPreset preset: TemporaryScheduleOverridePreset) { @@ -2117,29 +2167,21 @@ extension StatusTableViewController: OverrideSelectionViewControllerDelegate { os_log(.error, "Failed to donate intent: %{public}@", String(describing: error)) } } - deviceManager.loopManager.mutateSettings { settings in - settings.scheduleOverride = preset.createOverride(enactTrigger: .local) - } + temporaryPresetsManager.scheduleOverride = preset.createOverride(enactTrigger: .local) } func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didCancelOverride override: TemporaryScheduleOverride) { - deviceManager.loopManager.mutateSettings { settings in - settings.scheduleOverride = nil - } + temporaryPresetsManager.scheduleOverride = nil } } extension StatusTableViewController: AddEditOverrideTableViewControllerDelegate { func addEditOverrideTableViewController(_ vc: AddEditOverrideTableViewController, didSaveOverride override: TemporaryScheduleOverride) { - deviceManager.loopManager.mutateSettings { settings in - settings.scheduleOverride = override - } + temporaryPresetsManager.scheduleOverride = override } func addEditOverrideTableViewController(_ vc: AddEditOverrideTableViewController, didCancelOverride override: TemporaryScheduleOverride) { - deviceManager.loopManager.mutateSettings { settings in - settings.scheduleOverride = nil - } + temporaryPresetsManager.scheduleOverride = nil } } @@ -2163,9 +2205,9 @@ extension StatusTableViewController { extension StatusTableViewController { fileprivate func addPumpManager(withIdentifier identifier: String) { - guard let maximumBasalRate = deviceManager.loopManager.settings.maximumBasalRatePerHour, - let maxBolus = deviceManager.loopManager.settings.maximumBolus, - let basalSchedule = deviceManager.loopManager.settings.basalRateSchedule else + guard let maximumBasalRate = settingsManager.settings.maximumBasalRatePerHour, + let maxBolus = settingsManager.settings.maximumBolus, + let basalSchedule = settingsManager.settings.basalRateSchedule else { log.error("Failure to setup pump manager: incomplete settings") return @@ -2193,7 +2235,7 @@ extension StatusTableViewController { extension StatusTableViewController: BluetoothObserver { func bluetoothDidUpdateState(_ state: BluetoothState) { refreshContext.update(with: .status) - reloadData(animated: true) + Task { await reloadData(animated: true) } } } @@ -2204,13 +2246,13 @@ extension StatusTableViewController: SettingsViewModelDelegate { } func dosingEnabledChanged(_ value: Bool) { - deviceManager.loopManager.mutateSettings { settings in + settingsManager.mutateLoopSettings { settings in settings.dosingEnabled = value } } func dosingStrategyChanged(_ strategy: AutomaticDosingStrategy) { - self.deviceManager.loopManager.mutateSettings { settings in + settingsManager.mutateLoopSettings { settings in settings.automaticDosingStrategy = strategy } } @@ -2219,7 +2261,7 @@ extension StatusTableViewController: SettingsViewModelDelegate { // TODO: this dismiss here is temporary, until we know exactly where // we want this screen to belong in the navigation flow dismiss(animated: true) { - let vc = CommandResponseViewController.generateDiagnosticReport(deviceManager: self.deviceManager) + let vc = CommandResponseViewController.generateDiagnosticReport(reportGenerator: self.diagnosticReportGenerator) vc.title = NSLocalizedString("Issue Report", comment: "The view controller title for the issue report screen") self.show(vc, sender: nil) } @@ -2230,13 +2272,13 @@ extension StatusTableViewController: SettingsViewModelDelegate { extension StatusTableViewController: ServicesViewModelDelegate { func addService(withIdentifier identifier: String) { - switch deviceManager.servicesManager.setupService(withIdentifier: identifier) { + switch servicesManager.setupService(withIdentifier: identifier) { case .failure(let error): log.default("Failure to setup service with identifier '%{public}@': %{public}@", identifier, String(describing: error)) case .success(let success): switch success { case .userInteractionRequired(var setupViewController): - setupViewController.serviceOnboardingDelegate = deviceManager.servicesManager + setupViewController.serviceOnboardingDelegate = servicesManager setupViewController.completionDelegate = self show(setupViewController, sender: self) case .createdAndOnboarded: @@ -2246,15 +2288,15 @@ extension StatusTableViewController: ServicesViewModelDelegate { } func gotoService(withIdentifier identifier: String) { - guard let serviceUI = deviceManager.servicesManager.activeServices.first(where: { $0.pluginIdentifier == identifier }) as? ServiceUI else { + guard let serviceUI = servicesManager.activeServices.first(where: { $0.pluginIdentifier == identifier }) as? ServiceUI else { return } showServiceSettings(serviceUI) } fileprivate func showServiceSettings(_ serviceUI: ServiceUI) { - var settingsViewController = serviceUI.settingsViewController(colorPalette: .default) - settingsViewController.serviceOnboardingDelegate = deviceManager.servicesManager + var settingsViewController = serviceUI.settingsViewController(colorPalette: .default, allowDebugFeatures: FeatureFlags.allowDebugFeatures) + settingsViewController.serviceOnboardingDelegate = servicesManager settingsViewController.completionDelegate = self show(settingsViewController, sender: self) } diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index a86f20e0cc..4b88387fd5 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -17,43 +17,37 @@ import LoopKitUI import LoopUI import SwiftUI import SwiftCharts +import LoopAlgorithm protocol BolusEntryViewModelDelegate: AnyObject { - - func withLoopState(do block: @escaping (LoopState) -> Void) - func saveGlucose(sample: NewGlucoseSample) async -> StoredGlucoseSample? + var settings: StoredSettings { get } + var scheduleOverride: TemporaryScheduleOverride? { get } + var preMealOverride: TemporaryScheduleOverride? { get } + var mostRecentGlucoseDataDate: Date? { get } + var mostRecentPumpDataDate: Date? { get } - func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry? , - completion: @escaping (_ result: Result) -> Void) + func fetchData(for baseTime: Date, disablingPreMeal: Bool, ensureDosingCoverageStart: Date?) async throws -> StoredDataAlgorithmInput + func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool) -> GlucoseRangeSchedule? - func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) - - func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ error: Error?) -> Void) - - func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (_ samples: Swift.Result<[StoredGlucoseSample], Error>) -> Void) + func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?) async throws -> StoredCarbEntry + func saveGlucose(sample: NewGlucoseSample) async throws -> StoredGlucoseSample + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) async + func enactBolus(units: Double, activationType: BolusActivationType) async throws - func insulinOnBoard(at date: Date, completion: @escaping (_ result: DoseStoreResult) -> Void) - - func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (_ result: CarbStoreResult) -> Void) - - func insulinActivityDuration(for type: InsulinType?) -> TimeInterval + func insulinModel(for type: InsulinType?) -> InsulinModel - var mostRecentGlucoseDataDate: Date? { get } - - var mostRecentPumpDataDate: Date? { get } - - var isPumpConfigured: Bool { get } - - var pumpInsulinType: InsulinType? { get } - - var settings: LoopSettings { get } + func recommendManualBolus( + manualGlucoseSample: NewGlucoseSample?, + potentialCarbEntry: NewCarbEntry?, + originalCarbEntry: StoredCarbEntry? + ) async throws -> ManualBolusRecommendation? - var displayGlucosePreference: DisplayGlucosePreference { get } - func roundBolusVolume(units: Double) -> Double + func generatePrediction(input: StoredDataAlgorithmInput) throws -> [PredictedGlucoseValue] - func updateRemoteRecommendation() + var activeInsulin: InsulinValue? { get } + var activeCarbs: CarbValue? { get } } @MainActor @@ -67,7 +61,6 @@ final class BolusEntryViewModel: ObservableObject { case carbEntryPersistenceFailure case manualGlucoseEntryOutOfAcceptableRange case manualGlucoseEntryPersistenceFailure - case glucoseNoLongerStale case forecastInfo } @@ -151,6 +144,7 @@ final class BolusEntryViewModel: ObservableObject { // MARK: - Seams private weak var delegate: BolusEntryViewModelDelegate? + weak var deliveryDelegate: DeliveryDelegate? private let now: () -> Date private let screenWidth: CGFloat private let debounceIntervalMilliseconds: Int @@ -215,8 +209,8 @@ final class BolusEntryViewModel: ObservableObject { .receive(on: DispatchQueue.main) .sink { [weak self] note in Task { - if let rawContext = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as? LoopDataManager.LoopUpdateContext.RawValue, - let context = LoopDataManager.LoopUpdateContext(rawValue: rawContext), + if let rawContext = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as? LoopUpdateContext.RawValue, + let context = LoopUpdateContext(rawValue: rawContext), context == .preferences { self?.updateSettings() @@ -233,8 +227,8 @@ final class BolusEntryViewModel: ObservableObject { .removeDuplicates() .debounce(for: .milliseconds(debounceIntervalMilliseconds), scheduler: RunLoop.main) .sink { [weak self] _ in - self?.delegate?.withLoopState { [weak self] state in - self?.updatePredictedGlucoseValues(from: state) + Task { + await self?.updatePredictedGlucoseValues() } } .store(in: &cancellables) @@ -248,13 +242,11 @@ final class BolusEntryViewModel: ObservableObject { // Clear out any entered bolus whenever the glucose entry changes self.enteredBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0) - self.delegate?.withLoopState { [weak self] state in - self?.updatePredictedGlucoseValues(from: state, completion: { - // Ensure the manual glucose entry appears on the chart at the same time as the updated prediction - self?.updateGlucoseChartValues() - }) - - self?.updateRecommendedBolusAndNotice(from: state, isUpdatingFromUserInput: true) + Task { + await self.updatePredictedGlucoseValues() + // Ensure the manual glucose entry appears on the chart at the same time as the updated prediction + self.updateGlucoseChartValues() + await self.updateRecommendedBolusAndNotice(isUpdatingFromUserInput: true) } if let manualGlucoseQuantity = manualGlucoseQuantity { @@ -301,21 +293,7 @@ final class BolusEntryViewModel: ObservableObject { } func saveCarbEntry(_ entry: NewCarbEntry, replacingEntry: StoredCarbEntry?) async -> StoredCarbEntry? { - guard let delegate = delegate else { - return nil - } - - return await withCheckedContinuation { continuation in - delegate.addCarbEntry(entry, replacing: replacingEntry) { result in - switch result { - case .success(let storedCarbEntry): - continuation.resume(returning: storedCarbEntry) - case .failure(let error): - self.log.error("Failed to add carb entry: %{public}@", String(describing: error)) - continuation.resume(returning: nil) - } - } - } + try? await delegate?.addCarbEntry(entry, replacing: replacingEntry) } // returns true if action succeeded @@ -331,17 +309,28 @@ final class BolusEntryViewModel: ObservableObject { // returns true if no errors func saveAndDeliver() async -> Bool { - guard delegate?.isPumpConfigured ?? false else { + guard let delegate, let deliveryDelegate else { + assertionFailure("Missing Delegate") + return false + } + + guard deliveryDelegate.isPumpConfigured else { presentAlert(.noPumpManagerConfigured) return false } - guard let delegate = delegate else { - assertionFailure("Missing BolusEntryViewModelDelegate") + guard let maximumBolus = maximumBolus else { + presentAlert(.noMaxBolusConfigured) + return false + } + + guard enteredBolusAmount <= maximumBolus.doubleValue(for: .internationalUnit()) else { + presentAlert(.maxBolusExceeded) return false } - let amountToDeliver = delegate.roundBolusVolume(units: enteredBolusAmount) + let amountToDeliver = deliveryDelegate.roundBolusVolume(units: enteredBolusAmount) + guard enteredBolusAmount == 0 || amountToDeliver > 0 else { presentAlert(.bolusTooSmall) return false @@ -352,16 +341,6 @@ final class BolusEntryViewModel: ObservableObject { let manualGlucoseSample = manualGlucoseSample let potentialCarbEntry = potentialCarbEntry - guard let maximumBolus = maximumBolus else { - presentAlert(.noMaxBolusConfigured) - return false - } - - guard amountToDeliver <= maximumBolus.doubleValue(for: .internationalUnit()) else { - presentAlert(.maxBolusExceeded) - return false - } - if let manualGlucoseSample = manualGlucoseSample { guard LoopConstants.validManualGlucoseEntryRange.contains(manualGlucoseSample.quantity) else { presentAlert(.manualGlucoseEntryOutOfAcceptableRange) @@ -378,14 +357,10 @@ final class BolusEntryViewModel: ObservableObject { } } - defer { - delegate.updateRemoteRecommendation() - } - - if let manualGlucoseSample = manualGlucoseSample { - if let glucoseValue = await delegate.saveGlucose(sample: manualGlucoseSample) { - dosingDecision.manualGlucoseSample = glucoseValue - } else { + if let manualGlucoseSample { + do { + dosingDecision.manualGlucoseSample = try await delegate.saveGlucose(sample: manualGlucoseSample) + } catch { presentAlert(.manualGlucoseEntryPersistenceFailure) return false } @@ -407,7 +382,7 @@ final class BolusEntryViewModel: ObservableObject { } if let storedCarbEntry = await saveCarbEntry(carbEntry, replacingEntry: originalCarbEntry) { self.dosingDecision.carbEntry = storedCarbEntry - self.analyticsServicesManager?.didAddCarbs(source: "Phone", amount: storedCarbEntry.quantity.doubleValue(for: .gram())) + self.analyticsServicesManager?.didAddCarbs(source: "Phone", amount: storedCarbEntry.quantity.doubleValue(for: .gram()), isFavoriteFood: storedCarbEntry.favoriteFoodID != nil) } else { self.presentAlert(.carbEntryPersistenceFailure) return false @@ -417,20 +392,21 @@ final class BolusEntryViewModel: ObservableObject { dosingDecision.manualBolusRequested = amountToDeliver let now = self.now() - delegate.storeManualBolusDosingDecision(dosingDecision, withDate: now) + await delegate.storeManualBolusDosingDecision(dosingDecision, withDate: now) if amountToDeliver > 0 { savedPreMealOverride = nil - delegate.enactBolus(units: amountToDeliver, activationType: activationType, completion: { _ in - self.analyticsServicesManager?.didBolus(source: "Phone", units: amountToDeliver) - }) + do { + try await delegate.enactBolus(units: amountToDeliver, activationType: activationType) + } catch { + log.error("Failed to store bolus: %{public}@", String(describing: error)) + } + self.analyticsServicesManager?.didBolus(source: "Phone", units: amountToDeliver) } return true } private func presentAlert(_ alert: Alert) { - dispatchPrecondition(condition: .onQueue(.main)) - // As of iOS 13.6 / Xcode 11.6, swapping out an alert while one is active crashes SwiftUI. guard activeAlert == nil else { return @@ -497,60 +473,31 @@ final class BolusEntryViewModel: ObservableObject { // MARK: - Data upkeep func update() async { - dispatchPrecondition(condition: .onQueue(.main)) - // Prevent any UI updates after a bolus has been initiated. guard !enacting else { return } + self.activeCarbs = delegate?.activeCarbs?.quantity + self.activeInsulin = delegate?.activeInsulin?.quantity + dosingDecision.insulinOnBoard = delegate?.activeInsulin + disableManualGlucoseEntryIfNecessary() updateChartDateInterval() - updateStoredGlucoseValues() - await updatePredictionAndRecommendation() - - if let iob = await getInsulinOnBoard() { - self.activeInsulin = HKQuantity(unit: .internationalUnit(), doubleValue: iob.value) - self.dosingDecision.insulinOnBoard = iob - } else { - self.activeInsulin = nil - self.dosingDecision.insulinOnBoard = nil - } + await updateRecommendedBolusAndNotice(isUpdatingFromUserInput: false) + await updatePredictedGlucoseValues() + updateGlucoseChartValues() } private func disableManualGlucoseEntryIfNecessary() { - dispatchPrecondition(condition: .onQueue(.main)) - if isManualGlucoseEntryEnabled, !isGlucoseDataStale { isManualGlucoseEntryEnabled = false manualGlucoseQuantity = nil manualGlucoseSample = nil - presentAlert(.glucoseNoLongerStale) - } - } - - private func updateStoredGlucoseValues() { - let historicalGlucoseStartDate = Date(timeInterval: -LoopCoreConstants.dosingDecisionHistoricalGlucoseInterval, since: now()) - let chartStartDate = chartDateInterval.start - delegate?.getGlucoseSamples(start: min(historicalGlucoseStartDate, chartStartDate), end: nil) { [weak self] result in - DispatchQueue.main.async { - guard let self = self else { return } - switch result { - case .failure(let error): - self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) - self.storedGlucoseValues = [] - self.dosingDecision.historicalGlucose = [] - case .success(let samples): - self.storedGlucoseValues = samples.filter { $0.startDate >= chartStartDate } - self.dosingDecision.historicalGlucose = samples.filter { $0.startDate >= historicalGlucoseStartDate }.map { HistoricalGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) } - } - self.updateGlucoseChartValues() - } } } private func updateGlucoseChartValues() { - dispatchPrecondition(condition: .onQueue(.main)) var chartGlucoseValues = storedGlucoseValues if let manualGlucoseSample = manualGlucoseSample { @@ -561,110 +508,60 @@ final class BolusEntryViewModel: ObservableObject { } /// - NOTE: `completion` is invoked on the main queue after predicted glucose values are updated - private func updatePredictedGlucoseValues(from state: LoopState, completion: @escaping () -> Void = {}) { - dispatchPrecondition(condition: .notOnQueue(.main)) - - let (manualGlucoseSample, enteredBolus, insulinType) = DispatchQueue.main.sync { (self.manualGlucoseSample, self.enteredBolus, delegate?.pumpInsulinType) } - - let enteredBolusDose = DoseEntry(type: .bolus, startDate: Date(), value: enteredBolus.doubleValue(for: .internationalUnit()), unit: .units, insulinType: insulinType) + private func updatePredictedGlucoseValues() async { + guard let delegate else { + return + } - let predictedGlucoseValues: [PredictedGlucoseValue] do { - if let manualGlucoseEntry = manualGlucoseSample { - predictedGlucoseValues = try state.predictGlucoseFromManualGlucose( - manualGlucoseEntry, - potentialBolus: enteredBolusDose, - potentialCarbEntry: potentialCarbEntry, - replacingCarbEntry: originalCarbEntry, - includingPendingInsulin: true, - considerPositiveVelocityAndRC: true - ) - } else { - predictedGlucoseValues = try state.predictGlucose( - using: .all, - potentialBolus: enteredBolusDose, - potentialCarbEntry: potentialCarbEntry, - replacingCarbEntry: originalCarbEntry, - includingPendingInsulin: true, - considerPositiveVelocityAndRC: true - ) - } - } catch { - predictedGlucoseValues = [] - } + let startDate = now() + var input = try await delegate.fetchData(for: startDate, disablingPreMeal: potentialCarbEntry != nil, ensureDosingCoverageStart: nil) - DispatchQueue.main.async { - self.predictedGlucoseValues = predictedGlucoseValues - self.dosingDecision.predictedGlucose = predictedGlucoseValues - completion() - } - } + let insulinModel = delegate.insulinModel(for: deliveryDelegate?.pumpInsulinType) - private func getInsulinOnBoard() async -> InsulinValue? { - guard let delegate = delegate else { - return nil - } + let enteredBolusDose = SimpleInsulinDose( + deliveryType: .bolus, + startDate: startDate, + endDate: startDate, + volume: enteredBolus.doubleValue(for: .internationalUnit()), + insulinModel: insulinModel + ) - return await withCheckedContinuation { continuation in - delegate.insulinOnBoard(at: Date()) { result in - switch result { - case .success(let iob): - continuation.resume(returning: iob) - case .failure: - continuation.resume(returning: nil) - } - } - } - } + storedGlucoseValues = input.glucoseHistory - private func updatePredictionAndRecommendation() async { - guard let delegate = delegate else { - return - } - return await withCheckedContinuation { continuation in - delegate.withLoopState { [weak self] state in - self?.updateCarbsOnBoard(from: state) - self?.updateRecommendedBolusAndNotice(from: state, isUpdatingFromUserInput: false) - self?.updatePredictedGlucoseValues(from: state) - continuation.resume() - } - } - } + // Add potential bolus, carbs, manual glucose + input = input + .addingDose(dose: enteredBolusDose) + .addingGlucoseSample(sample: manualGlucoseSample?.asStoredGlucoseStample) + .removingCarbEntry(carbEntry: originalCarbEntry) + .addingCarbEntry(carbEntry: potentialCarbEntry?.asStoredCarbEntry) - private func updateCarbsOnBoard(from state: LoopState) { - delegate?.carbsOnBoard(at: Date(), effectVelocities: state.insulinCounteractionEffects) { result in - DispatchQueue.main.async { - switch result { - case .success(let carbValue): - self.activeCarbs = carbValue.quantity - self.dosingDecision.carbsOnBoard = carbValue - case .failure: - self.activeCarbs = nil - self.dosingDecision.carbsOnBoard = nil - } - } + let prediction = try delegate.generatePrediction(input: input) + predictedGlucoseValues = prediction + dosingDecision.predictedGlucose = prediction + } catch { + predictedGlucoseValues = [] + dosingDecision.predictedGlucose = [] } + } - private func updateRecommendedBolusAndNotice(from state: LoopState, isUpdatingFromUserInput: Bool) { - dispatchPrecondition(condition: .notOnQueue(.main)) + private func updateRecommendedBolusAndNotice(isUpdatingFromUserInput: Bool) async { - guard let delegate = delegate else { + guard let delegate else { assertionFailure("Missing BolusEntryViewModelDelegate") return } - let now = Date() var recommendation: ManualBolusRecommendation? let recommendedBolus: HKQuantity? let notice: Notice? do { - recommendation = try computeBolusRecommendation(from: state) + recommendation = try await computeBolusRecommendation() + + if let recommendation, deliveryDelegate != nil { + recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: recommendation.amount) - if let recommendation = recommendation { - recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: delegate.roundBolusVolume(units: recommendation.amount)) - //recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: recommendation.amount) - switch recommendation.notice { case .glucoseBelowSuspendThreshold: if let suspendThreshold = delegate.settings.suspendThreshold { @@ -687,7 +584,7 @@ final class BolusEntryViewModel: ObservableObject { recommendedBolus = nil switch error { - case LoopError.missingDataError(.glucose), LoopError.glucoseTooOld: + case LoopError.missingDataError(.glucose), LoopError.glucoseTooOld, AlgorithmError.missingGlucose, AlgorithmError.glucoseTooOld: notice = .staleGlucoseData case LoopError.invalidFutureGlucose: notice = .futureGlucoseData @@ -698,53 +595,41 @@ final class BolusEntryViewModel: ObservableObject { } } - DispatchQueue.main.async { - let priorRecommendedBolus = self.recommendedBolus - self.recommendedBolus = recommendedBolus - self.dosingDecision.manualBolusRecommendation = recommendation.map { ManualBolusRecommendationWithDate(recommendation: $0, date: now) } - self.activeNotice = notice + let priorRecommendedBolus = self.recommendedBolus + self.recommendedBolus = recommendedBolus + self.dosingDecision.manualBolusRecommendation = recommendation.map { ManualBolusRecommendationWithDate(recommendation: $0, date: now()) } + self.activeNotice = notice - if priorRecommendedBolus != nil, - priorRecommendedBolus != recommendedBolus, - !self.enacting, - !isUpdatingFromUserInput - { - self.presentAlert(.recommendationChanged) - } + if priorRecommendedBolus != nil, + priorRecommendedBolus != recommendedBolus, + !self.enacting, + !isUpdatingFromUserInput + { + self.presentAlert(.recommendationChanged) } } - private func computeBolusRecommendation(from state: LoopState) throws -> ManualBolusRecommendation? { - dispatchPrecondition(condition: .notOnQueue(.main)) - - let manualGlucoseSample = DispatchQueue.main.sync { self.manualGlucoseSample } - if manualGlucoseSample != nil { - return try state.recommendBolusForManualGlucose( - manualGlucoseSample!, - consideringPotentialCarbEntry: potentialCarbEntry, - replacingCarbEntry: originalCarbEntry, - considerPositiveVelocityAndRC: FeatureFlags.usePositiveMomentumAndRCForManualBoluses - ) - } else { - return try state.recommendBolus( - consideringPotentialCarbEntry: potentialCarbEntry, - replacingCarbEntry: originalCarbEntry, - considerPositiveVelocityAndRC: FeatureFlags.usePositiveMomentumAndRCForManualBoluses - ) + private func computeBolusRecommendation() async throws -> ManualBolusRecommendation? { + guard let delegate else { + return nil } + + return try await delegate.recommendManualBolus( + manualGlucoseSample: manualGlucoseSample, + potentialCarbEntry: potentialCarbEntry, + originalCarbEntry: originalCarbEntry + ) } func updateSettings() { - dispatchPrecondition(condition: .onQueue(.main)) - guard let delegate = delegate else { return } targetGlucoseSchedule = delegate.settings.glucoseTargetRangeSchedule // Pre-meal override should be ignored if we have carbs (LOOP-1964) - preMealOverride = potentialCarbEntry == nil ? delegate.settings.preMealOverride : nil - scheduleOverride = delegate.settings.scheduleOverride + preMealOverride = potentialCarbEntry == nil ? delegate.preMealOverride : nil + scheduleOverride = delegate.scheduleOverride if preMealOverride?.hasFinished() == true { preMealOverride = nil @@ -761,21 +646,21 @@ final class BolusEntryViewModel: ObservableObject { dosingDecision.scheduleOverride = scheduleOverride if scheduleOverride != nil || preMealOverride != nil { - dosingDecision.glucoseTargetRangeSchedule = delegate.settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) + dosingDecision.glucoseTargetRangeSchedule = delegate.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) } else { dosingDecision.glucoseTargetRangeSchedule = targetGlucoseSchedule } } private func updateChartDateInterval() { - dispatchPrecondition(condition: .onQueue(.main)) - // How far back should we show data? Use the screen size as a guide. let viewMarginInset: CGFloat = 14 let availableWidth = screenWidth - chartManager.fixedHorizontalMargin - 2 * viewMarginInset let totalHours = floor(Double(availableWidth / LoopConstants.minimumChartWidthPerHour)) - let futureHours = ceil((delegate?.insulinActivityDuration(for: delegate?.pumpInsulinType) ?? .hours(4)).hours) + let insulinType = deliveryDelegate?.pumpInsulinType + let insulinModel = delegate?.insulinModel(for: insulinType) + let futureHours = ceil((insulinModel?.effectDuration ?? .hours(4)).hours) let historyHours = max(LoopConstants.statusChartMinimumHistoryDisplay.hours, totalHours - futureHours) let date = Date(timeInterval: -TimeInterval(hours: historyHours), since: now()) @@ -818,12 +703,12 @@ extension BolusEntryViewModel { var isGlucoseDataStale: Bool { guard let latestGlucoseDataDate = delegate?.mostRecentGlucoseDataDate else { return true } - return now().timeIntervalSince(latestGlucoseDataDate) > LoopCoreConstants.inputDataRecencyInterval + return now().timeIntervalSince(latestGlucoseDataDate) > LoopAlgorithm.inputDataRecencyInterval } var isPumpDataStale: Bool { guard let latestPumpDataDate = delegate?.mostRecentPumpDataDate else { return true } - return now().timeIntervalSince(latestPumpDataDate) > LoopCoreConstants.inputDataRecencyInterval + return now().timeIntervalSince(latestPumpDataDate) > LoopAlgorithm.inputDataRecencyInterval } var isManualGlucosePromptVisible: Bool { diff --git a/Loop/View Models/CarbEntryViewModel.swift b/Loop/View Models/CarbEntryViewModel.swift index 37dedee326..eebc78f503 100644 --- a/Loop/View Models/CarbEntryViewModel.swift +++ b/Loop/View Models/CarbEntryViewModel.swift @@ -10,10 +10,14 @@ import SwiftUI import LoopKit import HealthKit import Combine +import LoopCore +import LoopAlgorithm +import os.log -protocol CarbEntryViewModelDelegate: AnyObject, BolusEntryViewModelDelegate { - var analyticsServicesManager: AnalyticsServicesManager { get } - var defaultAbsorptionTimes: CarbStore.DefaultAbsorptionTimes { get } +protocol CarbEntryViewModelDelegate: AnyObject, BolusEntryViewModelDelegate, FavoriteFoodInsightsViewModelDelegate { + var defaultAbsorptionTimes: DefaultAbsorptionTimes { get } + func scheduleOverrideEnabled(at date: Date) -> Bool + func getGlucoseSamples(start: Date?, end: Date?) async throws -> [StoredGlucoseSample] } final class CarbEntryViewModel: ObservableObject { @@ -37,11 +41,14 @@ final class CarbEntryViewModel: ObservableObject { return 1 case .overrideInProgress: return 2 + case .glucoseRisingRapidly: + return 3 } } case entryIsMissedMeal case overrideInProgress + case glucoseRisingRapidly } @Published var alert: CarbEntryViewModel.Alert? @@ -72,7 +79,7 @@ final class CarbEntryViewModel: ObservableObject { private var absorptionEditIsProgrammatic = false // needed for when absorption time is changed due to favorite food selection, so that absorptionTimeWasEdited does not get set to true @Published var absorptionTime: TimeInterval - let defaultAbsorptionTimes: CarbStore.DefaultAbsorptionTimes + let defaultAbsorptionTimes: DefaultAbsorptionTimes let minAbsorptionTime = LoopConstants.minCarbAbsorptionTime let maxAbsorptionTime = LoopConstants.maxCarbAbsorptionTime var absorptionRimesRange: ClosedRange { @@ -80,10 +87,29 @@ final class CarbEntryViewModel: ObservableObject { } @Published var favoriteFoods = UserDefaults.standard.favoriteFoods - @Published var selectedFavoriteFoodIndex = -1 + @Published var selectedFavoriteFoodIndex = -1 { + willSet { + self.selectedFavoriteFoodLastEaten = nil + } + } + var selectedFavoriteFood: StoredFavoriteFood? { + let foodExistsForIndex = 0..() /// Initalizer for when`CarbEntryView` is presented from the home screen @@ -113,15 +139,22 @@ final class CarbEntryViewModel: ObservableObject { self.usesCustomFoodType = true self.shouldBeginEditingQuantity = false + if let favoriteFoodIndex = favoriteFoods.firstIndex(where: { $0.id == originalCarbEntry.favoriteFoodID }) { + self.selectedFavoriteFoodIndex = favoriteFoodIndex + updateFavoriteFoodLastEatenDate(for: favoriteFoods[favoriteFoodIndex]) + } + + observeFavoriteFoodIndexChange() observeLoopUpdates() } var originalCarbEntry: StoredCarbEntry? = nil - private var favoriteFood: FavoriteFood? = nil private var updatedCarbEntry: NewCarbEntry? { if let quantity = carbsQuantity, quantity != 0 { - if let o = originalCarbEntry, o.quantity.doubleValue(for: preferredCarbUnit) == quantity && o.startDate == time && o.foodType == foodType && o.absorptionTime == absorptionTime { + let favoriteFoodID = selectedFavoriteFoodIndex == -1 ? nil : favoriteFoods[selectedFavoriteFoodIndex].id + + if let o = originalCarbEntry, o.quantity.doubleValue(for: preferredCarbUnit) == quantity && o.startDate == time && o.foodType == foodType && o.absorptionTime == absorptionTime, o.favoriteFoodID == favoriteFoodID { return nil // No changes were made } @@ -130,7 +163,8 @@ final class CarbEntryViewModel: ObservableObject { quantity: HKQuantity(unit: preferredCarbUnit, doubleValue: quantity), startDate: time, foodType: usesCustomFoodType ? foodType : selectedDefaultAbsorptionTimeEmoji, - absorptionTime: absorptionTime + absorptionTime: absorptionTime, + favoriteFoodID: favoriteFoodID ) } else { @@ -189,14 +223,12 @@ final class CarbEntryViewModel: ObservableObject { potentialCarbEntry: updatedCarbEntry, selectedCarbAbsorptionTimeEmoji: selectedDefaultAbsorptionTimeEmoji ) - Task { - await viewModel.generateRecommendationAndStartObserving() - } - viewModel.analyticsServicesManager = delegate?.analyticsServicesManager + viewModel.analyticsServicesManager = analyticsServicesManager + viewModel.deliveryDelegate = deliveryDelegate bolusViewModel = viewModel - delegate?.analyticsServicesManager.didDisplayBolusScreen() + analyticsServicesManager?.didDisplayBolusScreen() } func clearAlert() { @@ -239,12 +271,15 @@ final class CarbEntryViewModel: ObservableObject { private func favoriteFoodSelected(at index: Int) { self.absorptionEditIsProgrammatic = true + // only updates carb entry fields if on new carb entry screen if index == -1 { - self.carbsQuantity = 0 + if originalCarbEntry == nil { + self.carbsQuantity = 0 + self.absorptionTime = defaultAbsorptionTimes.medium + self.absorptionTimeWasEdited = false + self.usesCustomFoodType = false + } self.foodType = "" - self.absorptionTime = defaultAbsorptionTimes.medium - self.absorptionTimeWasEdited = false - self.usesCustomFoodType = false } else { let food = favoriteFoods[index] @@ -253,6 +288,23 @@ final class CarbEntryViewModel: ObservableObject { self.absorptionTime = food.absorptionTime self.absorptionTimeWasEdited = true self.usesCustomFoodType = true + updateFavoriteFoodLastEatenDate(for: food) + } + } + + private func updateFavoriteFoodLastEatenDate(for food: StoredFavoriteFood) { + // Update favorite food insights last eaten date + Task { @MainActor in + do { + if let lastEaten = try await delegate?.selectedFavoriteFoodLastEaten(food) { + withAnimation(.default) { + self.selectedFavoriteFoodLastEaten = lastEaten + } + } + } + catch { + log.error("Failed to fetch last eaten date for favorite food: %{public}@, %{public}@", String(describing: selectedFavoriteFood), String(describing: error)) + } } } @@ -279,28 +331,72 @@ final class CarbEntryViewModel: ObservableObject { } private func observeLoopUpdates() { - self.checkIfOverrideEnabled() + checkIfOverrideEnabled() + checkGlucoseRisingRapidly() NotificationCenter.default .publisher(for: .LoopDataUpdated) .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.checkIfOverrideEnabled() + self?.checkGlucoseRisingRapidly() } .store(in: &cancellables) } private func checkIfOverrideEnabled() { - if let managerSettings = delegate?.settings, - managerSettings.scheduleOverrideEnabled(at: Date()), - let overrideSettings = managerSettings.scheduleOverride?.settings, - overrideSettings.effectiveInsulinNeedsScaleFactor != 1.0 { - self.warnings.insert(.overrideInProgress) + guard let delegate else { + return } - else { + + if delegate.scheduleOverrideEnabled(at: Date()), + let overrideSettings = delegate.scheduleOverride?.settings, + overrideSettings.effectiveInsulinNeedsScaleFactor != 1.0 + { + self.warnings.insert(.overrideInProgress) + } else { self.warnings.remove(.overrideInProgress) } } + private func checkGlucoseRisingRapidly() { + guard let delegate else { + warnings.remove(.glucoseRisingRapidly) + return + } + + let now = Date() + let startDate = now.addingTimeInterval(-LoopConstants.missedMealWarningGlucoseRecencyWindow) + + Task { @MainActor in + let glucoseSamples = try? await delegate.getGlucoseSamples(start: startDate, end: nil) + guard let glucoseSamples else { + warnings.remove(.glucoseRisingRapidly) + return + } + + let filteredGlucoseSamples = glucoseSamples.filterDateRange(startDate, now) + guard let startSample = filteredGlucoseSamples.first, let endSample = filteredGlucoseSamples.last else { + warnings.remove(.glucoseRisingRapidly) + return + } + + let duration = endSample.startDate.timeIntervalSince(startSample.startDate) + guard duration >= LoopConstants.missedMealWarningVelocitySampleMinDuration else { + warnings.remove(.glucoseRisingRapidly) + return + } + + let delta = endSample.quantity.doubleValue(for: .milligramsPerDeciliter) - startSample.quantity.doubleValue(for: .milligramsPerDeciliter) + let velocity = delta / duration.minutes // Unit = mg/dL/m + + if velocity >= LoopConstants.missedMealWarningGlucoseRiseThreshold { + warnings.insert(.glucoseRisingRapidly) + } else { + warnings.remove(.glucoseRisingRapidly) + } + } + } + private func observeAbsorptionTimeChange() { $absorptionTime .receive(on: RunLoop.main) diff --git a/Loop/View Models/AddEditFavoriteFoodViewModel.swift b/Loop/View Models/FavoriteFoodAddEditViewModel.swift similarity index 88% rename from Loop/View Models/AddEditFavoriteFoodViewModel.swift rename to Loop/View Models/FavoriteFoodAddEditViewModel.swift index 5bd6eb8775..225766db4c 100644 --- a/Loop/View Models/AddEditFavoriteFoodViewModel.swift +++ b/Loop/View Models/FavoriteFoodAddEditViewModel.swift @@ -1,5 +1,5 @@ // -// AddEditFavoriteFoodViewModel.swift +// FavoriteFoodAddEditViewModel.swift // Loop // // Created by Noah Brauner on 7/31/23. @@ -10,7 +10,7 @@ import SwiftUI import LoopKit import HealthKit -final class AddEditFavoriteFoodViewModel: ObservableObject { +final class FavoriteFoodAddEditViewModel: ObservableObject { enum Alert: Identifiable { var id: Self { return self @@ -36,7 +36,7 @@ final class AddEditFavoriteFoodViewModel: ObservableObject { return minAbsorptionTime...maxAbsorptionTime } - @Published var alert: AddEditFavoriteFoodViewModel.Alert? + @Published var alert: FavoriteFoodAddEditViewModel.Alert? private let onSave: (NewFavoriteFood) -> () @@ -57,8 +57,14 @@ final class AddEditFavoriteFoodViewModel: ObservableObject { init(carbsQuantity: Double?, foodType: String, absorptionTime: TimeInterval, onSave: @escaping (NewFavoriteFood) -> ()) { self.onSave = onSave self.carbsQuantity = carbsQuantity - self.foodType = foodType self.absorptionTime = absorptionTime + + // foodType of Apple 🍎 --> name: Apple, foodType: 🍎 + var name = foodType + name.removeAll(where: \.isEmoji) + name = name.trimmingCharacters(in: .whitespacesAndNewlines) + self.foodType = foodType.filter(\.isEmoji) + self.name = name } var originalFavoriteFood: StoredFavoriteFood? diff --git a/Loop/View Models/FavoriteFoodInsightsViewModel.swift b/Loop/View Models/FavoriteFoodInsightsViewModel.swift new file mode 100644 index 0000000000..e7403c2c48 --- /dev/null +++ b/Loop/View Models/FavoriteFoodInsightsViewModel.swift @@ -0,0 +1,166 @@ +// +// FavoriteFoodInsightsViewModel.swift +// Loop +// +// Created by Noah Brauner on 7/15/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopKit +import LoopKitUI +import LoopAlgorithm +import os.log +import Combine +import HealthKit + +protocol FavoriteFoodInsightsViewModelDelegate: AnyObject { + func selectedFavoriteFoodLastEaten(_ favoriteFood: StoredFavoriteFood) async throws -> Date? + func getFavoriteFoodCarbEntries(_ favoriteFood: StoredFavoriteFood) async throws -> [StoredCarbEntry] + func getHistoricalChartsData(start: Date, end: Date) async throws -> HistoricalChartsData +} + +struct HistoricalChartsData { + let glucoseValues: [GlucoseValue] + let carbEntries: [StoredCarbEntry] + let doses: [BasalRelativeDose] + let iobValues: [InsulinValue] + let carbAbsorptionReview: CarbAbsorptionReview? +} + +class FavoriteFoodInsightsViewModel: ObservableObject { + let food: StoredFavoriteFood + var carbEntries: [StoredCarbEntry] = [] + @Published var carbEntryIndex = 0 + var carbEntry: StoredCarbEntry? { + let entryExistsForIndex = 0..() + + init(delegate: FavoriteFoodInsightsViewModelDelegate?, food: StoredFavoriteFood) { + self.delegate = delegate + self.food = food + fetchCarbEntries(food) + observeCarbEntryIndexChange() + } + + private func fetchCarbEntries(_ food: StoredFavoriteFood) { + Task { @MainActor in + do { + if let entries = try await delegate?.getFavoriteFoodCarbEntries(food), !entries.isEmpty { + self.carbEntries = entries + updateStartDateAndRefreshCharts(from: entries.first!) + } + } + catch { + log.error("Failed to fetch carb entries for favorite food: %{public}@", String(describing: error)) + } + } + } + + private func updateStartDateAndRefreshCharts(from entry: StoredCarbEntry) { + var components = DateComponents() + components.minute = 0 + let minimumStartDate = entry.startDate.addingTimeInterval(-FavoriteFoodInsightsViewModel.minTimeIntervalPrecedingFoodEaten) + let hourRoundedStartDate = Calendar.current.nextDate(after: minimumStartDate, matching: components, matchingPolicy: .strict, direction: .backward) ?? minimumStartDate + + startDate = hourRoundedStartDate + refreshCharts() + } + + private func refreshCharts() { + Task { @MainActor in + do { + if let historicalChartsData = try await delegate?.getHistoricalChartsData(start: dateInterval.start, end: dateInterval.end) { + var carbEntriesWithCorrectedFavoriteFoods = historicalChartsData.carbEntries.map({ historicalCarbEntry in + // only show a favorite food icon in the glcuose-carb chart if carb entry is currently viewed favorite food + StoredCarbEntry( + startDate: historicalCarbEntry.startDate, + quantity: historicalCarbEntry.quantity, + favoriteFoodID: historicalCarbEntry.uuid == carbEntry?.uuid ? historicalCarbEntry.favoriteFoodID : nil + ) + }) + self.historicalGlucoseValues = historicalChartsData.glucoseValues + self.historicalCarbEntries = carbEntriesWithCorrectedFavoriteFoods + self.historicalDoses = historicalChartsData.doses + self.historicalIOBValues = historicalChartsData.iobValues + self.historicalCarbAbsorptionReview = historicalChartsData.carbAbsorptionReview + } + } catch { + log.error("Failed to fetch historical data in date interval: %{public}@, %{public}@", String(describing: dateInterval), String(describing: error)) + } + } + } + + private func observeCarbEntryIndexChange() { + $carbEntryIndex + .receive(on: RunLoop.main) + .dropFirst() + .sink { [weak self] index in + guard let strongSelf = self else { return } + strongSelf.updateStartDateAndRefreshCharts(from: strongSelf.carbEntries[strongSelf.carbEntryIndex]) + } + .store(in: &cancellables) + } +} diff --git a/Loop/View Models/FavoriteFoodsViewModel.swift b/Loop/View Models/FavoriteFoodsViewModel.swift index 48934d1c10..f9055c46f2 100644 --- a/Loop/View Models/FavoriteFoodsViewModel.swift +++ b/Loop/View Models/FavoriteFoodsViewModel.swift @@ -10,6 +10,7 @@ import SwiftUI import HealthKit import LoopKit import Combine +import os.log final class FavoriteFoodsViewModel: ObservableObject { @Published var favoriteFoods = UserDefaults.standard.favoriteFoods @@ -28,10 +29,24 @@ final class FavoriteFoodsViewModel: ObservableObject { return formatter }() + // Favorite Food Insights + @Published var selectedFoodLastEaten: Date? = nil + lazy var relativeDateFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return formatter + }() + + private let log = OSLog(category: "CarbEntryViewModel") + + weak var insightsDelegate: FavoriteFoodInsightsViewModelDelegate? + private lazy var cancellables = Set() - init() { + init(insightsDelegate: FavoriteFoodInsightsViewModelDelegate?) { + self.insightsDelegate = insightsDelegate observeFavoriteFoodChange() + observeDetailViewPresentation() } func onFoodSave(_ newFood: NewFavoriteFood) { @@ -48,14 +63,21 @@ final class FavoriteFoodsViewModel: ObservableObject { selectedFood.foodType = newFood.foodType selectedFood.absorptionTime = newFood.absorptionTime favoriteFoods[selectedFooxIndex] = selectedFood + if isDetailViewActive { + self.selectedFood = selectedFood + } isEditViewActive = false } } - func onFoodDelete(_ food: StoredFavoriteFood) { - if isDetailViewActive { - isDetailViewActive = false + func deleteSelectedFood() { + if let selectedFood { + onFoodDelete(selectedFood) } + isDetailViewActive = false + } + + func onFoodDelete(_ food: StoredFavoriteFood) { withAnimation { _ = favoriteFoods.remove(food) } @@ -80,4 +102,29 @@ final class FavoriteFoodsViewModel: ObservableObject { } .store(in: &cancellables) } + + private func observeDetailViewPresentation() { + $isDetailViewActive + .sink { [weak self] newValue in + if newValue { + self?.fetchFoodLastEaten() + } + else { + self?.selectedFoodLastEaten = nil + } + } + .store(in: &cancellables) + } + + private func fetchFoodLastEaten() { + Task { @MainActor in + do { + if let selectedFood, let lastEaten = try await insightsDelegate?.selectedFavoriteFoodLastEaten(selectedFood) { + self.selectedFoodLastEaten = lastEaten + } + } catch { + log.error("Failed to fetch last eaten date for favorite food: %{public}@, %{public}@", String(describing: selectedFood), String(describing: error)) + } + } + } } diff --git a/Loop/View Models/ManualEntryDoseViewModel.swift b/Loop/View Models/ManualEntryDoseViewModel.swift index 5fcd966c62..5b2d45e4c6 100644 --- a/Loop/View Models/ManualEntryDoseViewModel.swift +++ b/Loop/View Models/ManualEntryDoseViewModel.swift @@ -16,42 +16,29 @@ import LoopKit import LoopKitUI import LoopUI import SwiftUI +import LoopAlgorithm -protocol ManualDoseViewModelDelegate: AnyObject { - - func withLoopState(do block: @escaping (LoopState) -> Void) - - func addManuallyEnteredDose(startDate: Date, units: Double, insulinType: InsulinType?) - - func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (_ samples: Swift.Result<[StoredGlucoseSample], Error>) -> Void) - - func insulinOnBoard(at date: Date, completion: @escaping (_ result: DoseStoreResult) -> Void) - - func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (_ result: CarbStoreResult) -> Void) - - func insulinActivityDuration(for type: InsulinType?) -> TimeInterval +enum ManualEntryDoseViewModelError: Error { + case notAuthenticated +} - var mostRecentGlucoseDataDate: Date? { get } - - var mostRecentPumpDataDate: Date? { get } - - var isPumpConfigured: Bool { get } - - var preferredGlucoseUnit: HKUnit { get } - +protocol ManualDoseViewModelDelegate: AnyObject { + var algorithmDisplayState: AlgorithmDisplayState { get async } var pumpInsulinType: InsulinType? { get } + var settings: StoredSettings { get } + var scheduleOverride: TemporaryScheduleOverride? { get } + + func addManuallyEnteredDose(startDate: Date, units: Double, insulinType: InsulinType?) async + func insulinModel(for type: InsulinType?) -> InsulinModel - var settings: LoopSettings { get } + func fetchData(for baseTime: Date, disablingPreMeal: Bool, ensureDosingCoverageStart: Date?) async throws -> StoredDataAlgorithmInput } +@MainActor final class ManualEntryDoseViewModel: ObservableObject { - - var authenticate: AuthenticationChallenge = LocalAuthentication.deviceOwnerCheck - // MARK: - State @Published var glucoseValues: [GlucoseValue] = [] // stored glucose values - private var storedGlucoseValues: [GlucoseValue] = [] @Published var predictedGlucoseValues: [GlucoseValue] = [] @Published var glucoseUnit: HKUnit = .milligramsPerDeciliter @Published var chartDateInterval: DateInterval @@ -83,27 +70,40 @@ final class ManualEntryDoseViewModel: ObservableObject { @Published var selectedDoseDate: Date = Date() var insulinTypePickerOptions: [InsulinType] - + // MARK: - Seams private weak var delegate: ManualDoseViewModelDelegate? private let now: () -> Date private let screenWidth: CGFloat private let debounceIntervalMilliseconds: Int private let uuidProvider: () -> String - + + var authenticationHandler: (String) async -> Bool = { message in + return await withCheckedContinuation { continuation in + LocalAuthentication.deviceOwnerCheck(message) { result in + switch result { + case .success: + continuation.resume(returning: true) + case .failure: + continuation.resume(returning: false) + } + } + } + } + + // MARK: - Initialization init( delegate: ManualDoseViewModelDelegate, now: @escaping () -> Date = { Date() }, - screenWidth: CGFloat = UIScreen.main.bounds.width, debounceIntervalMilliseconds: Int = 400, uuidProvider: @escaping () -> String = { UUID().uuidString }, timeZone: TimeZone? = nil ) { self.delegate = delegate self.now = now - self.screenWidth = screenWidth + self.screenWidth = UIScreen.main.bounds.width self.debounceIntervalMilliseconds = debounceIntervalMilliseconds self.uuidProvider = uuidProvider @@ -138,9 +138,7 @@ final class ManualEntryDoseViewModel: ObservableObject { .removeDuplicates() .debounce(for: .milliseconds(debounceIntervalMilliseconds), scheduler: RunLoop.main) .sink { [weak self] _ in - self?.delegate?.withLoopState { [weak self] state in - self?.updatePredictedGlucoseValues(from: state) - } + self?.updateTriggered() } .store(in: &cancellables) } @@ -150,9 +148,7 @@ final class ManualEntryDoseViewModel: ObservableObject { .removeDuplicates() .debounce(for: .milliseconds(400), scheduler: RunLoop.main) .sink { [weak self] _ in - self?.delegate?.withLoopState { [weak self] state in - self?.updatePredictedGlucoseValues(from: state) - } + self?.updateTriggered() } .store(in: &cancellables) } @@ -162,41 +158,41 @@ final class ManualEntryDoseViewModel: ObservableObject { .removeDuplicates() .debounce(for: .milliseconds(400), scheduler: RunLoop.main) .sink { [weak self] _ in - self?.delegate?.withLoopState { [weak self] state in - self?.updatePredictedGlucoseValues(from: state) - } + self?.updateTriggered() } .store(in: &cancellables) } + private func updateTriggered() { + Task { @MainActor in + await updateFromLoopState() + } + } + + // MARK: - View API - func saveManualDose(onSuccess completion: @escaping () -> Void) { + func saveManualDose() async throws { + guard enteredBolus.doubleValue(for: .internationalUnit()) > 0 else { + return + } + // Authenticate before saving anything - if enteredBolus.doubleValue(for: .internationalUnit()) > 0 { - let message = String(format: NSLocalizedString("Authenticate to log %@ Units", comment: "The message displayed during a device authentication prompt to log an insulin dose"), enteredBolusAmountString) - authenticate(message) { - switch $0 { - case .success: - self.continueSaving(onSuccess: completion) - case .failure: - break - } - } - } else { - completion() + let message = String(format: NSLocalizedString("Authenticate to log %@ Units", comment: "The message displayed during a device authentication prompt to log an insulin dose"), enteredBolusAmountString) + + if !(await authenticationHandler(message)) { + throw ManualEntryDoseViewModelError.notAuthenticated } + await self.continueSaving() } - private func continueSaving(onSuccess completion: @escaping () -> Void) { + private func continueSaving() async { let doseVolume = enteredBolus.doubleValue(for: .internationalUnit()) guard doseVolume > 0 else { - completion() return } - delegate?.addManuallyEnteredDose(startDate: selectedDoseDate, units: doseVolume, insulinType: selectedInsulinType) - completion() + await delegate?.addManuallyEnteredDose(startDate: selectedDoseDate, units: doseVolume, insulinType: selectedInsulinType) } private lazy var bolusVolumeFormatter = QuantityFormatter(for: .internationalUnit()) @@ -218,117 +214,59 @@ final class ManualEntryDoseViewModel: ObservableObject { // MARK: - Data upkeep private func update() { - dispatchPrecondition(condition: .onQueue(.main)) // Prevent any UI updates after a bolus has been initiated. guard !isInitiatingSaveOrBolus else { return } updateChartDateInterval() - updateStoredGlucoseValues() - updateFromLoopState() - updateActiveInsulin() + Task { + await updateFromLoopState() + } } - private func updateStoredGlucoseValues() { - delegate?.getGlucoseSamples(start: chartDateInterval.start, end: nil) { [weak self] result in - DispatchQueue.main.async { - guard let self = self else { return } - switch result { - case .failure(let error): - self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) - self.storedGlucoseValues = [] - case .success(let samples): - self.storedGlucoseValues = samples - } - self.updateGlucoseChartValues() - } + private func updateFromLoopState() async { + guard let delegate = delegate else { + return } - } - private func updateGlucoseChartValues() { - dispatchPrecondition(condition: .onQueue(.main)) + let displayState = await delegate.algorithmDisplayState + self.activeInsulin = displayState.activeInsulin?.quantity + self.activeCarbs = displayState.activeCarbs?.quantity - self.glucoseValues = storedGlucoseValues - } + let startDate = now() - /// - NOTE: `completion` is invoked on the main queue after predicted glucose values are updated - private func updatePredictedGlucoseValues(from state: LoopState, completion: @escaping () -> Void = {}) { - dispatchPrecondition(condition: .notOnQueue(.main)) + let insulinModel = delegate.insulinModel(for: selectedInsulinType) - let (enteredBolus, doseDate, insulinType) = DispatchQueue.main.sync { (self.enteredBolus, self.selectedDoseDate, self.selectedInsulinType) } - - let enteredBolusDose = DoseEntry(type: .bolus, startDate: doseDate, value: enteredBolus.doubleValue(for: .internationalUnit()), unit: .units, insulinType: insulinType) + let enteredBolusDose = SimpleInsulinDose( + deliveryType: .bolus, + startDate: selectedDoseDate, + endDate: selectedDoseDate, + volume: enteredBolus.doubleValue(for: .internationalUnit()), + insulinModel: insulinModel + ) - let predictedGlucoseValues: [PredictedGlucoseValue] do { - predictedGlucoseValues = try state.predictGlucose( - using: .all, - potentialBolus: enteredBolusDose, - potentialCarbEntry: nil, - replacingCarbEntry: nil, - includingPendingInsulin: true, - considerPositiveVelocityAndRC: true - ) - } catch { - predictedGlucoseValues = [] - } - - DispatchQueue.main.async { - self.predictedGlucoseValues = predictedGlucoseValues - completion() - } - } - - private func updateActiveInsulin() { - delegate?.insulinOnBoard(at: Date()) { [weak self] result in - guard let self = self else { return } + let input = try await delegate.fetchData(for: startDate, disablingPreMeal: false, ensureDosingCoverageStart: nil) - DispatchQueue.main.async { - switch result { - case .success(let iob): - self.activeInsulin = HKQuantity(unit: .internationalUnit(), doubleValue: iob.value) - case .failure: - self.activeInsulin = nil - } - } - } - } + self.glucoseValues = input.glucoseHistory - private func updateFromLoopState() { - delegate?.withLoopState { [weak self] state in - self?.updatePredictedGlucoseValues(from: state) - self?.updateCarbsOnBoard(from: state) - DispatchQueue.main.async { - self?.updateSettings() - } + predictedGlucoseValues = try input + .addingDose(dose: enteredBolusDose) + .predictGlucose() + } catch { + predictedGlucoseValues = [] } - } - private func updateCarbsOnBoard(from state: LoopState) { - delegate?.carbsOnBoard(at: Date(), effectVelocities: state.insulinCounteractionEffects) { result in - DispatchQueue.main.async { - switch result { - case .success(let carbValue): - self.activeCarbs = carbValue.quantity - case .failure: - self.activeCarbs = nil - } - } - } + updateSettings() } - private func updateSettings() { - dispatchPrecondition(condition: .onQueue(.main)) - - guard let delegate = delegate else { + guard let delegate else { return } - glucoseUnit = delegate.preferredGlucoseUnit - targetGlucoseSchedule = delegate.settings.glucoseTargetRangeSchedule - scheduleOverride = delegate.settings.scheduleOverride + scheduleOverride = delegate.scheduleOverride if preMealOverride?.hasFinished() == true { preMealOverride = nil @@ -347,7 +285,9 @@ final class ManualEntryDoseViewModel: ObservableObject { let availableWidth = screenWidth - chartManager.fixedHorizontalMargin - 2 * viewMarginInset let totalHours = floor(Double(availableWidth / LoopConstants.minimumChartWidthPerHour)) - let futureHours = ceil((delegate?.insulinActivityDuration(for: selectedInsulinType) ?? .hours(4)).hours) + + let insulinModel = delegate?.insulinModel(for: selectedInsulinType) + let futureHours = ceil(insulinModel?.effectDuration.hours ?? 4) let historyHours = max(LoopConstants.statusChartMinimumHistoryDisplay.hours, totalHours - futureHours) let date = Date(timeInterval: -TimeInterval(hours: historyHours), since: now()) diff --git a/Loop/View Models/ServicesViewModel.swift b/Loop/View Models/ServicesViewModel.swift index 19fb2a7d57..3021247b2c 100644 --- a/Loop/View Models/ServicesViewModel.swift +++ b/Loop/View Models/ServicesViewModel.swift @@ -54,7 +54,7 @@ public class ServicesViewModel: ObservableObject { extension ServicesViewModel { fileprivate class FakeService1: Service { static var localizedTitle: String = "Service 1" - static var pluginIdentifier: String = "FakeService1" + var pluginIdentifier: String = "FakeService1" var stateDelegate: StatefulPluggableDelegate? var serviceDelegate: ServiceDelegate? var rawState: RawStateValue = [:] @@ -65,7 +65,7 @@ extension ServicesViewModel { } fileprivate class FakeService2: Service { static var localizedTitle: String = "Service 2" - static var pluginIdentifier: String = "FakeService2" + var pluginIdentifier: String = "FakeService2" var stateDelegate: StatefulPluggableDelegate? var serviceDelegate: ServiceDelegate? var rawState: RawStateValue = [:] diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index d4b48766b3..ea93e53d7d 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -79,8 +79,12 @@ public class SettingsViewModel: ObservableObject { let sensitivityOverridesEnabled: Bool let isOnboardingComplete: Bool let therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? - - @Published var isClosedLoopAllowed: Bool + + @Published private(set) var automaticDosingStatus: AutomaticDosingStatus + + @Published private(set) var lastLoopCompletion: Date? + @Published private(set) var mostRecentGlucoseDataDate: Date? + @Published private(set) var mostRecentPumpDataDate: Date? var closedLoopDescriptiveText: String? { return delegate?.closedLoopDescriptiveText @@ -93,16 +97,33 @@ public class SettingsViewModel: ObservableObject { } } - var closedLoopPreference: Bool { + @Published var closedLoopPreference: Bool { didSet { delegate?.dosingEnabledChanged(closedLoopPreference) } } + + weak var favoriteFoodInsightsDelegate: FavoriteFoodInsightsViewModelDelegate? var showDeleteTestData: Bool { availableSupports.contains(where: { $0.showsDeleteTestDataUI }) } + var loopStatusCircleFreshness: LoopCompletionFreshness { + var age: TimeInterval + + if automaticDosingStatus.automaticDosingEnabled { + let lastLoopCompletion = lastLoopCompletion ?? Date().addingTimeInterval(.minutes(16)) + age = abs(min(0, lastLoopCompletion.timeIntervalSinceNow)) + } else { + let mostRecentGlucoseDataDate = mostRecentGlucoseDataDate ?? Date().addingTimeInterval(.minutes(16)) + let mostRecentPumpDataDate = mostRecentPumpDataDate ?? Date().addingTimeInterval(.minutes(16)) + age = max(abs(min(0, mostRecentPumpDataDate.timeIntervalSinceNow)), abs(min(0, mostRecentGlucoseDataDate.timeIntervalSinceNow))) + } + + return LoopCompletionFreshness(age: age) + } + lazy private var cancellables = Set() public init(alertPermissionsChecker: AlertPermissionsChecker, @@ -115,8 +136,11 @@ public class SettingsViewModel: ObservableObject { therapySettings: @escaping () -> TherapySettings, sensitivityOverridesEnabled: Bool, initialDosingEnabled: Bool, - isClosedLoopAllowed: Published.Publisher, + automaticDosingStatus: AutomaticDosingStatus, automaticDosingStrategy: AutomaticDosingStrategy, + lastLoopCompletion: Published.Publisher, + mostRecentGlucoseDataDate: Published.Publisher, + mostRecentPumpDataDate: Published.Publisher, availableSupports: [SupportUI], isOnboardingComplete: Bool, therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate?, @@ -132,8 +156,11 @@ public class SettingsViewModel: ObservableObject { self.therapySettings = therapySettings self.sensitivityOverridesEnabled = sensitivityOverridesEnabled self.closedLoopPreference = initialDosingEnabled - self.isClosedLoopAllowed = false + self.automaticDosingStatus = automaticDosingStatus self.automaticDosingStrategy = automaticDosingStrategy + self.lastLoopCompletion = nil + self.mostRecentGlucoseDataDate = nil + self.mostRecentPumpDataDate = nil self.availableSupports = availableSupports self.isOnboardingComplete = isOnboardingComplete self.therapySettingsViewModelDelegate = therapySettingsViewModelDelegate @@ -156,23 +183,33 @@ public class SettingsViewModel: ObservableObject { self?.objectWillChange.send() } .store(in: &cancellables) - - isClosedLoopAllowed - .assign(to: \.isClosedLoopAllowed, on: self) + automaticDosingStatus.objectWillChange.sink { [weak self] in + self?.objectWillChange.send() + } + .store(in: &cancellables) + lastLoopCompletion + .assign(to: \.lastLoopCompletion, on: self) + .store(in: &cancellables) + mostRecentGlucoseDataDate + .assign(to: \.mostRecentGlucoseDataDate, on: self) + .store(in: &cancellables) + mostRecentPumpDataDate + .assign(to: \.mostRecentPumpDataDate, on: self) .store(in: &cancellables) } } // For previews only +@MainActor extension SettingsViewModel { - fileprivate class FakeClosedLoopAllowedPublisher { - @Published var mockIsClosedLoopAllowed: Bool = false + fileprivate class FakeLastLoopCompletionPublisher { + @Published var mockLastLoopCompletion: Date? = nil } static var preview: SettingsViewModel { return SettingsViewModel(alertPermissionsChecker: AlertPermissionsChecker(), alertMuter: AlertMuter(), - versionUpdateViewModel: VersionUpdateViewModel(supportManager: nil, guidanceColors: GuidanceColors()), + versionUpdateViewModel: VersionUpdateViewModel(supportManager: nil, guidanceColors: .default), pumpManagerSettingsViewModel: DeviceViewModel(), cgmManagerSettingsViewModel: DeviceViewModel(), servicesViewModel: ServicesViewModel.preview, @@ -180,11 +217,15 @@ extension SettingsViewModel { therapySettings: { TherapySettings() }, sensitivityOverridesEnabled: false, initialDosingEnabled: true, - isClosedLoopAllowed: FakeClosedLoopAllowedPublisher().$mockIsClosedLoopAllowed, + automaticDosingStatus: AutomaticDosingStatus(automaticDosingEnabled: true, isAutomaticDosingAllowed: true), automaticDosingStrategy: .automaticBolus, + lastLoopCompletion: FakeLastLoopCompletionPublisher().$mockLastLoopCompletion, + mostRecentGlucoseDataDate: FakeLastLoopCompletionPublisher().$mockLastLoopCompletion, + mostRecentPumpDataDate: FakeLastLoopCompletionPublisher().$mockLastLoopCompletion, availableSupports: [], isOnboardingComplete: false, therapySettingsViewModelDelegate: nil, - delegate: nil) + delegate: nil + ) } } diff --git a/Loop/View Models/SimpleBolusViewModel.swift b/Loop/View Models/SimpleBolusViewModel.swift index f803bfa595..3d90042d3e 100644 --- a/Loop/View Models/SimpleBolusViewModel.swift +++ b/Loop/View Models/SimpleBolusViewModel.swift @@ -15,33 +15,37 @@ import SwiftUI import LoopCore import Intents import LocalAuthentication +import LoopAlgorithm protocol SimpleBolusViewModelDelegate: AnyObject { - func addGlucose(_ samples: [NewGlucoseSample], completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) - - func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry? , - completion: @escaping (_ result: Result) -> Void) + func saveGlucose(sample: NewGlucoseSample) async throws -> StoredGlucoseSample + + func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?) async throws -> StoredCarbEntry - func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) async - func enactBolus(units: Double, activationType: BolusActivationType) + func enactBolus(units: Double, activationType: BolusActivationType) async throws - func insulinOnBoard(at date: Date, completion: @escaping (_ result: DoseStoreResult) -> Void) + func insulinOnBoard(at date: Date) async -> InsulinValue? func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? - var displayGlucosePreference: DisplayGlucosePreference { get } - - var maximumBolus: Double { get } + var maximumBolus: Double? { get } - var suspendThreshold: HKQuantity { get } + var suspendThreshold: HKQuantity? { get } } +@MainActor class SimpleBolusViewModel: ObservableObject { var authenticate: AuthenticationChallenge = LocalAuthentication.deviceOwnerCheck + // For testing + func setAuthenticationMethdod(_ authenticate: @escaping AuthenticationChallenge) { + self.authenticate = authenticate + } + enum Alert: Int { case carbEntryPersistenceFailure case manualGlucoseEntryPersistenceFailure @@ -93,7 +97,7 @@ class SimpleBolusViewModel: ObservableObject { _manualGlucoseString = "" return _manualGlucoseString } - self._manualGlucoseString = delegate.displayGlucosePreference.format(manualGlucoseQuantity, includeUnit: false) + self._manualGlucoseString = displayGlucosePreference.format(manualGlucoseQuantity, includeUnit: false) } return _manualGlucoseString @@ -104,7 +108,11 @@ class SimpleBolusViewModel: ObservableObject { } private func updateNotice() { - + + guard let maxBolus = delegate.maximumBolus, let suspendThreshold = delegate.suspendThreshold else { + return + } + if let carbs = self.carbQuantity { guard carbs <= LoopConstants.maxCarbEntryQuantity else { activeNotice = .carbohydrateEntryTooLarge @@ -113,7 +121,7 @@ class SimpleBolusViewModel: ObservableObject { } if let bolus = bolus { - guard bolus.doubleValue(for: .internationalUnit()) <= delegate.maximumBolus else { + guard bolus.doubleValue(for: .internationalUnit()) <= maxBolus else { activeNotice = .maxBolusExceeded return } @@ -141,7 +149,7 @@ class SimpleBolusViewModel: ObservableObject { case let g? where g < suspendThreshold: activeNotice = .glucoseBelowSuspendThreshold default: - if let recommendation = recommendation, recommendation > delegate.maximumBolus { + if let recommendation = recommendation, recommendation > maxBolus { activeNotice = .recommendationExceedsMaxBolus } else { activeNotice = nil @@ -152,7 +160,7 @@ class SimpleBolusViewModel: ObservableObject { @Published private var _manualGlucoseString: String = "" { didSet { - guard let manualGlucoseValue = delegate.displayGlucosePreference.formatter.numberFormatter.number(from: _manualGlucoseString)?.doubleValue + guard let manualGlucoseValue = displayGlucosePreference.formatter.numberFormatter.number(from: _manualGlucoseString)?.doubleValue else { manualGlucoseQuantity = nil return @@ -160,7 +168,7 @@ class SimpleBolusViewModel: ObservableObject { // if needed update manualGlucoseQuantity and related activeNotice if manualGlucoseQuantity == nil || - _manualGlucoseString != delegate.displayGlucosePreference.format(manualGlucoseQuantity!, includeUnit: false) + _manualGlucoseString != displayGlucosePreference.format(manualGlucoseQuantity!, includeUnit: false) { manualGlucoseQuantity = HKQuantity(unit: cachedDisplayGlucoseUnit, doubleValue: manualGlucoseValue) updateNotice() @@ -195,19 +203,21 @@ class SimpleBolusViewModel: ObservableObject { } return false } + + let displayGlucosePreference: DisplayGlucosePreference + + var displayGlucoseUnit: HKUnit { return displayGlucosePreference.unit } - var displayGlucoseUnit: HKUnit { return delegate.displayGlucosePreference.unit } - - var suspendThreshold: HKQuantity { return delegate.suspendThreshold } + var suspendThreshold: HKQuantity? { return delegate.suspendThreshold } private var recommendation: Double? = nil { didSet { - if let recommendation = recommendation { + if let recommendation = recommendation, let maxBolus = delegate.maximumBolus { recommendedBolus = Self.doseAmountFormatter.string(from: recommendation)! - enteredBolusString = Self.doseAmountFormatter.string(from: min(recommendation, delegate.maximumBolus))! + enteredBolusString = Self.doseAmountFormatter.string(from: min(recommendation, maxBolus))! } else { recommendedBolus = NSLocalizedString("–", comment: "String denoting lack of a recommended bolus amount in the simple bolus calculator") - enteredBolusString = Self.doseAmountFormatter.string(from: 0.0)! + enteredBolusString = "" } } } @@ -271,14 +281,18 @@ class SimpleBolusViewModel: ObservableObject { private lazy var bolusVolumeFormatter = QuantityFormatter(for: .internationalUnit()) var maximumBolusAmountString: String { - let maxBolusQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: delegate.maximumBolus) + guard let maxBolus = delegate.maximumBolus else { + return "" + } + let maxBolusQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: maxBolus) return bolusVolumeFormatter.string(from: maxBolusQuantity)! } - init(delegate: SimpleBolusViewModelDelegate, displayMealEntry: Bool) { + init(delegate: SimpleBolusViewModelDelegate, displayMealEntry: Bool, displayGlucosePreference: DisplayGlucosePreference) { self.delegate = delegate self.displayMealEntry = displayMealEntry - cachedDisplayGlucoseUnit = delegate.displayGlucosePreference.unit + self.displayGlucosePreference = displayGlucosePreference + cachedDisplayGlucoseUnit = displayGlucosePreference.unit enteredBolusString = Self.doseAmountFormatter.string(from: 0.0)! updateRecommendation() dosingDecision = BolusDosingDecision(for: .simpleBolus) @@ -323,121 +337,79 @@ class SimpleBolusViewModel: ObservableObject { } } - func saveAndDeliver(completion: @escaping (Bool) -> Void) { - + func saveAndDeliver() async -> Bool { + let saveDate = Date() - // Authenticate the bolus before saving anything - func authenticateIfNeeded(_ completion: @escaping (Bool) -> Void) { - if let bolus = bolus, bolus.doubleValue(for: .internationalUnit()) > 0 { - let message = String(format: NSLocalizedString("Authenticate to Bolus %@ Units", comment: "The message displayed during a device authentication prompt for bolus specification"), enteredBolusString) + // Authenticate if needed + if let bolus = bolus, bolus.doubleValue(for: .internationalUnit()) > 0 { + let message = String(format: NSLocalizedString("Authenticate to Bolus %@ Units", comment: "The message displayed during a device authentication prompt for bolus specification"), enteredBolusString) + let authenticated = await withCheckedContinuation { continuation in authenticate(message) { switch $0 { case .success: - completion(true) + continuation.resume(returning: true) case .failure: - completion(false) + continuation.resume(returning: false) } } - } else { - completion(true) } - } - - func saveManualGlucose(_ completion: @escaping (Bool) -> Void) { - if let manualGlucoseQuantity = manualGlucoseQuantity { - let manualGlucoseSample = NewGlucoseSample(date: saveDate, - quantity: manualGlucoseQuantity, - condition: nil, // All manual glucose entries are assumed to have no condition. - trend: nil, // All manual glucose entries are assumed to have no trend. - trendRate: nil, // All manual glucose entries are assumed to have no trend rate. - isDisplayOnly: false, - wasUserEntered: true, - syncIdentifier: UUID().uuidString) - delegate.addGlucose([manualGlucoseSample]) { result in - DispatchQueue.main.async { - switch result { - case .failure(let error): - self.presentAlert(.manualGlucoseEntryPersistenceFailure) - self.log.error("Failed to add manual glucose entry: %{public}@", String(describing: error)) - completion(false) - case .success(let storedSamples): - self.dosingDecision?.manualGlucoseSample = storedSamples.first - completion(true) - } - } - } - } else { - completion(true) + if !authenticated { + return false } } - - func saveCarbs(_ completion: @escaping (Bool) -> Void) { - if let carbs = carbQuantity { - - let interaction = INInteraction(intent: NewCarbEntryIntent(), response: nil) - interaction.donate { [weak self] (error) in - if let error = error { - self?.log.error("Failed to donate intent: %{public}@", String(describing: error)) - } - } - - let carbEntry = NewCarbEntry(date: saveDate, quantity: carbs, startDate: saveDate, foodType: nil, absorptionTime: nil) - - delegate.addCarbEntry(carbEntry, replacing: nil) { result in - DispatchQueue.main.async { - switch result { - case .failure(let error): - self.presentAlert(.carbEntryPersistenceFailure) - self.log.error("Failed to add carb entry: %{public}@", String(describing: error)) - completion(false) - case .success(let storedEntry): - self.dosingDecision?.carbEntry = storedEntry - completion(true) - } - } - } - } else { - completion(true) + + if let manualGlucoseQuantity = manualGlucoseQuantity { + let manualGlucoseSample = NewGlucoseSample(date: saveDate, + quantity: manualGlucoseQuantity, + condition: nil, // All manual glucose entries are assumed to have no condition. + trend: nil, // All manual glucose entries are assumed to have no trend. + trendRate: nil, // All manual glucose entries are assumed to have no trend rate. + isDisplayOnly: false, + wasUserEntered: true, + syncIdentifier: UUID().uuidString) + do { + self.dosingDecision?.manualGlucoseSample = try await delegate.saveGlucose(sample: manualGlucoseSample) + } catch { + self.presentAlert(.manualGlucoseEntryPersistenceFailure) + self.log.error("Failed to add manual glucose entry: %{public}@", String(describing: error)) + return false } } - func enactBolus() { - if let bolusVolume = bolus?.doubleValue(for: .internationalUnit()), bolusVolume > 0 { - delegate.enactBolus(units: bolusVolume, activationType: .activationTypeFor(recommendedAmount: recommendation, bolusAmount: bolusVolume)) - dosingDecision?.manualBolusRequested = bolusVolume + if let carbs = carbQuantity { + let interaction = INInteraction(intent: NewCarbEntryIntent(), response: nil) + do { + try await interaction.donate() + } catch { + log.error("Failed to donate intent: %{public}@", String(describing: error)) } - } - - func saveBolusDecision() { - if let decision = dosingDecision, let recommendationDate = recommendationDate { - delegate.storeManualBolusDosingDecision(decision, withDate: recommendationDate) + + let carbEntry = NewCarbEntry(date: saveDate, quantity: carbs, startDate: saveDate, foodType: nil, absorptionTime: nil) + + do { + self.dosingDecision?.carbEntry = try await delegate.addCarbEntry(carbEntry, replacing: nil) + } catch { + self.presentAlert(.carbEntryPersistenceFailure) + self.log.error("Failed to add carb entry: %{public}@", String(describing: error)) + return false } } - - func finishWithResult(_ success: Bool) { - saveBolusDecision() - completion(success) - } - - authenticateIfNeeded { (success) in - if success { - saveManualGlucose { (success) in - if success { - saveCarbs { (success) in - if success { - enactBolus() - } - finishWithResult(success) - } - } else { - finishWithResult(false) - } - } - } else { - finishWithResult(false) + + if let bolusVolume = bolus?.doubleValue(for: .internationalUnit()), bolusVolume > 0 { + do { + try await delegate.enactBolus(units: bolusVolume, activationType: .activationTypeFor(recommendedAmount: recommendation, bolusAmount: bolusVolume)) + dosingDecision?.manualBolusRequested = bolusVolume + } catch { + log.error("Unable to enact bolus: %{public}@", String(describing: error)) + return false } } + + if let decision = dosingDecision, let recommendationDate = recommendationDate { + await delegate.storeManualBolusDosingDecision(decision, withDate: recommendationDate) + } + return true } private func presentAlert(_ alert: Alert) { diff --git a/Loop/View Models/VersionUpdateViewModel.swift b/Loop/View Models/VersionUpdateViewModel.swift index fa2b87e6c5..72267c6651 100644 --- a/Loop/View Models/VersionUpdateViewModel.swift +++ b/Loop/View Models/VersionUpdateViewModel.swift @@ -12,6 +12,7 @@ import LoopKit import SwiftUI import LoopKitUI +@MainActor public class VersionUpdateViewModel: ObservableObject { @Published var versionUpdate: VersionUpdate? diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index e9a38e72a0..327e344644 100644 --- a/Loop/Views/AlertManagementView.swift +++ b/Loop/Views/AlertManagementView.swift @@ -17,8 +17,18 @@ struct AlertManagementView: View { @ObservedObject private var checker: AlertPermissionsChecker @ObservedObject private var alertMuter: AlertMuter - @State private var showMuteAlertOptions: Bool = false - @State private var showHowMuteAlertWork: Bool = false + enum Sheet: Hashable, Identifiable { + case durationSelection + case confirmation(resumeDate: Date) + + var id: Int { + hashValue + } + } + + @State private var sheet: Sheet? + @State private var durationSelection: TimeInterval? + @State private var durationWasSelection: Bool = false private var formatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() @@ -31,7 +41,7 @@ struct AlertManagementView: View { Binding( get: { formatter.string(from: alertMuter.configuration.duration)! }, set: { newValue in - guard let selectedDurationIndex = formatterDurations.firstIndex(of: newValue) + guard let selectedDurationIndex = AlertMuter.allowedDurations.compactMap({ formatter.string(from: $0) }).firstIndex(of: newValue) else { return } DispatchQueue.main.async { // avoid publishing during view update @@ -41,10 +51,6 @@ struct AlertManagementView: View { } ) } - - private var formatterDurations: [String] { - AlertMuter.allowedDurations.compactMap { formatter.string(from: $0) } - } private var missedMealNotificationsEnabled: Binding { Binding( @@ -69,91 +75,24 @@ struct AlertManagementView: View { if FeatureFlags.missedMealNotifications { missedMealAlertSection } + supportSection } .navigationTitle(NSLocalizedString("Alert Management", comment: "Title of alert management screen")) } - - private var footerView: some View { - VStack(alignment: .leading, spacing: 24) { - HStack(alignment: .top, spacing: 8) { - Image("phone") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: 64, maxHeight: 64) - - VStack(alignment: .leading, spacing: 4) { - Text( - String( - format: NSLocalizedString( - "%1$@ APP SOUNDS", - comment: "App sounds title text (1: app name)" - ), - appName.uppercased() - ) - ) - - Text( - String( - format: NSLocalizedString( - "While mute alerts is on, all alerts from your %1$@ app including Critical and Time Sensitive alerts will temporarily display without sounds and will vibrate only.", - comment: "App sounds descriptive text (1: app name)" - ), - appName - ) - ) - } - } - - HStack(alignment: .top, spacing: 8) { - Image("hardware") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: 64, maxHeight: 64) - - VStack(alignment: .leading, spacing: 4) { - Text("HARDWARE SOUNDS") - - Text("While mute alerts is on, your insulin pump and CGM hardware may still sound.") - } - } - - HStack(alignment: .top, spacing: 8) { - Image(systemName: "moon.fill") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: 64, maxHeight: 48) - .foregroundColor(.accentColor) - - VStack(alignment: .leading, spacing: 4) { - Text("IOS FOCUS MODES") - - Text( - String( - format: NSLocalizedString( - "If iOS Focus Mode is ON and Mute Alerts is OFF, Critical Alerts will still be delivered and non-Critical Alerts will be silenced until %1$@ is added to each Focus mode as an Allowed App.", - comment: "Focus modes descriptive text (1: app name)" - ), - appName - ) - ) - } - } - } - .padding(.top) - } private var alertPermissionsSection: some View { - Section(footer: DescriptiveText(label: String(format: NSLocalizedString("Notifications give you important %1$@ app information without requiring you to open the app.", comment: "Alert Permissions descriptive text (1: app name)"), appName))) { + Section(header: Text("iOS").textCase(nil)) { NavigationLink(destination: NotificationsCriticalAlertPermissionsView(mode: .flow, checker: checker)) { HStack { - Text(NSLocalizedString("Alert Permissions", comment: "Alert Permissions button text")) + Text(NSLocalizedString("iOS Permissions", comment: "iOS Permissions button text")) if checker.showWarning || checker.notificationCenterSettings.scheduledDeliveryEnabled { Spacer() Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.critical) + .accessibilityIdentifier("settingsViewAlertManagementAlertPermissionsAlertWarning") } } } @@ -162,79 +101,83 @@ struct AlertManagementView: View { @ViewBuilder private var muteAlertsSection: some View { - Section(footer: footerView) { + Section( + header: Text(String(format: "%1$@", appName)), + footer: !alertMuter.configuration.shouldMute ? Text(String(format: NSLocalizedString("Temporarily silence all sounds from %1$@, including sounds for all critical alerts such as Urgent Low, Sensor Fail, Pump Expiration and others.", comment: ""), appName)) : nil + ) { if !alertMuter.configuration.shouldMute { - howMuteAlertsWork - Button(action: { showMuteAlertOptions = true }) { - HStack { - muteAlertIcon - Text(NSLocalizedString("Mute All Alerts", comment: "Label for button to mute all alerts")) - } - } - .actionSheet(isPresented: $showMuteAlertOptions) { - muteAlertOptionsActionSheet - } + muteAlertsButton } else { - Button(action: alertMuter.unmuteAlerts) { - HStack { - unmuteAlertIcon - Text(NSLocalizedString("Tap to Unmute Alerts", comment: "Label for button to unmute all alerts")) - } - } - HStack { - Text(NSLocalizedString("All alerts muted until", comment: "Label for when mute alert will end")) - Spacer() - Text(alertMuter.formattedEndTime) - .foregroundColor(.secondary) - } + unmuteAlertsButton + .listRowSeparator(.visible, edges: .all) + muteAlertsSummary } } } - - private var muteAlertIcon: some View { - Image(systemName: "speaker.slash.fill") - .foregroundColor(.white) - .padding(5) - .background(guidanceColors.warning) - .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) - } - - private var unmuteAlertIcon: some View { - Image(systemName: "speaker.wave.2.fill") - .foregroundColor(.white) - .padding(.vertical, 5) - .padding(.horizontal, 2) - .background(guidanceColors.warning) - .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) - } - - private var howMuteAlertsWork: some View { - Button(action: { showHowMuteAlertWork = true }) { - HStack { - Text(NSLocalizedString("Frequently asked questions about alerts", comment: "Label for link to see frequently asked questions")) - .font(.footnote) - .foregroundColor(.secondary) + + private var muteAlertsButton: some View { + Button { + if !alertMuter.configuration.shouldMute { + sheet = .durationSelection + } + } label: { + HStack(spacing: 12) { + Spacer() + Text(NSLocalizedString("Mute All App Sounds", comment: "Label for button to mute all app sounds")) + .fontWeight(.semibold) Spacer() - Image(systemName: "info.circle") - .font(.body) + } + .padding(.vertical, 8) + } + .sheet(item: $sheet) { sheet in + switch sheet { + case .durationSelection: + DurationSheet( + allowedDurations: AlertMuter.allowedDurations, + duration: $durationSelection, + durationWasSelected: $durationWasSelection + ) + case .confirmation(let resumeDate): + ConfirmationSheet(resumeDate: resumeDate) } } - .sheet(isPresented: $showHowMuteAlertWork) { - HowMuteAlertWorkView() + .onChange(of: durationWasSelection) { _ in + if durationWasSelection, let durationSelection, let durationSelectionString = formatter.string(from: durationSelection) { + sheet = .confirmation(resumeDate: Date().addingTimeInterval(durationSelection)) + formattedSelectedDuration.wrappedValue = durationSelectionString + self.durationSelection = nil + self.durationWasSelection = false + } } } - - private var muteAlertOptionsActionSheet: ActionSheet { - var muteAlertDurationOptions: [SwiftUI.Alert.Button] = formatterDurations.map { muteAlertDuration in - .default(Text(muteAlertDuration), - action: { formattedSelectedDuration.wrappedValue = muteAlertDuration }) + + private var unmuteAlertsButton: some View { + Button(action: alertMuter.unmuteAlerts) { + Group { + Text(Image(systemName: "speaker.slash.fill")) + .foregroundColor(guidanceColors.warning) + + Text(" ") + + Text(NSLocalizedString("Tap to Unmute All App Sounds", comment: "Label for button to unmute all app sounds")) + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding(8) + } + } + + private var muteAlertsSummary: some View { + VStack(spacing: 12) { + HStack { + Text(NSLocalizedString("Muted until", comment: "Label for when mute alert will end")) + Spacer() + Text(alertMuter.formattedEndTime) + .foregroundColor(.secondary) + } + + Text("All app sounds, including sounds for all critical alerts such as Urgent Low, Sensor Fail, Pump Expiration, and others will NOT sound.", comment: "Warning label that all alerts will not sound") + .font(.footnote) + .frame(maxWidth: .infinity, alignment: .leading) } - muteAlertDurationOptions.append(.cancel()) - - return ActionSheet( - title: Text(NSLocalizedString("Mute All Alerts Temporarily", comment: "Title for mute alert duration selection action sheet")), - message: Text(NSLocalizedString("No alerts or alarms will sound while muted. Select how long you would you like to mute for.", comment: "Message for mute alert duration selection action sheet")), - buttons: muteAlertDurationOptions) } private var missedMealAlertSection: some View { @@ -242,6 +185,20 @@ struct AlertManagementView: View { Toggle(NSLocalizedString("Missed Meal Notifications", comment: "Title for missed meal notifications toggle"), isOn: missedMealNotificationsEnabled) } } + + @ViewBuilder + private var supportSection: some View { + Section( + header: SectionHeader(label: NSLocalizedString("Support", comment: "Section title for Support")).padding(.leading, -16).padding(.bottom, 4), + footer: Text(String(format: "Frequently asked questions about alerts from iOS and %1$@.", appName))) { + NavigationLink { + HowMuteAlertWorkView() + } label: { + Text("Learn more about Alerts", comment: "Link to learn more about alerts") + } + + } + } } extension UserDefaults { diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index 4dd0c11a52..7e01c87a8e 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -36,16 +36,12 @@ struct BolusEntryView: View { self.chartSection self.summarySection } - // As of iOS 13, we can't programmatically scroll to the Bolus entry text field. This ugly hack scoots the - // list up instead, so the summarySection is visible and the keyboard shows when you tap "Enter Bolus". - // Unfortunately, after entry, the field scoots back down and remains hidden. So this is not a great solution. - // TODO: Fix this in Xcode 12 when we're building for iOS 14. - .padding(.top, self.shouldAutoScroll(basedOn: geometry) ? -200 : -28) + .padding(.top, -28) .insetGroupedListStyle() self.actionArea - .frame(height: self.isKeyboardVisible ? 0 : nil) - .opacity(self.isKeyboardVisible ? 0 : 1) + .frame(height: self.isKeyboardVisible || shouldBolusEntryBecomeFirstResponder ? 0 : nil) + .opacity(self.isKeyboardVisible || shouldBolusEntryBecomeFirstResponder ? 0 : 1) } .onKeyboardStateChange { state in self.isKeyboardVisible = state.height > 0 @@ -73,6 +69,9 @@ struct BolusEntryView: View { enteredBolusStringBinding.wrappedValue = newEnteredBolusString } } + .task { + await self.viewModel.generateRecommendationAndStartObserving() + } } } @@ -82,12 +81,6 @@ struct BolusEntryView: View { } return Text("Meal Bolus", comment: "Title for bolus entry screen when also entering carbs") } - - private func shouldAutoScroll(basedOn geometry: GeometryProxy) -> Bool { - // Taking a guess of 640 to cover iPhone SE, iPod Touch, and other smaller devices. - // Devices such as the iPhone 11 Pro Max do not need to auto-scroll. - return shouldBolusEntryBecomeFirstResponder && geometry.size.height > 640 - } private var chartSection: some View { Section { @@ -268,7 +261,6 @@ struct BolusEntryView: View { bolusUnitsLabel } } - .accessibilityElement(children: .combine) } private var bolusUnitsLabel: some View { @@ -428,11 +420,6 @@ struct BolusEntryView: View { title: Text("Unable to Save Manual Glucose Entry", comment: "Alert title for a manual glucose entry persistence error"), message: Text("An error occurred while trying to save your manual glucose entry.", comment: "Alert message for a manual glucose entry persistence error") ) - case .glucoseNoLongerStale: - return SwiftUI.Alert( - title: Text("Glucose Data Now Available", comment: "Alert title when glucose data returns while on bolus screen"), - message: Text("An updated bolus recommendation is available.", comment: "Alert message when glucose data returns while on bolus screen") - ) case .forecastInfo: return SwiftUI.Alert( title: Text("Forecasted Glucose", comment: "Title for forecast explanation modal on bolus view"), @@ -480,15 +467,3 @@ struct LabeledQuantity: View { return Text(string) } } - -struct LabelBackground: ViewModifier { - func body(content: Content) -> some View { - content - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - RoundedRectangle(cornerRadius: 5, style: .continuous) - .fill(Color(.systemGray6)) - ) - } -} diff --git a/Loop/Views/BolusProgressTableViewCell.swift b/Loop/Views/BolusProgressTableViewCell.swift index 3752201d7d..3ac0af962b 100644 --- a/Loop/Views/BolusProgressTableViewCell.swift +++ b/Loop/Views/BolusProgressTableViewCell.swift @@ -14,6 +14,17 @@ import MKRingProgressView public class BolusProgressTableViewCell: UITableViewCell { + + public enum Configuration { + case starting + case bolusing(delivered: Double?, ofTotalVolume: Double) + case canceling + case canceled(delivered: Double, ofTotalVolume: Double) + } + + @IBOutlet weak var activityIndicator: UIActivityIndicatorView! + @IBOutlet weak var paddedView: UIView! + @IBOutlet weak var progressIndicator: RingProgressView! @IBOutlet weak var progressLabel: UILabel! @IBOutlet weak var tapToStopLabel: UILabel! { @@ -28,26 +39,12 @@ public class BolusProgressTableViewCell: UITableViewCell { } } - @IBOutlet weak var progressIndicator: RingProgressView! - - public var totalUnits: Double? { - didSet { - updateProgress() - } - } - - public var deliveredUnits: Double? { + public var configuration: Configuration? { didSet { updateProgress() } } - private lazy var gradient = CAGradientLayer() - - private var doseTotalUnits: Double? - - private var disableUpdates: Bool = false - lazy var insulinFormatter: QuantityFormatter = { let formatter = QuantityFormatter(for: .internationalUnit()) formatter.numberFormatter.minimumFractionDigits = 2 @@ -57,17 +54,14 @@ public class BolusProgressTableViewCell: UITableViewCell { override public func awakeFromNib() { super.awakeFromNib() - gradient.frame = bounds - backgroundView?.layer.insertSublayer(gradient, at: 0) + paddedView.layer.masksToBounds = true + paddedView.layer.cornerRadius = 10 + paddedView.layer.borderWidth = 1 + paddedView.layer.borderColor = UIColor.systemGray5.cgColor + updateColors() } - override public func layoutSubviews() { - super.layoutSubviews() - - gradient.frame = bounds - } - public override func tintColorDidChange() { super.tintColorDidChange() updateColors() @@ -83,40 +77,69 @@ public class BolusProgressTableViewCell: UITableViewCell { progressIndicator.startColor = tintColor progressIndicator.endColor = tintColor stopSquare.backgroundColor = tintColor - gradient.colors = [ - UIColor.cellBackgroundColor.withAlphaComponent(0).cgColor, - UIColor.cellBackgroundColor.cgColor - ] } private func updateProgress() { - guard !disableUpdates, let totalUnits = totalUnits else { + guard let configuration else { + progressIndicator.isHidden = true + activityIndicator.isHidden = true + tapToStopLabel.isHidden = true return } - - let totalUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: totalUnits) - let totalUnitsString = insulinFormatter.string(from: totalUnitsQuantity) ?? "" - - if let deliveredUnits = deliveredUnits { - let deliveredUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: deliveredUnits) - let deliveredUnitsString = insulinFormatter.string(from: deliveredUnitsQuantity, includeUnit: false) ?? "" - - progressLabel.text = String(format: NSLocalizedString("Bolused %1$@ of %2$@", comment: "The format string for bolus progress. (1: delivered volume)(2: total volume)"), deliveredUnitsString, totalUnitsString) - - let progress = deliveredUnits / totalUnits - UIView.animate(withDuration: 0.3) { - self.progressIndicator.progress = progress + + switch configuration { + case .starting: + progressIndicator.isHidden = true + activityIndicator.isHidden = false + tapToStopLabel.isHidden = true + + progressLabel.text = NSLocalizedString("Starting Bolus", comment: "The title of the cell indicating a bolus is being sent") + case let .bolusing(delivered, totalVolume): + progressIndicator.isHidden = false + activityIndicator.isHidden = true + tapToStopLabel.isHidden = false + + let totalUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: totalVolume) + let totalUnitsString = insulinFormatter.string(from: totalUnitsQuantity) ?? "" + + if let delivered { + let deliveredUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: delivered) + let deliveredUnitsString = insulinFormatter.string(from: deliveredUnitsQuantity, includeUnit: false) ?? "" + + progressLabel.text = String(format: NSLocalizedString("Bolused %1$@ of %2$@", comment: "The format string for bolus progress. (1: delivered volume)(2: total volume)"), deliveredUnitsString, totalUnitsString) + + let progress = delivered / totalVolume + + UIView.animate(withDuration: 0.3) { + self.progressIndicator.progress = progress + } + } else { + progressLabel.text = String(format: NSLocalizedString("Bolusing %1$@", comment: "The format string for bolus in progress showing total volume. (1: total volume)"), totalUnitsString) } - } else { - progressLabel.text = String(format: NSLocalizedString("Bolusing %1$@", comment: "The format string for bolus in progress showing total volume. (1: total volume)"), totalUnitsString) + case .canceling: + progressIndicator.isHidden = true + activityIndicator.isHidden = false + tapToStopLabel.isHidden = true + + progressLabel.text = NSLocalizedString("Canceling Bolus", comment: "The title of the cell indicating a bolus is being canceled") + case let .canceled(delivered, totalVolume): + progressIndicator.isHidden = true + activityIndicator.isHidden = true + tapToStopLabel.isHidden = true + + let totalUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: totalVolume) + let totalUnitsString = insulinFormatter.string(from: totalUnitsQuantity) ?? "" + + let deliveredUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: delivered) + let deliveredUnitsString = insulinFormatter.string(from: deliveredUnitsQuantity, includeUnit: false) ?? "" + + progressLabel.text = String(format: NSLocalizedString("Bolus Canceled: Delivered %1$@ of %2$@", comment: "The title of the cell indicating a bolus has been canceled. (1: delivered volume)(2: total volume)"), deliveredUnitsString, totalUnitsString) } } override public func prepareForReuse() { super.prepareForReuse() - disableUpdates = true - deliveredUnits = 0 - disableUpdates = false + configuration = nil progressIndicator.progress = 0 CATransaction.flush() progressLabel.text = "" diff --git a/Loop/Views/BolusProgressTableViewCell.xib b/Loop/Views/BolusProgressTableViewCell.xib index 44dc259f2e..9b6aa0e223 100644 --- a/Loop/Views/BolusProgressTableViewCell.xib +++ b/Loop/Views/BolusProgressTableViewCell.xib @@ -1,105 +1,126 @@ - - + + - + + + - - + + - + - - - - - - + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + - + - + - - - - + + + + - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - + + + + - + + - + + + + + + + diff --git a/Loop/Views/CarbEntryView.swift b/Loop/Views/CarbEntryView.swift index 14c6b2c460..c5faffa1b4 100644 --- a/Loop/Views/CarbEntryView.swift +++ b/Loop/Views/CarbEntryView.swift @@ -14,6 +14,7 @@ import HealthKit struct CarbEntryView: View, HorizontalSizeClassOverride { @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference @Environment(\.dismissAction) private var dismiss + @Environment(\.guidanceColors) private var guidanceColors @ObservedObject var viewModel: CarbEntryViewModel @@ -21,6 +22,7 @@ struct CarbEntryView: View, HorizontalSizeClassOverride { @State private var showHowAbsorptionTimeWorks = false @State private var showAddFavoriteFood = false + @State private var showFavoriteFoodInsights = false private let isNewEntry: Bool @@ -47,8 +49,8 @@ struct CarbEntryView: View, HorizontalSizeClassOverride { continueButton } } - } + .navigationViewStyle(.stack) } else { content @@ -77,6 +79,16 @@ struct CarbEntryView: View, HorizontalSizeClassOverride { favoriteFoodsCard } + if viewModel.selectedFavoriteFoodLastEaten != nil, FeatureFlags.allowExperimentalFeatures { + FavoriteFoodInsightsCardView( + showFavoriteFoodInsights: $showFavoriteFoodInsights, + foodName: viewModel.selectedFavoriteFood?.name, + lastEatenDate: viewModel.selectedFavoriteFoodLastEaten, + relativeDateFormatter: viewModel.relativeDateFormatter + ) + .padding(.top, 8) + } + let isBolusViewActive = Binding(get: { viewModel.bolusViewModel != nil }, set: { _, _ in viewModel.bolusViewModel = nil }) NavigationLink(destination: bolusView, isActive: isBolusViewActive) { EmptyView() @@ -88,11 +100,16 @@ struct CarbEntryView: View, HorizontalSizeClassOverride { } .alert(item: $viewModel.alert, content: alert(for:)) .sheet(isPresented: $showAddFavoriteFood, onDismiss: clearExpandedRow) { - AddEditFavoriteFoodView(carbsQuantity: $viewModel.carbsQuantity.wrappedValue, foodType: $viewModel.foodType.wrappedValue, absorptionTime: $viewModel.absorptionTime.wrappedValue, onSave: onFavoriteFoodSave(_:)) + FavoriteFoodAddEditView(carbsQuantity: $viewModel.carbsQuantity.wrappedValue, foodType: $viewModel.foodType.wrappedValue, absorptionTime: $viewModel.absorptionTime.wrappedValue, onSave: onFavoriteFoodSave(_:)) } .sheet(isPresented: $showHowAbsorptionTimeWorks) { HowAbsorptionTimeWorksView() } + .sheet(isPresented: $showFavoriteFoodInsights) { + if let food = viewModel.selectedFavoriteFood { + FavoriteFoodInsightsView(viewModel: FavoriteFoodInsightsViewModel(delegate: viewModel.delegate, food: food)) + } + } } private var mainCard: some View { @@ -101,6 +118,14 @@ struct CarbEntryView: View, HorizontalSizeClassOverride { let timeFocused: Binding = Binding(get: { expandedRow == .time }, set: { expandedRow = $0 ? .time : nil }) let foodTypeFocused: Binding = Binding(get: { expandedRow == .foodType }, set: { expandedRow = $0 ? .foodType : nil }) let absorptionTimeFocused: Binding = Binding(get: { expandedRow == .absorptionTime }, set: { expandedRow = $0 ? .absorptionTime : nil }) + // Food type row shows an x button next to favorite food chip that clears favorite food by setting this binding to nil + let selectedFavoriteFoodBinding = Binding( + get: { viewModel.selectedFavoriteFood }, + set: { food in + guard food == nil else { return } + viewModel.selectedFavoriteFoodIndex = -1 + } + ) CarbQuantityRow(quantity: $viewModel.carbsQuantity, isFocused: amountConsumedFocused, title: NSLocalizedString("Amount Consumed", comment: "Label for carb quantity entry row on carb entry screen"), preferredCarbUnit: viewModel.preferredCarbUnit) @@ -110,7 +135,7 @@ struct CarbEntryView: View, HorizontalSizeClassOverride { CardSectionDivider() - FoodTypeRow(foodType: $viewModel.foodType, absorptionTime: $viewModel.absorptionTime, selectedDefaultAbsorptionTimeEmoji: $viewModel.selectedDefaultAbsorptionTimeEmoji, usesCustomFoodType: $viewModel.usesCustomFoodType, absorptionTimeWasEdited: $viewModel.absorptionTimeWasEdited, isFocused: foodTypeFocused, defaultAbsorptionTimes: viewModel.defaultAbsorptionTimes) + FoodTypeRow(selectedFavoriteFood: selectedFavoriteFoodBinding, foodType: $viewModel.foodType, absorptionTime: $viewModel.absorptionTime, selectedDefaultAbsorptionTimeEmoji: $viewModel.selectedDefaultAbsorptionTimeEmoji, usesCustomFoodType: $viewModel.usesCustomFoodType, absorptionTimeWasEdited: $viewModel.absorptionTimeWasEdited, isFocused: foodTypeFocused, showClearFavoriteFoodButton: !isNewEntry, defaultAbsorptionTimes: viewModel.defaultAbsorptionTimes) CardSectionDivider() @@ -129,6 +154,7 @@ struct CarbEntryView: View, HorizontalSizeClassOverride { BolusEntryView(viewModel: viewModel) .environmentObject(displayGlucosePreference) .environment(\.dismissAction, dismiss) + .environment(\.guidanceColors, guidanceColors) } } @@ -167,6 +193,8 @@ extension CarbEntryView { return .critical case .overrideInProgress: return .warning + case .glucoseRisingRapidly: + return .critical } } @@ -176,6 +204,8 @@ extension CarbEntryView { return NSLocalizedString("Loop has detected an missed meal and estimated its size. Edit the carb amount to match the amount of any carbs you may have eaten.", comment: "Warning displayed when user is adding a meal from an missed meal notification") case .overrideInProgress: return NSLocalizedString("An active override is modifying your carb ratio and insulin sensitivity. If you don't want this to affect your bolus calculation and projected glucose, consider turning off the override.", comment: "Warning to ensure the carb entry is accurate during an override") + case .glucoseRisingRapidly: + return NSLocalizedString("Your glucose is rapidly rising. Check that any carbs you've eaten were logged. If you logged carbs, check that the time you entered lines up with when you started eating.", comment: "Warning to ensure the carb entry is accurate") } } @@ -249,19 +279,32 @@ extension CarbEntryView { } } - CardSectionDivider() + if viewModel.selectedFavoriteFood == nil { + CardSectionDivider() + } } - Button(action: saveAsFavoriteFood) { - Text("Save as favorite food") - .frame(maxWidth: .infinity) + if viewModel.selectedFavoriteFood == nil { + Button(action: saveAsFavoriteFood) { + Text("Save as favorite food") + .frame(maxWidth: .infinity) + } + .disabled(viewModel.saveFavoriteFoodButtonDisabled) } - .disabled(viewModel.saveFavoriteFoodButtonDisabled) } .padding(.vertical, 12) .padding(.horizontal) .background(CardBackground()) .padding(.horizontal) + .onChange(of: viewModel.selectedFavoriteFoodIndex, perform: collapseFavoriteFoodsRowIfNeeded(_:)) + } + } + + private func collapseFavoriteFoodsRowIfNeeded(_ newIndex: Int) { + if newIndex != -1 { + withAnimation { + clearExpandedRow() + } } } diff --git a/Loop/Views/Charts/CarbEffectChartView.swift b/Loop/Views/Charts/CarbEffectChartView.swift new file mode 100644 index 0000000000..8a6f4eda1f --- /dev/null +++ b/Loop/Views/Charts/CarbEffectChartView.swift @@ -0,0 +1,32 @@ +// +// CarbEffectChartView.swift +// Loop +// +// Created by Noah Brauner on 7/25/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import HealthKit +import LoopKit +import LoopKitUI +import LoopAlgorithm + +struct CarbEffectChartView: View { + let chartManager: ChartsManager + var glucoseUnit: HKUnit + var carbAbsorptionReview: CarbAbsorptionReview? + var dateInterval: DateInterval + + @Binding var isInteractingWithChart: Bool + + var body: some View { + LoopChartView(chartManager: chartManager, dateInterval: dateInterval, isInteractingWithChart: $isInteractingWithChart) { carbEffectChart in + carbEffectChart.glucoseUnit = glucoseUnit + if let carbAbsorptionReview { + carbEffectChart.setCarbEffects(carbAbsorptionReview.carbEffects.filterDateRange(dateInterval.start, dateInterval.end)) + carbEffectChart.setInsulinCounteractionEffects(carbAbsorptionReview.effectsVelocities.filterDateRange(dateInterval.start, dateInterval.end)) + } + } + } +} diff --git a/Loop/Views/Charts/DoseChartView.swift b/Loop/Views/Charts/DoseChartView.swift new file mode 100644 index 0000000000..44f4de087e --- /dev/null +++ b/Loop/Views/Charts/DoseChartView.swift @@ -0,0 +1,26 @@ +// +// DoseChartView.swift +// Loop +// +// Created by Noah Brauner on 7/22/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI +import LoopAlgorithm + +struct DoseChartView: View { + let chartManager: ChartsManager + var doses: [BasalRelativeDose] + var dateInterval: DateInterval + + @Binding var isInteractingWithChart: Bool + + var body: some View { + LoopChartView(chartManager: chartManager, dateInterval: dateInterval, isInteractingWithChart: $isInteractingWithChart) { doseChart in + doseChart.doseEntries = doses + } + } +} diff --git a/Loop/Views/Charts/GlucoseCarbChartView.swift b/Loop/Views/Charts/GlucoseCarbChartView.swift new file mode 100644 index 0000000000..7b6ed91b37 --- /dev/null +++ b/Loop/Views/Charts/GlucoseCarbChartView.swift @@ -0,0 +1,33 @@ +// +// GlucoseCarbChartView.swift +// Loop +// +// Created by Noah Brauner on 7/29/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import HealthKit +import LoopKit +import LoopKitUI +import LoopAlgorithm + +struct GlucoseCarbChartView: View { + let chartManager: ChartsManager + var glucoseUnit: HKUnit + var glucoseValues: [GlucoseValue] + var carbEntries: [StoredCarbEntry] + var dateInterval: DateInterval + + @Binding var isInteractingWithChart: Bool + + var body: some View { + LoopChartView(chartManager: chartManager, dateInterval: dateInterval, isInteractingWithChart: $isInteractingWithChart) { glucoseCarbChart in + glucoseCarbChart.glucoseUnit = glucoseUnit + glucoseCarbChart.setGlucoseValues(glucoseValues) + glucoseCarbChart.carbEntries = carbEntries + glucoseCarbChart.carbEntryImage = UIImage(named: "carbs") + glucoseCarbChart.carbEntryFavoriteFoodImage = UIImage(named: "Favorite Foods Icon") + } + } +} diff --git a/Loop/Views/Charts/IOBChartView.swift b/Loop/Views/Charts/IOBChartView.swift new file mode 100644 index 0000000000..e164a42045 --- /dev/null +++ b/Loop/Views/Charts/IOBChartView.swift @@ -0,0 +1,26 @@ +// +// IOBChartView.swift +// Loop +// +// Created by Noah Brauner on 7/22/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI +import LoopAlgorithm + +struct IOBChartView: View { + let chartManager: ChartsManager + var iobValues: [InsulinValue] + var dateInterval: DateInterval + + @Binding var isInteractingWithChart: Bool + + var body: some View { + LoopChartView(chartManager: chartManager, dateInterval: dateInterval, isInteractingWithChart: $isInteractingWithChart) { iobChart in + iobChart.setIOBValues(iobValues) + } + } +} diff --git a/Loop/Views/PredictedGlucoseChartView.swift b/Loop/Views/Charts/LoopChartView.swift similarity index 56% rename from Loop/Views/PredictedGlucoseChartView.swift rename to Loop/Views/Charts/LoopChartView.swift index b7e34a3bdb..be6965c9b5 100644 --- a/Loop/Views/PredictedGlucoseChartView.swift +++ b/Loop/Views/Charts/LoopChartView.swift @@ -1,82 +1,68 @@ // -// PredictedGlucoseChartView.swift +// LoopChartView.swift // Loop // -// Created by Michael Pangburn on 7/22/20. -// Copyright © 2020 LoopKit Authors. All rights reserved. +// Created by Noah Brauner on 7/25/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. // -import HealthKit import SwiftUI -import LoopKit import LoopKitUI -import LoopUI - -struct PredictedGlucoseChartView: UIViewRepresentable { +struct LoopChartView: UIViewRepresentable { let chartManager: ChartsManager - var glucoseUnit: HKUnit - var glucoseValues: [GlucoseValue] - var predictedGlucoseValues: [GlucoseValue] - var targetGlucoseSchedule: GlucoseRangeSchedule? - var preMealOverride: TemporaryScheduleOverride? - var scheduleOverride: TemporaryScheduleOverride? - var dateInterval: DateInterval - + let dateInterval: DateInterval @Binding var isInteractingWithChart: Bool + var configuration = { (view: Chart) in } func makeUIView(context: Context) -> ChartContainerView { + guard let chartIndex = chartManager.charts.firstIndex(where: { $0 is Chart }) else { + fatalError("Expected exactly one matching chart in ChartsManager") + } + let view = ChartContainerView() view.chartGenerator = { [chartManager] frame in - chartManager.chart(atIndex: 0, frame: frame)?.view + chartManager.chart(atIndex: chartIndex, frame: frame)?.view } let gestureRecognizer = UILongPressGestureRecognizer() gestureRecognizer.minimumPressDuration = 0.1 gestureRecognizer.addTarget(context.coordinator, action: #selector(Coordinator.handlePan(_:))) - chartManager.gestureRecognizer = gestureRecognizer view.addGestureRecognizer(gestureRecognizer) return view } func updateUIView(_ chartContainerView: ChartContainerView, context: Context) { - chartManager.invalidateChart(atIndex: 0) + guard let chartIndex = chartManager.charts.firstIndex(where: { $0 is Chart }), + let chart = chartManager.charts[chartIndex] as? Chart else { + fatalError("Expected exactly one matching chart in ChartsManager") + } + + chartManager.invalidateChart(atIndex: chartIndex) chartManager.startDate = dateInterval.start chartManager.maxEndDate = dateInterval.end chartManager.updateEndDate(dateInterval.end) - predictedGlucoseChart.glucoseUnit = glucoseUnit - predictedGlucoseChart.targetGlucoseSchedule = targetGlucoseSchedule - predictedGlucoseChart.preMealOverride = preMealOverride - predictedGlucoseChart.scheduleOverride = scheduleOverride - predictedGlucoseChart.setGlucoseValues(glucoseValues) - predictedGlucoseChart.setPredictedGlucoseValues(predictedGlucoseValues) + configuration(chart) chartManager.prerender() chartContainerView.reloadChart() } - var predictedGlucoseChart: PredictedGlucoseChart { - guard chartManager.charts.count == 1, let predictedGlucoseChart = chartManager.charts.first as? PredictedGlucoseChart else { - fatalError("Expected exactly one predicted glucose chart in ChartsManager") - } - - return predictedGlucoseChart - } - func makeCoordinator() -> Coordinator { Coordinator(self) } final class Coordinator { - var parent: PredictedGlucoseChartView + var parent: LoopChartView - init(_ parent: PredictedGlucoseChartView) { + init(_ parent: LoopChartView) { self.parent = parent } - + @objc func handlePan(_ recognizer: UIPanGestureRecognizer) { switch recognizer.state { case .began: + parent.chartManager.gestureRecognizer = recognizer withAnimation(.easeInOut(duration: 0.2)) { parent.isInteractingWithChart = true } diff --git a/Loop/Views/Charts/PredictedGlucoseChartView.swift b/Loop/Views/Charts/PredictedGlucoseChartView.swift new file mode 100644 index 0000000000..2d6725cbed --- /dev/null +++ b/Loop/Views/Charts/PredictedGlucoseChartView.swift @@ -0,0 +1,37 @@ +// +// PredictedGlucoseChartView.swift +// Loop +// +// Created by Michael Pangburn on 7/22/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import HealthKit +import SwiftUI +import LoopKit +import LoopKitUI +import LoopAlgorithm + +struct PredictedGlucoseChartView: View { + let chartManager: ChartsManager + var glucoseUnit: HKUnit + var glucoseValues: [GlucoseValue] + var predictedGlucoseValues: [GlucoseValue] = [] + var targetGlucoseSchedule: GlucoseRangeSchedule? = nil + var preMealOverride: TemporaryScheduleOverride? = nil + var scheduleOverride: TemporaryScheduleOverride? = nil + var dateInterval: DateInterval + + @Binding var isInteractingWithChart: Bool + + var body: some View { + LoopChartView(chartManager: chartManager, dateInterval: dateInterval, isInteractingWithChart: $isInteractingWithChart) { predictedGlucoseChart in + predictedGlucoseChart.glucoseUnit = glucoseUnit + predictedGlucoseChart.targetGlucoseSchedule = targetGlucoseSchedule + predictedGlucoseChart.preMealOverride = preMealOverride + predictedGlucoseChart.scheduleOverride = scheduleOverride + predictedGlucoseChart.setGlucoseValues(glucoseValues) + predictedGlucoseChart.setPredictedGlucoseValues(predictedGlucoseValues) + } + } +} diff --git a/Loop/Views/AddEditFavoriteFoodView.swift b/Loop/Views/Favorite Foods/FavoriteFoodAddEditView.swift similarity index 92% rename from Loop/Views/AddEditFavoriteFoodView.swift rename to Loop/Views/Favorite Foods/FavoriteFoodAddEditView.swift index b647523a13..69e52b2d46 100644 --- a/Loop/Views/AddEditFavoriteFoodView.swift +++ b/Loop/Views/Favorite Foods/FavoriteFoodAddEditView.swift @@ -10,10 +10,10 @@ import SwiftUI import LoopKit import LoopKitUI -struct AddEditFavoriteFoodView: View { +struct FavoriteFoodAddEditView: View { @Environment(\.dismiss) var dismiss - @StateObject private var viewModel: AddEditFavoriteFoodViewModel + @StateObject private var viewModel: FavoriteFoodAddEditViewModel @State private var expandedRow: Row? @State private var showHowAbsorptionTimeWorks = false @@ -22,13 +22,13 @@ struct AddEditFavoriteFoodView: View { /// Initializer for adding a new favorite food or editing a `StoredFavoriteFood` init(originalFavoriteFood: StoredFavoriteFood? = nil, onSave: @escaping (NewFavoriteFood) -> Void) { - self._viewModel = StateObject(wrappedValue: AddEditFavoriteFoodViewModel(originalFavoriteFood: originalFavoriteFood, onSave: onSave)) + self._viewModel = StateObject(wrappedValue: FavoriteFoodAddEditViewModel(originalFavoriteFood: originalFavoriteFood, onSave: onSave)) self.isNewEntry = originalFavoriteFood == nil } - /// Initializer for presenting the `AddEditFavoriteFoodView` prepopulated from the `CarbEntryView` + /// Initializer for presenting the `FavoriteFoodAddEditView` prepopulated from the `CarbEntryView` init(carbsQuantity: Double?, foodType: String, absorptionTime: TimeInterval, onSave: @escaping (NewFavoriteFood) -> Void) { - self._viewModel = StateObject(wrappedValue: AddEditFavoriteFoodViewModel(carbsQuantity: carbsQuantity, foodType: foodType, absorptionTime: absorptionTime, onSave: onSave)) + self._viewModel = StateObject(wrappedValue: FavoriteFoodAddEditViewModel(carbsQuantity: carbsQuantity, foodType: foodType, absorptionTime: absorptionTime, onSave: onSave)) } var body: some View { @@ -114,7 +114,7 @@ struct AddEditFavoriteFoodView: View { .padding(.horizontal) } - private func alert(for alert: AddEditFavoriteFoodViewModel.Alert) -> SwiftUI.Alert { + private func alert(for alert: FavoriteFoodAddEditViewModel.Alert) -> SwiftUI.Alert { switch alert { case .maxQuantityExceded: let message = String( @@ -142,7 +142,7 @@ struct AddEditFavoriteFoodView: View { } } -extension AddEditFavoriteFoodView { +extension FavoriteFoodAddEditView { private var dismissButton: some View { Button(action: dismiss.callAsFunction) { Text("Cancel") @@ -166,7 +166,7 @@ extension AddEditFavoriteFoodView { } } -extension AddEditFavoriteFoodView { +extension FavoriteFoodAddEditView { enum Row { case name, carbQuantity, foodType, absorptionTime } diff --git a/Loop/Views/Favorite Foods/FavoriteFoodDetailView.swift b/Loop/Views/Favorite Foods/FavoriteFoodDetailView.swift new file mode 100644 index 0000000000..10f7625d68 --- /dev/null +++ b/Loop/Views/Favorite Foods/FavoriteFoodDetailView.swift @@ -0,0 +1,105 @@ +// +// FavoriteFoodDetailView.swift +// Loop +// +// Created by Noah Brauner on 8/2/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI +import HealthKit + +public struct FavoriteFoodDetailView: View { + @ObservedObject var viewModel: FavoriteFoodsViewModel + + @State private var isConfirmingDelete = false + @State private var showFavoriteFoodInsights = false + + public var body: some View { + if let food = viewModel.selectedFood { + Group { + List { + informationSection(for: food) + actionsSection(for: food) + FavoriteFoodInsightsCardView( + showFavoriteFoodInsights: $showFavoriteFoodInsights, + foodName: viewModel.selectedFood?.name, + lastEatenDate: viewModel.selectedFoodLastEaten, + relativeDateFormatter: viewModel.relativeDateFormatter, + presentInSection: true + ) + } + .alert(isPresented: $isConfirmingDelete) { + Alert( + title: Text("Delete “\(food.name)”?"), + message: Text("Are you sure you want to delete this food?"), + primaryButton: .cancel(), + secondaryButton: .destructive(Text("Delete"), action: viewModel.deleteSelectedFood) + ) + } + .insetGroupedListStyle() + .navigationTitle(food.title) + + NavigationLink(destination: FavoriteFoodAddEditView(originalFavoriteFood: viewModel.selectedFood, onSave: viewModel.onFoodSave(_:)), isActive: $viewModel.isEditViewActive) { + EmptyView() + } + + NavigationLink(destination: FavoriteFoodInsightsView(viewModel: FavoriteFoodInsightsViewModel(delegate: viewModel.insightsDelegate, food: food), presentedAsSheet: false), isActive: $showFavoriteFoodInsights) { + EmptyView() + } + } + } + } + + private func informationSection(for food: StoredFavoriteFood) -> some View { + Section("Information") { + VStack(spacing: 16) { + let rows: [(field: String, value: String)] = [ + ("Name", food.name), + ("Carb Quantity", food.carbsString(formatter: viewModel.carbFormatter)), + ("Food Type", food.foodType), + ("Absorption Time", food.absorptionTimeString(formatter: viewModel.absorptionTimeFormatter)) + ] + ForEach(rows, id: \.field) { row in + HStack { + Text(row.field) + .font(.subheadline) + Spacer() + Text(row.value) + .font(.subheadline) + } + } + } + } + .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) + } + + private func actionsSection(for food: StoredFavoriteFood) -> some View { + Section { + Button(action: { viewModel.isEditViewActive.toggle() }) { + HStack { + // Fix the list row inset with centered content from shifting to the center. + // https://stackoverflow.com/questions/75046730/swiftui-list-divider-unwanted-inset-at-the-start-when-non-text-component-is-u + Text("") + .frame(maxWidth: 0) + .accessibilityHidden(true) + + Spacer() + + Text("Edit Food") + .frame(maxWidth: .infinity, alignment: .center) + .foregroundColor(.accentColor) + + Spacer() + } + } + + Button(role: .destructive, action: { isConfirmingDelete.toggle() }) { + Text("Delete Food") + .frame(maxWidth: .infinity, alignment: .center) + } + } + } +} diff --git a/Loop/Views/Favorite Foods/FavoriteFoodInsightsCardView.swift b/Loop/Views/Favorite Foods/FavoriteFoodInsightsCardView.swift new file mode 100644 index 0000000000..ea2ee25f43 --- /dev/null +++ b/Loop/Views/Favorite Foods/FavoriteFoodInsightsCardView.swift @@ -0,0 +1,82 @@ +// +// FavoriteFoodInsightsCardView.swift +// Loop +// +// Created by Noah Brauner on 8/7/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKitUI + +struct FavoriteFoodInsightsCardView: View { + @Binding var showFavoriteFoodInsights: Bool + let foodName: String? + let lastEatenDate: Date? + let relativeDateFormatter: RelativeDateTimeFormatter + var presentInSection: Bool = false + + var body: some View { + if presentInSection { + Section { + content + .overlay(border) + .contentShape(Rectangle()) + .listRowInsets(EdgeInsets()) + .buttonStyle(PlainButtonStyle()) + } + } + else { + content + .background(CardBackground()) + .overlay(border) + .padding(.horizontal) + .contentShape(Rectangle()) + } + } + + private var border: some View { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(Color.accentColor, lineWidth: 2) + } + + private var content: some View { + Button(action: { + showFavoriteFoodInsights = true + }) { + VStack(spacing: 10) { + HStack(spacing: 4) { + Image(systemName: "sparkles") + + Text("Favorite Food Insights") + } + .font(.headline) + .foregroundColor(.accentColor) + .frame(maxWidth: .infinity, alignment: .leading) + + if let foodName, let lastEatenDate { + let relativeTime = relativeDateFormatter.localizedString(for: lastEatenDate, relativeTo: Date()) + let attributedFoodDescription = attributedFoodInsightsDescription(for: foodName, timeAgo: relativeTime) + + Text(attributedFoodDescription) + .foregroundColor(.primary) + .multilineTextAlignment(.center) + } + } + .padding(.vertical, 12) + .padding(.horizontal) + } + } + + private func attributedFoodInsightsDescription(for food: String, timeAgo: String) -> AttributedString { + var attributedString = AttributedString("You last ate ") + + var foodString = AttributedString(food) + foodString.inlinePresentationIntent = .stronglyEmphasized + + attributedString.append(foodString) + attributedString.append(AttributedString(" \(timeAgo)\n Tap to see more")) + + return attributedString + } +} diff --git a/Loop/Views/Favorite Foods/FavoriteFoodInsightsChartsView.swift b/Loop/Views/Favorite Foods/FavoriteFoodInsightsChartsView.swift new file mode 100644 index 0000000000..71205485f7 --- /dev/null +++ b/Loop/Views/Favorite Foods/FavoriteFoodInsightsChartsView.swift @@ -0,0 +1,139 @@ +// +// FavoriteFoodInsightsChartsView.swift +// Loop +// +// Created by Noah Brauner on 7/30/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI +import LoopAlgorithm +import HealthKit +import Combine + +struct FavoriteFoodsInsightsChartsView: View { + private enum ChartRow: Int, CaseIterable { + case glucose + case iob + case dose + case carbEffects + + var title: String { + switch self { + case .glucose: "Glucose" + case .iob: "Active Insulin" + case .dose: "Insulin Delivery" + case .carbEffects: "Glucose Change" + } + } + } + + @ObservedObject var viewModel: FavoriteFoodInsightsViewModel + @Binding var showHowCarbEffectsWorks: Bool + + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + + @State private var isInteractingWithChart = false + + var body: some View { + VStack(spacing: 10) { + let charts = ChartRow.allCases + ForEach(charts, id: \.rawValue) { chart in + ZStack(alignment: .topLeading) { + HStack { + Text(chart.title) + .font(.subheadline) + .bold() + + if chart == .carbEffects { + explainCarbEffectsButton + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .opacity(isInteractingWithChart ? 0 : 1) + + Group { + switch chart { + case .glucose: + glucoseChart + case .iob: + iobChart + case .dose: + doseChart + case .carbEffects: + carbEffectsChart + } + } + } + } + } + } + + private var glucoseChart: some View { + GlucoseCarbChartView( + chartManager: viewModel.chartManager, + glucoseUnit: displayGlucosePreference.unit, + glucoseValues: viewModel.historicalGlucoseValues, + carbEntries: viewModel.historicalCarbEntries, + dateInterval: viewModel.dateInterval, + isInteractingWithChart: $isInteractingWithChart + ) + .modifier(ChartModifier(horizontalPadding: 4, fractionOfScreenHeight: 1/4)) + } + + private var iobChart: some View { + IOBChartView( + chartManager: viewModel.chartManager, + iobValues: viewModel.historicalIOBValues, + dateInterval: viewModel.dateInterval, + isInteractingWithChart: $isInteractingWithChart + ) + .modifier(ChartModifier()) + } + + private var doseChart: some View { + DoseChartView( + chartManager: viewModel.chartManager, + doses: viewModel.historicalDoses, + dateInterval: viewModel.dateInterval, + isInteractingWithChart: $isInteractingWithChart + ) + .modifier(ChartModifier()) + } + + private var carbEffectsChart: some View { + CarbEffectChartView( + chartManager: viewModel.chartManager, + glucoseUnit: displayGlucosePreference.unit, + carbAbsorptionReview: viewModel.historicalCarbAbsorptionReview, + dateInterval: viewModel.dateInterval, + isInteractingWithChart: $isInteractingWithChart + ) + .modifier(ChartModifier()) + } + + private var explainCarbEffectsButton: some View { + Button(action: { showHowCarbEffectsWorks = true }) { + Image(systemName: "info.circle") + .font(.body) + .foregroundColor(.accentColor) + } + .buttonStyle(BorderlessButtonStyle()) + } +} + +fileprivate struct ChartModifier: ViewModifier { + var horizontalPadding: CGFloat = 8 + var fractionOfScreenHeight: CGFloat = 1/6 + + func body(content: Content) -> some View { + content + .padding(.horizontal, -4) + .padding(.top, UIFont.preferredFont(forTextStyle: .subheadline).lineHeight + 8) + .clipped() + .frame(height: floor(max(UIScreen.main.bounds.height, UIScreen.main.bounds.width) * fractionOfScreenHeight)) + } +} + diff --git a/Loop/Views/Favorite Foods/FavoriteFoodInsightsView.swift b/Loop/Views/Favorite Foods/FavoriteFoodInsightsView.swift new file mode 100644 index 0000000000..582a661f3a --- /dev/null +++ b/Loop/Views/Favorite Foods/FavoriteFoodInsightsView.swift @@ -0,0 +1,156 @@ +// +// FavoriteFoodInsightsView.swift +// Loop +// +// Created by Noah Brauner on 7/15/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI +import LoopAlgorithm + +struct FavoriteFoodInsightsView: View { + @StateObject private var viewModel: FavoriteFoodInsightsViewModel + + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + @Environment(\.dismiss) private var dismiss + + @State private var isInteractingWithChart = false + + @State private var showHowCarbEffectsWorks = false + + let presentedAsSheet: Bool + + init(viewModel: FavoriteFoodInsightsViewModel, presentedAsSheet: Bool = true) { + self._viewModel = StateObject(wrappedValue: viewModel) + self.presentedAsSheet = presentedAsSheet + } + + var body: some View { + if presentedAsSheet { + NavigationView { + content + .toolbar { + dismissButton + } + } + } + else { + content + .insetGroupedListStyle() + } + } + + private var content: some View { + List { + historicalCarbEntriesSection + historicalDataReviewSection + } + .padding(.top, -28) + .navigationTitle("Favorite Food Insights") + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showHowCarbEffectsWorks) { + HowCarbEffectsWorksView() + } + } + + private var historicalCarbEntriesSection: some View { + Section { + if let carbEntry = viewModel.carbEntry { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 10) { + Spacer() + + let isAtStart = viewModel.carbEntryIndex == 0 + Button(action: { + guard !isAtStart else { return } + viewModel.carbEntryIndex -= 1 + }) { + Image(systemName: "chevron.left") + .font(.title3.bold()) + } + .disabled(isAtStart) + .opacity(isAtStart ? 0.4 : 1) + .buttonStyle(BorderlessButtonStyle()) + .contentShape(Rectangle()) + + Text("Viewing entry \(viewModel.carbEntryIndex + 1) of \(viewModel.carbEntries.count)") + .font(.headline) + + let isAtEnd = viewModel.carbEntryIndex >= viewModel.carbEntries.count - 1 + Button(action: { + guard !isAtEnd else { return } + viewModel.carbEntryIndex += 1 + }) { + Image(systemName: "chevron.right") + .font(.title3.bold()) + } + .disabled(isAtEnd) + .opacity(isAtEnd ? 0.4 : 1) + .buttonStyle(BorderlessButtonStyle()) + .contentShape(Rectangle()) + + Spacer() + } + + if let formattedCarbQuantity = viewModel.carbFormatter.string(from: carbEntry.quantity), let absorptionTime = carbEntry.absorptionTime, let formattedAbsorptionTime = viewModel.absorptionTimeFormatter.string(from: absorptionTime) { + let formattedRelativeDate = viewModel.relativeDateFormatter.localizedString(for: carbEntry.startDate, relativeTo: viewModel.now) + let formattedDate = viewModel.dateFormater.string(from: carbEntry.startDate) + + let rows: [(field: String, value: String)] = [ + ("Food", viewModel.food.title), + ("Carb Quantity", formattedCarbQuantity), + ("Date", "\(formattedDate) - \(formattedRelativeDate)"), + ("Absorption Time", "\(formattedAbsorptionTime)") + ] + + ForEach(rows, id: \.field) { row in + HStack(alignment: .top) { + Text(row.field) + .font(.subheadline) + Spacer() + Text(row.value) + .font(.subheadline) + .multilineTextAlignment(.trailing) + } + } + } + } + .padding(.vertical, 8) + } + } + } + + private var historicalDataReviewSection: some View { + Section(header: historicalDataReviewHeader) { + FavoriteFoodsInsightsChartsView(viewModel: viewModel, showHowCarbEffectsWorks: $showHowCarbEffectsWorks) + } + } + + private var historicalDataReviewHeader: some View { + HStack(alignment: .top) { + VStack(alignment: .leading) { + Text("Historical Data") + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.primary) + + Text(viewModel.dateIntervalFormatter.string(from: viewModel.startDate, to: viewModel.endDate)) + } + + Spacer() + } + .textCase(nil) + .listRowInsets(EdgeInsets(top: 20, leading: 4, bottom: 10, trailing: 4)) + } + + private var dismissButton: some View { + Button(action: { + dismiss() + }) { + Text("Done") + } + } +} diff --git a/Loop/Views/FavoriteFoodsView.swift b/Loop/Views/Favorite Foods/FavoriteFoodsView.swift similarity index 86% rename from Loop/Views/FavoriteFoodsView.swift rename to Loop/Views/Favorite Foods/FavoriteFoodsView.swift index c2bb941c26..8ded4d57db 100644 --- a/Loop/Views/FavoriteFoodsView.swift +++ b/Loop/Views/Favorite Foods/FavoriteFoodsView.swift @@ -13,7 +13,11 @@ import LoopKitUI struct FavoriteFoodsView: View { @Environment(\.dismissAction) private var dismiss - @StateObject private var viewModel = FavoriteFoodsViewModel() + @StateObject private var viewModel: FavoriteFoodsViewModel + + init(insightsDelegate: FavoriteFoodInsightsViewModelDelegate? = nil) { + self._viewModel = StateObject(wrappedValue: FavoriteFoodsViewModel(insightsDelegate: insightsDelegate)) + } @State private var foodToConfirmDeleteId: String? = nil @State private var editMode: EditMode = .inactive @@ -47,12 +51,12 @@ struct FavoriteFoodsView: View { } .insetGroupedListStyle() - - NavigationLink(destination: AddEditFavoriteFoodView(originalFavoriteFood: viewModel.selectedFood, onSave: viewModel.onFoodSave(_:)), isActive: $viewModel.isEditViewActive) { + let editViewIsActive = Binding(get: { viewModel.isEditViewActive && !viewModel.isDetailViewActive }, set: { viewModel.isEditViewActive = $0 }) + NavigationLink(destination: FavoriteFoodAddEditView(originalFavoriteFood: viewModel.selectedFood, onSave: viewModel.onFoodSave(_:)), isActive: editViewIsActive) { EmptyView() } - NavigationLink(destination: FavoriteFoodDetailView(food: viewModel.selectedFood, onFoodDelete: viewModel.onFoodDelete(_:), carbFormatter: viewModel.carbFormatter, absorptionTimeFormatter: viewModel.absorptionTimeFormatter, preferredCarbUnit: viewModel.preferredCarbUnit), isActive: $viewModel.isDetailViewActive) { + NavigationLink(destination: FavoriteFoodDetailView(viewModel: viewModel), isActive: $viewModel.isDetailViewActive) { EmptyView() } } @@ -64,7 +68,7 @@ struct FavoriteFoodsView: View { .navigationBarTitle("Favorite Foods", displayMode: .large) } .sheet(isPresented: $viewModel.isAddViewActive) { - AddEditFavoriteFoodView(onSave: viewModel.onFoodSave(_:)) + FavoriteFoodAddEditView(onSave: viewModel.onFoodSave(_:)) } .onChange(of: editMode) { newValue in if !newValue.isEditing { diff --git a/Loop/Views/FavoriteFoodDetailView.swift b/Loop/Views/FavoriteFoodDetailView.swift deleted file mode 100644 index 44c7a83150..0000000000 --- a/Loop/Views/FavoriteFoodDetailView.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// FavoriteFoodDetailView.swift -// Loop -// -// Created by Noah Brauner on 8/2/23. -// Copyright © 2023 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import LoopKit -import HealthKit - -public struct FavoriteFoodDetailView: View { - let food: StoredFavoriteFood? - let onFoodDelete: (StoredFavoriteFood) -> Void - - @State private var isConfirmingDelete = false - - let carbFormatter: QuantityFormatter - let absorptionTimeFormatter: DateComponentsFormatter - let preferredCarbUnit: HKUnit - - public init(food: StoredFavoriteFood?, onFoodDelete: @escaping (StoredFavoriteFood) -> Void, isConfirmingDelete: Bool = false, carbFormatter: QuantityFormatter, absorptionTimeFormatter: DateComponentsFormatter, preferredCarbUnit: HKUnit = HKUnit.gram()) { - self.food = food - self.onFoodDelete = onFoodDelete - self.isConfirmingDelete = isConfirmingDelete - self.carbFormatter = carbFormatter - self.absorptionTimeFormatter = absorptionTimeFormatter - self.preferredCarbUnit = preferredCarbUnit - } - - public var body: some View { - if let food { - List { - Section("Information") { - VStack(spacing: 16) { - let rows: [(field: String, value: String)] = [ - ("Name", food.name), - ("Carb Quantity", food.carbsString(formatter: carbFormatter)), - ("Food Type", food.foodType), - ("Absorption Time", food.absorptionTimeString(formatter: absorptionTimeFormatter)) - ] - ForEach(rows, id: \.field) { row in - HStack { - Text(row.field) - .font(.subheadline) - Spacer() - Text(row.value) - .font(.subheadline) - } - } - } - } - .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) - - Button(role: .destructive, action: { isConfirmingDelete.toggle() }) { - Text("Delete Food") - .frame(maxWidth: .infinity, alignment: .center) // Align text in center - } - } - .alert(isPresented: $isConfirmingDelete) { - Alert( - title: Text("Delete “\(food.name)”?"), - message: Text("Are you sure you want to delete this food?"), - primaryButton: .cancel(), - secondaryButton: .destructive(Text("Delete"), action: { onFoodDelete(food) }) - ) - } - .insetGroupedListStyle() - .navigationTitle(food.title) - } - } -} diff --git a/Loop/Views/HowCarbEffectsWorksView.swift b/Loop/Views/HowCarbEffectsWorksView.swift new file mode 100644 index 0000000000..1af9e6c2e9 --- /dev/null +++ b/Loop/Views/HowCarbEffectsWorksView.swift @@ -0,0 +1,33 @@ +// +// HowCarbEffectsWorksView.swift +// Loop +// +// Created by Noah Brauner on 7/25/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct HowCarbEffectsWorksView: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationView { + List { + Section { + Text("Observed changes in glucose, subtracting changes modeled from insulin delivery, can be used to estimate carbohydrate absorption.", comment: "Section explaining carb effects chart") + } + } + .navigationTitle("Glucose Change Chart") + .toolbar { + dismissButton + } + } + } + + private var dismissButton: some View { + Button(action: dismiss.callAsFunction) { + Text("Close") + } + } +} diff --git a/Loop/Views/HowMuteAlertWorkView.swift b/Loop/Views/HowMuteAlertWorkView.swift index 08443a6b80..30f72d574a 100644 --- a/Loop/Views/HowMuteAlertWorkView.swift +++ b/Loop/Views/HowMuteAlertWorkView.swift @@ -10,124 +10,154 @@ import SwiftUI import LoopKitUI struct HowMuteAlertWorkView: View { - @Environment(\.dismissAction) private var dismiss @Environment(\.guidanceColors) private var guidanceColors @Environment(\.appName) private var appName var body: some View { - NavigationView { - List { - VStack(alignment: .leading, spacing: 24) { - VStack(alignment: .leading, spacing: 8) { - Text("What are examples of Critical and Time Sensitive alerts?") - .bold() - - Text("iOS Critical Alerts and Time Sensitive Alerts are types of Apple notifications. They are used for high-priority events. Some examples include:") - } + List { + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + Text("What are examples of Critical Alerts and Time Sensitive Notifications?") + .bold() - HStack { - VStack(alignment: .leading, spacing: 16) { - VStack(alignment: .leading, spacing: 4) { - Text("Critical Alerts") - .bold() - - Text("Urgent Low") - .bulleted() - Text("Sensor Failed") - .bulleted() - Text("Reservoir Empty") - .bulleted() - Text("Pump Expired") - .bulleted() - } + Text("Critical Alerts and Time Sensitive Notifications are important types of iOS notifications used for events that require immediate attention. Examples include:") + } + + HStack { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("Critical Alerts") + .bold() - VStack(alignment: .leading, spacing: 4) { - Text("Time Sensitive Alerts") - .bold() - - Text("High Glucose") - .bulleted() - Text("Transmitter Low Battery") - .bulleted() - } + Text("Urgent Low") + .bulleted() + Text("Sensor Failed") + .bulleted() + Text("Reservoir Empty") + .bulleted() + Text("Pump Expired") + .bulleted() } - Spacer() + VStack(alignment: .leading, spacing: 4) { + Text("Time Sensitive Notifications") + .bold() + + Text("High Glucose") + .bulleted() + Text("Transmitter Low Battery") + .bulleted() + } } - .font(.footnote) - .foregroundColor(.black.opacity(0.6)) - .padding() - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(Color(.systemFill), lineWidth: 1) + + Spacer() + } + .font(.subheadline) + .padding() + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color(.systemFill), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + + VStack(alignment: .leading, spacing: 8) { + Text( + String( + format: NSLocalizedString( + "How can I temporarily silence all %1$@ app sounds?", + comment: "Title text for temporarily silencing all sounds (1: app name)" + ), + appName + ) ) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .bold() - VStack(alignment: .leading, spacing: 8) { - Text( - String( - format: NSLocalizedString( - "How can I temporarily silence all %1$@ app sounds?", - comment: "Title text for temporarily silencing all sounds (1: app name)" - ), - appName - ) + Text( + String( + format: NSLocalizedString( + "Use the Mute All App Sounds feature. It allows you to temporarily silence (up to 4 hours) all of the sounds from %1$@, including Critical Alerts and Time Sensitive Notifications.", + comment: "Description text for temporarily silencing all sounds (1: app name)" + ), + appName ) + ) + } + + VStack(alignment: .leading, spacing: 8) { + Text("How can I silence non-Critical Alerts?") .bold() - - Text( - String( - format: NSLocalizedString( - "Use the Mute Alerts feature. It allows you to temporarily silence all of your alerts and alarms via the %1$@ app, including Critical Alerts and Time Sensitive Alerts.", - comment: "Description text for temporarily silencing all sounds (1: app name)" - ), - appName - ) + + Text( + NSLocalizedString( + "To turn Silent mode on, flip the Ring/Silent switch toward the back of your iPhone.", + comment: "Description text for temporarily silencing non-critical alerts" ) - } + ) - VStack(alignment: .leading, spacing: 8) { - Text("How can I silence non-Critical Alerts?") - .bold() - - Text( - String( - format: NSLocalizedString( - "Turn off the volume on your iOS device or add %1$@ as an allowed app to each Focus Mode. Time Sensitive and Critical Alerts will still sound, but non-Critical Alerts will be silenced.", - comment: "Description text for temporarily silencing non-critical alerts (1: app name)" - ), - appName - ) + Text( + NSLocalizedString( + "Critical Alerts will still sound, but all others will be silenced.", + comment: "Additional description text for temporarily silencing non-critical alerts" ) - } + ) + .italic() + } + + Callout( + .warning, + title: Text( + String( + format: NSLocalizedString( + "Keep All Notifications ON for %1$@", + comment: "Time sensitive notifications callout title (1: app name)" + ), + appName + ) + ), + message: Text( + NSLocalizedString( + "Make sure to keep Notifications, Time Sensitive Notifications, and Critical Alerts turned ON in iOS Settings to receive essential safety and maintenance notifications.", + comment: "Time sensitive notifications callout message" + ) + ) + ) + .padding(.horizontal, -20) + + VStack(alignment: .leading, spacing: 8) { + Text( + String( + format: NSLocalizedString( + "Can I use Focus modes with %1$@?", + comment: "Focus modes section title (1: app name)" + ), + appName + ) + ) + .bold() - VStack(alignment: .leading, spacing: 8) { - Text("How can I silence only Time Sensitive and Non-Critical alerts?") - .bold() - - Text( - String( - format: NSLocalizedString( - "For safety purposes, you should allow Critical Alerts, Time Sensitive and Notification Permissions (non-critical alerts) on your device to continue using %1$@ and cannot turn off individual alarms.", - comment: "Description text for silencing time sensitive and non-critical alerts (1: app name)" - ), - appName - ) + Text( + String( + format: NSLocalizedString( + "iOS Focus Modes enable you to have more control over when apps can send you notifications. If you decide to use these, ensure that notifications are allowed and NOT silenced from %1$@.", + comment: "Description text for focus modes (1: app name)" + ), + appName ) - } + ) } - .padding(.vertical, 8) } - .insetGroupedListStyle() - .navigationTitle(NSLocalizedString("Managing Alerts", comment: "View title for how mute alerts work")) - .navigationBarItems(trailing: closeButton) - } - } + + Section(header: SectionHeader(label: NSLocalizedString("Learn More", comment: "Learn more section header")).padding(.leading, -16).padding(.bottom, 4)) { + NavigationLink { + IOSFocusModesView() + } label: { + Text("iOS Focus Modes", comment: "iOS focus modes navigation link label") + } - private var closeButton: some View { - Button(action: dismiss) { - Text(NSLocalizedString("Close", comment: "Button title to close view")) + } } + .insetGroupedListStyle() + .navigationTitle(NSLocalizedString("FAQ about Alerts", comment: "View title for how mute alerts work")) } } diff --git a/Loop/Views/IOSFocusModesView.swift b/Loop/Views/IOSFocusModesView.swift new file mode 100644 index 0000000000..4188c19cb5 --- /dev/null +++ b/Loop/Views/IOSFocusModesView.swift @@ -0,0 +1,96 @@ +// +// IOSFocusModesView.swift +// Loop +// +// Created by Cameron Ingham on 6/11/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopKitUI +import SwiftUI + +struct IOSFocusModesView: View { + @Environment(\.guidanceColors) private var guidanceColors + @Environment(\.appName) private var appName + + var bullets: [String] { + [ + NSLocalizedString("Go to Settings > Focus.", comment: "Focus modes step 1"), + NSLocalizedString("Tap a provided Focus option — like Do Not Disturb, Personal, or Sleep.", comment: "Focus modes step 2"), + NSLocalizedString("Tap “Apps”.", comment: "Focus modes step 3"), + String(format: NSLocalizedString("Ensure that notifications are allowed and NOT silenced from %1$@.", comment: "Focus modes step 4 (1: appName)"), appName) + ] + } + + var body: some View { + List { + VStack(alignment: .leading, spacing: 24) { + Text( + String( + format: NSLocalizedString( + "iOS has added features such as ‘Focus Mode’ that enable you to have more control over when apps can send you notifications.\n\nIf you wish to continue receiving important notifications from %1$@ while in a Focus Mode, you must ensure that notifications are allowed and NOT silenced from %1$@ for each Focus Mode.", + comment: "Description text for iOS Focus Modes (1: app name) (2: app name)" + ), + appName, + appName + ) + ) + + ForEach(Array(zip(bullets.indices, bullets)), id: \.0) { index, bullet in + HStack(spacing: 10) { + NumberCircle(index + 1) + + Text(bullet) + } + } + + // MARK: To be removed before next DIY Sync + if appName.contains("Tidepool") { + VStack(alignment: .leading, spacing: 8) { + Image("focus-mode-1") + + Text( + String( + format: NSLocalizedString( + "Example: Allow Notifications from %1$@", + comment: "Focus mode image 1 caption (1: appName)" + ), + appName + ) + ) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .fixedSize(horizontal: false, vertical: true) + + VStack(alignment: .leading, spacing: 8) { + Image("focus-mode-2") + + Text( + NSLocalizedString( + "Example: Silence Notifications from other apps", + comment: "Focus mode image 2 caption" + ) + ) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + Callout( + .caution, + title: Text( + NSLocalizedString( + "You’ll need to ensure these settings for each Focus Mode you have enabled or plan to enable.", + comment: "iOS focus modes callout title" + ) + ) + ) + .padding(.horizontal, -16) + .padding(.bottom, -16) + } + } + .insetGroupedListStyle() + .navigationTitle(NSLocalizedString("iOS Focus Modes", comment: "View title for iOS focus modes")) + } +} diff --git a/Loop/Views/ManualEntryDoseView.swift b/Loop/Views/ManualEntryDoseView.swift index e81dccdabb..ea70d235dc 100644 --- a/Loop/Views/ManualEntryDoseView.swift +++ b/Loop/Views/ManualEntryDoseView.swift @@ -197,7 +197,8 @@ struct ManualEntryDoseView: View { textAlignment: .right, keyboardType: .decimalPad, shouldBecomeFirstResponder: shouldBolusEntryBecomeFirstResponder, - maxLength: 5 + maxLength: 5, + doneButtonColor: .loopAccent ) bolusUnitsLabel } @@ -239,7 +240,13 @@ struct ManualEntryDoseView: View { private var actionButton: some View { Button( action: { - self.viewModel.saveManualDose(onSuccess: self.dismiss) + Task { + do { + try await self.viewModel.saveManualDose() + self.dismiss() + } catch { + } + } }, label: { return Text("Log Dose", comment: "Button text to log a dose") diff --git a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift index b9e1552036..e73195634a 100644 --- a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift +++ b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift @@ -68,7 +68,7 @@ public struct NotificationsCriticalAlertPermissionsView: View { notificationAndCriticalAlertPermissionSupportSection } .insetGroupedListStyle() - .navigationBarTitle(Text(NSLocalizedString("Alert Permissions", comment: "Notification & Critical Alert Permissions screen title"))) + .navigationBarTitle(Text(NSLocalizedString("iOS Permissions", comment: "Notification & Critical Alert Permissions screen title"))) } } @@ -89,7 +89,7 @@ extension NotificationsCriticalAlertPermissionsView { private var manageNotifications: some View { Button( action: { AlertPermissionsChecker.gotoSettings() } ) { HStack { - Text(NSLocalizedString("Manage Permissions in Settings", comment: "Manage Permissions in Settings button text")) + Text(NSLocalizedString("Manage iOS Permissions", comment: "Manage Permissions in Settings button text")) Spacer() Image(systemName: "chevron.right").foregroundColor(.gray).font(.footnote) } @@ -97,19 +97,29 @@ extension NotificationsCriticalAlertPermissionsView { .accentColor(.primary) } + private var notificationsStatusIdentifier: String { + !checker.notificationCenterSettings.notificationsDisabled ? "settingsViewAlertManagementAlertPermissionsNotificationsEnabled" : "settingsViewAlertManagementAlertPermissionsNotificationsDisabled" + } + private var notificationsEnabledStatus: some View { HStack { Text("Notifications", comment: "Notifications Status text") Spacer() onOff(!checker.notificationCenterSettings.notificationsDisabled) + .accessibilityIdentifier(notificationsStatusIdentifier) } } + + private var criticalAlertsStatusIdentifier: String { + !checker.notificationCenterSettings.criticalAlertsDisabled ? "settingsViewAlertManagementAlertPermissionsCriticalAlertsEnabled" : "settingsViewAlertManagementAlertPermissionsCriticalAlertsDisabled" + } private var criticalAlertsStatus: some View { HStack { Text("Critical Alerts", comment: "Critical Alerts Status text") Spacer() onOff(!checker.notificationCenterSettings.criticalAlertsDisabled) + .accessibilityIdentifier(criticalAlertsStatusIdentifier) } } @@ -118,7 +128,7 @@ extension NotificationsCriticalAlertPermissionsView { HStack { Text("Time Sensitive Notifications", comment: "Time Sensitive Status text") Spacer() - onOff(!checker.notificationCenterSettings.timeSensitiveNotificationsDisabled) + onOff(!checker.notificationCenterSettings.timeSensitiveDisabled) } } @@ -137,9 +147,18 @@ extension NotificationsCriticalAlertPermissionsView { } private var notificationAndCriticalAlertPermissionSupportSection: some View { - Section(header: SectionHeader(label: NSLocalizedString("Support", comment: "Section title for Support"))) { - NavigationLink(destination: Text("Get help with Alert Permissions")) { - Text(NSLocalizedString("Get help with Alert Permissions", comment: "Get help with Alert Permissions support button text")) + Section(header: SectionHeader(label: NSLocalizedString("Support", comment: "Section title for Support")).padding(.leading, -16).padding(.bottom, 4)) { + // MARK: TO be reverted to NavigationLink once we have a page to link to + HStack { + Text(NSLocalizedString("Get help with iOS Permissions", comment: "Get help with iOS Permissions support button text")) + + Spacer() + + Image(systemName: "chevron.forward") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 12) + .foregroundStyle(.secondary) } } } diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index c3ec98b8dd..a8fb09a3f8 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -11,6 +11,8 @@ import LoopKitUI import MockKit import SwiftUI import HealthKit +import LoopUI + public struct SettingsView: View { @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference @@ -20,6 +22,7 @@ public struct SettingsView: View { @Environment(\.carbTintColor) private var carbTintColor @Environment(\.glucoseTintColor) private var glucoseTintColor @Environment(\.insulinTintColor) private var insulinTintColor + @Environment(\.isInvestigationalDevice) private var isInvestigationalDevice @ObservedObject var viewModel: SettingsViewModel @ObservedObject var versionUpdateViewModel: VersionUpdateViewModel @@ -79,7 +82,7 @@ public struct SettingsView: View { } alertManagementSection if viewModel.pumpManagerSettingsViewModel.isSetUp() { - configurationSection + therapySection } deviceSettingsSection if FeatureFlags.allowExperimentalFeatures { @@ -156,7 +159,7 @@ public struct SettingsView: View { .environment(\.guidanceColors, self.guidanceColors) .environment(\.insulinTintColor, self.insulinTintColor) case .favoriteFoods: - FavoriteFoodsView() + FavoriteFoodsView(insightsDelegate: viewModel.favoriteFoodInsightsDelegate) } } } @@ -183,7 +186,7 @@ public struct SettingsView: View { private var closedLoopToggleState: Binding { Binding( - get: { self.viewModel.isClosedLoopAllowed && self.viewModel.closedLoopPreference }, + get: { self.viewModel.automaticDosingStatus.isAutomaticDosingAllowed && self.viewModel.closedLoopPreference }, set: { self.viewModel.closedLoopPreference = $0 } ) } @@ -216,20 +219,54 @@ extension SettingsView { } private var loopSection: some View { - Section(header: SectionHeader(label: localizedAppNameAndVersion)) { - Toggle(isOn: closedLoopToggleState) { - VStack(alignment: .leading) { - Text("Closed Loop", comment: "The title text for the looping enabled switch cell") - .padding(.vertical, 3) - if !viewModel.isOnboardingComplete { - DescriptiveText(label: NSLocalizedString("Closed Loop requires Setup to be Complete", comment: "The description text for the looping enabled switch cell when onboarding is not complete")) - } else if let closedLoopDescriptiveText = viewModel.closedLoopDescriptiveText { - DescriptiveText(label: closedLoopDescriptiveText) + Section( + header: + VStack(alignment: .leading, spacing: 8) { + SectionHeader(label: localizedAppNameAndVersion.description) + + if isInvestigationalDevice { + Group { + Text(Image(systemName: "exclamationmark.triangle.fill")) + .foregroundColor(guidanceColors.warning) + + Text(" ") + + Text("CAUTION - Investigational device. Limited by Federal (or United States) law to investigational use.") + } + .font(.footnote) + .textCase(nil) + .foregroundColor(.primary) + .padding(.bottom, 6) + } + } + ) { + ConfirmationToggle( + isOn: closedLoopToggleState, + confirmOn: false, + alertTitle: NSLocalizedString("Are you sure you want to turn automation OFF?", comment: "Closed loop alert title"), + alertBody: NSLocalizedString("Your pump and CGM will continue operating but the app will not make automatic adjustments. You will receive your scheduled basal rate(s).", comment: "Closed loop alert message"), + confirmAction: .init(label: { Text("Yes, turn OFF") }) + ) { + HStack(spacing: 12) { + LoopCircleView( + closedLoop: viewModel.automaticDosingStatus.automaticDosingEnabled, + freshness: viewModel.loopStatusCircleFreshness + ) + .frame(width: 36, height: 36) + .padding(12) + + VStack(alignment: .leading) { + Text("Closed Loop", comment: "The title text for the looping enabled switch cell") + DescriptiveText(label: NSLocalizedString("Insulin Automation", comment: "Closed loop settings button descriptive text")) + if !viewModel.isOnboardingComplete { + DescriptiveText(label: NSLocalizedString("Closed Loop requires Setup to be Complete", comment: "The description text for the looping enabled switch cell when onboarding is not complete")) + } else if let closedLoopDescriptiveText = viewModel.closedLoopDescriptiveText { + DescriptiveText(label: closedLoopDescriptiveText) + } } } - .fixedSize(horizontal: false, vertical: true) } - .disabled(!viewModel.isOnboardingComplete || !viewModel.isClosedLoopAllowed) + .accessibilityIdentifier("settingsViewClosedLoopToggle") + .disabled(!viewModel.isOnboardingComplete || !viewModel.automaticDosingStatus.isAutomaticDosingAllowed) + .padding(.vertical) } } @@ -260,12 +297,13 @@ extension SettingsView { if viewModel.alertPermissionsChecker.showWarning || viewModel.alertPermissionsChecker.notificationCenterSettings.scheduledDeliveryEnabled { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.critical) + .accessibilityIdentifier("settingsViewAlertManagementAlertWarning") } else if viewModel.alertMuter.configuration.shouldMute { Image(systemName: "speaker.slash.fill") - .foregroundColor(.white) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(guidanceColors.warning) .padding(5) - .background(guidanceColors.warning) - .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) } } @@ -281,14 +319,15 @@ extension SettingsView { .frame(width: 30), secondaryImageView: alertWarning, label: NSLocalizedString("Alert Management", comment: "Alert Permissions button text"), - descriptiveText: NSLocalizedString("Alert Permissions and Mute Alerts", comment: "Alert Permissions descriptive text") + descriptiveText: NSLocalizedString("iOS Permissions and Mute All App Sounds", comment: "Alert Permissions descriptive text") ) + .accessibilityIdentifier("settingsViewAlertManagement") } } } - private var configurationSection: some View { - Section(header: SectionHeader(label: NSLocalizedString("Configuration", comment: "The title of the Configuration section in settings"))) { + private var therapySection: some View { + Section { LargeButton(action: { sheet = .therapySettings }, includeArrow: true, imageView: Image("Therapy Icon"), @@ -314,9 +353,12 @@ extension SettingsView { } private var deviceSettingsSection: some View { - Section { + Section(header: SectionHeader(label: NSLocalizedString("Devices", comment: ""))) { pumpSection + .accessibilityIdentifier("settingsViewInsulinPump") + cgmSection + .accessibilityIdentifier("settingsViewCGM") } } diff --git a/Loop/Views/SimpleBolusView.swift b/Loop/Views/SimpleBolusView.swift index 2a7fc3fe59..903caf4c8c 100644 --- a/Loop/Views/SimpleBolusView.swift +++ b/Loop/Views/SimpleBolusView.swift @@ -11,12 +11,13 @@ import LoopKit import LoopKitUI import HealthKit import LoopCore +import LoopAlgorithm struct SimpleBolusView: View { @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference @Environment(\.dismissAction) var dismiss - @State private var shouldBolusEntryBecomeFirstResponder = false + @State private var shouldGlucoseEntryBecomeFirstResponder = false @State private var isKeyboardVisible = false @State private var isClosedLoopOffInformationalModalVisible = false @@ -42,48 +43,35 @@ struct SimpleBolusView: View { } var body: some View { - GeometryReader { geometry in - VStack(spacing: 0) { - List() { - self.infoSection - self.summarySection - } - // As of iOS 13, we can't programmatically scroll to the Bolus entry text field. This ugly hack scoots the - // list up instead, so the summarySection is visible and the keyboard shows when you tap "Enter Bolus". - // Unfortunately, after entry, the field scoots back down and remains hidden. So this is not a great solution. - // TODO: Fix this in Xcode 12 when we're building for iOS 14. - .padding(.top, self.shouldAutoScroll(basedOn: geometry) ? -200 : 0) - .insetGroupedListStyle() - .navigationBarTitle(Text(self.title), displayMode: .inline) - - self.actionArea - .frame(height: self.isKeyboardVisible ? 0 : nil) - .opacity(self.isKeyboardVisible ? 0 : 1) + VStack(spacing: 0) { + List() { + self.infoSection + self.summarySection } - .onKeyboardStateChange { state in - self.isKeyboardVisible = state.height > 0 - - if state.height == 0 { - // Ensure tapping 'Enter Bolus' can make the text field the first responder again - self.shouldBolusEntryBecomeFirstResponder = false - } + .insetGroupedListStyle() + .navigationBarTitle(Text(self.title), displayMode: .inline) + + self.actionArea + .frame(height: self.isKeyboardVisible ? 0 : nil) + .opacity(self.isKeyboardVisible ? 0 : 1) + } + .onKeyboardStateChange { state in + self.isKeyboardVisible = state.height > 0 + + if state.height == 0 { + // Ensure tapping 'Enter Bolus' can make the text field the first responder again + self.shouldGlucoseEntryBecomeFirstResponder = false } - .keyboardAware() - .edgesIgnoringSafeArea(self.isKeyboardVisible ? [] : .bottom) - .alert(item: self.$viewModel.activeAlert, content: self.alert(for:)) } + .keyboardAware() + .edgesIgnoringSafeArea(self.isKeyboardVisible ? [] : .bottom) + .alert(item: self.$viewModel.activeAlert, content: self.alert(for:)) } private func formatGlucose(_ quantity: HKQuantity) -> String { return displayGlucosePreference.format(quantity) } - private func shouldAutoScroll(basedOn geometry: GeometryProxy) -> Bool { - // Taking a guess of 640 to cover iPhone SE, iPod Touch, and other smaller devices. - // Devices such as the iPhone 11 Pro Max do not need to auto-scroll. - shouldBolusEntryBecomeFirstResponder && geometry.size.height < 640 - } - private var infoSection: some View { HStack { Image("Open Loop") @@ -109,10 +97,10 @@ struct SimpleBolusView: View { private var summarySection: some View { Section { + glucoseEntryRow if viewModel.displayMealEntry { carbEntryRow } - glucoseEntryRow recommendedBolusRow bolusEntryRow } @@ -150,9 +138,13 @@ struct SimpleBolusView: View { font: .heavy(.title1), textAlignment: .right, keyboardType: .decimalPad, + shouldBecomeFirstResponder: shouldGlucoseEntryBecomeFirstResponder, maxLength: 4, doneButtonColor: .loopAccent ) + .onAppear { + shouldGlucoseEntryBecomeFirstResponder = true + } glucoseUnitsLabel } @@ -202,12 +194,11 @@ struct SimpleBolusView: View { HStack(alignment: .firstTextBaseline) { DismissibleKeyboardTextField( text: $viewModel.enteredBolusString, - placeholder: "", + placeholder: "0", font: .preferredFont(forTextStyle: .title1), textColor: .loopAccent, textAlignment: .right, keyboardType: .decimalPad, - shouldBecomeFirstResponder: shouldBolusEntryBecomeFirstResponder, maxLength: 5, doneButtonColor: .loopAccent ) @@ -221,6 +212,7 @@ struct SimpleBolusView: View { private var carbUnitsLabel: some View { Text(QuantityFormatter(for: .gram()).localizedUnitStringWithPlurality()) + .foregroundColor(Color(.secondaryLabel)) } private var glucoseUnitsLabel: some View { @@ -250,14 +242,13 @@ struct SimpleBolusView: View { Button( action: { if self.viewModel.actionButtonAction == .enterBolus { - self.shouldBolusEntryBecomeFirstResponder = true + self.shouldGlucoseEntryBecomeFirstResponder = true } else { - self.viewModel.saveAndDeliver { (success) in - if success { + Task { + if await viewModel.saveAndDeliver() { self.dismiss() } } - } }, label: { @@ -306,7 +297,7 @@ struct SimpleBolusView: View { } else { title = Text("No Bolus Recommended", comment: "Title for bolus screen warning when glucose is below suspend threshold, and a bolus is not recommended") } - let suspendThresholdString = formatGlucose(viewModel.suspendThreshold) + let suspendThresholdString = formatGlucose(viewModel.suspendThreshold!) return WarningView( title: title, caption: Text(String(format: NSLocalizedString("Your glucose is below your glucose safety limit, %1$@.", comment: "Format string for bolus screen warning when no bolus is recommended due input value below glucose safety limit. (1: suspendThreshold)"), suspendThresholdString)) @@ -362,13 +353,12 @@ struct SimpleBolusView: View { struct SimpleBolusCalculatorView_Previews: PreviewProvider { class MockSimpleBolusViewDelegate: SimpleBolusViewModelDelegate { - func addGlucose(_ samples: [NewGlucoseSample], completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) { - completion(.success([])) + func saveGlucose(sample: NewGlucoseSample) async throws -> StoredGlucoseSample { + return StoredGlucoseSample(startDate: sample.date, quantity: sample.quantity) } - func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?, completion: @escaping (Result) -> Void) { - - let storedCarbEntry = StoredCarbEntry( + func addCarbEntry(_ carbEntry: LoopKit.NewCarbEntry, replacing replacingEntry: StoredCarbEntry?) async throws -> StoredCarbEntry { + StoredCarbEntry( startDate: carbEntry.startDate, quantity: carbEntry.quantity, uuid: UUID(), @@ -380,9 +370,12 @@ struct SimpleBolusCalculatorView_Previews: PreviewProvider { createdByCurrentApp: true, userCreatedDate: Date(), userUpdatedDate: nil) - completion(.success(storedCarbEntry)) } + func insulinOnBoard(at date: Date) async -> InsulinValue? { + return nil + } + func enactBolus(units: Double, activationType: BolusActivationType) { } @@ -392,7 +385,7 @@ struct SimpleBolusCalculatorView_Previews: PreviewProvider { func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? { var decision = BolusDosingDecision(for: .simpleBolus) - decision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: 3, pendingInsulin: 0), + decision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: 3), date: Date()) return decision } @@ -404,20 +397,24 @@ struct SimpleBolusCalculatorView_Previews: PreviewProvider { return DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) } - var maximumBolus: Double { + var maximumBolus: Double? { return 6 } - var suspendThreshold: HKQuantity { + var suspendThreshold: HKQuantity? { return HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 75) } } - static var viewModel: SimpleBolusViewModel = SimpleBolusViewModel(delegate: MockSimpleBolusViewDelegate(), displayMealEntry: true) - + static var previewViewModel: SimpleBolusViewModel = SimpleBolusViewModel( + delegate: MockSimpleBolusViewDelegate(), + displayMealEntry: true, + displayGlucosePreference: DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) + ) + static var previews: some View { NavigationView { - SimpleBolusView(viewModel: viewModel) + SimpleBolusView(viewModel: previewViewModel) } .previewDevice("iPod touch (7th generation)") .environmentObject(DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter)) diff --git a/Loop/en.lproj/Main.strings b/Loop/en.lproj/Main.strings index 032954b583..31ebb58355 100644 --- a/Loop/en.lproj/Main.strings +++ b/Loop/en.lproj/Main.strings @@ -1,3 +1,3 @@ /* Class = "UILabel"; text = "g Active Carbs"; ObjectID = "SQx-au-ZcM"; */ -"SQx-au-ZcM.text" = "g COB"; +"SQx-au-ZcM.text" = "g Active Carbs"; diff --git a/LoopCore/LoopCompletionFreshness.swift b/LoopCore/LoopCompletionFreshness.swift deleted file mode 100644 index baa2cd7232..0000000000 --- a/LoopCore/LoopCompletionFreshness.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// LoopCompletionFreshness.swift -// Loop -// -// Created by Pete Schwamb on 1/17/20. -// Copyright © 2020 LoopKit Authors. All rights reserved. -// - -import Foundation - -public enum LoopCompletionFreshness { - case fresh - case aging - case stale - - public var maxAge: TimeInterval? { - switch self { - case .fresh: - return TimeInterval(minutes: 6) - case .aging: - return TimeInterval(minutes: 16) - case .stale: - return nil - } - } - - public init(age: TimeInterval?) { - guard let age = age else { - self = .stale - return - } - - switch age { - case let t where t <= LoopCompletionFreshness.fresh.maxAge!: - self = .fresh - case let t where t <= LoopCompletionFreshness.aging.maxAge!: - self = .aging - default: - self = .stale - } - } - - public init(lastCompletion: Date?, at date: Date = Date()) { - guard let lastCompletion = lastCompletion else { - self = .stale - return - } - - self = LoopCompletionFreshness(age: date.timeIntervalSince(lastCompletion)) - } - -} diff --git a/LoopCore/LoopCoreConstants.swift b/LoopCore/LoopCoreConstants.swift index d56f2ab9b6..6d8edfea82 100644 --- a/LoopCore/LoopCoreConstants.swift +++ b/LoopCore/LoopCoreConstants.swift @@ -9,14 +9,13 @@ import Foundation import LoopKit +public typealias DefaultAbsorptionTimes = (fast: TimeInterval, medium: TimeInterval, slow: TimeInterval) + public enum LoopCoreConstants { - /// The amount of time since a given date that input data should be considered valid - public static let inputDataRecencyInterval = TimeInterval(minutes: 15) - /// The amount of time in the future a glucose value should be considered valid public static let futureGlucoseDataInterval = TimeInterval(minutes: 5) - public static let defaultCarbAbsorptionTimes: CarbStore.DefaultAbsorptionTimes = (fast: .minutes(30), medium: .hours(3), slow: .hours(5)) + public static let defaultCarbAbsorptionTimes: DefaultAbsorptionTimes = (fast: .minutes(30), medium: .hours(3), slow: .hours(5)) /// How much historical glucose to include in a dosing decision /// Somewhat arbitrary, but typical maximum visible in bolus glucose preview diff --git a/LoopCore/LoopSettings.swift b/LoopCore/LoopSettings.swift index 82ad76b6cc..b93aecf837 100644 --- a/LoopCore/LoopSettings.swift +++ b/LoopCore/LoopSettings.swift @@ -7,6 +7,7 @@ import HealthKit import LoopKit +import LoopAlgorithm public extension AutomaticDosingStrategy { var title: String { @@ -20,11 +21,6 @@ public extension AutomaticDosingStrategy { } public struct LoopSettings: Equatable { - public var isScheduleOverrideInfiniteWorkout: Bool { - guard let scheduleOverride = scheduleOverride else { return false } - return scheduleOverride.context == .legacyWorkout && scheduleOverride.duration.isInfinite - } - public var dosingEnabled = false public var glucoseTargetRangeSchedule: GlucoseRangeSchedule? @@ -41,30 +37,6 @@ public struct LoopSettings: Equatable { public var overridePresets: [TemporaryScheduleOverridePreset] = [] - public var scheduleOverride: TemporaryScheduleOverride? { - didSet { - if let newValue = scheduleOverride, newValue.context == .preMeal { - preconditionFailure("The `scheduleOverride` field should not be used for a pre-meal target range override; use `preMealOverride` instead") - } - - if scheduleOverride?.context == .legacyWorkout { - preMealOverride = nil - } - } - } - - public var preMealOverride: TemporaryScheduleOverride? { - didSet { - if let newValue = preMealOverride, newValue.context != .preMeal || newValue.settings.insulinNeedsScaleFactor != nil { - preconditionFailure("The `preMealOverride` field should be used only for a pre-meal target range override") - } - - if preMealOverride != nil, scheduleOverride?.context == .legacyWorkout { - scheduleOverride = nil - } - } - } - public var maximumBasalRatePerHour: Double? public var maximumBolus: Double? @@ -88,8 +60,6 @@ public struct LoopSettings: Equatable { preMealTargetRange: ClosedRange? = nil, legacyWorkoutTargetRange: ClosedRange? = nil, overridePresets: [TemporaryScheduleOverridePreset]? = nil, - scheduleOverride: TemporaryScheduleOverride? = nil, - preMealOverride: TemporaryScheduleOverride? = nil, maximumBasalRatePerHour: Double? = nil, maximumBolus: Double? = nil, suspendThreshold: GlucoseThreshold? = nil, @@ -104,8 +74,6 @@ public struct LoopSettings: Equatable { self.preMealTargetRange = preMealTargetRange self.legacyWorkoutTargetRange = legacyWorkoutTargetRange self.overridePresets = overridePresets ?? [] - self.scheduleOverride = scheduleOverride - self.preMealOverride = preMealOverride self.maximumBasalRatePerHour = maximumBasalRatePerHour self.maximumBolus = maximumBolus self.suspendThreshold = suspendThreshold @@ -114,105 +82,6 @@ public struct LoopSettings: Equatable { } } -extension LoopSettings { - public func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool = false) -> GlucoseRangeSchedule? { - - let preMealOverride = presumingMealEntry ? nil : self.preMealOverride - - let currentEffectiveOverride: TemporaryScheduleOverride? - switch (preMealOverride, scheduleOverride) { - case (let preMealOverride?, nil): - currentEffectiveOverride = preMealOverride - case (nil, let scheduleOverride?): - currentEffectiveOverride = scheduleOverride - case (let preMealOverride?, let scheduleOverride?): - currentEffectiveOverride = preMealOverride.scheduledEndDate > Date() - ? preMealOverride - : scheduleOverride - case (nil, nil): - currentEffectiveOverride = nil - } - - if let effectiveOverride = currentEffectiveOverride { - return glucoseTargetRangeSchedule?.applyingOverride(effectiveOverride) - } else { - return glucoseTargetRangeSchedule - } - } - - public func scheduleOverrideEnabled(at date: Date = Date()) -> Bool { - return scheduleOverride?.isActive(at: date) == true - } - - public func nonPreMealOverrideEnabled(at date: Date = Date()) -> Bool { - return scheduleOverride?.isActive(at: date) == true - } - - public func preMealTargetEnabled(at date: Date = Date()) -> Bool { - return preMealOverride?.isActive(at: date) == true - } - - public func futureOverrideEnabled(relativeTo date: Date = Date()) -> Bool { - guard let scheduleOverride = scheduleOverride else { return false } - return scheduleOverride.startDate > date - } - - public mutating func enablePreMealOverride(at date: Date = Date(), for duration: TimeInterval) { - preMealOverride = makePreMealOverride(beginningAt: date, for: duration) - } - - private func makePreMealOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { - guard let preMealTargetRange = preMealTargetRange else { - return nil - } - return TemporaryScheduleOverride( - context: .preMeal, - settings: TemporaryScheduleOverrideSettings(targetRange: preMealTargetRange), - startDate: date, - duration: .finite(duration), - enactTrigger: .local, - syncIdentifier: UUID() - ) - } - - public mutating func enableLegacyWorkoutOverride(at date: Date = Date(), for duration: TimeInterval) { - scheduleOverride = legacyWorkoutOverride(beginningAt: date, for: duration) - preMealOverride = nil - } - - public mutating func legacyWorkoutOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { - guard let legacyWorkoutTargetRange = legacyWorkoutTargetRange else { - return nil - } - - return TemporaryScheduleOverride( - context: .legacyWorkout, - settings: TemporaryScheduleOverrideSettings(targetRange: legacyWorkoutTargetRange), - startDate: date, - duration: duration.isInfinite ? .indefinite : .finite(duration), - enactTrigger: .local, - syncIdentifier: UUID() - ) - } - - public mutating func clearOverride(matching context: TemporaryScheduleOverride.Context? = nil) { - if context == .preMeal { - preMealOverride = nil - return - } - - guard let scheduleOverride = scheduleOverride else { return } - - if let context = context { - if scheduleOverride.context == context { - self.scheduleOverride = nil - } - } else { - self.scheduleOverride = nil - } - } -} - extension LoopSettings: RawRepresentable { public typealias RawValue = [String: Any] private static let version = 1 @@ -256,14 +125,6 @@ extension LoopSettings: RawRepresentable { self.overridePresets = rawPresets.compactMap(TemporaryScheduleOverridePreset.init(rawValue:)) } - if let rawPreMealOverride = rawValue["preMealOverride"] as? TemporaryScheduleOverride.RawValue { - self.preMealOverride = TemporaryScheduleOverride(rawValue: rawPreMealOverride) - } - - if let rawOverride = rawValue["scheduleOverride"] as? TemporaryScheduleOverride.RawValue { - self.scheduleOverride = TemporaryScheduleOverride(rawValue: rawOverride) - } - self.maximumBasalRatePerHour = rawValue["maximumBasalRatePerHour"] as? Double self.maximumBolus = rawValue["maximumBolus"] as? Double @@ -289,8 +150,6 @@ extension LoopSettings: RawRepresentable { raw["glucoseTargetRangeSchedule"] = glucoseTargetRangeSchedule?.rawValue raw["preMealTargetRange"] = preMealTargetRange?.doubleRange(for: LoopSettings.codingGlucoseUnit).rawValue raw["legacyWorkoutTargetRange"] = legacyWorkoutTargetRange?.doubleRange(for: LoopSettings.codingGlucoseUnit).rawValue - raw["preMealOverride"] = preMealOverride?.rawValue - raw["scheduleOverride"] = scheduleOverride?.rawValue raw["maximumBasalRatePerHour"] = maximumBasalRatePerHour raw["maximumBolus"] = maximumBolus raw["minimumBGGuard"] = suspendThreshold?.rawValue diff --git a/LoopCore/NSUserDefaults.swift b/LoopCore/NSUserDefaults.swift index 93fa7e17d6..c4a2cbe172 100644 --- a/LoopCore/NSUserDefaults.swift +++ b/LoopCore/NSUserDefaults.swift @@ -9,7 +9,7 @@ import Foundation import LoopKit import HealthKit - +import LoopAlgorithm extension UserDefaults { @@ -23,6 +23,7 @@ extension UserDefaults { case allowSimulators = "com.loopkit.Loop.allowSimulators" case LastMissedMealNotification = "com.loopkit.Loop.lastMissedMealNotification" case userRequestedLoopReset = "com.loopkit.Loop.userRequestedLoopReset" + case defaultEnvironment = "org.tidepool.TidepoolKit.DefaultEnvironment" } public static let appGroup = UserDefaults(suiteName: Bundle.main.appGroupSuiteName) @@ -165,6 +166,15 @@ extension UserDefaults { setValue(newValue, forKey: Key.userRequestedLoopReset.rawValue) } } + + public var defaultEnvironment: Data? { + get { + data(forKey: Key.defaultEnvironment.rawValue) + } + set { + setValue(newValue, forKey: Key.defaultEnvironment.rawValue) + } + } public func removeLegacyLoopSettings() { removeObject(forKey: "com.loudnate.Naterade.BasalRateSchedule") diff --git a/LoopCore/Result.swift b/LoopCore/Result.swift deleted file mode 100644 index 580595159d..0000000000 --- a/LoopCore/Result.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Result.swift -// Loop -// -// Copyright © 2017 LoopKit Authors. All rights reserved. -// - - -public enum Result { - case success(T) - case failure(Error) -} diff --git a/LoopTests/DIYLoopUnitTestPlan.xctestplan b/LoopTests/DIYLoopUnitTestPlan.xctestplan new file mode 100644 index 0000000000..88f5cc6436 --- /dev/null +++ b/LoopTests/DIYLoopUnitTestPlan.xctestplan @@ -0,0 +1,99 @@ +{ + "configurations" : [ + { + "id" : "72E4773C-B5CB-4058-99B1-BFC87A45A4FF", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "codeCoverage" : false, + "targetForVariableExpansion" : { + "containerPath" : "container:..\/Loop\/Loop.xcodeproj", + "identifier" : "43776F8B1B8022E90074EA36", + "name" : "Loop" + } + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:..\/Common\/LoopKit\/LoopKit.xcodeproj", + "identifier" : "43D8FDD41C728FDF0073BE78", + "name" : "LoopKitTests" + } + }, + { + "target" : { + "containerPath" : "container:CGMBLEKit\/CGMBLEKit.xcodeproj", + "identifier" : "43CABDFC1C3506F100005705", + "name" : "CGMBLEKitTests" + } + }, + { + "target" : { + "containerPath" : "container:NightscoutService\/NightscoutService.xcodeproj", + "identifier" : "A91BAC2322BC691A00ABF1BB", + "name" : "NightscoutServiceKitTests" + } + }, + { + "target" : { + "containerPath" : "container:..\/Loop\/Loop.xcodeproj", + "identifier" : "43E2D90A1D20C581004DA55F", + "name" : "LoopTests" + } + }, + { + "target" : { + "containerPath" : "container:..\/Common\/LoopKit\/LoopKit.xcodeproj", + "identifier" : "1DEE226824A676A300693C32", + "name" : "LoopKitHostedTests" + } + }, + { + "target" : { + "containerPath" : "container:..\/Common\/LoopKit\/LoopKit.xcodeproj", + "identifier" : "B4CEE2DF257129780093111B", + "name" : "MockKitTests" + } + }, + { + "target" : { + "containerPath" : "container:OmniBLE\/OmniBLE.xcodeproj", + "identifier" : "84752E8A26ED0FFE009FD801", + "name" : "OmniBLETests" + } + }, + { + "target" : { + "containerPath" : "container:rileylink_ios\/RileyLinkKit.xcodeproj", + "identifier" : "431CE7761F98564200255374", + "name" : "RileyLinkBLEKitTests" + } + }, + { + "target" : { + "containerPath" : "container:G7SensorKit\/G7SensorKit.xcodeproj", + "identifier" : "C17F50CD291EAC3800555EB5", + "name" : "G7SensorKitTests" + } + }, + { + "target" : { + "containerPath" : "container:MinimedKit\/MinimedKit.xcodeproj", + "identifier" : "C13CC34029C7B73A007F25DE", + "name" : "MinimedKitTests" + } + }, + { + "target" : { + "containerPath" : "container:OmniKit\/OmniKit.xcodeproj", + "identifier" : "C12ED9C929C7DBA900435701", + "name" : "OmniKitTests" + } + } + ], + "version" : 1 +} diff --git a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_carb_effect.json b/LoopTests/Fixtures/flat_and_stable/flat_and_stable_carb_effect.json deleted file mode 100644 index 0637a088a0..0000000000 --- a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_carb_effect.json +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file diff --git a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_counteraction_effect.json b/LoopTests/Fixtures/flat_and_stable/flat_and_stable_counteraction_effect.json deleted file mode 100644 index 28e66e4932..0000000000 --- a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_counteraction_effect.json +++ /dev/null @@ -1,230 +0,0 @@ -[ - { - "startDate": "2020-08-11T17:25:13", - "endDate": "2020-08-11T17:30:13", - "unit": "mg\/min·dL", - "value": -0.17427698848393616 - }, - { - "startDate": "2020-08-11T17:30:13", - "endDate": "2020-08-11T17:35:13", - "unit": "mg\/min·dL", - "value": -0.172884893111717 - }, - { - "startDate": "2020-08-11T17:35:13", - "endDate": "2020-08-11T17:40:13", - "unit": "mg\/min·dL", - "value": -0.16620062119698026 - }, - { - "startDate": "2020-08-11T17:40:13", - "endDate": "2020-08-11T17:45:13", - "unit": "mg\/min·dL", - "value": -0.15239126546960888 - }, - { - "startDate": "2020-08-11T17:45:13", - "endDate": "2020-08-11T17:50:13", - "unit": "mg\/min·dL", - "value": -0.13844620387243192 - }, - { - "startDate": "2020-08-11T17:50:13", - "endDate": "2020-08-11T17:55:13", - "unit": "mg\/min·dL", - "value": -0.12440053903505803 - }, - { - "startDate": "2020-08-11T17:55:13", - "endDate": "2020-08-11T18:00:13", - "unit": "mg\/min·dL", - "value": -0.1102033787233404 - }, - { - "startDate": "2020-08-11T18:00:13", - "endDate": "2020-08-11T18:05:13", - "unit": "mg\/min·dL", - "value": -0.09582040633985235 - }, - { - "startDate": "2020-08-11T18:05:13", - "endDate": "2020-08-11T18:10:13", - "unit": "mg\/min·dL", - "value": -0.08123290693932182 - }, - { - "startDate": "2020-08-11T18:10:13", - "endDate": "2020-08-11T18:15:13", - "unit": "mg\/min·dL", - "value": -0.06643676319414542 - }, - { - "startDate": "2020-08-11T18:15:13", - "endDate": "2020-08-11T18:20:13", - "unit": "mg\/min·dL", - "value": -0.051441423013083416 - }, - { - "startDate": "2020-08-11T18:20:13", - "endDate": "2020-08-11T18:25:13", - "unit": "mg\/min·dL", - "value": -0.0362688411105418 - }, - { - "startDate": "2020-08-11T18:25:13", - "endDate": "2020-08-11T18:30:13", - "unit": "mg\/min·dL", - "value": -0.020952397377567107 - }, - { - "startDate": "2020-08-11T18:30:13", - "endDate": "2020-08-11T18:35:13", - "unit": "mg\/min·dL", - "value": -0.005535795415598254 - }, - { - "startDate": "2020-08-11T18:35:13", - "endDate": "2020-08-11T18:40:13", - "unit": "mg\/min·dL", - "value": 0.009928054942067454 - }, - { - "startDate": "2020-08-11T18:40:13", - "endDate": "2020-08-11T18:45:13", - "unit": "mg\/min·dL", - "value": 0.02537816688081129 - }, - { - "startDate": "2020-08-11T18:45:13", - "endDate": "2020-08-11T18:50:13", - "unit": "mg\/min·dL", - "value": 0.040746613021907935 - }, - { - "startDate": "2020-08-11T18:50:13", - "endDate": "2020-08-11T18:55:13", - "unit": "mg\/min·dL", - "value": 0.05595966408835151 - }, - { - "startDate": "2020-08-11T18:55:13", - "endDate": "2020-08-11T19:00:13", - "unit": "mg\/min·dL", - "value": 0.07093892464198123 - }, - { - "startDate": "2020-08-11T19:00:13", - "endDate": "2020-08-11T19:05:13", - "unit": "mg\/min·dL", - "value": 0.08560246050964196 - }, - { - "startDate": "2020-08-11T19:05:13", - "endDate": "2020-08-11T19:10:13", - "unit": "mg\/min·dL", - "value": 0.09986591236653002 - }, - { - "startDate": "2020-08-11T19:10:13", - "endDate": "2020-08-11T19:15:13", - "unit": "mg\/min·dL", - "value": 0.11364358985065513 - }, - { - "startDate": "2020-08-11T19:15:13", - "endDate": "2020-08-11T19:20:13", - "unit": "mg\/min·dL", - "value": 0.12684954054338973 - }, - { - "startDate": "2020-08-11T19:20:13", - "endDate": "2020-08-11T19:25:13", - "unit": "mg\/min·dL", - "value": 0.13939858816698666 - }, - { - "startDate": "2020-08-11T19:25:13", - "endDate": "2020-08-11T19:30:13", - "unit": "mg\/min·dL", - "value": 0.15120733442007542 - }, - { - "startDate": "2020-08-11T19:30:13", - "endDate": "2020-08-11T19:35:13", - "unit": "mg\/min·dL", - "value": 0.16219511899486355 - }, - { - "startDate": "2020-08-11T19:35:13", - "endDate": "2020-08-11T19:40:13", - "unit": "mg\/min·dL", - "value": 0.17228493249382398 - }, - { - "startDate": "2020-08-11T19:40:13", - "endDate": "2020-08-11T19:45:13", - "unit": "mg\/min·dL", - "value": 0.1814042771871964 - }, - { - "startDate": "2020-08-11T19:45:13", - "endDate": "2020-08-11T19:50:13", - "unit": "mg\/min·dL", - "value": 0.18948597082306992 - }, - { - "startDate": "2020-08-11T19:50:13", - "endDate": "2020-08-11T19:55:13", - "unit": "mg\/min·dL", - "value": 0.196468889016708 - }, - { - "startDate": "2020-08-11T19:55:13", - "endDate": "2020-08-11T20:00:13", - "unit": "mg\/min·dL", - "value": 0.20229864210263385 - }, - { - "startDate": "2020-08-11T20:00:13", - "endDate": "2020-08-11T20:05:13", - "unit": "mg\/min·dL", - "value": 0.2069281827278072 - }, - { - "startDate": "2020-08-11T20:05:13", - "endDate": "2020-08-11T20:10:13", - "unit": "mg\/min·dL", - "value": 0.21031834089428644 - }, - { - "startDate": "2020-08-11T20:10:13", - "endDate": "2020-08-11T20:15:13", - "unit": "mg\/min·dL", - "value": 0.21243828362120673 - }, - { - "startDate": "2020-08-11T20:15:13", - "endDate": "2020-08-11T20:20:13", - "unit": "mg\/min·dL", - "value": 0.213265896884441 - }, - { - "startDate": "2020-08-11T20:20:13", - "endDate": "2020-08-11T20:25:13", - "unit": "mg\/min·dL", - "value": 0.212788088004482 - }, - { - "startDate": "2020-08-11T20:25:13", - "endDate": "2020-08-11T20:32:50", - "unit": "mg\/min·dL", - "value": 0.17396858033976298 - }, - { - "startDate": "2020-08-11T20:32:50", - "endDate": "2020-08-11T20:45:02", - "unit": "mg\/min·dL", - "value": 0.18555611348135584 - } -] diff --git a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_insulin_effect.json b/LoopTests/Fixtures/flat_and_stable/flat_and_stable_insulin_effect.json deleted file mode 100644 index e83d91e34b..0000000000 --- a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_insulin_effect.json +++ /dev/null @@ -1,377 +0,0 @@ -[ - { - "date": "2020-08-11T20:50:00", - "amount": -0.21997829342610006, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T20:55:00", - "amount": -0.4261395410590354, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:00:00", - "amount": -0.7096583179105603, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:05:00", - "amount": -1.0621881093826662, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:10:00", - "amount": -1.4740341427597377, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:15:00", - "amount": -1.9363888584472242, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:20:00", - "amount": -2.441263560467393, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:25:00", - "amount": -2.9814248393095815, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:30:00", - "amount": -3.5503354629325354, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:35:00", - "amount": -4.142099441439137, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:40:00", - "amount": -4.751410989493849, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:45:00", - "amount": -5.373507127973413, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:50:00", - "amount": -6.004123682698768, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:55:00", - "amount": -6.639454453454031, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:00:00", - "amount": -7.276113340916081, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:05:00", - "amount": -7.911099232651796, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:10:00", - "amount": -8.541763462042216, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:15:00", - "amount": -9.165779665913185, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:20:00", - "amount": -9.7811158778376, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:25:00", - "amount": -10.386008704568662, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:30:00", - "amount": -10.97893944290868, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:35:00", - "amount": -11.558612003552255, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:40:00", - "amount": -12.12393251710345, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:45:00", - "amount": -12.673990505588074, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:50:00", - "amount": -13.20804151039699, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:55:00", - "amount": -13.725491074735217, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:00:00", - "amount": -14.225879985343203, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:05:00", - "amount": -14.708870684528089, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:10:00", - "amount": -15.174234769419765, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:15:00", - "amount": -15.62184150087279, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:20:00", - "amount": -16.05164724959357, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:25:00", - "amount": -16.463685811903716, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:30:00", - "amount": -16.858059532075337, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:35:00", - "amount": -17.234931172410647, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:40:00", - "amount": -17.594516476204813, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:45:00", - "amount": -17.93707737244358, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:50:00", - "amount": -18.26291577456192, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:55:00", - "amount": -18.572367928841064, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:00:00", - "amount": -18.86579927106296, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:05:00", - "amount": -19.14359975288623, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:10:00", - "amount": -19.406179602068192, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:15:00", - "amount": -19.65396548314523, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:20:00", - "amount": -19.887397027509305, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:25:00", - "amount": -20.106923703991654, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:30:00", - "amount": -20.313002003095814, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:35:00", - "amount": -20.506092909919293, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:40:00", - "amount": -20.686659642575187, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:45:00", - "amount": -20.855165634580125, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:50:00", - "amount": -21.012072741219335, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:55:00", - "amount": -21.157839651341906, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:00:00", - "amount": -21.292920487384542, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:05:00", - "amount": -21.41776357767731, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:10:00", - "amount": -21.532810386255537, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:15:00", - "amount": -21.638494586493437, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:20:00", - "amount": -21.735241265892345, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:25:00", - "amount": -21.823466250304694, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:30:00", - "amount": -21.903575536757817, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:35:00", - "amount": -21.975964824864416, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:40:00", - "amount": -22.041019137572135, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:45:00", - "amount": -22.099112522717494, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:50:00", - "amount": -22.150607827512605, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:55:00", - "amount": -22.19585653870966, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:00:00", - "amount": -22.235198681761787, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:05:00", - "amount": -22.268962772831713, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:10:00", - "amount": -22.297465817994798, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:15:00", - "amount": -22.32101335444279, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:20:00", - "amount": -22.33989952892162, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:25:00", - "amount": -22.354407209032342, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:30:00", - "amount": -22.364808123391935, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:35:00", - "amount": -22.371363026991066, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:40:00", - "amount": -22.374909853783546, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:45:00", - "amount": -22.37661999205696, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:50:00", - "amount": -22.377128476655095, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:55:00", - "amount": -22.377194743725912, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:00:00", - "amount": -22.37719474401739, - "unit": "mg/dL" - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_momentum_effect.json b/LoopTests/Fixtures/flat_and_stable/flat_and_stable_momentum_effect.json deleted file mode 100644 index 0637a088a0..0000000000 --- a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_momentum_effect.json +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file diff --git a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_predicted_glucose.json b/LoopTests/Fixtures/flat_and_stable/flat_and_stable_predicted_glucose.json deleted file mode 100644 index a969a34495..0000000000 --- a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_predicted_glucose.json +++ /dev/null @@ -1,382 +0,0 @@ -[ - { - "date": "2020-08-11T20:45:02", - "unit": "mg/dL", - "amount": 123.42849966275706 - }, - { - "date": "2020-08-11T20:50:00", - "unit": "mg/dL", - "amount": 124.26018046469977 - }, - { - "date": "2020-08-11T20:55:00", - "unit": "mg/dL", - "amount": 124.81009267337839 - }, - { - "date": "2020-08-11T21:00:00", - "unit": "mg/dL", - "amount": 125.20704000720727 - }, - { - "date": "2020-08-11T21:05:00", - "unit": "mg/dL", - "amount": 125.4593689807844 - }, - { - "date": "2020-08-11T21:10:00", - "unit": "mg/dL", - "amount": 125.57677436682542 - }, - { - "date": "2020-08-11T21:15:00", - "unit": "mg/dL", - "amount": 125.56806372492487 - }, - { - "date": "2020-08-11T21:20:00", - "unit": "mg/dL", - "amount": 125.44122575106047 - }, - { - "date": "2020-08-11T21:25:00", - "unit": "mg/dL", - "amount": 125.2034938547429 - }, - { - "date": "2020-08-11T21:30:00", - "unit": "mg/dL", - "amount": 124.86140526801341 - }, - { - "date": "2020-08-11T21:35:00", - "unit": "mg/dL", - "amount": 124.42085598076912 - }, - { - "date": "2020-08-11T21:40:00", - "unit": "mg/dL", - "amount": 123.88715177834555 - }, - { - "date": "2020-08-11T21:45:00", - "unit": "mg/dL", - "amount": 123.26505563986599 - }, - { - "date": "2020-08-11T21:50:00", - "unit": "mg/dL", - "amount": 122.63443908514064 - }, - { - "date": "2020-08-11T21:55:00", - "unit": "mg/dL", - "amount": 121.99910831438538 - }, - { - "date": "2020-08-11T22:00:00", - "unit": "mg/dL", - "amount": 121.36244942692333 - }, - { - "date": "2020-08-11T22:05:00", - "unit": "mg/dL", - "amount": 120.72746353518762 - }, - { - "date": "2020-08-11T22:10:00", - "unit": "mg/dL", - "amount": 120.0967993057972 - }, - { - "date": "2020-08-11T22:15:00", - "unit": "mg/dL", - "amount": 119.47278310192624 - }, - { - "date": "2020-08-11T22:20:00", - "unit": "mg/dL", - "amount": 118.85744689000182 - }, - { - "date": "2020-08-11T22:25:00", - "unit": "mg/dL", - "amount": 118.25255406327076 - }, - { - "date": "2020-08-11T22:30:00", - "unit": "mg/dL", - "amount": 117.65962332493075 - }, - { - "date": "2020-08-11T22:35:00", - "unit": "mg/dL", - "amount": 117.07995076428718 - }, - { - "date": "2020-08-11T22:40:00", - "unit": "mg/dL", - "amount": 116.51463025073598 - }, - { - "date": "2020-08-11T22:45:00", - "unit": "mg/dL", - "amount": 115.96457226225135 - }, - { - "date": "2020-08-11T22:50:00", - "unit": "mg/dL", - "amount": 115.43052125744244 - }, - { - "date": "2020-08-11T22:55:00", - "unit": "mg/dL", - "amount": 114.91307169310421 - }, - { - "date": "2020-08-11T23:00:00", - "unit": "mg/dL", - "amount": 114.41268278249623 - }, - { - "date": "2020-08-11T23:05:00", - "unit": "mg/dL", - "amount": 113.92969208331135 - }, - { - "date": "2020-08-11T23:10:00", - "unit": "mg/dL", - "amount": 113.46432799841968 - }, - { - "date": "2020-08-11T23:15:00", - "unit": "mg/dL", - "amount": 113.01672126696666 - }, - { - "date": "2020-08-11T23:20:00", - "unit": "mg/dL", - "amount": 112.58691551824587 - }, - { - "date": "2020-08-11T23:25:00", - "unit": "mg/dL", - "amount": 112.17487695593573 - }, - { - "date": "2020-08-11T23:30:00", - "unit": "mg/dL", - "amount": 111.78050323576412 - }, - { - "date": "2020-08-11T23:35:00", - "unit": "mg/dL", - "amount": 111.4036315954288 - }, - { - "date": "2020-08-11T23:40:00", - "unit": "mg/dL", - "amount": 111.04404629163464 - }, - { - "date": "2020-08-11T23:45:00", - "unit": "mg/dL", - "amount": 110.70148539539588 - }, - { - "date": "2020-08-11T23:50:00", - "unit": "mg/dL", - "amount": 110.37564699327754 - }, - { - "date": "2020-08-11T23:55:00", - "unit": "mg/dL", - "amount": 110.0661948389984 - }, - { - "date": "2020-08-12T00:00:00", - "unit": "mg/dL", - "amount": 109.7727634967765 - }, - { - "date": "2020-08-12T00:05:00", - "unit": "mg/dL", - "amount": 109.49496301495324 - }, - { - "date": "2020-08-12T00:10:00", - "unit": "mg/dL", - "amount": 109.23238316577128 - }, - { - "date": "2020-08-12T00:15:00", - "unit": "mg/dL", - "amount": 108.98459728469425 - }, - { - "date": "2020-08-12T00:20:00", - "unit": "mg/dL", - "amount": 108.75116574033018 - }, - { - "date": "2020-08-12T00:25:00", - "unit": "mg/dL", - "amount": 108.53163906384783 - }, - { - "date": "2020-08-12T00:30:00", - "unit": "mg/dL", - "amount": 108.32556076474367 - }, - { - "date": "2020-08-12T00:35:00", - "unit": "mg/dL", - "amount": 108.1324698579202 - }, - { - "date": "2020-08-12T00:40:00", - "unit": "mg/dL", - "amount": 107.95190312526431 - }, - { - "date": "2020-08-12T00:45:00", - "unit": "mg/dL", - "amount": 107.78339713325937 - }, - { - "date": "2020-08-12T00:50:00", - "unit": "mg/dL", - "amount": 107.62649002662016 - }, - { - "date": "2020-08-12T00:55:00", - "unit": "mg/dL", - "amount": 107.48072311649759 - }, - { - "date": "2020-08-12T01:00:00", - "unit": "mg/dL", - "amount": 107.34564228045495 - }, - { - "date": "2020-08-12T01:05:00", - "unit": "mg/dL", - "amount": 107.22079919016218 - }, - { - "date": "2020-08-12T01:10:00", - "unit": "mg/dL", - "amount": 107.10575238158395 - }, - { - "date": "2020-08-12T01:15:00", - "unit": "mg/dL", - "amount": 107.00006818134605 - }, - { - "date": "2020-08-12T01:20:00", - "unit": "mg/dL", - "amount": 106.90332150194715 - }, - { - "date": "2020-08-12T01:25:00", - "unit": "mg/dL", - "amount": 106.8150965175348 - }, - { - "date": "2020-08-12T01:30:00", - "unit": "mg/dL", - "amount": 106.73498723108167 - }, - { - "date": "2020-08-12T01:35:00", - "unit": "mg/dL", - "amount": 106.66259794297508 - }, - { - "date": "2020-08-12T01:40:00", - "unit": "mg/dL", - "amount": 106.59754363026737 - }, - { - "date": "2020-08-12T01:45:00", - "unit": "mg/dL", - "amount": 106.53945024512201 - }, - { - "date": "2020-08-12T01:50:00", - "unit": "mg/dL", - "amount": 106.4879549403269 - }, - { - "date": "2020-08-12T01:55:00", - "unit": "mg/dL", - "amount": 106.44270622912984 - }, - { - "date": "2020-08-12T02:00:00", - "unit": "mg/dL", - "amount": 106.40336408607772 - }, - { - "date": "2020-08-12T02:05:00", - "unit": "mg/dL", - "amount": 106.36959999500779 - }, - { - "date": "2020-08-12T02:10:00", - "unit": "mg/dL", - "amount": 106.34109694984471 - }, - { - "date": "2020-08-12T02:15:00", - "unit": "mg/dL", - "amount": 106.31754941339672 - }, - { - "date": "2020-08-12T02:20:00", - "unit": "mg/dL", - "amount": 106.2986632389179 - }, - { - "date": "2020-08-12T02:25:00", - "unit": "mg/dL", - "amount": 106.28415555880717 - }, - { - "date": "2020-08-12T02:30:00", - "unit": "mg/dL", - "amount": 106.27375464444758 - }, - { - "date": "2020-08-12T02:35:00", - "unit": "mg/dL", - "amount": 106.26719974084844 - }, - { - "date": "2020-08-12T02:40:00", - "unit": "mg/dL", - "amount": 106.26365291405597 - }, - { - "date": "2020-08-12T02:45:00", - "unit": "mg/dL", - "amount": 106.26194277578256 - }, - { - "date": "2020-08-12T02:50:00", - "unit": "mg/dL", - "amount": 106.26143429118443 - }, - { - "date": "2020-08-12T02:55:00", - "unit": "mg/dL", - "amount": 106.26136802411361 - }, - { - "date": "2020-08-12T03:00:00", - "unit": "mg/dL", - "amount": 106.26136802382213 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_falling/high_and_falling_carb_effect.json b/LoopTests/Fixtures/high_and_falling/high_and_falling_carb_effect.json deleted file mode 100644 index 0637a088a0..0000000000 --- a/LoopTests/Fixtures/high_and_falling/high_and_falling_carb_effect.json +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_falling/high_and_falling_counteraction_effect.json b/LoopTests/Fixtures/high_and_falling/high_and_falling_counteraction_effect.json deleted file mode 100644 index 3cd84a4d76..0000000000 --- a/LoopTests/Fixtures/high_and_falling/high_and_falling_counteraction_effect.json +++ /dev/null @@ -1,236 +0,0 @@ -[ - { - "startDate": "2020-08-11T19:44:58", - "endDate": "2020-08-11T19:49:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T19:49:58", - "endDate": "2020-08-11T19:54:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T19:54:58", - "endDate": "2020-08-11T19:59:58", - "unit": "mg\/min·dL", - "value": 0.06065363877984119 - }, - { - "startDate": "2020-08-11T19:59:58", - "endDate": "2020-08-11T20:04:58", - "unit": "mg\/min·dL", - "value": 0.1829111566180655 - }, - { - "startDate": "2020-08-11T20:04:58", - "endDate": "2020-08-11T20:09:58", - "unit": "mg\/min·dL", - "value": 0.29002744966453 - }, - { - "startDate": "2020-08-11T20:09:58", - "endDate": "2020-08-11T20:14:58", - "unit": "mg\/min·dL", - "value": 0.38321365736330676 - }, - { - "startDate": "2020-08-11T20:14:58", - "endDate": "2020-08-11T20:19:58", - "unit": "mg\/min·dL", - "value": 0.4637144729903035 - }, - { - "startDate": "2020-08-11T20:19:58", - "endDate": "2020-08-11T20:24:58", - "unit": "mg\/min·dL", - "value": 0.5326798223434369 - }, - { - "startDate": "2020-08-11T20:24:58", - "endDate": "2020-08-11T20:29:58", - "unit": "mg\/min·dL", - "value": 0.5911714460685378 - }, - { - "startDate": "2020-08-11T20:29:58", - "endDate": "2020-08-11T20:34:58", - "unit": "mg\/min·dL", - "value": 0.6401690515783915 - }, - { - "startDate": "2020-08-11T20:34:58", - "endDate": "2020-08-11T20:39:58", - "unit": "mg\/min·dL", - "value": 0.6805760615235243 - }, - { - "startDate": "2020-08-11T20:39:58", - "endDate": "2020-08-11T20:44:58", - "unit": "mg\/min·dL", - "value": 0.7132249841389473 - }, - { - "startDate": "2020-08-11T20:44:58", - "endDate": "2020-08-11T20:49:58", - "unit": "mg\/min·dL", - "value": 0.7388824292522805 - }, - { - "startDate": "2020-08-11T20:49:58", - "endDate": "2020-08-11T20:54:58", - "unit": "mg\/min·dL", - "value": 0.758253792292099 - }, - { - "startDate": "2020-08-11T20:54:58", - "endDate": "2020-08-11T20:59:58", - "unit": "mg\/min·dL", - "value": 0.7719876272734658 - }, - { - "startDate": "2020-08-11T20:59:58", - "endDate": "2020-08-11T21:04:58", - "unit": "mg\/min·dL", - "value": 0.7806797284574882 - }, - { - "startDate": "2020-08-11T21:04:58", - "endDate": "2020-08-11T21:09:58", - "unit": "mg\/min·dL", - "value": 0.7848769391771567 - }, - { - "startDate": "2020-08-11T21:09:58", - "endDate": "2020-08-11T21:14:58", - "unit": "mg\/min·dL", - "value": 0.7850807051888878 - }, - { - "startDate": "2020-08-11T21:14:58", - "endDate": "2020-08-11T21:19:58", - "unit": "mg\/min·dL", - "value": 0.7817503888440966 - }, - { - "startDate": "2020-08-11T21:19:58", - "endDate": "2020-08-11T21:24:58", - "unit": "mg\/min·dL", - "value": 0.7753063593735205 - }, - { - "startDate": "2020-08-11T21:24:58", - "endDate": "2020-08-11T21:29:58", - "unit": "mg\/min·dL", - "value": 0.7661328736349247 - }, - { - "startDate": "2020-08-11T21:29:58", - "endDate": "2020-08-11T21:34:58", - "unit": "mg\/min·dL", - "value": 0.7545807607898111 - }, - { - "startDate": "2020-08-11T21:34:58", - "endDate": "2020-08-11T21:39:58", - "unit": "mg\/min·dL", - "value": 0.7409699235419351 - }, - { - "startDate": "2020-08-11T21:39:58", - "endDate": "2020-08-11T21:44:58", - "unit": "mg\/min·dL", - "value": 0.7255916677884272 - }, - { - "startDate": "2020-08-11T21:44:58", - "endDate": "2020-08-11T21:49:58", - "unit": "mg\/min·dL", - "value": 0.7087108717986296 - }, - { - "startDate": "2020-08-11T21:49:58", - "endDate": "2020-08-11T21:54:58", - "unit": "mg\/min·dL", - "value": 0.6905680053447725 - }, - { - "startDate": "2020-08-11T21:54:58", - "endDate": "2020-08-11T21:59:58", - "unit": "mg\/min·dL", - "value": 0.6713810085591916 - }, - { - "startDate": "2020-08-11T21:59:58", - "endDate": "2020-08-11T22:04:58", - "unit": "mg\/min·dL", - "value": 0.6513470396824913 - }, - { - "startDate": "2020-08-11T22:04:58", - "endDate": "2020-08-11T22:09:58", - "unit": "mg\/min·dL", - "value": 0.6306441002936196 - }, - { - "startDate": "2020-08-11T22:09:58", - "endDate": "2020-08-11T22:14:58", - "unit": "mg\/min·dL", - "value": 0.6094325460745351 - }, - { - "startDate": "2020-08-11T22:14:58", - "endDate": "2020-08-11T22:19:58", - "unit": "mg\/min·dL", - "value": 0.5878564906558068 - }, - { - "startDate": "2020-08-11T22:19:58", - "endDate": "2020-08-11T22:24:58", - "unit": "mg\/min·dL", - "value": 0.566045109614535 - }, - { - "startDate": "2020-08-11T22:24:58", - "endDate": "2020-08-11T22:29:58", - "unit": "mg\/min·dL", - "value": 0.5441138512497218 - }, - { - "startDate": "2020-08-11T22:29:58", - "endDate": "2020-08-11T22:34:58", - "unit": "mg\/min·dL", - "value": 0.5221655603410653 - }, - { - "startDate": "2020-08-11T22:34:58", - "endDate": "2020-08-11T22:39:58", - "unit": "mg\/min·dL", - "value": 0.5002915207035925 - }, - { - "startDate": "2020-08-11T22:39:58", - "endDate": "2020-08-11T22:44:58", - "unit": "mg\/min·dL", - "value": 0.47857242198147665 - }, - { - "startDate": "2020-08-11T22:44:58", - "endDate": "2020-08-11T22:49:44", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:49:44", - "endDate": "2020-08-11T22:54:44", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:54:44", - "endDate": "2020-08-11T22:59:45", - "unit": "mg\/min·dL", - "value": 0.060537504513367056 - } -] diff --git a/LoopTests/Fixtures/high_and_falling/high_and_falling_insulin_effect.json b/LoopTests/Fixtures/high_and_falling/high_and_falling_insulin_effect.json deleted file mode 100644 index bea7fb07a4..0000000000 --- a/LoopTests/Fixtures/high_and_falling/high_and_falling_insulin_effect.json +++ /dev/null @@ -1,377 +0,0 @@ -[ - { - "date": "2020-08-11T23:00:00", - "amount": -0.30324421735766016, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:05:00", - "amount": -1.2074805603814895, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:10:00", - "amount": -2.6198776769809875, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:15:00", - "amount": -4.465672057725821, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:20:00", - "amount": -6.685266802723275, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:25:00", - "amount": -9.224809473113943, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:30:00", - "amount": -12.03541189572141, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:35:00", - "amount": -15.072766324251951, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:40:00", - "amount": -18.296788509858903, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:45:00", - "amount": -21.671285910499947, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:50:00", - "amount": -25.16364937991473, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:55:00", - "amount": -28.744566781673353, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:00:00", - "amount": -32.38775707198973, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:05:00", - "amount": -36.069723487241404, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:10:00", - "amount": -39.7695245587422, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:15:00", - "amount": -43.46856175861266, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:20:00", - "amount": -47.150382656903005, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:25:00", - "amount": -50.8004985417413, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:30:00", - "amount": -54.40621552148487, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:35:00", - "amount": -57.956478190913245, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:40:00", - "amount": -61.44172500265972, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:45:00", - "amount": -64.85375454057544, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:50:00", - "amount": -68.1856019437701, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:55:00", - "amount": -71.43142477888769, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:00:00", - "amount": -74.58639770394838, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:05:00", - "amount": -77.64661531000066, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:10:00", - "amount": -80.60900256705762, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:15:00", - "amount": -83.47123233849673, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:20:00", - "amount": -86.23164946343942, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:25:00", - "amount": -88.88920093973691, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:30:00", - "amount": -91.44337177121069, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:35:00", - "amount": -93.89412607185396, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:40:00", - "amount": -96.24185304691466, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:45:00", - "amount": -98.4873174962681, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:50:00", - "amount": -100.63161450934751, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:55:00", - "amount": -102.67612804323775, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:00:00", - "amount": -104.62249309644574, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:05:00", - "amount": -106.47256121042342, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:10:00", - "amount": -108.22836904922634, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:15:00", - "amount": -109.89210982481272, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:20:00", - "amount": -111.46610735150391, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:25:00", - "amount": -112.95279252810269, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:30:00", - "amount": -114.35468206016674, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:35:00", - "amount": -115.67435924802191, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:40:00", - "amount": -116.91445667832986, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:45:00", - "amount": -118.07764066845148, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:50:00", - "amount": -119.16659732352176, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:55:00", - "amount": -120.18402007612107, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:00:00", - "amount": -121.13259858773439, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:05:00", - "amount": -122.0150088998796, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:10:00", - "amount": -122.83390473089393, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:15:00", - "amount": -123.59190982193347, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:20:00", - "amount": -124.29161124279706, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:25:00", - "amount": -124.93555357476642, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:30:00", - "amount": -125.52623389378984, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:35:00", - "amount": -126.06609748305398, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:40:00", - "amount": -126.55753420931575, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:45:00", - "amount": -127.00287550232932, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:50:00", - "amount": -127.4043918813229, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:55:00", - "amount": -127.76429097678248, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:00:00", - "amount": -128.08471599980103, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:05:00", - "amount": -128.36774461497714, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:10:00", - "amount": -128.61538817630728, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:15:00", - "amount": -128.8295912887364, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:20:00", - "amount": -129.0122316610227, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:25:00", - "amount": -129.16512021834833, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:30:00", - "amount": -129.29000144569122, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:35:00", - "amount": -129.38855393536335, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:40:00", - "amount": -129.46239111434534, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:45:00", - "amount": -129.51306212910382, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:50:00", - "amount": -129.54205286749004, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:55:00", - "amount": -129.5507870990832, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T05:00:00", - "amount": -129.54961066748092, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T05:05:00", - "amount": -129.54931273055175, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T05:10:00", - "amount": -129.54930222233963, - "unit": "mg/dL" - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_falling/high_and_falling_momentum_effect.json b/LoopTests/Fixtures/high_and_falling/high_and_falling_momentum_effect.json deleted file mode 100644 index 1166b913bb..0000000000 --- a/LoopTests/Fixtures/high_and_falling/high_and_falling_momentum_effect.json +++ /dev/null @@ -1,27 +0,0 @@ -[ - { - "date": "2020-08-11T22:55:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T23:00:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T23:05:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T23:10:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T23:15:00", - "unit": "mg/dL", - "amount": 0.0 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_falling/high_and_falling_predicted_glucose.json b/LoopTests/Fixtures/high_and_falling/high_and_falling_predicted_glucose.json deleted file mode 100644 index 61f60a5e6a..0000000000 --- a/LoopTests/Fixtures/high_and_falling/high_and_falling_predicted_glucose.json +++ /dev/null @@ -1,382 +0,0 @@ -[ - { - "date": "2020-08-11T22:59:45", - "unit": "mg/dL", - "amount": 200.0 - }, - { - "date": "2020-08-11T23:00:00", - "unit": "mg/dL", - "amount": 200.0 - }, - { - "date": "2020-08-11T23:05:00", - "unit": "mg/dL", - "amount": 200.0111032633726 - }, - { - "date": "2020-08-11T23:10:00", - "unit": "mg/dL", - "amount": 200.01924237216699 - }, - { - "date": "2020-08-11T23:15:00", - "unit": "mg/dL", - "amount": 199.63033966967689 - }, - { - "date": "2020-08-11T23:20:00", - "unit": "mg/dL", - "amount": 198.52739386494645 - }, - { - "date": "2020-08-11T23:25:00", - "unit": "mg/dL", - "amount": 196.9449788576418 - }, - { - "date": "2020-08-11T23:30:00", - "unit": "mg/dL", - "amount": 194.9319828209393 - }, - { - "date": "2020-08-11T23:35:00", - "unit": "mg/dL", - "amount": 192.53271350113278 - }, - { - "date": "2020-08-11T23:40:00", - "unit": "mg/dL", - "amount": 189.78725514706883 - }, - { - "date": "2020-08-11T23:45:00", - "unit": "mg/dL", - "amount": 186.73180030078979 - }, - { - "date": "2020-08-11T23:50:00", - "unit": "mg/dL", - "amount": 183.398958108556 - }, - { - "date": "2020-08-11T23:55:00", - "unit": "mg/dL", - "amount": 179.81804070679738 - }, - { - "date": "2020-08-12T00:00:00", - "unit": "mg/dL", - "amount": 176.174850416481 - }, - { - "date": "2020-08-12T00:05:00", - "unit": "mg/dL", - "amount": 172.49288400122933 - }, - { - "date": "2020-08-12T00:10:00", - "unit": "mg/dL", - "amount": 168.79308292972854 - }, - { - "date": "2020-08-12T00:15:00", - "unit": "mg/dL", - "amount": 165.09404572985807 - }, - { - "date": "2020-08-12T00:20:00", - "unit": "mg/dL", - "amount": 161.41222483156773 - }, - { - "date": "2020-08-12T00:25:00", - "unit": "mg/dL", - "amount": 157.76210894672943 - }, - { - "date": "2020-08-12T00:30:00", - "unit": "mg/dL", - "amount": 154.15639196698586 - }, - { - "date": "2020-08-12T00:35:00", - "unit": "mg/dL", - "amount": 150.6061292975575 - }, - { - "date": "2020-08-12T00:40:00", - "unit": "mg/dL", - "amount": 147.12088248581102 - }, - { - "date": "2020-08-12T00:45:00", - "unit": "mg/dL", - "amount": 143.7088529478953 - }, - { - "date": "2020-08-12T00:50:00", - "unit": "mg/dL", - "amount": 140.37700554470064 - }, - { - "date": "2020-08-12T00:55:00", - "unit": "mg/dL", - "amount": 137.13118270958304 - }, - { - "date": "2020-08-12T01:00:00", - "unit": "mg/dL", - "amount": 133.97620978452235 - }, - { - "date": "2020-08-12T01:05:00", - "unit": "mg/dL", - "amount": 130.91599217847008 - }, - { - "date": "2020-08-12T01:10:00", - "unit": "mg/dL", - "amount": 127.95360492141312 - }, - { - "date": "2020-08-12T01:15:00", - "unit": "mg/dL", - "amount": 125.091375149974 - }, - { - "date": "2020-08-12T01:20:00", - "unit": "mg/dL", - "amount": 122.33095802503131 - }, - { - "date": "2020-08-12T01:25:00", - "unit": "mg/dL", - "amount": 119.67340654873382 - }, - { - "date": "2020-08-12T01:30:00", - "unit": "mg/dL", - "amount": 117.11923571726004 - }, - { - "date": "2020-08-12T01:35:00", - "unit": "mg/dL", - "amount": 114.66848141661677 - }, - { - "date": "2020-08-12T01:40:00", - "unit": "mg/dL", - "amount": 112.32075444155608 - }, - { - "date": "2020-08-12T01:45:00", - "unit": "mg/dL", - "amount": 110.07528999220263 - }, - { - "date": "2020-08-12T01:50:00", - "unit": "mg/dL", - "amount": 107.93099297912322 - }, - { - "date": "2020-08-12T01:55:00", - "unit": "mg/dL", - "amount": 105.88647944523298 - }, - { - "date": "2020-08-12T02:00:00", - "unit": "mg/dL", - "amount": 103.940114392025 - }, - { - "date": "2020-08-12T02:05:00", - "unit": "mg/dL", - "amount": 102.09004627804731 - }, - { - "date": "2020-08-12T02:10:00", - "unit": "mg/dL", - "amount": 100.33423843924439 - }, - { - "date": "2020-08-12T02:15:00", - "unit": "mg/dL", - "amount": 98.67049766365801 - }, - { - "date": "2020-08-12T02:20:00", - "unit": "mg/dL", - "amount": 97.09650013696682 - }, - { - "date": "2020-08-12T02:25:00", - "unit": "mg/dL", - "amount": 95.60981496036804 - }, - { - "date": "2020-08-12T02:30:00", - "unit": "mg/dL", - "amount": 94.207925428304 - }, - { - "date": "2020-08-12T02:35:00", - "unit": "mg/dL", - "amount": 92.88824824044882 - }, - { - "date": "2020-08-12T02:40:00", - "unit": "mg/dL", - "amount": 91.64815081014088 - }, - { - "date": "2020-08-12T02:45:00", - "unit": "mg/dL", - "amount": 90.48496682001925 - }, - { - "date": "2020-08-12T02:50:00", - "unit": "mg/dL", - "amount": 89.39601016494898 - }, - { - "date": "2020-08-12T02:55:00", - "unit": "mg/dL", - "amount": 88.37858741234966 - }, - { - "date": "2020-08-12T03:00:00", - "unit": "mg/dL", - "amount": 87.43000890073634 - }, - { - "date": "2020-08-12T03:05:00", - "unit": "mg/dL", - "amount": 86.54759858859113 - }, - { - "date": "2020-08-12T03:10:00", - "unit": "mg/dL", - "amount": 85.7287027575768 - }, - { - "date": "2020-08-12T03:15:00", - "unit": "mg/dL", - "amount": 84.97069766653726 - }, - { - "date": "2020-08-12T03:20:00", - "unit": "mg/dL", - "amount": 84.27099624567367 - }, - { - "date": "2020-08-12T03:25:00", - "unit": "mg/dL", - "amount": 83.62705391370432 - }, - { - "date": "2020-08-12T03:30:00", - "unit": "mg/dL", - "amount": 83.0363735946809 - }, - { - "date": "2020-08-12T03:35:00", - "unit": "mg/dL", - "amount": 82.49651000541675 - }, - { - "date": "2020-08-12T03:40:00", - "unit": "mg/dL", - "amount": 82.00507327915498 - }, - { - "date": "2020-08-12T03:45:00", - "unit": "mg/dL", - "amount": 81.55973198614141 - }, - { - "date": "2020-08-12T03:50:00", - "unit": "mg/dL", - "amount": 81.15821560714784 - }, - { - "date": "2020-08-12T03:55:00", - "unit": "mg/dL", - "amount": 80.79831651168826 - }, - { - "date": "2020-08-12T04:00:00", - "unit": "mg/dL", - "amount": 80.4778914886697 - }, - { - "date": "2020-08-12T04:05:00", - "unit": "mg/dL", - "amount": 80.19486287349359 - }, - { - "date": "2020-08-12T04:10:00", - "unit": "mg/dL", - "amount": 79.94721931216345 - }, - { - "date": "2020-08-12T04:15:00", - "unit": "mg/dL", - "amount": 79.73301619973434 - }, - { - "date": "2020-08-12T04:20:00", - "unit": "mg/dL", - "amount": 79.55037582744804 - }, - { - "date": "2020-08-12T04:25:00", - "unit": "mg/dL", - "amount": 79.3974872701224 - }, - { - "date": "2020-08-12T04:30:00", - "unit": "mg/dL", - "amount": 79.27260604277951 - }, - { - "date": "2020-08-12T04:35:00", - "unit": "mg/dL", - "amount": 79.17405355310738 - }, - { - "date": "2020-08-12T04:40:00", - "unit": "mg/dL", - "amount": 79.1002163741254 - }, - { - "date": "2020-08-12T04:45:00", - "unit": "mg/dL", - "amount": 79.04954535936692 - }, - { - "date": "2020-08-12T04:50:00", - "unit": "mg/dL", - "amount": 79.02055462098069 - }, - { - "date": "2020-08-12T04:55:00", - "unit": "mg/dL", - "amount": 79.01182038938752 - }, - { - "date": "2020-08-12T05:00:00", - "unit": "mg/dL", - "amount": 79.01299682098981 - }, - { - "date": "2020-08-12T05:05:00", - "unit": "mg/dL", - "amount": 79.01329475791898 - }, - { - "date": "2020-08-12T05:10:00", - "unit": "mg/dL", - "amount": 79.0133052661311 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_carb_effect.json b/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_carb_effect.json deleted file mode 100644 index 47d656b872..0000000000 --- a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_carb_effect.json +++ /dev/null @@ -1,322 +0,0 @@ -[ - { - "date": "2020-08-11T21:15:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:20:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:25:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:30:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:35:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:40:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:45:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:50:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:55:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:00:00", - "unit": "mg/dL", - "amount": 0.5054689190453953 - }, - { - "date": "2020-08-11T22:05:00", - "unit": "mg/dL", - "amount": 2.033246696823173 - }, - { - "date": "2020-08-11T22:10:00", - "unit": "mg/dL", - "amount": 3.5610244746009507 - }, - { - "date": "2020-08-11T22:15:00", - "unit": "mg/dL", - "amount": 5.088802252378729 - }, - { - "date": "2020-08-11T22:20:00", - "unit": "mg/dL", - "amount": 6.616580030156507 - }, - { - "date": "2020-08-11T22:25:00", - "unit": "mg/dL", - "amount": 8.144357807934284 - }, - { - "date": "2020-08-11T22:30:00", - "unit": "mg/dL", - "amount": 9.672135585712061 - }, - { - "date": "2020-08-11T22:35:00", - "unit": "mg/dL", - "amount": 11.199913363489841 - }, - { - "date": "2020-08-11T22:40:00", - "unit": "mg/dL", - "amount": 12.727691141267618 - }, - { - "date": "2020-08-11T22:45:00", - "unit": "mg/dL", - "amount": 14.255468919045395 - }, - { - "date": "2020-08-11T22:50:00", - "unit": "mg/dL", - "amount": 15.783246696823173 - }, - { - "date": "2020-08-11T22:55:00", - "unit": "mg/dL", - "amount": 17.311024474600952 - }, - { - "date": "2020-08-11T23:00:00", - "unit": "mg/dL", - "amount": 18.83880225237873 - }, - { - "date": "2020-08-11T23:05:00", - "unit": "mg/dL", - "amount": 20.366580030156506 - }, - { - "date": "2020-08-11T23:10:00", - "unit": "mg/dL", - "amount": 21.89435780793428 - }, - { - "date": "2020-08-11T23:15:00", - "unit": "mg/dL", - "amount": 23.422135585712063 - }, - { - "date": "2020-08-11T23:20:00", - "unit": "mg/dL", - "amount": 24.949913363489838 - }, - { - "date": "2020-08-11T23:25:00", - "unit": "mg/dL", - "amount": 26.477691141267616 - }, - { - "date": "2020-08-11T23:30:00", - "unit": "mg/dL", - "amount": 28.00546891904539 - }, - { - "date": "2020-08-11T23:35:00", - "unit": "mg/dL", - "amount": 29.533246696823177 - }, - { - "date": "2020-08-11T23:40:00", - "unit": "mg/dL", - "amount": 31.061024474600952 - }, - { - "date": "2020-08-11T23:45:00", - "unit": "mg/dL", - "amount": 32.58880225237873 - }, - { - "date": "2020-08-11T23:50:00", - "unit": "mg/dL", - "amount": 34.116580030156506 - }, - { - "date": "2020-08-11T23:55:00", - "unit": "mg/dL", - "amount": 35.644357807934284 - }, - { - "date": "2020-08-12T00:00:00", - "unit": "mg/dL", - "amount": 37.17213558571207 - }, - { - "date": "2020-08-12T00:05:00", - "unit": "mg/dL", - "amount": 38.69991336348984 - }, - { - "date": "2020-08-12T00:10:00", - "unit": "mg/dL", - "amount": 40.22769114126762 - }, - { - "date": "2020-08-12T00:15:00", - "unit": "mg/dL", - "amount": 41.7554689190454 - }, - { - "date": "2020-08-12T00:20:00", - "unit": "mg/dL", - "amount": 43.28324669682318 - }, - { - "date": "2020-08-12T00:25:00", - "unit": "mg/dL", - "amount": 44.81102447460095 - }, - { - "date": "2020-08-12T00:30:00", - "unit": "mg/dL", - "amount": 46.33880225237873 - }, - { - "date": "2020-08-12T00:35:00", - "unit": "mg/dL", - "amount": 47.86658003015651 - }, - { - "date": "2020-08-12T00:40:00", - "unit": "mg/dL", - "amount": 49.394357807934284 - }, - { - "date": "2020-08-12T00:45:00", - "unit": "mg/dL", - "amount": 50.922135585712056 - }, - { - "date": "2020-08-12T00:50:00", - "unit": "mg/dL", - "amount": 52.44991336348984 - }, - { - "date": "2020-08-12T00:55:00", - "unit": "mg/dL", - "amount": 53.97769114126762 - }, - { - "date": "2020-08-12T01:00:00", - "unit": "mg/dL", - "amount": 55.50546891904539 - }, - { - "date": "2020-08-12T01:05:00", - "unit": "mg/dL", - "amount": 57.03324669682318 - }, - { - "date": "2020-08-12T01:10:00", - "unit": "mg/dL", - "amount": 58.56102447460095 - }, - { - "date": "2020-08-12T01:15:00", - "unit": "mg/dL", - "amount": 60.08880225237873 - }, - { - "date": "2020-08-12T01:20:00", - "unit": "mg/dL", - "amount": 61.6165800301565 - }, - { - "date": "2020-08-12T01:25:00", - "unit": "mg/dL", - "amount": 63.144357807934284 - }, - { - "date": "2020-08-12T01:30:00", - "unit": "mg/dL", - "amount": 64.67213558571206 - }, - { - "date": "2020-08-12T01:35:00", - "unit": "mg/dL", - "amount": 66.19991336348984 - }, - { - "date": "2020-08-12T01:40:00", - "unit": "mg/dL", - "amount": 67.72769114126763 - }, - { - "date": "2020-08-12T01:45:00", - "unit": "mg/dL", - "amount": 69.2554689190454 - }, - { - "date": "2020-08-12T01:50:00", - "unit": "mg/dL", - "amount": 70.78324669682317 - }, - { - "date": "2020-08-12T01:55:00", - "unit": "mg/dL", - "amount": 72.31102447460096 - }, - { - "date": "2020-08-12T02:00:00", - "unit": "mg/dL", - "amount": 73.83880225237873 - }, - { - "date": "2020-08-12T02:05:00", - "unit": "mg/dL", - "amount": 75.3665800301565 - }, - { - "date": "2020-08-12T02:10:00", - "unit": "mg/dL", - "amount": 76.89435780793428 - }, - { - "date": "2020-08-12T02:15:00", - "unit": "mg/dL", - "amount": 78.42213558571207 - }, - { - "date": "2020-08-12T02:20:00", - "unit": "mg/dL", - "amount": 79.94991336348984 - }, - { - "date": "2020-08-12T02:25:00", - "unit": "mg/dL", - "amount": 81.47769114126761 - }, - { - "date": "2020-08-12T02:30:00", - "unit": "mg/dL", - "amount": 82.5 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_counteraction_effect.json b/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_counteraction_effect.json deleted file mode 100644 index 7032287fe7..0000000000 --- a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_counteraction_effect.json +++ /dev/null @@ -1,266 +0,0 @@ -[ - { - "startDate": "2020-08-11T17:25:13", - "endDate": "2020-08-11T17:30:13", - "unit": "mg\/min·dL", - "value": -0.17427698848393616 - }, - { - "startDate": "2020-08-11T17:30:13", - "endDate": "2020-08-11T17:35:13", - "unit": "mg\/min·dL", - "value": -0.172884893111717 - }, - { - "startDate": "2020-08-11T17:35:13", - "endDate": "2020-08-11T17:40:13", - "unit": "mg\/min·dL", - "value": -0.16620062119698026 - }, - { - "startDate": "2020-08-11T17:40:13", - "endDate": "2020-08-11T17:45:13", - "unit": "mg\/min·dL", - "value": -0.15239126546960888 - }, - { - "startDate": "2020-08-11T17:45:13", - "endDate": "2020-08-11T17:50:13", - "unit": "mg\/min·dL", - "value": -0.13844620387243192 - }, - { - "startDate": "2020-08-11T17:50:13", - "endDate": "2020-08-11T17:55:13", - "unit": "mg\/min·dL", - "value": -0.12440053903505803 - }, - { - "startDate": "2020-08-11T17:55:13", - "endDate": "2020-08-11T18:00:13", - "unit": "mg\/min·dL", - "value": -0.1102033787233404 - }, - { - "startDate": "2020-08-11T18:00:13", - "endDate": "2020-08-11T18:05:13", - "unit": "mg\/min·dL", - "value": -0.09582040633985235 - }, - { - "startDate": "2020-08-11T18:05:13", - "endDate": "2020-08-11T18:10:13", - "unit": "mg\/min·dL", - "value": -0.08123290693932182 - }, - { - "startDate": "2020-08-11T18:10:13", - "endDate": "2020-08-11T18:15:13", - "unit": "mg\/min·dL", - "value": -0.06643676319414542 - }, - { - "startDate": "2020-08-11T18:15:13", - "endDate": "2020-08-11T18:20:13", - "unit": "mg\/min·dL", - "value": -0.051441423013083416 - }, - { - "startDate": "2020-08-11T18:20:13", - "endDate": "2020-08-11T18:25:13", - "unit": "mg\/min·dL", - "value": -0.0362688411105418 - }, - { - "startDate": "2020-08-11T18:25:13", - "endDate": "2020-08-11T18:30:13", - "unit": "mg\/min·dL", - "value": -0.020952397377567107 - }, - { - "startDate": "2020-08-11T18:30:13", - "endDate": "2020-08-11T18:35:13", - "unit": "mg\/min·dL", - "value": -0.005535795415598254 - }, - { - "startDate": "2020-08-11T18:35:13", - "endDate": "2020-08-11T18:40:13", - "unit": "mg\/min·dL", - "value": 0.009928054942067454 - }, - { - "startDate": "2020-08-11T18:40:13", - "endDate": "2020-08-11T18:45:13", - "unit": "mg\/min·dL", - "value": 0.02537816688081129 - }, - { - "startDate": "2020-08-11T18:45:13", - "endDate": "2020-08-11T18:50:13", - "unit": "mg\/min·dL", - "value": 0.040746613021907935 - }, - { - "startDate": "2020-08-11T18:50:13", - "endDate": "2020-08-11T18:55:13", - "unit": "mg\/min·dL", - "value": 0.05595966408835151 - }, - { - "startDate": "2020-08-11T18:55:13", - "endDate": "2020-08-11T19:00:13", - "unit": "mg\/min·dL", - "value": 0.07093892464198123 - }, - { - "startDate": "2020-08-11T19:00:13", - "endDate": "2020-08-11T19:05:13", - "unit": "mg\/min·dL", - "value": 0.08560246050964196 - }, - { - "startDate": "2020-08-11T19:05:13", - "endDate": "2020-08-11T19:10:13", - "unit": "mg\/min·dL", - "value": 0.09986591236653002 - }, - { - "startDate": "2020-08-11T19:10:13", - "endDate": "2020-08-11T19:15:13", - "unit": "mg\/min·dL", - "value": 0.11364358985065513 - }, - { - "startDate": "2020-08-11T19:15:13", - "endDate": "2020-08-11T19:20:13", - "unit": "mg\/min·dL", - "value": 0.12684954054338973 - }, - { - "startDate": "2020-08-11T19:20:13", - "endDate": "2020-08-11T19:25:13", - "unit": "mg\/min·dL", - "value": 0.13939858816698666 - }, - { - "startDate": "2020-08-11T19:25:13", - "endDate": "2020-08-11T19:30:13", - "unit": "mg\/min·dL", - "value": 0.15120733442007542 - }, - { - "startDate": "2020-08-11T19:30:13", - "endDate": "2020-08-11T19:35:13", - "unit": "mg\/min·dL", - "value": 0.16219511899486355 - }, - { - "startDate": "2020-08-11T19:35:13", - "endDate": "2020-08-11T19:40:13", - "unit": "mg\/min·dL", - "value": 0.17228493249382398 - }, - { - "startDate": "2020-08-11T19:40:13", - "endDate": "2020-08-11T19:45:13", - "unit": "mg\/min·dL", - "value": 0.1814042771871964 - }, - { - "startDate": "2020-08-11T19:45:13", - "endDate": "2020-08-11T19:50:13", - "unit": "mg\/min·dL", - "value": 0.18948597082306992 - }, - { - "startDate": "2020-08-11T19:50:13", - "endDate": "2020-08-11T19:55:13", - "unit": "mg\/min·dL", - "value": 0.196468889016708 - }, - { - "startDate": "2020-08-11T19:55:13", - "endDate": "2020-08-11T20:00:13", - "unit": "mg\/min·dL", - "value": 0.20229864210263385 - }, - { - "startDate": "2020-08-11T20:00:13", - "endDate": "2020-08-11T20:05:13", - "unit": "mg\/min·dL", - "value": 0.2069281827278072 - }, - { - "startDate": "2020-08-11T20:05:13", - "endDate": "2020-08-11T20:10:13", - "unit": "mg\/min·dL", - "value": 0.21031834089428644 - }, - { - "startDate": "2020-08-11T20:10:13", - "endDate": "2020-08-11T20:15:13", - "unit": "mg\/min·dL", - "value": 0.21243828362120673 - }, - { - "startDate": "2020-08-11T20:15:13", - "endDate": "2020-08-11T20:20:13", - "unit": "mg\/min·dL", - "value": 0.213265896884441 - }, - { - "startDate": "2020-08-11T20:20:13", - "endDate": "2020-08-11T20:25:13", - "unit": "mg\/min·dL", - "value": 0.212788088004482 - }, - { - "startDate": "2020-08-11T20:25:13", - "endDate": "2020-08-11T20:32:50", - "unit": "mg\/min·dL", - "value": 0.17396858033976298 - }, - { - "startDate": "2020-08-11T20:32:50", - "endDate": "2020-08-11T20:45:02", - "unit": "mg\/min·dL", - "value": 0.18555611348135584 - }, - { - "startDate": "2020-08-11T20:45:02", - "endDate": "2020-08-11T21:09:23", - "unit": "mg\/min·dL", - "value": 0.2025162808274117 - }, - { - "startDate": "2020-08-11T21:09:23", - "endDate": "2020-08-11T21:21:34", - "unit": "mg\/min·dL", - "value": 0.2789312761868744 - }, - { - "startDate": "2020-08-11T21:21:34", - "endDate": "2020-08-11T21:33:17", - "unit": "mg\/min·dL", - "value": 0.17878610561707597 - }, - { - "startDate": "2020-08-11T21:33:17", - "endDate": "2020-08-11T21:38:17", - "unit": "mg\/min·dL", - "value": 0.29216469125794187 - }, - { - "startDate": "2020-08-11T21:38:17", - "endDate": "2020-08-11T21:43:17", - "unit": "mg\/min·dL", - "value": 0.2807908049199831 - }, - { - "startDate": "2020-08-11T21:43:17", - "endDate": "2020-08-11T21:48:04", - "unit": "mg\/min·dL", - "value": 0.27828132940268346 - } -] diff --git a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_insulin_effect.json b/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_insulin_effect.json deleted file mode 100644 index cd281f68d0..0000000000 --- a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_insulin_effect.json +++ /dev/null @@ -1,387 +0,0 @@ -[ - { - "date": "2020-08-11T21:50:00", - "amount": -8.639981829288883, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:55:00", - "amount": -9.789850828431643, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:00:00", - "amount": -10.963763653811602, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:05:00", - "amount": -12.153219270860628, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:10:00", - "amount": -13.350959307658405, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:15:00", - "amount": -14.550659188660132, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:20:00", - "amount": -15.7467157330705, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:25:00", - "amount": -16.934186099027563, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:30:00", - "amount": -18.108731231758313, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:35:00", - "amount": -19.266563509430355, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:40:00", - "amount": -20.404398300145722, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:45:00", - "amount": -21.51940916202376, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:50:00", - "amount": -22.60918643567319, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:55:00", - "amount": -23.67169899462816, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:00:00", - "amount": -24.705258934584283, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:05:00", - "amount": -25.708488996579725, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:10:00", - "amount": -26.680292532680262, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:15:00", - "amount": -27.619825835301093, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:20:00", - "amount": -28.526472663081833, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:25:00", - "amount": -29.3998208072736, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:30:00", - "amount": -30.239640552942898, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:35:00", - "amount": -31.045864898988505, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:40:00", - "amount": -31.818571410045358, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:45:00", - "amount": -32.557965581850254, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:50:00", - "amount": -33.26436560960395, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:55:00", - "amount": -33.93818845631585, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:00:00", - "amount": -34.57993712509237, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:05:00", - "amount": -35.190189045857444, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:10:00", - "amount": -35.76958549310118, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:15:00", - "amount": -36.31882195696637, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:20:00", - "amount": -36.838639395326744, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:25:00", - "amount": -37.32981629950877, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:30:00", - "amount": -37.7931615109809, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:35:00", - "amount": -38.229507730702785, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:40:00", - "amount": -38.639705666908725, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:45:00", - "amount": -39.024618770914344, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:50:00", - "amount": -39.38511851409847, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:55:00", - "amount": -39.72208016254089, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:00:00", - "amount": -40.03637900890356, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:05:00", - "amount": -40.32888702404502, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:10:00", - "amount": -40.600469893564416, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:15:00", - "amount": -40.85198440699897, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:20:00", - "amount": -41.08427616975518, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:25:00", - "amount": -41.29817761005208, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:30:00", - "amount": -41.494506255204584, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:35:00", - "amount": -41.674063253484796, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:40:00", - "amount": -41.837632119579226, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:45:00", - "amount": -41.98597768331826, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:50:00", - "amount": -42.11984522289805, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:55:00", - "amount": -42.23995976525269, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:00:00", - "amount": -42.347025537572655, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:05:00", - "amount": -42.441725555209814, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:10:00", - "amount": -42.524721332367534, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:15:00", - "amount": -42.59665270305005, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:20:00", - "amount": -42.65813774074594, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:25:00", - "amount": -42.70977276624976, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:30:00", - "amount": -42.752132433888306, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:35:00", - "amount": -42.785769887219686, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:40:00", - "amount": -42.81180494139787, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:45:00", - "amount": -42.831680423307795, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:50:00", - "amount": -42.84629245946508, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:55:00", - "amount": -42.856651353619604, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:00:00", - "amount": -42.86358529644523, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:05:00", - "amount": -42.867860863240104, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:10:00", - "amount": -42.87013681511805, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:15:00", - "amount": -42.871030675309036, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:20:00", - "amount": -42.871120411104464, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:25:00", - "amount": -42.87094608563874, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:30:00", - "amount": -42.870799980845575, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:35:00", - "amount": -42.87068168789406, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:40:00", - "amount": -42.870589529145974, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:45:00", - "amount": -42.870521892591526, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:50:00", - "amount": -42.87047723091304, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:55:00", - "amount": -42.870454060415405, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:00:00", - "amount": -42.87044984787703, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:05:00", - "amount": -42.87044984787703, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:10:00", - "amount": -42.87044984787703, - "unit": "mg/dL" - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_momentum_effect.json b/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_momentum_effect.json deleted file mode 100644 index a8472461b2..0000000000 --- a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_momentum_effect.json +++ /dev/null @@ -1,27 +0,0 @@ -[ - { - "date": "2020-08-11T21:45:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:50:00", - "unit": "mg/dL", - "amount": 0.0596641 - }, - { - "date": "2020-08-11T21:55:00", - "unit": "mg/dL", - "amount": 0.233866 - }, - { - "date": "2020-08-11T22:00:00", - "unit": "mg/dL", - "amount": 0.408067 - }, - { - "date": "2020-08-11T22:05:00", - "unit": "mg/dL", - "amount": 0.582269 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_predicted_glucose.json b/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_predicted_glucose.json deleted file mode 100644 index 7dbe1a743c..0000000000 --- a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_predicted_glucose.json +++ /dev/null @@ -1,392 +0,0 @@ -[ - { - "date": "2020-08-11T21:48:17", - "unit": "mg/dL", - "amount": 129.93174411197853 - }, - { - "date": "2020-08-11T21:50:00", - "unit": "mg/dL", - "amount": 129.99140823711906 - }, - { - "date": "2020-08-11T21:55:00", - "unit": "mg/dL", - "amount": 130.12765634266816 - }, - { - "date": "2020-08-11T22:00:00", - "unit": "mg/dL", - "amount": 130.32415384711314 - }, - { - "date": "2020-08-11T22:05:00", - "unit": "mg/dL", - "amount": 131.24594584675708 - }, - { - "date": "2020-08-11T22:10:00", - "unit": "mg/dL", - "amount": 132.27012597044103 - }, - { - "date": "2020-08-11T22:15:00", - "unit": "mg/dL", - "amount": 133.19318305239187 - }, - { - "date": "2020-08-11T22:20:00", - "unit": "mg/dL", - "amount": 134.02072027340495 - }, - { - "date": "2020-08-11T22:25:00", - "unit": "mg/dL", - "amount": 134.75768047534217 - }, - { - "date": "2020-08-11T22:30:00", - "unit": "mg/dL", - "amount": 135.4084027129766 - }, - { - "date": "2020-08-11T22:35:00", - "unit": "mg/dL", - "amount": 135.9766746081406 - }, - { - "date": "2020-08-11T22:40:00", - "unit": "mg/dL", - "amount": 136.46578079273215 - }, - { - "date": "2020-08-11T22:45:00", - "unit": "mg/dL", - "amount": 136.87854770863188 - }, - { - "date": "2020-08-11T22:50:00", - "unit": "mg/dL", - "amount": 137.31654821276024 - }, - { - "date": "2020-08-11T22:55:00", - "unit": "mg/dL", - "amount": 137.78181343158306 - }, - { - "date": "2020-08-11T23:00:00", - "unit": "mg/dL", - "amount": 138.2760312694047 - }, - { - "date": "2020-08-11T23:05:00", - "unit": "mg/dL", - "amount": 138.80057898518703 - }, - { - "date": "2020-08-11T23:10:00", - "unit": "mg/dL", - "amount": 139.35655322686426 - }, - { - "date": "2020-08-11T23:15:00", - "unit": "mg/dL", - "amount": 139.94479770202122 - }, - { - "date": "2020-08-11T23:20:00", - "unit": "mg/dL", - "amount": 140.56592865201824 - }, - { - "date": "2020-08-11T23:25:00", - "unit": "mg/dL", - "amount": 141.22035828560425 - }, - { - "date": "2020-08-11T23:30:00", - "unit": "mg/dL", - "amount": 141.90831631771272 - }, - { - "date": "2020-08-11T23:35:00", - "unit": "mg/dL", - "amount": 142.6298697494449 - }, - { - "date": "2020-08-11T23:40:00", - "unit": "mg/dL", - "amount": 143.38494101616584 - }, - { - "date": "2020-08-11T23:45:00", - "unit": "mg/dL", - "amount": 144.17332462213872 - }, - { - "date": "2020-08-11T23:50:00", - "unit": "mg/dL", - "amount": 144.9947023721628 - }, - { - "date": "2020-08-11T23:55:00", - "unit": "mg/dL", - "amount": 145.8486573032287 - }, - { - "date": "2020-08-12T00:00:00", - "unit": "mg/dL", - "amount": 146.73468641222996 - }, - { - "date": "2020-08-12T00:05:00", - "unit": "mg/dL", - "amount": 147.65221226924265 - }, - { - "date": "2020-08-12T00:10:00", - "unit": "mg/dL", - "amount": 148.6005935997767 - }, - { - "date": "2020-08-12T00:15:00", - "unit": "mg/dL", - "amount": 149.57913491368927 - }, - { - "date": "2020-08-12T00:20:00", - "unit": "mg/dL", - "amount": 150.58709525310667 - }, - { - "date": "2020-08-12T00:25:00", - "unit": "mg/dL", - "amount": 151.6236961267024 - }, - { - "date": "2020-08-12T00:30:00", - "unit": "mg/dL", - "amount": 152.68812869300805 - }, - { - "date": "2020-08-12T00:35:00", - "unit": "mg/dL", - "amount": 153.77956025106397 - }, - { - "date": "2020-08-12T00:40:00", - "unit": "mg/dL", - "amount": 154.8971400926358 - }, - { - "date": "2020-08-12T00:45:00", - "unit": "mg/dL", - "amount": 156.04000476640795 - }, - { - "date": "2020-08-12T00:50:00", - "unit": "mg/dL", - "amount": 157.2072828010016 - }, - { - "date": "2020-08-12T00:55:00", - "unit": "mg/dL", - "amount": 158.39809893033697 - }, - { - "date": "2020-08-12T01:00:00", - "unit": "mg/dL", - "amount": 159.61157786175207 - }, - { - "date": "2020-08-12T01:05:00", - "unit": "mg/dL", - "amount": 160.8468476243884 - }, - { - "date": "2020-08-12T01:10:00", - "unit": "mg/dL", - "amount": 162.10304253264678 - }, - { - "date": "2020-08-12T01:15:00", - "unit": "mg/dL", - "amount": 163.37930579699 - }, - { - "date": "2020-08-12T01:20:00", - "unit": "mg/dL", - "amount": 164.67479181201156 - }, - { - "date": "2020-08-12T01:25:00", - "unit": "mg/dL", - "amount": 165.98866814949244 - }, - { - "date": "2020-08-12T01:30:00", - "unit": "mg/dL", - "amount": 167.3201172821177 - }, - { - "date": "2020-08-12T01:35:00", - "unit": "mg/dL", - "amount": 168.6683380616153 - }, - { - "date": "2020-08-12T01:40:00", - "unit": "mg/dL", - "amount": 170.03254697329865 - }, - { - "date": "2020-08-12T01:45:00", - "unit": "mg/dL", - "amount": 171.41197918733738 - }, - { - "date": "2020-08-12T01:50:00", - "unit": "mg/dL", - "amount": 172.80588942553536 - }, - { - "date": "2020-08-12T01:55:00", - "unit": "mg/dL", - "amount": 174.2135526609585 - }, - { - "date": "2020-08-12T02:00:00", - "unit": "mg/dL", - "amount": 175.6342646664163 - }, - { - "date": "2020-08-12T02:05:00", - "unit": "mg/dL", - "amount": 177.0673424265569 - }, - { - "date": "2020-08-12T02:10:00", - "unit": "mg/dL", - "amount": 178.51212442717696 - }, - { - "date": "2020-08-12T02:15:00", - "unit": "mg/dL", - "amount": 179.96797083427222 - }, - { - "date": "2020-08-12T02:20:00", - "unit": "mg/dL", - "amount": 181.4342635743541 - }, - { - "date": "2020-08-12T02:25:00", - "unit": "mg/dL", - "amount": 182.91040632662805 - }, - { - "date": "2020-08-12T02:30:00", - "unit": "mg/dL", - "amount": 183.8903555177219 - }, - { - "date": "2020-08-12T02:35:00", - "unit": "mg/dL", - "amount": 183.85671806439052 - }, - { - "date": "2020-08-12T02:40:00", - "unit": "mg/dL", - "amount": 183.83068301021234 - }, - { - "date": "2020-08-12T02:45:00", - "unit": "mg/dL", - "amount": 183.8108075283024 - }, - { - "date": "2020-08-12T02:50:00", - "unit": "mg/dL", - "amount": 183.7961954921451 - }, - { - "date": "2020-08-12T02:55:00", - "unit": "mg/dL", - "amount": 183.78583659799057 - }, - { - "date": "2020-08-12T03:00:00", - "unit": "mg/dL", - "amount": 183.77890265516493 - }, - { - "date": "2020-08-12T03:05:00", - "unit": "mg/dL", - "amount": 183.77462708837004 - }, - { - "date": "2020-08-12T03:10:00", - "unit": "mg/dL", - "amount": 183.7723511364921 - }, - { - "date": "2020-08-12T03:15:00", - "unit": "mg/dL", - "amount": 183.7714572763011 - }, - { - "date": "2020-08-12T03:20:00", - "unit": "mg/dL", - "amount": 183.77136754050568 - }, - { - "date": "2020-08-12T03:25:00", - "unit": "mg/dL", - "amount": 183.77154186597141 - }, - { - "date": "2020-08-12T03:30:00", - "unit": "mg/dL", - "amount": 183.7716879707646 - }, - { - "date": "2020-08-12T03:35:00", - "unit": "mg/dL", - "amount": 183.7718062637161 - }, - { - "date": "2020-08-12T03:40:00", - "unit": "mg/dL", - "amount": 183.77189842246418 - }, - { - "date": "2020-08-12T03:45:00", - "unit": "mg/dL", - "amount": 183.7719660590186 - }, - { - "date": "2020-08-12T03:50:00", - "unit": "mg/dL", - "amount": 183.7720107206971 - }, - { - "date": "2020-08-12T03:55:00", - "unit": "mg/dL", - "amount": 183.77203389119472 - }, - { - "date": "2020-08-12T04:00:00", - "unit": "mg/dL", - "amount": 183.7720381037331 - }, - { - "date": "2020-08-12T04:05:00", - "unit": "mg/dL", - "amount": 183.7720381037331 - }, - { - "date": "2020-08-12T04:10:00", - "unit": "mg/dL", - "amount": 183.7720381037331 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_stable/high_and_stable_carb_effect.json b/LoopTests/Fixtures/high_and_stable/high_and_stable_carb_effect.json deleted file mode 100644 index 64848ef5a2..0000000000 --- a/LoopTests/Fixtures/high_and_stable/high_and_stable_carb_effect.json +++ /dev/null @@ -1,322 +0,0 @@ -[ - { - "date": "2020-08-12T12:05:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:10:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:15:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:20:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:25:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:30:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:35:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:40:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:45:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:50:00", - "unit": "mg/dL", - "amount": 0.03198444727394316 - }, - { - "date": "2020-08-12T12:55:00", - "unit": "mg/dL", - "amount": 0.4486511139406098 - }, - { - "date": "2020-08-12T13:00:00", - "unit": "mg/dL", - "amount": 0.8653177806072766 - }, - { - "date": "2020-08-12T13:05:00", - "unit": "mg/dL", - "amount": 1.281984447273943 - }, - { - "date": "2020-08-12T13:10:00", - "unit": "mg/dL", - "amount": 1.6986511139406095 - }, - { - "date": "2020-08-12T13:15:00", - "unit": "mg/dL", - "amount": 2.1153177806072767 - }, - { - "date": "2020-08-12T13:20:00", - "unit": "mg/dL", - "amount": 2.5319844472739432 - }, - { - "date": "2020-08-12T13:25:00", - "unit": "mg/dL", - "amount": 2.9486511139406097 - }, - { - "date": "2020-08-12T13:30:00", - "unit": "mg/dL", - "amount": 3.3653177806072763 - }, - { - "date": "2020-08-12T13:35:00", - "unit": "mg/dL", - "amount": 3.7819844472739437 - }, - { - "date": "2020-08-12T13:40:00", - "unit": "mg/dL", - "amount": 4.19865111394061 - }, - { - "date": "2020-08-12T13:45:00", - "unit": "mg/dL", - "amount": 4.615317780607277 - }, - { - "date": "2020-08-12T13:50:00", - "unit": "mg/dL", - "amount": 5.031984447273943 - }, - { - "date": "2020-08-12T13:55:00", - "unit": "mg/dL", - "amount": 5.44865111394061 - }, - { - "date": "2020-08-12T14:00:00", - "unit": "mg/dL", - "amount": 5.865317780607277 - }, - { - "date": "2020-08-12T14:05:00", - "unit": "mg/dL", - "amount": 6.281984447273943 - }, - { - "date": "2020-08-12T14:10:00", - "unit": "mg/dL", - "amount": 6.69865111394061 - }, - { - "date": "2020-08-12T14:15:00", - "unit": "mg/dL", - "amount": 7.115317780607277 - }, - { - "date": "2020-08-12T14:20:00", - "unit": "mg/dL", - "amount": 7.531984447273944 - }, - { - "date": "2020-08-12T14:25:00", - "unit": "mg/dL", - "amount": 7.94865111394061 - }, - { - "date": "2020-08-12T14:30:00", - "unit": "mg/dL", - "amount": 8.365317780607278 - }, - { - "date": "2020-08-12T14:35:00", - "unit": "mg/dL", - "amount": 8.781984447273942 - }, - { - "date": "2020-08-12T14:40:00", - "unit": "mg/dL", - "amount": 9.19865111394061 - }, - { - "date": "2020-08-12T14:45:00", - "unit": "mg/dL", - "amount": 9.615317780607278 - }, - { - "date": "2020-08-12T14:50:00", - "unit": "mg/dL", - "amount": 10.031984447273944 - }, - { - "date": "2020-08-12T14:55:00", - "unit": "mg/dL", - "amount": 10.44865111394061 - }, - { - "date": "2020-08-12T15:00:00", - "unit": "mg/dL", - "amount": 10.865317780607276 - }, - { - "date": "2020-08-12T15:05:00", - "unit": "mg/dL", - "amount": 11.281984447273942 - }, - { - "date": "2020-08-12T15:10:00", - "unit": "mg/dL", - "amount": 11.69865111394061 - }, - { - "date": "2020-08-12T15:15:00", - "unit": "mg/dL", - "amount": 12.115317780607278 - }, - { - "date": "2020-08-12T15:20:00", - "unit": "mg/dL", - "amount": 12.531984447273942 - }, - { - "date": "2020-08-12T15:25:00", - "unit": "mg/dL", - "amount": 12.94865111394061 - }, - { - "date": "2020-08-12T15:30:00", - "unit": "mg/dL", - "amount": 13.365317780607276 - }, - { - "date": "2020-08-12T15:35:00", - "unit": "mg/dL", - "amount": 13.781984447273944 - }, - { - "date": "2020-08-12T15:40:00", - "unit": "mg/dL", - "amount": 14.19865111394061 - }, - { - "date": "2020-08-12T15:45:00", - "unit": "mg/dL", - "amount": 14.615317780607274 - }, - { - "date": "2020-08-12T15:50:00", - "unit": "mg/dL", - "amount": 15.031984447273942 - }, - { - "date": "2020-08-12T15:55:00", - "unit": "mg/dL", - "amount": 15.44865111394061 - }, - { - "date": "2020-08-12T16:00:00", - "unit": "mg/dL", - "amount": 15.865317780607276 - }, - { - "date": "2020-08-12T16:05:00", - "unit": "mg/dL", - "amount": 16.281984447273942 - }, - { - "date": "2020-08-12T16:10:00", - "unit": "mg/dL", - "amount": 16.698651113940613 - }, - { - "date": "2020-08-12T16:15:00", - "unit": "mg/dL", - "amount": 17.115317780607278 - }, - { - "date": "2020-08-12T16:20:00", - "unit": "mg/dL", - "amount": 17.531984447273942 - }, - { - "date": "2020-08-12T16:25:00", - "unit": "mg/dL", - "amount": 17.94865111394061 - }, - { - "date": "2020-08-12T16:30:00", - "unit": "mg/dL", - "amount": 18.365317780607278 - }, - { - "date": "2020-08-12T16:35:00", - "unit": "mg/dL", - "amount": 18.781984447273945 - }, - { - "date": "2020-08-12T16:40:00", - "unit": "mg/dL", - "amount": 19.19865111394061 - }, - { - "date": "2020-08-12T16:45:00", - "unit": "mg/dL", - "amount": 19.615317780607278 - }, - { - "date": "2020-08-12T16:50:00", - "unit": "mg/dL", - "amount": 20.031984447273942 - }, - { - "date": "2020-08-12T16:55:00", - "unit": "mg/dL", - "amount": 20.44865111394061 - }, - { - "date": "2020-08-12T17:00:00", - "unit": "mg/dL", - "amount": 20.865317780607278 - }, - { - "date": "2020-08-12T17:05:00", - "unit": "mg/dL", - "amount": 21.281984447273942 - }, - { - "date": "2020-08-12T17:10:00", - "unit": "mg/dL", - "amount": 21.69865111394061 - }, - { - "date": "2020-08-12T17:15:00", - "unit": "mg/dL", - "amount": 22.115317780607278 - }, - { - "date": "2020-08-12T17:20:00", - "unit": "mg/dL", - "amount": 22.5 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_stable/high_and_stable_counteraction_effect.json b/LoopTests/Fixtures/high_and_stable/high_and_stable_counteraction_effect.json deleted file mode 100644 index c7e1881c48..0000000000 --- a/LoopTests/Fixtures/high_and_stable/high_and_stable_counteraction_effect.json +++ /dev/null @@ -1,512 +0,0 @@ -[ - { - "startDate": "2020-08-11T19:44:58", - "endDate": "2020-08-11T19:49:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T19:49:58", - "endDate": "2020-08-11T19:54:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T19:54:58", - "endDate": "2020-08-11T19:59:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T19:59:58", - "endDate": "2020-08-11T20:04:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T20:04:58", - "endDate": "2020-08-11T20:09:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T20:09:58", - "endDate": "2020-08-11T20:14:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T20:14:58", - "endDate": "2020-08-11T20:19:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T20:19:58", - "endDate": "2020-08-11T20:24:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T20:24:58", - "endDate": "2020-08-11T20:29:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T20:29:58", - "endDate": "2020-08-11T20:34:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T20:34:58", - "endDate": "2020-08-11T20:39:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T20:39:58", - "endDate": "2020-08-11T20:44:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T20:44:58", - "endDate": "2020-08-11T20:49:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T20:49:58", - "endDate": "2020-08-11T20:54:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T20:54:58", - "endDate": "2020-08-11T20:59:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T20:59:58", - "endDate": "2020-08-11T21:04:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T21:04:58", - "endDate": "2020-08-11T21:09:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T21:09:58", - "endDate": "2020-08-11T21:14:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T21:14:58", - "endDate": "2020-08-11T21:19:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T21:19:58", - "endDate": "2020-08-11T21:24:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T21:24:58", - "endDate": "2020-08-11T21:29:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T21:29:58", - "endDate": "2020-08-11T21:34:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T21:34:58", - "endDate": "2020-08-11T21:39:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T21:39:58", - "endDate": "2020-08-11T21:44:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T21:44:58", - "endDate": "2020-08-11T21:49:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T21:49:58", - "endDate": "2020-08-11T21:54:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T21:54:58", - "endDate": "2020-08-11T21:59:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T21:59:58", - "endDate": "2020-08-11T22:04:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:04:58", - "endDate": "2020-08-11T22:09:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:09:58", - "endDate": "2020-08-11T22:14:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:14:58", - "endDate": "2020-08-11T22:19:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:19:58", - "endDate": "2020-08-11T22:24:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:24:58", - "endDate": "2020-08-11T22:29:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:29:58", - "endDate": "2020-08-11T22:34:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:34:58", - "endDate": "2020-08-11T22:39:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:39:58", - "endDate": "2020-08-11T22:44:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:44:58", - "endDate": "2020-08-11T22:49:44", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:49:44", - "endDate": "2020-08-11T22:54:44", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:54:44", - "endDate": "2020-08-11T22:59:45", - "unit": "mg\/min·dL", - "value": 0.060537504513367056 - }, - { - "startDate": "2020-08-11T22:59:45", - "endDate": "2020-08-11T23:07:01", - "unit": "mg\/min·dL", - "value": 0.318789967635506 - }, - { - "startDate": "2020-08-11T23:07:01", - "endDate": "2020-08-11T23:20:52", - "unit": "mg\/min·dL", - "value": 0.4770283365992919 - }, - { - "startDate": "2020-08-11T23:20:52", - "endDate": "2020-08-11T23:48:53", - "unit": "mg\/min·dL", - "value": 0.560721533302221 - }, - { - "startDate": "2020-08-11T23:48:53", - "endDate": "2020-08-11T23:59:30", - "unit": "mg\/min·dL", - "value": 0.6389946260986602 - }, - { - "startDate": "2020-08-11T23:59:30", - "endDate": "2020-08-12T00:04:20", - "unit": "mg\/min·dL", - "value": 0.6935601631312946 - }, - { - "startDate": "2020-08-12T00:04:20", - "endDate": "2020-08-12T01:00:27", - "unit": "mg\/min·dL", - "value": 0.688973517799663 - }, - { - "startDate": "2020-08-12T01:00:27", - "endDate": "2020-08-12T02:58:40", - "unit": "mg\/min·dL", - "value": 0.5439342789219825 - }, - { - "startDate": "2020-08-12T02:58:40", - "endDate": "2020-08-12T03:04:10", - "unit": "mg\/min·dL", - "value": 0.3751525560480912 - }, - { - "startDate": "2020-08-12T03:04:10", - "endDate": "2020-08-12T03:16:07", - "unit": "mg\/min·dL", - "value": 0.48551004284584887 - }, - { - "startDate": "2020-08-12T03:16:07", - "endDate": "2020-08-12T09:39:22", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-12T09:39:22", - "endDate": "2020-08-12T09:44:22", - "unit": "mg\/min·dL", - "value": 3.6693499969069935e-07 - }, - { - "startDate": "2020-08-12T09:44:22", - "endDate": "2020-08-12T09:49:22", - "unit": "mg\/min·dL", - "value": 1.23039439366464e-05 - }, - { - "startDate": "2020-08-12T09:49:22", - "endDate": "2020-08-12T09:54:22", - "unit": "mg\/min·dL", - "value": 2.8175153427568468e-05 - }, - { - "startDate": "2020-08-12T09:54:22", - "endDate": "2020-08-12T09:59:22", - "unit": "mg\/min·dL", - "value": 4.2046202615375436e-05 - }, - { - "startDate": "2020-08-12T09:59:22", - "endDate": "2020-08-12T10:04:22", - "unit": "mg\/min·dL", - "value": 5.409396554054199e-05 - }, - { - "startDate": "2020-08-12T10:04:22", - "endDate": "2020-08-12T10:09:22", - "unit": "mg\/min·dL", - "value": 6.448192040302968e-05 - }, - { - "startDate": "2020-08-12T10:09:22", - "endDate": "2020-08-12T10:14:22", - "unit": "mg\/min·dL", - "value": 7.336107701339417e-05 - }, - { - "startDate": "2020-08-12T10:14:22", - "endDate": "2020-08-12T10:19:22", - "unit": "mg\/min·dL", - "value": 8.08708437316198e-05 - }, - { - "startDate": "2020-08-12T10:19:22", - "endDate": "2020-08-12T10:24:22", - "unit": "mg\/min·dL", - "value": 8.713983767792378e-05 - }, - { - "startDate": "2020-08-12T10:24:22", - "endDate": "2020-08-12T10:29:22", - "unit": "mg\/min·dL", - "value": 9.228664177056543e-05 - }, - { - "startDate": "2020-08-12T10:29:22", - "endDate": "2020-08-12T10:34:22", - "unit": "mg\/min·dL", - "value": 9.642051192999891e-05 - }, - { - "startDate": "2020-08-12T10:34:22", - "endDate": "2020-08-12T10:39:22", - "unit": "mg\/min·dL", - "value": 9.964203758581272e-05 - }, - { - "startDate": "2020-08-12T10:39:22", - "endDate": "2020-08-12T10:44:22", - "unit": "mg\/min·dL", - "value": 0.0001020437584319726 - }, - { - "startDate": "2020-08-12T10:44:22", - "endDate": "2020-08-12T10:49:22", - "unit": "mg\/min·dL", - "value": 0.00010371074019636158 - }, - { - "startDate": "2020-08-12T10:49:22", - "endDate": "2020-08-12T10:54:22", - "unit": "mg\/min·dL", - "value": 0.00010472111202159181 - }, - { - "startDate": "2020-08-12T10:54:22", - "endDate": "2020-08-12T10:59:22", - "unit": "mg\/min·dL", - "value": 0.00010514656789532351 - }, - { - "startDate": "2020-08-12T10:59:22", - "endDate": "2020-08-12T11:04:22", - "unit": "mg\/min·dL", - "value": 0.00010505283441879423 - }, - { - "startDate": "2020-08-12T11:04:22", - "endDate": "2020-08-12T11:09:22", - "unit": "mg\/min·dL", - "value": 0.00010450010706183134 - }, - { - "startDate": "2020-08-12T11:09:22", - "endDate": "2020-08-12T11:14:22", - "unit": "mg\/min·dL", - "value": 0.00010354345692046938 - }, - { - "startDate": "2020-08-12T11:14:22", - "endDate": "2020-08-12T11:19:22", - "unit": "mg\/min·dL", - "value": 0.0001022332098690782 - }, - { - "startDate": "2020-08-12T11:19:22", - "endDate": "2020-08-12T11:24:22", - "unit": "mg\/min·dL", - "value": 0.00010061529988214819 - }, - { - "startDate": "2020-08-12T11:24:22", - "endDate": "2020-08-12T11:29:22", - "unit": "mg\/min·dL", - "value": 9.873159819104443e-05 - }, - { - "startDate": "2020-08-12T11:29:22", - "endDate": "2020-08-12T11:34:22", - "unit": "mg\/min·dL", - "value": 9.662021983793364e-05 - }, - { - "startDate": "2020-08-12T11:34:22", - "endDate": "2020-08-12T11:39:22", - "unit": "mg\/min·dL", - "value": 9.431580909200209e-05 - }, - { - "startDate": "2020-08-12T11:39:22", - "endDate": "2020-08-12T11:44:22", - "unit": "mg\/min·dL", - "value": 9.184980510203684e-05 - }, - { - "startDate": "2020-08-12T11:44:22", - "endDate": "2020-08-12T11:49:22", - "unit": "mg\/min·dL", - "value": 8.925068907371241e-05 - }, - { - "startDate": "2020-08-12T11:49:22", - "endDate": "2020-08-12T11:54:22", - "unit": "mg\/min·dL", - "value": 8.654421417950385e-05 - }, - { - "startDate": "2020-08-12T11:54:22", - "endDate": "2020-08-12T11:59:22", - "unit": "mg\/min·dL", - "value": 8.375361933351428e-05 - }, - { - "startDate": "2020-08-12T11:59:22", - "endDate": "2020-08-12T12:04:22", - "unit": "mg\/min·dL", - "value": 8.089982789249161e-05 - }, - { - "startDate": "2020-08-12T12:04:22", - "endDate": "2020-08-12T12:09:22", - "unit": "mg\/min·dL", - "value": 7.800163227757589e-05 - }, - { - "startDate": "2020-08-12T12:09:22", - "endDate": "2020-08-12T12:14:22", - "unit": "mg\/min·dL", - "value": 7.507586544868751e-05 - }, - { - "startDate": "2020-08-12T12:14:22", - "endDate": "2020-08-12T12:19:22", - "unit": "mg\/min·dL", - "value": 7.213756010459904e-05 - }, - { - "startDate": "2020-08-12T12:19:22", - "endDate": "2020-08-12T12:24:22", - "unit": "mg\/min·dL", - "value": 6.920009642648118e-05 - }, - { - "startDate": "2020-08-12T12:24:22", - "endDate": "2020-08-12T12:29:22", - "unit": "mg\/min·dL", - "value": 6.627533913084806e-05 - }, - { - "startDate": "2020-08-12T12:29:22", - "endDate": "2020-08-12T12:34:22", - "unit": "mg\/min·dL", - "value": 6.337376454910829e-05 - }, - { - "startDate": "2020-08-12T12:34:22", - "endDate": "2020-08-12T12:38:59", - "unit": "mg\/min·dL", - "value": 6.563204470819873e-05 - } -] diff --git a/LoopTests/Fixtures/high_and_stable/high_and_stable_insulin_effect.json b/LoopTests/Fixtures/high_and_stable/high_and_stable_insulin_effect.json deleted file mode 100644 index e27206385c..0000000000 --- a/LoopTests/Fixtures/high_and_stable/high_and_stable_insulin_effect.json +++ /dev/null @@ -1,382 +0,0 @@ -[ - { - "date": "2020-08-12T12:40:00", - "amount": 0.0, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T12:45:00", - "amount": 0.0, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T12:50:00", - "amount": -0.00010857088891486093, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T12:55:00", - "amount": -0.11764496465132551, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T13:00:00", - "amount": -0.43873902047529706, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T13:05:00", - "amount": -0.9379108424564665, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T13:10:00", - "amount": -1.5919285563573975, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T13:15:00", - "amount": -2.379638252059979, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T13:20:00", - "amount": -3.281805691343955, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T13:25:00", - "amount": -4.280969013729399, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T13:30:00", - "amount": -5.361301721085654, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T13:35:00", - "amount": -6.508485266770114, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T13:40:00", - "amount": -7.709590617387781, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T13:45:00", - "amount": -8.952968195018745, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T13:50:00", - "amount": -10.228145645097738, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T13:55:00", - "amount": -11.525732910191868, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T14:00:00", - "amount": -12.837334122842806, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T14:05:00", - "amount": -14.15546586154826, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T14:10:00", - "amount": -15.473481342970688, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T14:15:00", - "amount": -16.785500150695594, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T14:20:00", - "amount": -18.08634312642022, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T14:25:00", - "amount": -19.3714720734388, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T14:30:00", - "amount": -20.636933944795025, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T14:35:00", - "amount": -21.8793092095861, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T14:40:00", - "amount": -23.095664110708345, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T14:45:00", - "amount": -24.28350654591071, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T14:50:00", - "amount": -25.440745321443842, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T14:55:00", - "amount": -26.565652543928085, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T15:00:00", - "amount": -27.65682893137946, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T15:05:00", - "amount": -28.71317183868988, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T15:10:00", - "amount": -29.733845806315355, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T15:15:00", - "amount": -30.71825545353683, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T15:20:00", - "amount": -31.666020549476087, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T15:25:00", - "amount": -32.576953106120015, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T15:30:00", - "amount": -33.45103634797771, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T15:35:00", - "amount": -34.2884054227078, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T15:40:00", - "amount": -35.08932972614962, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T15:45:00", - "amount": -35.854196723707794, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T15:50:00", - "amount": -36.58349715801203, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T15:55:00", - "amount": -37.27781154023619, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T16:00:00", - "amount": -37.93779782944274, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T16:05:00", - "amount": -38.564180210852335, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T16:10:00", - "amount": -39.15773889005006, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T16:15:00", - "amount": -39.7193008258551, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T16:20:00", - "amount": -40.24973132992669, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T16:25:00", - "amount": -40.749926466175516, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T16:30:00", - "amount": -41.220806187721365, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T16:35:00", - "amount": -41.66330815350251, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T16:40:00", - "amount": -42.078382170721305, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T16:45:00", - "amount": -42.46698521311987, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T16:50:00", - "amount": -42.8300769686383, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T16:55:00", - "amount": -43.16861587332966, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T17:00:00", - "amount": -43.483555591507375, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T17:05:00", - "amount": -43.77584190499485, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T17:10:00", - "amount": -44.04640997704762, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T17:15:00", - "amount": -44.296181959036595, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T17:20:00", - "amount": -44.52606491033073, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T17:25:00", - "amount": -44.736949004006426, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T17:30:00", - "amount": -44.92970599305237, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T17:35:00", - "amount": -45.10518791363997, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T17:40:00", - "amount": -45.264226003800765, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T17:45:00", - "amount": -45.40762981750194, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T17:50:00", - "amount": -45.53618651564582, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T17:55:00", - "amount": -45.650660316948574, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T18:00:00", - "amount": -45.75179209298248, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T18:05:00", - "amount": -45.8402990929014, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T18:10:00", - "amount": -45.9168747845193, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T18:15:00", - "amount": -45.98218879947835, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T18:20:00", - "amount": -46.036886971235035, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T18:25:00", - "amount": -46.08159145551451, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T18:30:00", - "amount": -46.116900923736516, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T18:35:00", - "amount": -46.14339082071065, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T18:40:00", - "amount": -46.16161367863287, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T18:45:00", - "amount": -46.17209948009722, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T18:50:00", - "amount": -46.175359225273134, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T18:55:00", - "amount": -46.175359225273134, - "unit": "mg/dL" - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_stable/high_and_stable_momentum_effect.json b/LoopTests/Fixtures/high_and_stable/high_and_stable_momentum_effect.json deleted file mode 100644 index 4d59e70865..0000000000 --- a/LoopTests/Fixtures/high_and_stable/high_and_stable_momentum_effect.json +++ /dev/null @@ -1,27 +0,0 @@ -[ - { - "date": "2020-08-12T12:35:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:40:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:45:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:50:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:55:00", - "unit": "mg/dL", - "amount": 0.0 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_stable/high_and_stable_predicted_glucose.json b/LoopTests/Fixtures/high_and_stable/high_and_stable_predicted_glucose.json deleted file mode 100644 index 5f757341ae..0000000000 --- a/LoopTests/Fixtures/high_and_stable/high_and_stable_predicted_glucose.json +++ /dev/null @@ -1,387 +0,0 @@ -[ - { - "date": "2020-08-12T12:39:22", - "unit": "mg/dL", - "amount": 200.0 - }, - { - "date": "2020-08-12T12:40:00", - "unit": "mg/dL", - "amount": 200.0 - }, - { - "date": "2020-08-12T12:45:00", - "unit": "mg/dL", - "amount": 200.00001542044052 - }, - { - "date": "2020-08-12T12:50:00", - "unit": "mg/dL", - "amount": 200.0120908555042 - }, - { - "date": "2020-08-12T12:55:00", - "unit": "mg/dL", - "amount": 200.22415504165645 - }, - { - "date": "2020-08-12T13:00:00", - "unit": "mg/dL", - "amount": 200.31998733993237 - }, - { - "date": "2020-08-12T13:05:00", - "unit": "mg/dL", - "amount": 200.23770477384636 - }, - { - "date": "2020-08-12T13:10:00", - "unit": "mg/dL", - "amount": 200.00053921763583 - }, - { - "date": "2020-08-12T13:15:00", - "unit": "mg/dL", - "amount": 199.6296445814189 - }, - { - "date": "2020-08-12T13:20:00", - "unit": "mg/dL", - "amount": 199.14425510341582 - }, - { - "date": "2020-08-12T13:25:00", - "unit": "mg/dL", - "amount": 198.56183264410652 - }, - { - "date": "2020-08-12T13:30:00", - "unit": "mg/dL", - "amount": 197.8982037016217 - }, - { - "date": "2020-08-12T13:35:00", - "unit": "mg/dL", - "amount": 197.1676868226039 - }, - { - "date": "2020-08-12T13:40:00", - "unit": "mg/dL", - "amount": 196.3832481386529 - }, - { - "date": "2020-08-12T13:45:00", - "unit": "mg/dL", - "amount": 195.5565372276886 - }, - { - "date": "2020-08-12T13:50:00", - "unit": "mg/dL", - "amount": 194.69802644427628 - }, - { - "date": "2020-08-12T13:55:00", - "unit": "mg/dL", - "amount": 193.81710584584883 - }, - { - "date": "2020-08-12T14:00:00", - "unit": "mg/dL", - "amount": 192.92217129986454 - }, - { - "date": "2020-08-12T14:05:00", - "unit": "mg/dL", - "amount": 192.02070622782577 - }, - { - "date": "2020-08-12T14:10:00", - "unit": "mg/dL", - "amount": 191.11935741307002 - }, - { - "date": "2020-08-12T14:15:00", - "unit": "mg/dL", - "amount": 190.2240052720118 - }, - { - "date": "2020-08-12T14:20:00", - "unit": "mg/dL", - "amount": 189.33982896295385 - }, - { - "date": "2020-08-12T14:25:00", - "unit": "mg/dL", - "amount": 188.47136668260194 - }, - { - "date": "2020-08-12T14:30:00", - "unit": "mg/dL", - "amount": 187.62257147791237 - }, - { - "date": "2020-08-12T14:35:00", - "unit": "mg/dL", - "amount": 186.79686287978797 - }, - { - "date": "2020-08-12T14:40:00", - "unit": "mg/dL", - "amount": 185.9971746453324 - }, - { - "date": "2020-08-12T14:45:00", - "unit": "mg/dL", - "amount": 185.2259988767967 - }, - { - "date": "2020-08-12T14:50:00", - "unit": "mg/dL", - "amount": 184.48542676793022 - }, - { - "date": "2020-08-12T14:55:00", - "unit": "mg/dL", - "amount": 183.77718621211264 - }, - { - "date": "2020-08-12T15:00:00", - "unit": "mg/dL", - "amount": 183.10267649132794 - }, - { - "date": "2020-08-12T15:05:00", - "unit": "mg/dL", - "amount": 182.4630002506842 - }, - { - "date": "2020-08-12T15:10:00", - "unit": "mg/dL", - "amount": 181.85899294972538 - }, - { - "date": "2020-08-12T15:15:00", - "unit": "mg/dL", - "amount": 181.29124996917056 - }, - { - "date": "2020-08-12T15:20:00", - "unit": "mg/dL", - "amount": 180.76015153989798 - }, - { - "date": "2020-08-12T15:25:00", - "unit": "mg/dL", - "amount": 180.26588564992073 - }, - { - "date": "2020-08-12T15:30:00", - "unit": "mg/dL", - "amount": 179.80846907472971 - }, - { - "date": "2020-08-12T15:35:00", - "unit": "mg/dL", - "amount": 179.3877666666663 - }, - { - "date": "2020-08-12T15:40:00", - "unit": "mg/dL", - "amount": 179.00350902989112 - }, - { - "date": "2020-08-12T15:45:00", - "unit": "mg/dL", - "amount": 178.65530869899962 - }, - { - "date": "2020-08-12T15:50:00", - "unit": "mg/dL", - "amount": 178.34267493136204 - }, - { - "date": "2020-08-12T15:55:00", - "unit": "mg/dL", - "amount": 178.06502721580455 - }, - { - "date": "2020-08-12T16:00:00", - "unit": "mg/dL", - "amount": 177.82170759326468 - }, - { - "date": "2020-08-12T16:05:00", - "unit": "mg/dL", - "amount": 177.61199187852174 - }, - { - "date": "2020-08-12T16:10:00", - "unit": "mg/dL", - "amount": 177.43509986599068 - }, - { - "date": "2020-08-12T16:15:00", - "unit": "mg/dL", - "amount": 177.2902045968523 - }, - { - "date": "2020-08-12T16:20:00", - "unit": "mg/dL", - "amount": 177.1764407594474 - }, - { - "date": "2020-08-12T16:25:00", - "unit": "mg/dL", - "amount": 177.09291228986524 - }, - { - "date": "2020-08-12T16:30:00", - "unit": "mg/dL", - "amount": 177.03869923498607 - }, - { - "date": "2020-08-12T16:35:00", - "unit": "mg/dL", - "amount": 177.0128639358716 - }, - { - "date": "2020-08-12T16:40:00", - "unit": "mg/dL", - "amount": 177.01445658531946 - }, - { - "date": "2020-08-12T16:45:00", - "unit": "mg/dL", - "amount": 177.04252020958756 - }, - { - "date": "2020-08-12T16:50:00", - "unit": "mg/dL", - "amount": 177.0960951207358 - }, - { - "date": "2020-08-12T16:55:00", - "unit": "mg/dL", - "amount": 177.17422288271112 - }, - { - "date": "2020-08-12T17:00:00", - "unit": "mg/dL", - "amount": 177.27594983120008 - }, - { - "date": "2020-08-12T17:05:00", - "unit": "mg/dL", - "amount": 177.40033018437927 - }, - { - "date": "2020-08-12T17:10:00", - "unit": "mg/dL", - "amount": 177.54642877899317 - }, - { - "date": "2020-08-12T17:15:00", - "unit": "mg/dL", - "amount": 177.71332346367086 - }, - { - "date": "2020-08-12T17:20:00", - "unit": "mg/dL", - "amount": 177.86812273176946 - }, - { - "date": "2020-08-12T17:25:00", - "unit": "mg/dL", - "amount": 177.65723863809376 - }, - { - "date": "2020-08-12T17:30:00", - "unit": "mg/dL", - "amount": 177.4644816490478 - }, - { - "date": "2020-08-12T17:35:00", - "unit": "mg/dL", - "amount": 177.2889997284602 - }, - { - "date": "2020-08-12T17:40:00", - "unit": "mg/dL", - "amount": 177.1299616382994 - }, - { - "date": "2020-08-12T17:45:00", - "unit": "mg/dL", - "amount": 176.9865578245982 - }, - { - "date": "2020-08-12T17:50:00", - "unit": "mg/dL", - "amount": 176.85800112645433 - }, - { - "date": "2020-08-12T17:55:00", - "unit": "mg/dL", - "amount": 176.74352732515158 - }, - { - "date": "2020-08-12T18:00:00", - "unit": "mg/dL", - "amount": 176.64239554911768 - }, - { - "date": "2020-08-12T18:05:00", - "unit": "mg/dL", - "amount": 176.55388854919875 - }, - { - "date": "2020-08-12T18:10:00", - "unit": "mg/dL", - "amount": 176.47731285758084 - }, - { - "date": "2020-08-12T18:15:00", - "unit": "mg/dL", - "amount": 176.41199884262178 - }, - { - "date": "2020-08-12T18:20:00", - "unit": "mg/dL", - "amount": 176.3573006708651 - }, - { - "date": "2020-08-12T18:25:00", - "unit": "mg/dL", - "amount": 176.3125961865856 - }, - { - "date": "2020-08-12T18:30:00", - "unit": "mg/dL", - "amount": 176.2772867183636 - }, - { - "date": "2020-08-12T18:35:00", - "unit": "mg/dL", - "amount": 176.25079682138946 - }, - { - "date": "2020-08-12T18:40:00", - "unit": "mg/dL", - "amount": 176.23257396346725 - }, - { - "date": "2020-08-12T18:45:00", - "unit": "mg/dL", - "amount": 176.22208816200288 - }, - { - "date": "2020-08-12T18:50:00", - "unit": "mg/dL", - "amount": 176.21882841682697 - }, - { - "date": "2020-08-12T18:55:00", - "unit": "mg/dL", - "amount": 176.21882841682697 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/live_capture/live_capture_input.json b/LoopTests/Fixtures/live_capture/live_capture_input.json index f010194a63..26f8a3593e 100644 --- a/LoopTests/Fixtures/live_capture/live_capture_input.json +++ b/LoopTests/Fixtures/live_capture/live_capture_input.json @@ -2,1008 +2,919 @@ "carbEntries" : [ { "absorptionTime" : 10800, - "quantity" : 22, - "startDate" : "2023-06-22T19:20:53Z" + "grams" : 22, + "date" : "2023-06-22T19:20:53Z" }, { "absorptionTime" : 10800, - "quantity" : 75, - "startDate" : "2023-06-22T21:04:45Z" + "grams" : 75, + "date" : "2023-06-22T21:04:45Z" }, { "absorptionTime" : 10800, - "quantity" : 47, - "startDate" : "2023-06-23T02:10:13Z" + "grams" : 47, + "date" : "2023-06-23T02:10:13Z" } ], "doses" : [ + { + "endDate" : "2023-06-22T16:22:40Z", + "startDate" : "2023-06-22T16:12:40Z", + "type" : "basal", + "volume" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-22T16:17:54Z", + "startDate" : "2023-06-22T16:17:46Z", + "type" : "bolus", + "volume" : 0.20000000000000001 + }, + { + "endDate" : "2023-06-22T16:32:40Z", + "startDate" : "2023-06-22T16:22:40Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-22T16:47:39Z", + "startDate" : "2023-06-22T16:32:40Z", + "type" : "basal", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T16:57:41Z", + "startDate" : "2023-06-22T16:47:39Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-22T17:02:38Z", + "startDate" : "2023-06-22T16:57:41Z", + "type" : "basal", + "volume" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-22T17:07:38Z", + "startDate" : "2023-06-22T17:02:38Z", + "type" : "basal", + "volume" : 0.0041666666666666666 + }, + { + "endDate" : "2023-06-22T17:22:45Z", + "startDate" : "2023-06-22T17:07:38Z", + "type" : "basal", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T17:12:46Z", + "startDate" : "2023-06-22T17:12:42Z", + "type" : "bolus", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T17:27:39Z", + "startDate" : "2023-06-22T17:22:45Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-22T17:27:39Z", + "startDate" : "2023-06-22T17:27:39Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-22T17:32:39Z", + "startDate" : "2023-06-22T17:27:39Z", + "type" : "basal", + "volume" : 0.0041666666666666666 + }, + { + "endDate" : "2023-06-22T18:07:38Z", + "startDate" : "2023-06-22T17:32:39Z", + "type" : "basal", + "volume" : 0.25 + }, + { + "endDate" : "2023-06-22T17:32:45Z", + "startDate" : "2023-06-22T17:32:41Z", + "type" : "bolus", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T17:42:40Z", + "startDate" : "2023-06-22T17:42:38Z", + "type" : "bolus", + "volume" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-22T17:47:43Z", + "startDate" : "2023-06-22T17:47:39Z", + "type" : "bolus", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T18:12:38Z", + "startDate" : "2023-06-22T18:07:38Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-22T19:17:40Z", + "startDate" : "2023-06-22T18:12:38Z", + "type" : "basal", + "volume" : 0.45000000000000001 + }, + { + "endDate" : "2023-06-22T19:02:43Z", + "startDate" : "2023-06-22T19:02:39Z", + "type" : "bolus", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T19:22:43Z", + "startDate" : "2023-06-22T19:17:40Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-22T19:21:49Z", + "startDate" : "2023-06-22T19:21:01Z", + "type" : "bolus", + "volume" : 1.2 + }, + { + "endDate" : "2023-06-22T19:37:37Z", + "startDate" : "2023-06-22T19:22:43Z", + "type" : "basal", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T19:27:43Z", + "startDate" : "2023-06-22T19:27:39Z", + "type" : "bolus", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T19:57:48Z", + "startDate" : "2023-06-22T19:37:37Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-22T19:57:48Z", + "startDate" : "2023-06-22T19:57:48Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-22T20:02:39Z", + "startDate" : "2023-06-22T19:57:48Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-22T20:07:40Z", + "startDate" : "2023-06-22T20:02:39Z", + "type" : "basal", + "volume" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-22T20:12:40Z", + "startDate" : "2023-06-22T20:07:40Z", + "type" : "basal", + "volume" : 0.0083333333333333332 + }, + { + "endDate" : "2023-06-22T20:52:45Z", + "startDate" : "2023-06-22T20:12:40Z", + "type" : "basal", + "volume" : 0.25 + }, + { + "endDate" : "2023-06-22T21:07:43Z", + "startDate" : "2023-06-22T20:52:45Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-22T21:07:49Z", + "startDate" : "2023-06-22T21:04:51Z", + "type" : "bolus", + "volume" : 4.4500000000000002 + }, + { + "endDate" : "2023-06-22T21:47:38Z", + "startDate" : "2023-06-22T21:07:43Z", + "type" : "basal", + "volume" : 0.25 + }, + { + "endDate" : "2023-06-22T21:12:42Z", + "startDate" : "2023-06-22T21:12:40Z", + "type" : "bolus", + "volume" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-22T22:07:39Z", + "startDate" : "2023-06-22T21:47:38Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-22T23:42:40Z", + "startDate" : "2023-06-22T22:07:39Z", + "type" : "basal", + "volume" : 0.65000000000000002 + }, + { + "endDate" : "2023-06-22T22:27:46Z", + "startDate" : "2023-06-22T22:27:38Z", + "type" : "bolus", + "volume" : 0.20000000000000001 + }, + { + "endDate" : "2023-06-22T22:37:44Z", + "startDate" : "2023-06-22T22:37:40Z", + "type" : "bolus", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T22:42:42Z", + "startDate" : "2023-06-22T22:42:40Z", + "type" : "bolus", + "volume" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-22T23:52:44Z", + "startDate" : "2023-06-22T23:42:40Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-22T23:57:46Z", + "startDate" : "2023-06-22T23:52:44Z", + "type" : "basal", + "volume" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-23T00:02:37Z", + "startDate" : "2023-06-22T23:57:46Z", + "type" : "basal", + "volume" : 0.0040416666666666665 + }, + { + "endDate" : "2023-06-23T01:02:52Z", + "startDate" : "2023-06-23T00:02:37Z", + "type" : "basal", + "volume" : 0.40000000000000002 + }, + { + "endDate" : "2023-06-23T00:07:42Z", + "startDate" : "2023-06-23T00:07:40Z", + "type" : "bolus", + "volume" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-23T00:12:44Z", + "startDate" : "2023-06-23T00:12:38Z", + "type" : "bolus", + "volume" : 0.14999999999999999 + }, + { + "endDate" : "2023-06-23T00:22:43Z", + "startDate" : "2023-06-23T00:22:39Z", + "type" : "bolus", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-23T00:27:49Z", + "startDate" : "2023-06-23T00:27:41Z", + "type" : "bolus", + "volume" : 0.20000000000000001 + }, + { + "endDate" : "2023-06-23T00:32:43Z", + "startDate" : "2023-06-23T00:32:39Z", + "type" : "bolus", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-23T00:37:58Z", + "startDate" : "2023-06-23T00:37:48Z", + "type" : "bolus", + "volume" : 0.25 + }, + { + "endDate" : "2023-06-23T00:42:47Z", + "startDate" : "2023-06-23T00:42:39Z", + "type" : "bolus", + "volume" : 0.20000000000000001 + }, + { + "endDate" : "2023-06-23T00:47:44Z", + "startDate" : "2023-06-23T00:47:40Z", + "type" : "bolus", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-23T00:52:51Z", + "startDate" : "2023-06-23T00:52:45Z", + "type" : "bolus", + "volume" : 0.14999999999999999 + }, + { + "endDate" : "2023-06-23T01:12:49Z", + "startDate" : "2023-06-23T01:02:52Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-23T01:17:41Z", + "startDate" : "2023-06-23T01:12:49Z", + "type" : "basal", + "volume" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-23T01:12:54Z", + "startDate" : "2023-06-23T01:12:50Z", + "type" : "bolus", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-23T01:37:39Z", + "startDate" : "2023-06-23T01:17:41Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-23T01:37:39Z", + "startDate" : "2023-06-23T01:37:39Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-23T01:42:38Z", + "startDate" : "2023-06-23T01:37:39Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-23T02:07:42Z", + "startDate" : "2023-06-23T01:42:38Z", + "type" : "basal", + "volume" : 0.14999999999999999 + }, + { + "endDate" : "2023-06-23T01:47:46Z", + "startDate" : "2023-06-23T01:47:38Z", + "type" : "bolus", + "volume" : 0.20000000000000001 + }, + { + "endDate" : "2023-06-23T01:52:47Z", + "startDate" : "2023-06-23T01:52:39Z", + "type" : "bolus", + "volume" : 0.20000000000000001 + }, + { + "endDate" : "2023-06-23T01:57:50Z", + "startDate" : "2023-06-23T01:57:40Z", + "type" : "bolus", + "volume" : 0.25 + }, + { + "endDate" : "2023-06-23T02:02:49Z", + "startDate" : "2023-06-23T02:02:39Z", + "type" : "bolus", + "volume" : 0.25 + }, + { + "endDate" : "2023-06-23T02:07:36Z", + "startDate" : "2023-06-23T02:04:30Z", + "type" : "bolus", + "volume" : 4.6500000000000004 + }, + { + "endDate" : "2023-06-23T02:27:44Z", + "startDate" : "2023-06-23T02:07:42Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-23T02:27:44Z", + "startDate" : "2023-06-23T02:27:44Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-23T02:47:39Z", + "startDate" : "2023-06-23T02:27:44Z", + "type" : "basal", + "volume" : 0 + } + ], + "glucoseHistory" : [ { - "endDate" : "2023-06-22T16:22:40Z", - "startDate" : "2023-06-22T16:12:40Z", - "type" : "basal", - "unit" : "U", - "value" : 0.050000000000000003 - }, - { - "endDate" : "2023-06-22T16:17:54Z", - "startDate" : "2023-06-22T16:17:46Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.20000000000000001 - }, - { - "endDate" : "2023-06-22T16:32:40Z", - "startDate" : "2023-06-22T16:22:40Z", - "type" : "tempBasal", - "unit" : "U\/hour", - "value" : 0 - }, - { - "endDate" : "2023-06-22T16:47:39Z", - "startDate" : "2023-06-22T16:32:40Z", - "type" : "basal", - "unit" : "U", - "value" : 0.10000000000000001 - }, - { - "endDate" : "2023-06-22T16:57:41Z", - "startDate" : "2023-06-22T16:47:39Z", - "type" : "tempBasal", - "unit" : "U\/hour", - "value" : 0 - }, - { - "endDate" : "2023-06-22T17:02:38Z", - "startDate" : "2023-06-22T16:57:41Z", - "type" : "basal", - "unit" : "U", - "value" : 0.050000000000000003 - }, - { - "endDate" : "2023-06-22T17:07:38Z", - "startDate" : "2023-06-22T17:02:38Z", - "type" : "tempBasal", - "unit" : "U\/hour", - "value" : 0.050000000000000003 - }, - { - "endDate" : "2023-06-22T17:22:45Z", - "startDate" : "2023-06-22T17:07:38Z", - "type" : "basal", - "unit" : "U", - "value" : 0.10000000000000001 - }, - { - "endDate" : "2023-06-22T17:12:46Z", - "startDate" : "2023-06-22T17:12:42Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.10000000000000001 - }, - { - "endDate" : "2023-06-22T17:27:39Z", - "startDate" : "2023-06-22T17:22:45Z", - "type" : "tempBasal", - "unit" : "U\/hour", - "value" : 0 - }, - { - "endDate" : "2023-06-22T17:27:39Z", - "startDate" : "2023-06-22T17:27:39Z", - "type" : "basal", - "unit" : "U", - "value" : 0 - }, - { - "endDate" : "2023-06-22T17:32:39Z", - "startDate" : "2023-06-22T17:27:39Z", - "type" : "tempBasal", - "unit" : "U\/hour", - "value" : 0.050000000000000003 - }, - { - "endDate" : "2023-06-22T18:07:38Z", - "startDate" : "2023-06-22T17:32:39Z", - "type" : "basal", - "unit" : "U", - "value" : 0.25 - }, - { - "endDate" : "2023-06-22T17:32:45Z", - "startDate" : "2023-06-22T17:32:41Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.10000000000000001 - }, - { - "endDate" : "2023-06-22T17:42:40Z", - "startDate" : "2023-06-22T17:42:38Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.050000000000000003 - }, - { - "endDate" : "2023-06-22T17:47:43Z", - "startDate" : "2023-06-22T17:47:39Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.10000000000000001 - }, - { - "endDate" : "2023-06-22T18:12:38Z", - "startDate" : "2023-06-22T18:07:38Z", - "type" : "tempBasal", - "unit" : "U\/hour", - "value" : 0 - }, - { - "endDate" : "2023-06-22T19:17:40Z", - "startDate" : "2023-06-22T18:12:38Z", - "type" : "basal", - "unit" : "U", - "value" : 0.45000000000000001 - }, - { - "endDate" : "2023-06-22T19:02:43Z", - "startDate" : "2023-06-22T19:02:39Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.10000000000000001 - }, - { - "endDate" : "2023-06-22T19:22:43Z", - "startDate" : "2023-06-22T19:17:40Z", - "type" : "tempBasal", - "unit" : "U\/hour", - "value" : 0 - }, - { - "endDate" : "2023-06-22T19:21:49Z", - "startDate" : "2023-06-22T19:21:01Z", - "type" : "bolus", - "unit" : "U", - "value" : 1.2 - }, - { - "endDate" : "2023-06-22T19:37:37Z", - "startDate" : "2023-06-22T19:22:43Z", - "type" : "basal", - "unit" : "U", - "value" : 0.10000000000000001 - }, - { - "endDate" : "2023-06-22T19:27:43Z", - "startDate" : "2023-06-22T19:27:39Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.10000000000000001 - }, - { - "endDate" : "2023-06-22T19:57:48Z", - "startDate" : "2023-06-22T19:37:37Z", - "type" : "tempBasal", - "unit" : "U\/hour", - "value" : 0 - }, - { - "endDate" : "2023-06-22T19:57:48Z", - "startDate" : "2023-06-22T19:57:48Z", - "type" : "basal", - "unit" : "U", - "value" : 0 - }, - { - "endDate" : "2023-06-22T20:02:39Z", - "startDate" : "2023-06-22T19:57:48Z", - "type" : "tempBasal", - "unit" : "U\/hour", - "value" : 0 - }, - { - "endDate" : "2023-06-22T20:07:40Z", - "startDate" : "2023-06-22T20:02:39Z", - "type" : "basal", - "unit" : "U", - "value" : 0.050000000000000003 - }, - { - "endDate" : "2023-06-22T20:12:40Z", - "startDate" : "2023-06-22T20:07:40Z", - "type" : "tempBasal", - "unit" : "U\/hour", - "value" : 0.10000000000000001 - }, - { - "endDate" : "2023-06-22T20:52:45Z", - "startDate" : "2023-06-22T20:12:40Z", - "type" : "basal", - "unit" : "U", - "value" : 0.25 - }, - { - "endDate" : "2023-06-22T21:07:43Z", - "startDate" : "2023-06-22T20:52:45Z", - "type" : "tempBasal", - "unit" : "U\/hour", - "value" : 0 - }, - { - "endDate" : "2023-06-22T21:07:49Z", - "startDate" : "2023-06-22T21:04:51Z", - "type" : "bolus", - "unit" : "U", - "value" : 4.4500000000000002 - }, - { - "endDate" : "2023-06-22T21:47:38Z", - "startDate" : "2023-06-22T21:07:43Z", - "type" : "basal", - "unit" : "U", - "value" : 0.25 - }, - { - "endDate" : "2023-06-22T21:12:42Z", - "startDate" : "2023-06-22T21:12:40Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.050000000000000003 - }, - { - "endDate" : "2023-06-22T22:07:39Z", - "startDate" : "2023-06-22T21:47:38Z", - "type" : "tempBasal", - "unit" : "U\/hour", - "value" : 0 - }, - { - "endDate" : "2023-06-22T23:42:40Z", - "startDate" : "2023-06-22T22:07:39Z", - "type" : "basal", - "unit" : "U", - "value" : 0.65000000000000002 - }, - { - "endDate" : "2023-06-22T22:27:46Z", - "startDate" : "2023-06-22T22:27:38Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.20000000000000001 - }, - { - "endDate" : "2023-06-22T22:37:44Z", - "startDate" : "2023-06-22T22:37:40Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.10000000000000001 - }, - { - "endDate" : "2023-06-22T22:42:42Z", - "startDate" : "2023-06-22T22:42:40Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.050000000000000003 - }, - { - "endDate" : "2023-06-22T23:52:44Z", - "startDate" : "2023-06-22T23:42:40Z", - "type" : "tempBasal", - "unit" : "U\/hour", - "value" : 0 - }, - { - "endDate" : "2023-06-22T23:57:46Z", - "startDate" : "2023-06-22T23:52:44Z", - "type" : "basal", - "unit" : "U", - "value" : 0.050000000000000003 - }, - { - "endDate" : "2023-06-23T00:02:37Z", - "startDate" : "2023-06-22T23:57:46Z", - "type" : "tempBasal", - "unit" : "U\/hour", - "value" : 0.050000000000000003 - }, - { - "endDate" : "2023-06-23T01:02:52Z", - "startDate" : "2023-06-23T00:02:37Z", - "type" : "basal", - "unit" : "U", - "value" : 0.40000000000000002 - }, - { - "endDate" : "2023-06-23T00:07:42Z", - "startDate" : "2023-06-23T00:07:40Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.050000000000000003 - }, - { - "endDate" : "2023-06-23T00:12:44Z", - "startDate" : "2023-06-23T00:12:38Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.14999999999999999 - }, - { - "endDate" : "2023-06-23T00:22:43Z", - "startDate" : "2023-06-23T00:22:39Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.10000000000000001 - }, - { - "endDate" : "2023-06-23T00:27:49Z", - "startDate" : "2023-06-23T00:27:41Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.20000000000000001 - }, - { - "endDate" : "2023-06-23T00:32:43Z", - "startDate" : "2023-06-23T00:32:39Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.10000000000000001 - }, - { - "endDate" : "2023-06-23T00:37:58Z", - "startDate" : "2023-06-23T00:37:48Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.25 - }, - { - "endDate" : "2023-06-23T00:42:47Z", - "startDate" : "2023-06-23T00:42:39Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.20000000000000001 - }, - { - "endDate" : "2023-06-23T00:47:44Z", - "startDate" : "2023-06-23T00:47:40Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.10000000000000001 - }, - { - "endDate" : "2023-06-23T00:52:51Z", - "startDate" : "2023-06-23T00:52:45Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.14999999999999999 - }, - { - "endDate" : "2023-06-23T01:12:49Z", - "startDate" : "2023-06-23T01:02:52Z", - "type" : "tempBasal", - "unit" : "U\/hour", - "value" : 0 - }, - { - "endDate" : "2023-06-23T01:17:41Z", - "startDate" : "2023-06-23T01:12:49Z", - "type" : "basal", - "unit" : "U", - "value" : 0.050000000000000003 - }, - { - "endDate" : "2023-06-23T01:12:54Z", - "startDate" : "2023-06-23T01:12:50Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.10000000000000001 - }, - { - "endDate" : "2023-06-23T01:37:39Z", - "startDate" : "2023-06-23T01:17:41Z", - "type" : "tempBasal", - "unit" : "U\/hour", - "value" : 0 - }, - { - "endDate" : "2023-06-23T01:37:39Z", - "startDate" : "2023-06-23T01:37:39Z", - "type" : "basal", - "unit" : "U", - "value" : 0 - }, - { - "endDate" : "2023-06-23T01:42:38Z", - "startDate" : "2023-06-23T01:37:39Z", - "type" : "tempBasal", - "unit" : "U\/hour", - "value" : 0 - }, - { - "endDate" : "2023-06-23T02:07:42Z", - "startDate" : "2023-06-23T01:42:38Z", - "type" : "basal", - "unit" : "U", - "value" : 0.14999999999999999 - }, - { - "endDate" : "2023-06-23T01:47:46Z", - "startDate" : "2023-06-23T01:47:38Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.20000000000000001 - }, - { - "endDate" : "2023-06-23T01:52:47Z", - "startDate" : "2023-06-23T01:52:39Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.20000000000000001 - }, - { - "endDate" : "2023-06-23T01:57:50Z", - "startDate" : "2023-06-23T01:57:40Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.25 - }, - { - "endDate" : "2023-06-23T02:02:49Z", - "startDate" : "2023-06-23T02:02:39Z", - "type" : "bolus", - "unit" : "U", - "value" : 0.25 - }, - { - "endDate" : "2023-06-23T02:07:36Z", - "startDate" : "2023-06-23T02:04:30Z", - "type" : "bolus", - "unit" : "U", - "value" : 4.6500000000000004 + "value" : 120, + "date" : "2023-06-22T16:42:33Z" }, { - "endDate" : "2023-06-23T02:27:44Z", - "startDate" : "2023-06-23T02:07:42Z", - "type" : "tempBasal", - "unit" : "U\/hour", - "value" : 0 + "value" : 119, + "date" : "2023-06-22T16:47:33Z" }, { - "endDate" : "2023-06-23T02:27:44Z", - "startDate" : "2023-06-23T02:27:44Z", - "type" : "basal", - "unit" : "U", - "value" : 0 + "value" : 120, + "date" : "2023-06-22T16:52:34Z" }, { - "endDate" : "2023-06-23T02:47:39Z", - "startDate" : "2023-06-23T02:27:44Z", - "type" : "tempBasal", - "unit" : "U\/hour", - "value" : 0 - } - ], - "glucoseHistory" : [ - { - "quantity" : 120, - "startDate" : "2023-06-22T16:42:33Z" + "value" : 118, + "date" : "2023-06-22T16:57:34Z" }, { - "quantity" : 119, - "startDate" : "2023-06-22T16:47:33Z" + "value" : 115, + "date" : "2023-06-22T17:02:34Z" }, { - "quantity" : 120, - "startDate" : "2023-06-22T16:52:34Z" + "value" : 120, + "date" : "2023-06-22T17:07:34Z" }, { - "quantity" : 118, - "startDate" : "2023-06-22T16:57:34Z" + "value" : 121, + "date" : "2023-06-22T17:12:34Z" }, { - "quantity" : 115, - "startDate" : "2023-06-22T17:02:34Z" + "value" : 119, + "date" : "2023-06-22T17:17:34Z" }, { - "quantity" : 120, - "startDate" : "2023-06-22T17:07:34Z" + "value" : 116, + "date" : "2023-06-22T17:22:34Z" }, { - "quantity" : 121, - "startDate" : "2023-06-22T17:12:34Z" + "value" : 115, + "date" : "2023-06-22T17:27:34Z" }, { - "quantity" : 119, - "startDate" : "2023-06-22T17:17:34Z" + "value" : 124, + "date" : "2023-06-22T17:32:34Z" }, { - "quantity" : 116, - "startDate" : "2023-06-22T17:22:34Z" + "value" : 114, + "date" : "2023-06-22T17:37:34Z" }, { - "quantity" : 115, - "startDate" : "2023-06-22T17:27:34Z" + "value" : 124, + "date" : "2023-06-22T17:42:34Z" }, { - "quantity" : 124, - "startDate" : "2023-06-22T17:32:34Z" + "value" : 124, + "date" : "2023-06-22T17:47:33Z" }, { - "quantity" : 114, - "startDate" : "2023-06-22T17:37:34Z" + "value" : 124, + "date" : "2023-06-22T17:52:34Z" }, { - "quantity" : 124, - "startDate" : "2023-06-22T17:42:34Z" + "value" : 126, + "date" : "2023-06-22T17:57:33Z" }, { - "quantity" : 124, - "startDate" : "2023-06-22T17:47:33Z" + "value" : 125, + "date" : "2023-06-22T18:02:34Z" }, { - "quantity" : 124, - "startDate" : "2023-06-22T17:52:34Z" + "value" : 118, + "date" : "2023-06-22T18:07:34Z" }, { - "quantity" : 126, - "startDate" : "2023-06-22T17:57:33Z" + "value" : 122, + "date" : "2023-06-22T18:12:33Z" }, { - "quantity" : 125, - "startDate" : "2023-06-22T18:02:34Z" + "value" : 123, + "date" : "2023-06-22T18:17:34Z" }, { - "quantity" : 118, - "startDate" : "2023-06-22T18:07:34Z" + "value" : 123, + "date" : "2023-06-22T18:22:34Z" }, { - "quantity" : 122, - "startDate" : "2023-06-22T18:12:33Z" + "value" : 121, + "date" : "2023-06-22T18:27:34Z" }, { - "quantity" : 123, - "startDate" : "2023-06-22T18:17:34Z" + "value" : 118, + "date" : "2023-06-22T18:32:34Z" }, { - "quantity" : 123, - "startDate" : "2023-06-22T18:22:34Z" + "value" : 116, + "date" : "2023-06-22T18:37:34Z" }, { - "quantity" : 121, - "startDate" : "2023-06-22T18:27:34Z" + "value" : 118, + "date" : "2023-06-22T18:42:34Z" }, { - "quantity" : 118, - "startDate" : "2023-06-22T18:32:34Z" + "value" : 115, + "date" : "2023-06-22T18:47:34Z" }, { - "quantity" : 116, - "startDate" : "2023-06-22T18:37:34Z" + "value" : 117, + "date" : "2023-06-22T18:52:34Z" }, { - "quantity" : 118, - "startDate" : "2023-06-22T18:42:34Z" + "value" : 125, + "date" : "2023-06-22T18:57:34Z" }, { - "quantity" : 115, - "startDate" : "2023-06-22T18:47:34Z" + "value" : 122, + "date" : "2023-06-22T19:02:34Z" }, { - "quantity" : 117, - "startDate" : "2023-06-22T18:52:34Z" + "value" : 119, + "date" : "2023-06-22T19:07:34Z" }, { - "quantity" : 125, - "startDate" : "2023-06-22T18:57:34Z" + "value" : 120, + "date" : "2023-06-22T19:12:34Z" }, { - "quantity" : 122, - "startDate" : "2023-06-22T19:02:34Z" + "value" : 112, + "date" : "2023-06-22T19:17:34Z" }, { - "quantity" : 119, - "startDate" : "2023-06-22T19:07:34Z" + "value" : 111, + "date" : "2023-06-22T19:22:34Z" }, { - "quantity" : 120, - "startDate" : "2023-06-22T19:12:34Z" + "value" : 114, + "date" : "2023-06-22T19:27:34Z" }, { - "quantity" : 112, - "startDate" : "2023-06-22T19:17:34Z" + "value" : 117, + "date" : "2023-06-22T19:32:34Z" }, { - "quantity" : 111, - "startDate" : "2023-06-22T19:22:34Z" + "value" : 107, + "date" : "2023-06-22T19:37:34Z" }, { - "quantity" : 114, - "startDate" : "2023-06-22T19:27:34Z" + "value" : 113, + "date" : "2023-06-22T19:42:34Z" }, { - "quantity" : 117, - "startDate" : "2023-06-22T19:32:34Z" + "value" : 117, + "date" : "2023-06-22T19:47:34Z" }, { - "quantity" : 107, - "startDate" : "2023-06-22T19:37:34Z" + "value" : 109, + "date" : "2023-06-22T19:52:34Z" }, { - "quantity" : 113, - "startDate" : "2023-06-22T19:42:34Z" + "value" : 117, + "date" : "2023-06-22T19:57:34Z" }, { - "quantity" : 117, - "startDate" : "2023-06-22T19:47:34Z" + "value" : 121, + "date" : "2023-06-22T20:02:34Z" }, { - "quantity" : 109, - "startDate" : "2023-06-22T19:52:34Z" + "value" : 121, + "date" : "2023-06-22T20:07:34Z" }, { - "quantity" : 117, - "startDate" : "2023-06-22T19:57:34Z" + "value" : 127, + "date" : "2023-06-22T20:12:34Z" }, { - "quantity" : 121, - "startDate" : "2023-06-22T20:02:34Z" + "value" : 133, + "date" : "2023-06-22T20:17:34Z" }, { - "quantity" : 121, - "startDate" : "2023-06-22T20:07:34Z" + "value" : 131, + "date" : "2023-06-22T20:22:34Z" }, { - "quantity" : 127, - "startDate" : "2023-06-22T20:12:34Z" + "value" : 132, + "date" : "2023-06-22T20:27:34Z" }, { - "quantity" : 133, - "startDate" : "2023-06-22T20:17:34Z" + "value" : 134, + "date" : "2023-06-22T20:32:34Z" }, { - "quantity" : 131, - "startDate" : "2023-06-22T20:22:34Z" + "value" : 134, + "date" : "2023-06-22T20:37:34Z" }, { - "quantity" : 132, - "startDate" : "2023-06-22T20:27:34Z" + "value" : 139, + "date" : "2023-06-22T20:42:34Z" }, { - "quantity" : 134, - "startDate" : "2023-06-22T20:32:34Z" + "value" : 139, + "date" : "2023-06-22T20:47:34Z" }, { - "quantity" : 134, - "startDate" : "2023-06-22T20:37:34Z" + "value" : 132, + "date" : "2023-06-22T20:52:34Z" }, { - "quantity" : 139, - "startDate" : "2023-06-22T20:42:34Z" + "value" : 118, + "date" : "2023-06-22T20:57:34Z" }, { - "quantity" : 139, - "startDate" : "2023-06-22T20:47:34Z" + "value" : 123, + "date" : "2023-06-22T21:02:34Z" }, { - "quantity" : 132, - "startDate" : "2023-06-22T20:52:34Z" + "value" : 122, + "date" : "2023-06-22T21:07:34Z" }, { - "quantity" : 118, - "startDate" : "2023-06-22T20:57:34Z" + "value" : 119, + "date" : "2023-06-22T21:12:34Z" }, { - "quantity" : 123, - "startDate" : "2023-06-22T21:02:34Z" + "value" : 116, + "date" : "2023-06-22T21:17:34Z" }, { - "quantity" : 122, - "startDate" : "2023-06-22T21:07:34Z" + "value" : 113, + "date" : "2023-06-22T21:22:34Z" }, { - "quantity" : 119, - "startDate" : "2023-06-22T21:12:34Z" + "value" : 111, + "date" : "2023-06-22T21:27:34Z" }, { - "quantity" : 116, - "startDate" : "2023-06-22T21:17:34Z" + "value" : 112, + "date" : "2023-06-22T21:32:34Z" }, { - "quantity" : 113, - "startDate" : "2023-06-22T21:22:34Z" + "value" : 107, + "date" : "2023-06-22T21:37:34Z" }, { - "quantity" : 111, - "startDate" : "2023-06-22T21:27:34Z" + "value" : 102, + "date" : "2023-06-22T21:42:34Z" }, { - "quantity" : 112, - "startDate" : "2023-06-22T21:32:34Z" + "value" : 95, + "date" : "2023-06-22T21:47:34Z" }, { - "quantity" : 107, - "startDate" : "2023-06-22T21:37:34Z" + "value" : 96, + "date" : "2023-06-22T21:52:34Z" }, { - "quantity" : 102, - "startDate" : "2023-06-22T21:42:34Z" + "value" : 89, + "date" : "2023-06-22T21:57:34Z" }, { - "quantity" : 95, - "startDate" : "2023-06-22T21:47:34Z" + "value" : 95, + "date" : "2023-06-22T22:02:34Z" }, { - "quantity" : 96, - "startDate" : "2023-06-22T21:52:34Z" + "value" : 95, + "date" : "2023-06-22T22:07:34Z" }, { - "quantity" : 89, - "startDate" : "2023-06-22T21:57:34Z" + "value" : 93, + "date" : "2023-06-22T22:12:34Z" }, { - "quantity" : 95, - "startDate" : "2023-06-22T22:02:34Z" + "value" : 98, + "date" : "2023-06-22T22:17:35Z" }, { - "quantity" : 95, - "startDate" : "2023-06-22T22:07:34Z" + "value" : 95, + "date" : "2023-06-22T22:22:35Z" }, { - "quantity" : 93, - "startDate" : "2023-06-22T22:12:34Z" + "value" : 101, + "date" : "2023-06-22T22:27:34Z" }, { - "quantity" : 98, - "startDate" : "2023-06-22T22:17:35Z" + "value" : 97, + "date" : "2023-06-22T22:32:34Z" }, { - "quantity" : 95, - "startDate" : "2023-06-22T22:22:35Z" + "value" : 108, + "date" : "2023-06-22T22:37:35Z" }, { - "quantity" : 101, - "startDate" : "2023-06-22T22:27:34Z" + "value" : 109, + "date" : "2023-06-22T22:42:34Z" }, { - "quantity" : 97, - "startDate" : "2023-06-22T22:32:34Z" + "value" : 109, + "date" : "2023-06-22T22:47:34Z" }, { - "quantity" : 108, - "startDate" : "2023-06-22T22:37:35Z" + "value" : 114, + "date" : "2023-06-22T22:52:34Z" }, { - "quantity" : 109, - "startDate" : "2023-06-22T22:42:34Z" + "value" : 115, + "date" : "2023-06-22T22:57:34Z" }, { - "quantity" : 109, - "startDate" : "2023-06-22T22:47:34Z" + "value" : 114, + "date" : "2023-06-22T23:02:34Z" }, { - "quantity" : 114, - "startDate" : "2023-06-22T22:52:34Z" + "value" : 121, + "date" : "2023-06-22T23:07:34Z" }, { - "quantity" : 115, - "startDate" : "2023-06-22T22:57:34Z" + "value" : 119, + "date" : "2023-06-22T23:12:34Z" }, { - "quantity" : 114, - "startDate" : "2023-06-22T23:02:34Z" + "value" : 117, + "date" : "2023-06-22T23:17:34Z" }, { - "quantity" : 121, - "startDate" : "2023-06-22T23:07:34Z" + "value" : 120, + "date" : "2023-06-22T23:22:35Z" }, { - "quantity" : 119, - "startDate" : "2023-06-22T23:12:34Z" + "value" : 122, + "date" : "2023-06-22T23:27:34Z" }, { - "quantity" : 117, - "startDate" : "2023-06-22T23:17:34Z" + "value" : 123, + "date" : "2023-06-22T23:32:34Z" }, { - "quantity" : 120, - "startDate" : "2023-06-22T23:22:35Z" + "value" : 127, + "date" : "2023-06-22T23:37:34Z" }, { - "quantity" : 122, - "startDate" : "2023-06-22T23:27:34Z" + "value" : 118, + "date" : "2023-06-22T23:42:35Z" }, { - "quantity" : 123, - "startDate" : "2023-06-22T23:32:34Z" + "value" : 120, + "date" : "2023-06-22T23:47:34Z" }, { - "quantity" : 127, - "startDate" : "2023-06-22T23:37:34Z" + "value" : 119, + "date" : "2023-06-22T23:52:35Z" }, { - "quantity" : 118, - "startDate" : "2023-06-22T23:42:35Z" + "value" : 115, + "date" : "2023-06-22T23:57:34Z" }, { - "quantity" : 120, - "startDate" : "2023-06-22T23:47:34Z" + "value" : 116, + "date" : "2023-06-23T00:02:34Z" }, { - "quantity" : 119, - "startDate" : "2023-06-22T23:52:35Z" + "value" : 133, + "date" : "2023-06-23T00:07:34Z" }, { - "quantity" : 115, - "startDate" : "2023-06-22T23:57:34Z" + "value" : 145, + "date" : "2023-06-23T00:12:34Z" }, { - "quantity" : 116, - "startDate" : "2023-06-23T00:02:34Z" + "value" : 140, + "date" : "2023-06-23T00:17:34Z" }, { - "quantity" : 133, - "startDate" : "2023-06-23T00:07:34Z" + "value" : 161, + "date" : "2023-06-23T00:22:35Z" }, { - "quantity" : 145, - "startDate" : "2023-06-23T00:12:34Z" + "value" : 166, + "date" : "2023-06-23T00:27:34Z" }, { - "quantity" : 140, - "startDate" : "2023-06-23T00:17:34Z" + "value" : 172, + "date" : "2023-06-23T00:32:35Z" }, { - "quantity" : 161, - "startDate" : "2023-06-23T00:22:35Z" + "value" : 182, + "date" : "2023-06-23T00:37:35Z" }, { - "quantity" : 166, - "startDate" : "2023-06-23T00:27:34Z" + "value" : 184, + "date" : "2023-06-23T00:42:35Z" }, { - "quantity" : 172, - "startDate" : "2023-06-23T00:32:35Z" + "value" : 185, + "date" : "2023-06-23T00:47:34Z" }, { - "quantity" : 182, - "startDate" : "2023-06-23T00:37:35Z" + "value" : 190, + "date" : "2023-06-23T00:52:35Z" }, { - "quantity" : 184, - "startDate" : "2023-06-23T00:42:35Z" + "value" : 182, + "date" : "2023-06-23T00:57:34Z" }, { - "quantity" : 185, - "startDate" : "2023-06-23T00:47:34Z" + "value" : 166, + "date" : "2023-06-23T01:02:35Z" }, { - "quantity" : 190, - "startDate" : "2023-06-23T00:52:35Z" + "value" : 174, + "date" : "2023-06-23T01:07:34Z" }, { - "quantity" : 182, - "startDate" : "2023-06-23T00:57:34Z" + "value" : 179, + "date" : "2023-06-23T01:12:34Z" }, { - "quantity" : 166, - "startDate" : "2023-06-23T01:02:35Z" + "value" : 166, + "date" : "2023-06-23T01:17:35Z" }, { - "quantity" : 174, - "startDate" : "2023-06-23T01:07:34Z" + "value" : 134, + "date" : "2023-06-23T01:22:34Z" }, { - "quantity" : 179, - "startDate" : "2023-06-23T01:12:34Z" + "value" : 131, + "date" : "2023-06-23T01:27:35Z" }, { - "quantity" : 166, - "startDate" : "2023-06-23T01:17:35Z" + "value" : 129, + "date" : "2023-06-23T01:32:34Z" }, { - "quantity" : 134, - "startDate" : "2023-06-23T01:22:34Z" + "value" : 136, + "date" : "2023-06-23T01:37:34Z" }, { - "quantity" : 131, - "startDate" : "2023-06-23T01:27:35Z" + "value" : 152, + "date" : "2023-06-23T01:42:34Z" }, { - "quantity" : 129, - "startDate" : "2023-06-23T01:32:34Z" + "value" : 162, + "date" : "2023-06-23T01:47:35Z" }, { - "quantity" : 136, - "startDate" : "2023-06-23T01:37:34Z" + "value" : 165, + "date" : "2023-06-23T01:52:34Z" }, { - "quantity" : 152, - "startDate" : "2023-06-23T01:42:34Z" + "value" : 172, + "date" : "2023-06-23T01:57:34Z" }, { - "quantity" : 162, - "startDate" : "2023-06-23T01:47:35Z" + "value" : 176, + "date" : "2023-06-23T02:02:35Z" }, { - "quantity" : 165, - "startDate" : "2023-06-23T01:52:34Z" + "value" : 165, + "date" : "2023-06-23T02:07:35Z" }, { - "quantity" : 172, - "startDate" : "2023-06-23T01:57:34Z" + "value" : 172, + "date" : "2023-06-23T02:12:34Z" }, { - "quantity" : 176, - "startDate" : "2023-06-23T02:02:35Z" + "value" : 170, + "date" : "2023-06-23T02:17:35Z" }, { - "quantity" : 165, - "startDate" : "2023-06-23T02:07:35Z" + "value" : 177, + "date" : "2023-06-23T02:22:35Z" }, { - "quantity" : 172, - "startDate" : "2023-06-23T02:12:34Z" + "value" : 176, + "date" : "2023-06-23T02:27:35Z" }, { - "quantity" : 170, - "startDate" : "2023-06-23T02:17:35Z" + "value" : 173, + "date" : "2023-06-23T02:32:34Z" }, { - "quantity" : 177, - "startDate" : "2023-06-23T02:22:35Z" - }, + "value" : 180, + "date" : "2023-06-23T02:37:35Z" + } + ], + "basal" : [ { - "quantity" : 176, - "startDate" : "2023-06-23T02:27:35Z" - }, + "endDate" : "2023-06-23T05:00:00Z", + "startDate" : "2023-06-22T10:00:00Z", + "value" : 0.45000000000000001 + } + ], + "carbRatio" : [ { - "quantity" : 173, - "startDate" : "2023-06-23T02:32:34Z" - }, + "endDate" : "2023-06-23T07:00:00Z", + "startDate" : "2023-06-22T07:00:00Z", + "value" : 11 + } + ], + "sensitivity" : [ { - "quantity" : 180, - "startDate" : "2023-06-23T02:37:35Z" + "endDate" : "2023-06-23T05:00:00Z", + "startDate" : "2023-06-22T10:00:00Z", + "value" : 60 } ], - "settings" : { - "basal" : [ - { - "endDate" : "2023-06-23T05:00:00Z", - "startDate" : "2023-06-22T10:00:00Z", - "value" : 0.45000000000000001 - } - ], - "carbRatio" : [ - { - "endDate" : "2023-06-23T07:00:00Z", - "startDate" : "2023-06-22T07:00:00Z", - "value" : 11 - } - ], - "maximumBasalRatePerHour" : null, - "maximumBolus" : null, - "sensitivity" : [ - { - "endDate" : "2023-06-23T05:00:00Z", - "startDate" : "2023-06-22T10:00:00Z", - "value" : 60 - } - ], - "suspendThreshold" : null, - "target" : [ - { - "endDate" : "2023-06-23T07:00:00Z", - "startDate" : "2023-06-22T20:25:00Z", - "value" : { - "maxValue" : 115, - "minValue" : 100 - } - }, - { - "endDate" : "2023-06-23T08:50:00Z", - "startDate" : "2023-06-23T07:00:00Z", - "value" : { - "maxValue" : 115, - "minValue" : 100 - } - } - ] - } } diff --git a/LoopTests/Fixtures/live_capture/live_capture_predicted_glucose.json b/LoopTests/Fixtures/live_capture/live_capture_predicted_glucose.json index a98fbaccb7..b77cb55868 100644 --- a/LoopTests/Fixtures/live_capture/live_capture_predicted_glucose.json +++ b/LoopTests/Fixtures/live_capture/live_capture_predicted_glucose.json @@ -10,382 +10,382 @@ "startDate" : "2023-06-23T02:40:00Z" }, { - "quantity" : 180.51458820506667, + "quantity" : 180.52987493690765, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T02:45:00Z" }, { - "quantity" : 179.7158986124237, + "quantity" : 179.77931710835796, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T02:50:00Z" }, { - "quantity" : 177.66868460973922, + "quantity" : 177.81435588000684, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T02:55:00Z" }, { - "quantity" : 174.80252509117634, + "quantity" : 175.04920382978105, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T03:00:00Z" }, { - "quantity" : 171.74984493231631, + "quantity" : 172.09884468881066, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T03:05:00Z" }, { - "quantity" : 168.58187755437024, + "quantity" : 169.0341959170697, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T03:10:00Z" }, { - "quantity" : 165.36216340804185, + "quantity" : 165.91852357330802, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T03:15:00Z" }, { - "quantity" : 162.12697210734922, + "quantity" : 162.78787379965794, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T03:20:00Z" }, { - "quantity" : 158.90986429144345, + "quantity" : 159.67566374385987, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T03:25:00Z" }, { - "quantity" : 155.75684851046043, + "quantity" : 156.6278000530812, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T03:30:00Z" }, { - "quantity" : 152.70869296700107, + "quantity" : 153.68497899133908, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T03:35:00Z" }, { - "quantity" : 149.78068888956841, + "quantity" : 150.85857622089654, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T03:40:00Z" }, { - "quantity" : 147.00401242102828, + "quantity" : 148.1797464838103, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T03:45:00Z" }, { - "quantity" : 144.40563853768242, + "quantity" : 145.67546444468488, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T03:50:00Z" }, { - "quantity" : 142.0087170601098, + "quantity" : 143.36889813413907, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T03:55:00Z" }, { - "quantity" : 139.83295658233396, + "quantity" : 141.27978455565565, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T04:00:00Z" }, { - "quantity" : 137.89511837124121, + "quantity" : 139.4249156157845, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T04:05:00Z" }, { - "quantity" : 136.07526338088792, + "quantity" : 137.7082164432302, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T04:10:00Z" }, { - "quantity" : 134.25815754225141, + "quantity" : 135.9914530272836, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T04:15:00Z" }, { - "quantity" : 132.45275084533137, + "quantity" : 134.2827664300858, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T04:20:00Z" }, { - "quantity" : 130.66563522056958, + "quantity" : 132.58882252103788, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T04:25:00Z" }, { - "quantity" : 128.90146920949769, + "quantity" : 130.91436540926705, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T04:30:00Z" }, { - "quantity" : 127.16322092092855, + "quantity" : 129.26245506698106, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T04:35:00Z" }, { - "quantity" : 125.45215396105368, + "quantity" : 127.63445215517064, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T04:40:00Z" }, { - "quantity" : 123.76712483433676, + "quantity" : 126.02931442610466, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T04:45:00Z" }, { - "quantity" : 122.10683165409341, + "quantity" : 124.44584453318035, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T04:50:00Z" }, { - "quantity" : 120.46857875163471, + "quantity" : 122.88145382927624, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T04:55:00Z" }, { - "quantity" : 118.84903308222181, + "quantity" : 121.33291804466413, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T05:00:00Z" }, { - "quantity" : 117.24445077397047, + "quantity" : 119.79660318395023, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T05:05:00Z" }, { - "quantity" : 115.65043839655846, + "quantity" : 118.26822621269756, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T05:10:00Z" }, { - "quantity" : 114.06198688414838, + "quantity" : 116.74288846240054, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T05:15:00Z" }, { - "quantity" : 112.47356001340279, + "quantity" : 115.21516364934988, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T05:20:00Z" }, { - "quantity" : 110.87917488553444, + "quantity" : 113.67917795139525, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T05:25:00Z" }, { - "quantity" : 109.27247502015473, + "quantity" : 112.12868274578355, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T05:30:00Z" }, { - "quantity" : 107.64679662666447, + "quantity" : 110.55712056957398, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T05:35:00Z" }, { - "quantity" : 105.99522857963143, + "quantity" : 108.95768482515078, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T05:40:00Z" }, { - "quantity" : 104.31066658787131, + "quantity" : 107.32337371691418, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T05:45:00Z" }, { - "quantity" : 102.58586201263279, + "quantity" : 105.64703887119052, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T05:50:00Z" }, { - "quantity" : 100.81350120847731, + "quantity" : 103.92146136061618, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T05:55:00Z" }, { - "quantity" : 98.986445102805988, + "quantity" : 102.13957364029821, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T06:00:00Z" }, { - "quantity" : 97.097518927124952, + "quantity" : 100.29425666336888, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T06:05:00Z" }, { - "quantity" : 95.139330662672023, + "quantity" : 98.37810372588095, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T06:10:00Z" }, { - "quantity" : 93.104670202578632, + "quantity" : 96.38393930539169, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T06:15:00Z" }, { - "quantity" : 90.986165185301502, + "quantity" : 94.30446350902744, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T06:20:00Z" }, { - "quantity" : 88.909927040807588, + "quantity" : 92.24204127278486, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T06:25:00Z" }, { - "quantity" : 86.994338611676767, + "quantity" : 90.33818302395392, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T06:30:00Z" }, { - "quantity" : 85.232136877351081, + "quantity" : 88.58657375772682, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T06:35:00Z" }, { - "quantity" : 83.615651290380811, + "quantity" : 86.9796355549934, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T06:40:00Z" }, { - "quantity" : 82.136746744082188, + "quantity" : 85.50932186775859, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T06:45:00Z" }, { - "quantity" : 80.787935960558002, + "quantity" : 84.16822997919033, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T06:50:00Z" }, { - "quantity" : 79.561150334091622, + "quantity" : 82.94837192653554, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T06:55:00Z" }, { - "quantity" : 78.448809315519384, + "quantity" : 81.84224397138112, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T07:00:00Z" }, { - "quantity" : 77.444295000376087, + "quantity" : 80.8433012790305, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T07:05:00Z" }, { - "quantity" : 76.541144021775267, + "quantity" : 79.94514990703274, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T07:10:00Z" }, { - "quantity" : 75.734033247701291, + "quantity" : 79.1425285689858, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T07:15:00Z" }, { - "quantity" : 75.018229944400559, + "quantity" : 78.43073701607969, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T07:20:00Z" }, { - "quantity" : 74.389076912965834, + "quantity" : 77.80513210408813, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T07:25:00Z" }, { - "quantity" : 73.841309919727451, + "quantity" : 77.26038909817899, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T07:30:00Z" }, { - "quantity" : 73.370549918316215, + "quantity" : 76.79214128522554, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T07:35:00Z" }, { - "quantity" : 72.972744055408953, + "quantity" : 76.39636603545401, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T07:40:00Z" }, { - "quantity" : 72.643975082565134, + "quantity" : 76.06917517261084, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T07:45:00Z" }, { - "quantity" : 72.380461060355856, + "quantity" : 75.80681469169488, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T07:50:00Z" }, { - "quantity" : 72.178520063294286, + "quantity" : 75.60563685065486, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T07:55:00Z" }, { - "quantity" : 72.034174053629386, + "quantity" : 75.46174433219417, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T08:00:00Z" }, { - "quantity" : 71.942299096190823, + "quantity" : 75.3700976935867, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T08:05:00Z" }, { - "quantity" : 71.897751011456421, + "quantity" : 75.32563190200372, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T08:10:00Z" }, { - "quantity" : 71.895123880236383, + "quantity" : 75.32301505961473, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T08:15:00Z" }, { - "quantity" : 71.906254842464136, + "quantity" : 75.33414614640142, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T08:20:00Z" }, { - "quantity" : 71.914434937142801, + "quantity" : 75.34232624108009, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T08:25:00Z" }, { - "quantity" : 71.920167940771535, + "quantity" : 75.34805924470882, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T08:30:00Z" }, { - "quantity" : 71.923927819981145, + "quantity" : 75.35181912391843, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T08:35:00Z" }, { - "quantity" : 71.926159114246957, + "quantity" : 75.35405041818424, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T08:40:00Z" }, { - "quantity" : 71.927280081079402, + "quantity" : 75.35517138501669, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T08:45:00Z" }, { - "quantity" : 71.927682355083221, + "quantity" : 75.35557365902051, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T08:50:00Z" }, { - "quantity" : 71.927731342958282, + "quantity" : 75.35562264689557, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T08:55:00Z" }, { - "quantity" : 71.927731342958282, + "quantity" : 75.35562264689557, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T09:00:00Z" } diff --git a/LoopTests/Fixtures/low_and_falling/low_and_falling_carb_effect.json b/LoopTests/Fixtures/low_and_falling/low_and_falling_carb_effect.json deleted file mode 100644 index 3c22d51132..0000000000 --- a/LoopTests/Fixtures/low_and_falling/low_and_falling_carb_effect.json +++ /dev/null @@ -1,322 +0,0 @@ -[ - { - "date": "2020-08-11T21:35:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:40:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:45:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:50:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:55:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:00:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:05:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:10:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:15:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:20:00", - "unit": "mg/dL", - "amount": 1.113814925485187 - }, - { - "date": "2020-08-11T22:25:00", - "unit": "mg/dL", - "amount": 2.641592703262965 - }, - { - "date": "2020-08-11T22:30:00", - "unit": "mg/dL", - "amount": 4.169370481040743 - }, - { - "date": "2020-08-11T22:35:00", - "unit": "mg/dL", - "amount": 5.697148258818521 - }, - { - "date": "2020-08-11T22:40:00", - "unit": "mg/dL", - "amount": 7.224926036596299 - }, - { - "date": "2020-08-11T22:45:00", - "unit": "mg/dL", - "amount": 8.752703814374076 - }, - { - "date": "2020-08-11T22:50:00", - "unit": "mg/dL", - "amount": 10.280481592151855 - }, - { - "date": "2020-08-11T22:55:00", - "unit": "mg/dL", - "amount": 11.808259369929631 - }, - { - "date": "2020-08-11T23:00:00", - "unit": "mg/dL", - "amount": 13.336037147707408 - }, - { - "date": "2020-08-11T23:05:00", - "unit": "mg/dL", - "amount": 14.863814925485187 - }, - { - "date": "2020-08-11T23:10:00", - "unit": "mg/dL", - "amount": 16.391592703262965 - }, - { - "date": "2020-08-11T23:15:00", - "unit": "mg/dL", - "amount": 17.919370481040744 - }, - { - "date": "2020-08-11T23:20:00", - "unit": "mg/dL", - "amount": 19.44714825881852 - }, - { - "date": "2020-08-11T23:25:00", - "unit": "mg/dL", - "amount": 20.974926036596298 - }, - { - "date": "2020-08-11T23:30:00", - "unit": "mg/dL", - "amount": 22.502703814374076 - }, - { - "date": "2020-08-11T23:35:00", - "unit": "mg/dL", - "amount": 24.030481592151855 - }, - { - "date": "2020-08-11T23:40:00", - "unit": "mg/dL", - "amount": 25.558259369929633 - }, - { - "date": "2020-08-11T23:45:00", - "unit": "mg/dL", - "amount": 27.086037147707408 - }, - { - "date": "2020-08-11T23:50:00", - "unit": "mg/dL", - "amount": 28.613814925485187 - }, - { - "date": "2020-08-11T23:55:00", - "unit": "mg/dL", - "amount": 30.141592703262965 - }, - { - "date": "2020-08-12T00:00:00", - "unit": "mg/dL", - "amount": 31.66937048104074 - }, - { - "date": "2020-08-12T00:05:00", - "unit": "mg/dL", - "amount": 33.197148258818515 - }, - { - "date": "2020-08-12T00:10:00", - "unit": "mg/dL", - "amount": 34.7249260365963 - }, - { - "date": "2020-08-12T00:15:00", - "unit": "mg/dL", - "amount": 36.25270381437407 - }, - { - "date": "2020-08-12T00:20:00", - "unit": "mg/dL", - "amount": 37.78048159215186 - }, - { - "date": "2020-08-12T00:25:00", - "unit": "mg/dL", - "amount": 39.30825936992963 - }, - { - "date": "2020-08-12T00:30:00", - "unit": "mg/dL", - "amount": 40.83603714770741 - }, - { - "date": "2020-08-12T00:35:00", - "unit": "mg/dL", - "amount": 42.36381492548519 - }, - { - "date": "2020-08-12T00:40:00", - "unit": "mg/dL", - "amount": 43.891592703262965 - }, - { - "date": "2020-08-12T00:45:00", - "unit": "mg/dL", - "amount": 45.419370481040744 - }, - { - "date": "2020-08-12T00:50:00", - "unit": "mg/dL", - "amount": 46.947148258818515 - }, - { - "date": "2020-08-12T00:55:00", - "unit": "mg/dL", - "amount": 48.47492603659629 - }, - { - "date": "2020-08-12T01:00:00", - "unit": "mg/dL", - "amount": 50.00270381437408 - }, - { - "date": "2020-08-12T01:05:00", - "unit": "mg/dL", - "amount": 51.53048159215186 - }, - { - "date": "2020-08-12T01:10:00", - "unit": "mg/dL", - "amount": 53.05825936992963 - }, - { - "date": "2020-08-12T01:15:00", - "unit": "mg/dL", - "amount": 54.58603714770741 - }, - { - "date": "2020-08-12T01:20:00", - "unit": "mg/dL", - "amount": 56.113814925485194 - }, - { - "date": "2020-08-12T01:25:00", - "unit": "mg/dL", - "amount": 57.641592703262965 - }, - { - "date": "2020-08-12T01:30:00", - "unit": "mg/dL", - "amount": 59.169370481040744 - }, - { - "date": "2020-08-12T01:35:00", - "unit": "mg/dL", - "amount": 60.697148258818515 - }, - { - "date": "2020-08-12T01:40:00", - "unit": "mg/dL", - "amount": 62.2249260365963 - }, - { - "date": "2020-08-12T01:45:00", - "unit": "mg/dL", - "amount": 63.75270381437407 - }, - { - "date": "2020-08-12T01:50:00", - "unit": "mg/dL", - "amount": 65.28048159215186 - }, - { - "date": "2020-08-12T01:55:00", - "unit": "mg/dL", - "amount": 66.80825936992963 - }, - { - "date": "2020-08-12T02:00:00", - "unit": "mg/dL", - "amount": 68.33603714770742 - }, - { - "date": "2020-08-12T02:05:00", - "unit": "mg/dL", - "amount": 69.86381492548519 - }, - { - "date": "2020-08-12T02:10:00", - "unit": "mg/dL", - "amount": 71.39159270326296 - }, - { - "date": "2020-08-12T02:15:00", - "unit": "mg/dL", - "amount": 72.91937048104073 - }, - { - "date": "2020-08-12T02:20:00", - "unit": "mg/dL", - "amount": 74.44714825881853 - }, - { - "date": "2020-08-12T02:25:00", - "unit": "mg/dL", - "amount": 75.9749260365963 - }, - { - "date": "2020-08-12T02:30:00", - "unit": "mg/dL", - "amount": 77.50270381437407 - }, - { - "date": "2020-08-12T02:35:00", - "unit": "mg/dL", - "amount": 79.03048159215186 - }, - { - "date": "2020-08-12T02:40:00", - "unit": "mg/dL", - "amount": 80.55825936992963 - }, - { - "date": "2020-08-12T02:45:00", - "unit": "mg/dL", - "amount": 82.08603714770742 - }, - { - "date": "2020-08-12T02:50:00", - "unit": "mg/dL", - "amount": 82.5 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/low_and_falling/low_and_falling_counteraction_effect.json b/LoopTests/Fixtures/low_and_falling/low_and_falling_counteraction_effect.json deleted file mode 100644 index 5e9442a191..0000000000 --- a/LoopTests/Fixtures/low_and_falling/low_and_falling_counteraction_effect.json +++ /dev/null @@ -1,218 +0,0 @@ -[ - { - "startDate": "2020-08-11T19:06:06", - "endDate": "2020-08-11T19:11:06", - "unit": "mg\/min·dL", - "value": -0.3485359639226971 - }, - { - "startDate": "2020-08-11T19:11:06", - "endDate": "2020-08-11T19:16:06", - "unit": "mg\/min·dL", - "value": -0.34571948711910916 - }, - { - "startDate": "2020-08-11T19:16:06", - "endDate": "2020-08-11T19:21:06", - "unit": "mg\/min·dL", - "value": -0.3110996208816001 - }, - { - "startDate": "2020-08-11T19:21:06", - "endDate": "2020-08-11T19:26:06", - "unit": "mg\/min·dL", - "value": -0.17115290442012446 - }, - { - "startDate": "2020-08-11T19:26:06", - "endDate": "2020-08-11T19:31:06", - "unit": "mg\/min·dL", - "value": -0.035078937546724906 - }, - { - "startDate": "2020-08-11T19:31:06", - "endDate": "2020-08-11T19:36:06", - "unit": "mg\/min·dL", - "value": 0.08735109214809061 - }, - { - "startDate": "2020-08-11T19:36:06", - "endDate": "2020-08-11T19:41:06", - "unit": "mg\/min·dL", - "value": 0.19746935782304254 - }, - { - "startDate": "2020-08-11T19:41:06", - "endDate": "2020-08-11T19:46:06", - "unit": "mg\/min·dL", - "value": 0.2964814415989495 - }, - { - "startDate": "2020-08-11T19:46:06", - "endDate": "2020-08-11T19:51:06", - "unit": "mg\/min·dL", - "value": 0.3854747645772338 - }, - { - "startDate": "2020-08-11T19:51:06", - "endDate": "2020-08-11T19:56:06", - "unit": "mg\/min·dL", - "value": 0.4654266535840445 - }, - { - "startDate": "2020-08-11T19:56:06", - "endDate": "2020-08-11T20:01:06", - "unit": "mg\/min·dL", - "value": 0.5372120677140927 - }, - { - "startDate": "2020-08-11T20:01:06", - "endDate": "2020-08-11T20:06:06", - "unit": "mg\/min·dL", - "value": 0.6016110049547307 - }, - { - "startDate": "2020-08-11T20:06:06", - "endDate": "2020-08-11T20:11:06", - "unit": "mg\/min·dL", - "value": 0.6593156065538323 - }, - { - "startDate": "2020-08-11T20:11:06", - "endDate": "2020-08-11T20:16:06", - "unit": "mg\/min·dL", - "value": 0.7109369743543738 - }, - { - "startDate": "2020-08-11T20:16:06", - "endDate": "2020-08-11T20:21:06", - "unit": "mg\/min·dL", - "value": 0.7570117140551543 - }, - { - "startDate": "2020-08-11T20:21:06", - "endDate": "2020-08-11T20:26:06", - "unit": "mg\/min·dL", - "value": 0.7980082152690883 - }, - { - "startDate": "2020-08-11T20:26:06", - "endDate": "2020-08-11T20:31:06", - "unit": "mg\/min·dL", - "value": 0.8343326773392674 - }, - { - "startDate": "2020-08-11T20:31:06", - "endDate": "2020-08-11T20:36:06", - "unit": "mg\/min·dL", - "value": 0.8663348881353556 - }, - { - "startDate": "2020-08-11T20:36:06", - "endDate": "2020-08-11T20:41:06", - "unit": "mg\/min·dL", - "value": 0.894313761489434 - }, - { - "startDate": "2020-08-11T20:41:06", - "endDate": "2020-08-11T20:46:06", - "unit": "mg\/min·dL", - "value": 0.9185226375377566 - }, - { - "startDate": "2020-08-11T20:46:06", - "endDate": "2020-08-11T20:51:06", - "unit": "mg\/min·dL", - "value": 0.9391743490118711 - }, - { - "startDate": "2020-08-11T20:51:06", - "endDate": "2020-08-11T20:56:06", - "unit": "mg\/min·dL", - "value": 0.9564460554651047 - }, - { - "startDate": "2020-08-11T20:56:06", - "endDate": "2020-08-11T21:01:06", - "unit": "mg\/min·dL", - "value": 0.9704838465264228 - }, - { - "startDate": "2020-08-11T21:01:06", - "endDate": "2020-08-11T21:06:06", - "unit": "mg\/min·dL", - "value": 0.9814071145378676 - }, - { - "startDate": "2020-08-11T21:06:06", - "endDate": "2020-08-11T21:11:06", - "unit": "mg\/min·dL", - "value": 0.9893126963505664 - }, - { - "startDate": "2020-08-11T21:11:06", - "endDate": "2020-08-11T21:16:06", - "unit": "mg\/min·dL", - "value": 0.9942787836220887 - }, - { - "startDate": "2020-08-11T21:16:06", - "endDate": "2020-08-11T21:21:06", - "unit": "mg\/min·dL", - "value": 0.9963686006690381 - }, - { - "startDate": "2020-08-11T21:21:06", - "endDate": "2020-08-11T21:26:06", - "unit": "mg\/min·dL", - "value": 0.9956338487771189 - }, - { - "startDate": "2020-08-11T21:26:06", - "endDate": "2020-08-11T21:31:06", - "unit": "mg\/min·dL", - "value": 0.9921179158496414 - }, - { - "startDate": "2020-08-11T21:31:06", - "endDate": "2020-08-11T21:36:06", - "unit": "mg\/min·dL", - "value": 0.9858588503769765 - }, - { - "startDate": "2020-08-11T21:36:06", - "endDate": "2020-08-11T21:41:06", - "unit": "mg\/min·dL", - "value": 0.9768920989266177 - }, - { - "startDate": "2020-08-11T21:41:06", - "endDate": "2020-08-11T21:46:06", - "unit": "mg\/min·dL", - "value": 0.965253006677156 - }, - { - "startDate": "2020-08-11T21:46:06", - "endDate": "2020-08-11T21:51:06", - "unit": "mg\/min·dL", - "value": 0.9509790809419911 - }, - { - "startDate": "2020-08-11T21:51:06", - "endDate": "2020-08-11T21:56:06", - "unit": "mg\/min·dL", - "value": 0.9341120181395608 - }, - { - "startDate": "2020-08-11T21:56:06", - "endDate": "2020-08-11T22:01:06", - "unit": "mg\/min·dL", - "value": 0.9146994952586709 - }, - { - "startDate": "2020-08-11T22:01:06", - "endDate": "2020-08-11T22:06:06", - "unit": "mg\/min·dL", - "value": 0.8927967275284316 - } -] diff --git a/LoopTests/Fixtures/low_and_falling/low_and_falling_insulin_effect.json b/LoopTests/Fixtures/low_and_falling/low_and_falling_insulin_effect.json deleted file mode 100644 index fadbdb4765..0000000000 --- a/LoopTests/Fixtures/low_and_falling/low_and_falling_insulin_effect.json +++ /dev/null @@ -1,382 +0,0 @@ -[ - { - "date": "2020-08-11T22:05:00", - "amount": 0.0, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:10:00", - "amount": 0.0, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:15:00", - "amount": 0.0, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:20:00", - "amount": -0.1458612769290415, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:25:00", - "amount": -0.9512697097060898, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:30:00", - "amount": -2.3842190211605305, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:35:00", - "amount": -4.364249056420911, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:40:00", - "amount": -6.818055744021179, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:45:00", - "amount": -9.678947529165939, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:50:00", - "amount": -12.88633950788323, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:55:00", - "amount": -16.385282799253694, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:00:00", - "amount": -20.126026847056842, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:05:00", - "amount": -24.06361248698291, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:10:00", - "amount": -28.157493751577, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:15:00", - "amount": -32.37118651282725, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:20:00", - "amount": -36.67194218227609, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:25:00", - "amount": -41.03044480117815, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:30:00", - "amount": -45.420529958992645, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:35:00", - "amount": -49.81892407778424, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:40:00", - "amount": -54.205002693305985, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:45:00", - "amount": -58.56056645101005, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:50:00", - "amount": -62.869633617321924, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:55:00", - "amount": -67.11824798354047, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:00:00", - "amount": -71.29430111199574, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:05:00", - "amount": -75.38736794189215, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:10:00", - "amount": -79.38855483585641, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:15:00", - "amount": -83.29035920784969, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:20:00", - "amount": -87.0865399290309, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:25:00", - "amount": -90.77199776059544, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:30:00", - "amount": -94.34266511177375, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:35:00", - "amount": -97.79540446725352, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:40:00", - "amount": -101.12791487147612, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:45:00", - "amount": -104.33864589772718, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:50:00", - "amount": -107.42671856785853, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:55:00", - "amount": -110.39185272399912, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:00:00", - "amount": -113.23430038688294, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:05:00", - "amount": -115.95478466657976, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:10:00", - "amount": -118.55444382058852, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:15:00", - "amount": -121.03478008156584, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:20:00", - "amount": -123.39761290252783, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:25:00", - "amount": -125.64503629128907, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:30:00", - "amount": -127.7793799282899, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:35:00", - "amount": -129.8031737829074, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:40:00", - "amount": -131.71911596293566, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:45:00", - "amount": -133.53004355024044, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:50:00", - "amount": -135.23890619272336, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:55:00", - "amount": -136.84874223874277, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:00:00", - "amount": -138.3626572151035, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:05:00", - "amount": -139.78380446371185, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:10:00", - "amount": -141.1153677650564, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:15:00", - "amount": -142.3605457888761, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:20:00", - "amount": -143.5225382237712, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:25:00", - "amount": -144.6045334481494, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:30:00", - "amount": -145.60969761482852, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:35:00", - "amount": -146.54116503087826, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:40:00", - "amount": -147.40202972292892, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:45:00", - "amount": -148.19533808623217, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:50:00", - "amount": -148.9240825232751, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:55:00", - "amount": -149.5911959847532, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:00:00", - "amount": -150.1995473322352, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:05:00", - "amount": -150.7519374479337, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:10:00", - "amount": -151.251096022658, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:15:00", - "amount": -151.69967895829902, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:20:00", - "amount": -152.1002663261011, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:25:00", - "amount": -152.45536082654326, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:30:00", - "amount": -152.76738670089628, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:35:00", - "amount": -153.03868904847147, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:40:00", - "amount": -153.2715335072446, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:45:00", - "amount": -153.4681062589482, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:50:00", - "amount": -153.63051432289012, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:55:00", - "amount": -153.76078610569454, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:00:00", - "amount": -153.86087217688896, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:05:00", - "amount": -153.9326462427885, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:10:00", - "amount": -153.97790629347384, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:15:00", - "amount": -153.99837589983005, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:20:00", - "amount": -154.0, - "unit": "mg/dL" - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/low_and_falling/low_and_falling_momentum_effect.json b/LoopTests/Fixtures/low_and_falling/low_and_falling_momentum_effect.json deleted file mode 100644 index 984694a465..0000000000 --- a/LoopTests/Fixtures/low_and_falling/low_and_falling_momentum_effect.json +++ /dev/null @@ -1,27 +0,0 @@ -[ - { - "date": "2020-08-11T22:05:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:10:00", - "unit": "mg/dL", - "amount": 1.35325 - }, - { - "date": "2020-08-11T22:15:00", - "unit": "mg/dL", - "amount": 3.09052 - }, - { - "date": "2020-08-11T22:20:00", - "unit": "mg/dL", - "amount": 4.8278 - }, - { - "date": "2020-08-11T22:25:00", - "unit": "mg/dL", - "amount": 6.56507 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/low_and_falling/low_and_falling_predicted_glucose.json b/LoopTests/Fixtures/low_and_falling/low_and_falling_predicted_glucose.json deleted file mode 100644 index 06e2b7a85e..0000000000 --- a/LoopTests/Fixtures/low_and_falling/low_and_falling_predicted_glucose.json +++ /dev/null @@ -1,382 +0,0 @@ -[ - { - "date": "2020-08-11T22:06:06", - "unit": "mg/dL", - "amount": 75.10768374646841 - }, - { - "date": "2020-08-11T22:10:00", - "unit": "mg/dL", - "amount": 76.46093289895596 - }, - { - "date": "2020-08-11T22:15:00", - "unit": "mg/dL", - "amount": 79.04942397908675 - }, - { - "date": "2020-08-11T22:20:00", - "unit": "mg/dL", - "amount": 83.00725362848293 - }, - { - "date": "2020-08-11T22:25:00", - "unit": "mg/dL", - "amount": 87.52123075828584 - }, - { - "date": "2020-08-11T22:30:00", - "unit": "mg/dL", - "amount": 91.12697884165053 - }, - { - "date": "2020-08-11T22:35:00", - "unit": "mg/dL", - "amount": 93.68408625591766 - }, - { - "date": "2020-08-11T22:40:00", - "unit": "mg/dL", - "amount": 95.26585707255327 - }, - { - "date": "2020-08-11T22:45:00", - "unit": "mg/dL", - "amount": 95.93898284635277 - }, - { - "date": "2020-08-11T22:50:00", - "unit": "mg/dL", - "amount": 95.76404848128813 - }, - { - "date": "2020-08-11T22:55:00", - "unit": "mg/dL", - "amount": 94.7960028582787 - }, - { - "date": "2020-08-11T23:00:00", - "unit": "mg/dL", - "amount": 93.08459653354495 - }, - { - "date": "2020-08-11T23:05:00", - "unit": "mg/dL", - "amount": 90.67478867139667 - }, - { - "date": "2020-08-11T23:10:00", - "unit": "mg/dL", - "amount": 88.10868518458037 - }, - { - "date": "2020-08-11T23:15:00", - "unit": "mg/dL", - "amount": 85.4227702011079 - }, - { - "date": "2020-08-11T23:20:00", - "unit": "mg/dL", - "amount": 82.64979230943683 - }, - { - "date": "2020-08-11T23:25:00", - "unit": "mg/dL", - "amount": 79.81906746831255 - }, - { - "date": "2020-08-11T23:30:00", - "unit": "mg/dL", - "amount": 76.95676008827584 - }, - { - "date": "2020-08-11T23:35:00", - "unit": "mg/dL", - "amount": 74.08614374726203 - }, - { - "date": "2020-08-11T23:40:00", - "unit": "mg/dL", - "amount": 71.22784290951806 - }, - { - "date": "2020-08-11T23:45:00", - "unit": "mg/dL", - "amount": 68.40005692959177 - }, - { - "date": "2020-08-11T23:50:00", - "unit": "mg/dL", - "amount": 65.61876754105768 - }, - { - "date": "2020-08-11T23:55:00", - "unit": "mg/dL", - "amount": 62.8979309526169 - }, - { - "date": "2020-08-12T00:00:00", - "unit": "mg/dL", - "amount": 60.24965560193941 - }, - { - "date": "2020-08-12T00:05:00", - "unit": "mg/dL", - "amount": 57.684366549820766 - }, - { - "date": "2020-08-12T00:10:00", - "unit": "mg/dL", - "amount": 55.21095743363429 - }, - { - "date": "2020-08-12T00:15:00", - "unit": "mg/dL", - "amount": 52.836930839418784 - }, - { - "date": "2020-08-12T00:20:00", - "unit": "mg/dL", - "amount": 50.568527896015354 - }, - { - "date": "2020-08-12T00:25:00", - "unit": "mg/dL", - "amount": 48.41084784222859 - }, - { - "date": "2020-08-12T00:30:00", - "unit": "mg/dL", - "amount": 46.36795826882805 - }, - { - "date": "2020-08-12T00:35:00", - "unit": "mg/dL", - "amount": 44.442996691126055 - }, - { - "date": "2020-08-12T00:40:00", - "unit": "mg/dL", - "amount": 42.63826406468122 - }, - { - "date": "2020-08-12T00:45:00", - "unit": "mg/dL", - "amount": 40.955310816207955 - }, - { - "date": "2020-08-12T00:50:00", - "unit": "mg/dL", - "amount": 39.39501592385437 - }, - { - "date": "2020-08-12T00:55:00", - "unit": "mg/dL", - "amount": 37.95765954549155 - }, - { - "date": "2020-08-12T01:00:00", - "unit": "mg/dL", - "amount": 36.642989660385524 - }, - { - "date": "2020-08-12T01:05:00", - "unit": "mg/dL", - "amount": 35.45028315846649 - }, - { - "date": "2020-08-12T01:10:00", - "unit": "mg/dL", - "amount": 34.3784017822355 - }, - { - "date": "2020-08-12T01:15:00", - "unit": "mg/dL", - "amount": 33.42584329903596 - }, - { - "date": "2020-08-12T01:20:00", - "unit": "mg/dL", - "amount": 32.59078825585176 - }, - { - "date": "2020-08-12T01:25:00", - "unit": "mg/dL", - "amount": 31.871142644868286 - }, - { - "date": "2020-08-12T01:30:00", - "unit": "mg/dL", - "amount": 31.264576785645232 - }, - { - "date": "2020-08-12T01:35:00", - "unit": "mg/dL", - "amount": 30.768560708805495 - }, - { - "date": "2020-08-12T01:40:00", - "unit": "mg/dL", - "amount": 30.380396306555042 - }, - { - "date": "2020-08-12T01:45:00", - "unit": "mg/dL", - "amount": 30.09724649702804 - }, - { - "date": "2020-08-12T01:50:00", - "unit": "mg/dL", - "amount": 29.91616163232291 - }, - { - "date": "2020-08-12T01:55:00", - "unit": "mg/dL", - "amount": 29.834103364081273 - }, - { - "date": "2020-08-12T02:00:00", - "unit": "mg/dL", - "amount": 29.84796616549835 - }, - { - "date": "2020-08-12T02:05:00", - "unit": "mg/dL", - "amount": 29.95459669466777 - }, - { - "date": "2020-08-12T02:10:00", - "unit": "mg/dL", - "amount": 30.150811171100997 - }, - { - "date": "2020-08-12T02:15:00", - "unit": "mg/dL", - "amount": 30.433410925059064 - }, - { - "date": "2020-08-12T02:20:00", - "unit": "mg/dL", - "amount": 30.799196267941767 - }, - { - "date": "2020-08-12T02:25:00", - "unit": "mg/dL", - "amount": 31.244978821341334 - }, - { - "date": "2020-08-12T02:30:00", - "unit": "mg/dL", - "amount": 31.767592432439983 - }, - { - "date": "2020-08-12T02:35:00", - "unit": "mg/dL", - "amount": 32.36390279416804 - }, - { - "date": "2020-08-12T02:40:00", - "unit": "mg/dL", - "amount": 33.03081587989516 - }, - { - "date": "2020-08-12T02:45:00", - "unit": "mg/dL", - "amount": 33.765285294369704 - }, - { - "date": "2020-08-12T02:50:00", - "unit": "mg/dL", - "amount": 33.45050370961934 - }, - { - "date": "2020-08-12T02:55:00", - "unit": "mg/dL", - "amount": 32.783390248141245 - }, - { - "date": "2020-08-12T03:00:00", - "unit": "mg/dL", - "amount": 32.175038900659246 - }, - { - "date": "2020-08-12T03:05:00", - "unit": "mg/dL", - "amount": 31.622648784960745 - }, - { - "date": "2020-08-12T03:10:00", - "unit": "mg/dL", - "amount": 31.12349021023644 - }, - { - "date": "2020-08-12T03:15:00", - "unit": "mg/dL", - "amount": 30.674907274595427 - }, - { - "date": "2020-08-12T03:20:00", - "unit": "mg/dL", - "amount": 30.27431990679335 - }, - { - "date": "2020-08-12T03:25:00", - "unit": "mg/dL", - "amount": 29.919225406351188 - }, - { - "date": "2020-08-12T03:30:00", - "unit": "mg/dL", - "amount": 29.607199531998162 - }, - { - "date": "2020-08-12T03:35:00", - "unit": "mg/dL", - "amount": 29.335897184422976 - }, - { - "date": "2020-08-12T03:40:00", - "unit": "mg/dL", - "amount": 29.10305272564983 - }, - { - "date": "2020-08-12T03:45:00", - "unit": "mg/dL", - "amount": 28.906479973946233 - }, - { - "date": "2020-08-12T03:50:00", - "unit": "mg/dL", - "amount": 28.744071910004322 - }, - { - "date": "2020-08-12T03:55:00", - "unit": "mg/dL", - "amount": 28.61380012719991 - }, - { - "date": "2020-08-12T04:00:00", - "unit": "mg/dL", - "amount": 28.513714056005483 - }, - { - "date": "2020-08-12T04:05:00", - "unit": "mg/dL", - "amount": 28.441939990105936 - }, - { - "date": "2020-08-12T04:10:00", - "unit": "mg/dL", - "amount": 28.396679939420608 - }, - { - "date": "2020-08-12T04:15:00", - "unit": "mg/dL", - "amount": 28.376210333064392 - }, - { - "date": "2020-08-12T04:20:00", - "unit": "mg/dL", - "amount": 28.374586232894444 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_carb_effect.json b/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_carb_effect.json deleted file mode 100644 index c72f05d1b8..0000000000 --- a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_carb_effect.json +++ /dev/null @@ -1,312 +0,0 @@ -[ - { - "date": "2020-08-11T21:50:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:55:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:00:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:05:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:10:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:15:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:20:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:25:00", - "unit": "mg/dL", - "amount": 3.3782119779158717 - }, - { - "date": "2020-08-11T22:30:00", - "unit": "mg/dL", - "amount": 4.90598975569365 - }, - { - "date": "2020-08-11T22:35:00", - "unit": "mg/dL", - "amount": 6.4337675334714275 - }, - { - "date": "2020-08-11T22:40:00", - "unit": "mg/dL", - "amount": 13.234180198944518 - }, - { - "date": "2020-08-11T22:45:00", - "unit": "mg/dL", - "amount": 20.873069087833407 - }, - { - "date": "2020-08-11T22:50:00", - "unit": "mg/dL", - "amount": 28.511957976722293 - }, - { - "date": "2020-08-11T22:55:00", - "unit": "mg/dL", - "amount": 36.150846865611186 - }, - { - "date": "2020-08-11T23:00:00", - "unit": "mg/dL", - "amount": 43.78973575450007 - }, - { - "date": "2020-08-11T23:05:00", - "unit": "mg/dL", - "amount": 51.428624643388964 - }, - { - "date": "2020-08-11T23:10:00", - "unit": "mg/dL", - "amount": 59.06751353227786 - }, - { - "date": "2020-08-11T23:15:00", - "unit": "mg/dL", - "amount": 66.70640242116674 - }, - { - "date": "2020-08-11T23:20:00", - "unit": "mg/dL", - "amount": 74.34529131005563 - }, - { - "date": "2020-08-11T23:25:00", - "unit": "mg/dL", - "amount": 76.71154531124921 - }, - { - "date": "2020-08-11T23:30:00", - "unit": "mg/dL", - "amount": 78.23932308902698 - }, - { - "date": "2020-08-11T23:35:00", - "unit": "mg/dL", - "amount": 79.76710086680475 - }, - { - "date": "2020-08-11T23:40:00", - "unit": "mg/dL", - "amount": 81.29487864458254 - }, - { - "date": "2020-08-11T23:45:00", - "unit": "mg/dL", - "amount": 82.82265642236032 - }, - { - "date": "2020-08-11T23:50:00", - "unit": "mg/dL", - "amount": 84.3504342001381 - }, - { - "date": "2020-08-11T23:55:00", - "unit": "mg/dL", - "amount": 85.87821197791587 - }, - { - "date": "2020-08-12T00:00:00", - "unit": "mg/dL", - "amount": 87.40598975569364 - }, - { - "date": "2020-08-12T00:05:00", - "unit": "mg/dL", - "amount": 88.93376753347144 - }, - { - "date": "2020-08-12T00:10:00", - "unit": "mg/dL", - "amount": 90.46154531124921 - }, - { - "date": "2020-08-12T00:15:00", - "unit": "mg/dL", - "amount": 91.98932308902698 - }, - { - "date": "2020-08-12T00:20:00", - "unit": "mg/dL", - "amount": 93.51710086680475 - }, - { - "date": "2020-08-12T00:25:00", - "unit": "mg/dL", - "amount": 95.04487864458254 - }, - { - "date": "2020-08-12T00:30:00", - "unit": "mg/dL", - "amount": 96.57265642236032 - }, - { - "date": "2020-08-12T00:35:00", - "unit": "mg/dL", - "amount": 98.1004342001381 - }, - { - "date": "2020-08-12T00:40:00", - "unit": "mg/dL", - "amount": 99.62821197791587 - }, - { - "date": "2020-08-12T00:45:00", - "unit": "mg/dL", - "amount": 101.15598975569364 - }, - { - "date": "2020-08-12T00:50:00", - "unit": "mg/dL", - "amount": 102.68376753347144 - }, - { - "date": "2020-08-12T00:55:00", - "unit": "mg/dL", - "amount": 104.21154531124921 - }, - { - "date": "2020-08-12T01:00:00", - "unit": "mg/dL", - "amount": 105.73932308902698 - }, - { - "date": "2020-08-12T01:05:00", - "unit": "mg/dL", - "amount": 107.26710086680475 - }, - { - "date": "2020-08-12T01:10:00", - "unit": "mg/dL", - "amount": 108.79487864458254 - }, - { - "date": "2020-08-12T01:15:00", - "unit": "mg/dL", - "amount": 110.32265642236032 - }, - { - "date": "2020-08-12T01:20:00", - "unit": "mg/dL", - "amount": 111.8504342001381 - }, - { - "date": "2020-08-12T01:25:00", - "unit": "mg/dL", - "amount": 113.37821197791587 - }, - { - "date": "2020-08-12T01:30:00", - "unit": "mg/dL", - "amount": 114.90598975569367 - }, - { - "date": "2020-08-12T01:35:00", - "unit": "mg/dL", - "amount": 116.43376753347144 - }, - { - "date": "2020-08-12T01:40:00", - "unit": "mg/dL", - "amount": 117.96154531124921 - }, - { - "date": "2020-08-12T01:45:00", - "unit": "mg/dL", - "amount": 119.48932308902698 - }, - { - "date": "2020-08-12T01:50:00", - "unit": "mg/dL", - "amount": 121.01710086680477 - }, - { - "date": "2020-08-12T01:55:00", - "unit": "mg/dL", - "amount": 122.54487864458254 - }, - { - "date": "2020-08-12T02:00:00", - "unit": "mg/dL", - "amount": 124.07265642236031 - }, - { - "date": "2020-08-12T02:05:00", - "unit": "mg/dL", - "amount": 125.6004342001381 - }, - { - "date": "2020-08-12T02:10:00", - "unit": "mg/dL", - "amount": 127.12821197791588 - }, - { - "date": "2020-08-12T02:15:00", - "unit": "mg/dL", - "amount": 128.65598975569367 - }, - { - "date": "2020-08-12T02:20:00", - "unit": "mg/dL", - "amount": 130.18376753347144 - }, - { - "date": "2020-08-12T02:25:00", - "unit": "mg/dL", - "amount": 131.7115453112492 - }, - { - "date": "2020-08-12T02:30:00", - "unit": "mg/dL", - "amount": 133.23932308902698 - }, - { - "date": "2020-08-12T02:35:00", - "unit": "mg/dL", - "amount": 134.76710086680475 - }, - { - "date": "2020-08-12T02:40:00", - "unit": "mg/dL", - "amount": 136.29487864458252 - }, - { - "date": "2020-08-12T02:45:00", - "unit": "mg/dL", - "amount": 137.5 - }, - { - "date": "2020-08-12T02:50:00", - "unit": "mg/dL", - "amount": 137.5 - }, - { - "date": "2020-08-12T02:55:00", - "unit": "mg/dL", - "amount": 137.5 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_counteraction_effect.json b/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_counteraction_effect.json deleted file mode 100644 index 04a954b411..0000000000 --- a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_counteraction_effect.json +++ /dev/null @@ -1,230 +0,0 @@ -[ - { - "startDate": "2020-08-11T19:06:06", - "endDate": "2020-08-11T19:11:06", - "unit": "mg\/min·dL", - "value": -0.3485359639226971 - }, - { - "startDate": "2020-08-11T19:11:06", - "endDate": "2020-08-11T19:16:06", - "unit": "mg\/min·dL", - "value": -0.34571948711910916 - }, - { - "startDate": "2020-08-11T19:16:06", - "endDate": "2020-08-11T19:21:06", - "unit": "mg\/min·dL", - "value": -0.3110996208816001 - }, - { - "startDate": "2020-08-11T19:21:06", - "endDate": "2020-08-11T19:26:06", - "unit": "mg\/min·dL", - "value": -0.17115290442012446 - }, - { - "startDate": "2020-08-11T19:26:06", - "endDate": "2020-08-11T19:31:06", - "unit": "mg\/min·dL", - "value": -0.035078937546724906 - }, - { - "startDate": "2020-08-11T19:31:06", - "endDate": "2020-08-11T19:36:06", - "unit": "mg\/min·dL", - "value": 0.08735109214809061 - }, - { - "startDate": "2020-08-11T19:36:06", - "endDate": "2020-08-11T19:41:06", - "unit": "mg\/min·dL", - "value": 0.19746935782304254 - }, - { - "startDate": "2020-08-11T19:41:06", - "endDate": "2020-08-11T19:46:06", - "unit": "mg\/min·dL", - "value": 0.2964814415989495 - }, - { - "startDate": "2020-08-11T19:46:06", - "endDate": "2020-08-11T19:51:06", - "unit": "mg\/min·dL", - "value": 0.3854747645772338 - }, - { - "startDate": "2020-08-11T19:51:06", - "endDate": "2020-08-11T19:56:06", - "unit": "mg\/min·dL", - "value": 0.4654266535840445 - }, - { - "startDate": "2020-08-11T19:56:06", - "endDate": "2020-08-11T20:01:06", - "unit": "mg\/min·dL", - "value": 0.5372120677140927 - }, - { - "startDate": "2020-08-11T20:01:06", - "endDate": "2020-08-11T20:06:06", - "unit": "mg\/min·dL", - "value": 0.6016110049547307 - }, - { - "startDate": "2020-08-11T20:06:06", - "endDate": "2020-08-11T20:11:06", - "unit": "mg\/min·dL", - "value": 0.6593156065538323 - }, - { - "startDate": "2020-08-11T20:11:06", - "endDate": "2020-08-11T20:16:06", - "unit": "mg\/min·dL", - "value": 0.7109369743543738 - }, - { - "startDate": "2020-08-11T20:16:06", - "endDate": "2020-08-11T20:21:06", - "unit": "mg\/min·dL", - "value": 0.7570117140551543 - }, - { - "startDate": "2020-08-11T20:21:06", - "endDate": "2020-08-11T20:26:06", - "unit": "mg\/min·dL", - "value": 0.7980082152690883 - }, - { - "startDate": "2020-08-11T20:26:06", - "endDate": "2020-08-11T20:31:06", - "unit": "mg\/min·dL", - "value": 0.8343326773392674 - }, - { - "startDate": "2020-08-11T20:31:06", - "endDate": "2020-08-11T20:36:06", - "unit": "mg\/min·dL", - "value": 0.8663348881353556 - }, - { - "startDate": "2020-08-11T20:36:06", - "endDate": "2020-08-11T20:41:06", - "unit": "mg\/min·dL", - "value": 0.894313761489434 - }, - { - "startDate": "2020-08-11T20:41:06", - "endDate": "2020-08-11T20:46:06", - "unit": "mg\/min·dL", - "value": 0.9185226375377566 - }, - { - "startDate": "2020-08-11T20:46:06", - "endDate": "2020-08-11T20:51:06", - "unit": "mg\/min·dL", - "value": 0.9391743490118711 - }, - { - "startDate": "2020-08-11T20:51:06", - "endDate": "2020-08-11T20:56:06", - "unit": "mg\/min·dL", - "value": 0.9564460554651047 - }, - { - "startDate": "2020-08-11T20:56:06", - "endDate": "2020-08-11T21:01:06", - "unit": "mg\/min·dL", - "value": 0.9704838465264228 - }, - { - "startDate": "2020-08-11T21:01:06", - "endDate": "2020-08-11T21:06:06", - "unit": "mg\/min·dL", - "value": 0.9814071145378676 - }, - { - "startDate": "2020-08-11T21:06:06", - "endDate": "2020-08-11T21:11:06", - "unit": "mg\/min·dL", - "value": 0.9893126963505664 - }, - { - "startDate": "2020-08-11T21:11:06", - "endDate": "2020-08-11T21:16:06", - "unit": "mg\/min·dL", - "value": 0.9942787836220887 - }, - { - "startDate": "2020-08-11T21:16:06", - "endDate": "2020-08-11T21:21:06", - "unit": "mg\/min·dL", - "value": 0.9963686006690381 - }, - { - "startDate": "2020-08-11T21:21:06", - "endDate": "2020-08-11T21:26:06", - "unit": "mg\/min·dL", - "value": 0.9956338487771189 - }, - { - "startDate": "2020-08-11T21:26:06", - "endDate": "2020-08-11T21:31:06", - "unit": "mg\/min·dL", - "value": 0.9921179158496414 - }, - { - "startDate": "2020-08-11T21:31:06", - "endDate": "2020-08-11T21:36:06", - "unit": "mg\/min·dL", - "value": 0.9858588503769765 - }, - { - "startDate": "2020-08-11T21:36:06", - "endDate": "2020-08-11T21:41:06", - "unit": "mg\/min·dL", - "value": 0.9768920989266177 - }, - { - "startDate": "2020-08-11T21:41:06", - "endDate": "2020-08-11T21:46:06", - "unit": "mg\/min·dL", - "value": 0.965253006677156 - }, - { - "startDate": "2020-08-11T21:46:06", - "endDate": "2020-08-11T21:51:06", - "unit": "mg\/min·dL", - "value": 0.9509790809419911 - }, - { - "startDate": "2020-08-11T21:51:06", - "endDate": "2020-08-11T21:56:06", - "unit": "mg\/min·dL", - "value": 0.9341120181395608 - }, - { - "startDate": "2020-08-11T21:56:06", - "endDate": "2020-08-11T22:01:06", - "unit": "mg\/min·dL", - "value": 0.9146994952586709 - }, - { - "startDate": "2020-08-11T22:01:06", - "endDate": "2020-08-11T22:06:06", - "unit": "mg\/min·dL", - "value": 0.8927967275284316 - }, - { - "startDate": "2020-08-11T22:06:06", - "endDate": "2020-08-11T22:17:16", - "unit": "mg\/min·dL", - "value": 0.3597357885896396 - }, - { - "startDate": "2020-08-11T22:17:16", - "endDate": "2020-08-11T22:23:55", - "unit": "mg\/min·dL", - "value": 0.45827708950324664 - } -] diff --git a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_insulin_effect.json b/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_insulin_effect.json deleted file mode 100644 index c4576feeae..0000000000 --- a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_insulin_effect.json +++ /dev/null @@ -1,377 +0,0 @@ -[ - { - "date": "2020-08-11T22:25:00", - "amount": -0.9512697097060898, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:30:00", - "amount": -2.3813732447934624, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:35:00", - "amount": -4.341390140188103, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:40:00", - "amount": -6.753751683906663, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:45:00", - "amount": -9.55387357996081, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:50:00", - "amount": -12.683720606187977, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:55:00", - "amount": -16.090664681076284, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:00:00", - "amount": -19.72706463270244, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:05:00", - "amount": -23.549875518271012, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:10:00", - "amount": -27.520285545942553, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:15:00", - "amount": -31.60337877339881, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:20:00", - "amount": -35.76782187287874, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:25:00", - "amount": -39.98557336066623, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:30:00", - "amount": -44.23161379064487, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:35:00", - "amount": -48.48369550694377, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:40:00", - "amount": -52.72211064025665, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:45:00", - "amount": -56.92947611647066, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:50:00", - "amount": -61.090534525122315, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:55:00", - "amount": -65.19196976921322, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:00:00", - "amount": -69.22223648736112, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:05:00", - "amount": -73.17140230440528, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:10:00", - "amount": -77.03100202768849, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:15:00", - "amount": -80.79390296354336, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:20:00", - "amount": -84.45418058224833, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:25:00", - "amount": -88.00700381010158, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:30:00", - "amount": -91.44852927449627, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:35:00", - "amount": -94.77580387215212, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:40:00", - "amount": -97.98667507215376, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:45:00", - "amount": -101.07970840432662, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:50:00", - "amount": -104.05411161991226, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:55:00", - "amount": -106.9096650456303, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:00:00", - "amount": -109.64665768417882, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:05:00", - "amount": -112.26582864415883, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:10:00", - "amount": -114.7683135104363, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:15:00", - "amount": -117.15559529219412, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:20:00", - "amount": -119.42945961048726, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:25:00", - "amount": -121.59195381009803, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:30:00", - "amount": -123.64534970199563, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:35:00", - "amount": -125.592109662823, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:40:00", - "amount": -127.43485583665301, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:45:00", - "amount": -129.17634220185357, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:50:00", - "amount": -130.81942928235597, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:55:00", - "amount": -132.36706129800115, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:00:00", - "amount": -133.8222455630132, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:05:00", - "amount": -135.18803395508212, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:10:00", - "amount": -136.46750629008383, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:15:00", - "amount": -137.66375544918807, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:20:00", - "amount": -138.779874116044, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:25:00", - "amount": -139.81894299195267, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:30:00", - "amount": -140.78402036646978, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:35:00", - "amount": -141.67813292977746, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:40:00", - "amount": -142.50426772146503, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:45:00", - "amount": -143.2653651180992, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:50:00", - "amount": -143.9643127691786, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:55:00", - "amount": -144.60394039779732, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:00:00", - "amount": -145.18701538860697, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:05:00", - "amount": -145.71623909150875, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:10:00", - "amount": -146.19424377494204, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:15:00", - "amount": -146.62359016770122, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:20:00", - "amount": -147.00676553292078, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:25:00", - "amount": -147.3461822222548, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:30:00", - "amount": -147.64417666235195, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:35:00", - "amount": -147.90300872951764, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:40:00", - "amount": -148.12486147197743, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:45:00", - "amount": -148.3118411424277, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:50:00", - "amount": -148.46597750659902, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:55:00", - "amount": -148.58922439637678, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:00:00", - "amount": -148.68346047864273, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:05:00", - "amount": -148.75049021342636, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:10:00", - "amount": -148.79204497720696, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:15:00", - "amount": -148.8097843292894, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:20:00", - "amount": -148.80959176148318, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:25:00", - "amount": -148.80862975663214, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:30:00", - "amount": -148.8083823028405, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:35:00", - "amount": -148.80836238795683, - "unit": "mg/dL" - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_momentum_effect.json b/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_momentum_effect.json deleted file mode 100644 index 0637a088a0..0000000000 --- a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_momentum_effect.json +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file diff --git a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_predicted_glucose.json b/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_predicted_glucose.json deleted file mode 100644 index 4ac4d64f44..0000000000 --- a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_predicted_glucose.json +++ /dev/null @@ -1,382 +0,0 @@ -[ - { - "date": "2020-08-11T22:23:55", - "unit": "mg/dL", - "amount": 81.22399763523448 - }, - { - "date": "2020-08-11T22:25:00", - "unit": "mg/dL", - "amount": 87.005525216014 - }, - { - "date": "2020-08-11T22:30:00", - "unit": "mg/dL", - "amount": 89.28803182494407 - }, - { - "date": "2020-08-11T22:35:00", - "unit": "mg/dL", - "amount": 90.82214183694292 - }, - { - "date": "2020-08-11T22:40:00", - "unit": "mg/dL", - "amount": 96.95805885168919 - }, - { - "date": "2020-08-11T22:45:00", - "unit": "mg/dL", - "amount": 103.32620850089171 - }, - { - "date": "2020-08-11T22:50:00", - "unit": "mg/dL", - "amount": 109.14614978329723 - }, - { - "date": "2020-08-11T22:55:00", - "unit": "mg/dL", - "amount": 114.47051078041765 - }, - { - "date": "2020-08-11T23:00:00", - "unit": "mg/dL", - "amount": 119.34693266417625 - }, - { - "date": "2020-08-11T23:05:00", - "unit": "mg/dL", - "amount": 123.81846037736848 - }, - { - "date": "2020-08-11T23:10:00", - "unit": "mg/dL", - "amount": 127.92390571183377 - }, - { - "date": "2020-08-11T23:15:00", - "unit": "mg/dL", - "amount": 131.69818460989035 - }, - { - "date": "2020-08-11T23:20:00", - "unit": "mg/dL", - "amount": 135.1726303992993 - }, - { - "date": "2020-08-11T23:25:00", - "unit": "mg/dL", - "amount": 133.3211329127054 - }, - { - "date": "2020-08-11T23:30:00", - "unit": "mg/dL", - "amount": 130.60287026050452 - }, - { - "date": "2020-08-11T23:35:00", - "unit": "mg/dL", - "amount": 127.87856632198339 - }, - { - "date": "2020-08-11T23:40:00", - "unit": "mg/dL", - "amount": 125.16792896644829 - }, - { - "date": "2020-08-11T23:45:00", - "unit": "mg/dL", - "amount": 122.48834126801206 - }, - { - "date": "2020-08-11T23:50:00", - "unit": "mg/dL", - "amount": 119.85506063713818 - }, - { - "date": "2020-08-11T23:55:00", - "unit": "mg/dL", - "amount": 117.28140317082506 - }, - { - "date": "2020-08-12T00:00:00", - "unit": "mg/dL", - "amount": 114.77891423045493 - }, - { - "date": "2020-08-12T00:05:00", - "unit": "mg/dL", - "amount": 112.35752619118857 - }, - { - "date": "2020-08-12T00:10:00", - "unit": "mg/dL", - "amount": 110.02570424568313 - }, - { - "date": "2020-08-12T00:15:00", - "unit": "mg/dL", - "amount": 107.79058108760603 - }, - { - "date": "2020-08-12T00:20:00", - "unit": "mg/dL", - "amount": 105.65808124667883 - }, - { - "date": "2020-08-12T00:25:00", - "unit": "mg/dL", - "amount": 103.63303579660337 - }, - { - "date": "2020-08-12T00:30:00", - "unit": "mg/dL", - "amount": 101.71928810998646 - }, - { - "date": "2020-08-12T00:35:00", - "unit": "mg/dL", - "amount": 99.91979129010838 - }, - { - "date": "2020-08-12T00:40:00", - "unit": "mg/dL", - "amount": 98.23669786788452 - }, - { - "date": "2020-08-12T00:45:00", - "unit": "mg/dL", - "amount": 96.67144231348942 - }, - { - "date": "2020-08-12T00:50:00", - "unit": "mg/dL", - "amount": 95.22481687568158 - }, - { - "date": "2020-08-12T00:55:00", - "unit": "mg/dL", - "amount": 93.89704122774131 - }, - { - "date": "2020-08-12T01:00:00", - "unit": "mg/dL", - "amount": 92.68782636697057 - }, - { - "date": "2020-08-12T01:05:00", - "unit": "mg/dL", - "amount": 91.59643318476833 - }, - { - "date": "2020-08-12T01:10:00", - "unit": "mg/dL", - "amount": 90.62172609626865 - }, - { - "date": "2020-08-12T01:15:00", - "unit": "mg/dL", - "amount": 89.76222209228861 - }, - { - "date": "2020-08-12T01:20:00", - "unit": "mg/dL", - "amount": 89.01613555177325 - }, - { - "date": "2020-08-12T01:25:00", - "unit": "mg/dL", - "amount": 88.38141912994024 - }, - { - "date": "2020-08-12T01:30:00", - "unit": "mg/dL", - "amount": 87.85580101582045 - }, - { - "date": "2020-08-12T01:35:00", - "unit": "mg/dL", - "amount": 87.43681883277084 - }, - { - "date": "2020-08-12T01:40:00", - "unit": "mg/dL", - "amount": 87.1218504367186 - }, - { - "date": "2020-08-12T01:45:00", - "unit": "mg/dL", - "amount": 86.90814184929582 - }, - { - "date": "2020-08-12T01:50:00", - "unit": "mg/dL", - "amount": 86.79283254657122 - }, - { - "date": "2020-08-12T01:55:00", - "unit": "mg/dL", - "amount": 86.77297830870381 - }, - { - "date": "2020-08-12T02:00:00", - "unit": "mg/dL", - "amount": 86.84557182146952 - }, - { - "date": "2020-08-12T02:05:00", - "unit": "mg/dL", - "amount": 87.00756120717838 - }, - { - "date": "2020-08-12T02:10:00", - "unit": "mg/dL", - "amount": 87.25586664995447 - }, - { - "date": "2020-08-12T02:15:00", - "unit": "mg/dL", - "amount": 87.58739526862803 - }, - { - "date": "2020-08-12T02:20:00", - "unit": "mg/dL", - "amount": 87.99905437954988 - }, - { - "date": "2020-08-12T02:25:00", - "unit": "mg/dL", - "amount": 88.48776328141898 - }, - { - "date": "2020-08-12T02:30:00", - "unit": "mg/dL", - "amount": 89.05046368467964 - }, - { - "date": "2020-08-12T02:35:00", - "unit": "mg/dL", - "amount": 89.68412889914973 - }, - { - "date": "2020-08-12T02:40:00", - "unit": "mg/dL", - "amount": 90.38577188523993 - }, - { - "date": "2020-08-12T02:45:00", - "unit": "mg/dL", - "amount": 90.82979584402324 - }, - { - "date": "2020-08-12T02:50:00", - "unit": "mg/dL", - "amount": 90.13084819294383 - }, - { - "date": "2020-08-12T02:55:00", - "unit": "mg/dL", - "amount": 89.49122056432512 - }, - { - "date": "2020-08-12T03:00:00", - "unit": "mg/dL", - "amount": 88.90814557351547 - }, - { - "date": "2020-08-12T03:05:00", - "unit": "mg/dL", - "amount": 88.37892187061368 - }, - { - "date": "2020-08-12T03:10:00", - "unit": "mg/dL", - "amount": 87.9009171871804 - }, - { - "date": "2020-08-12T03:15:00", - "unit": "mg/dL", - "amount": 87.47157079442121 - }, - { - "date": "2020-08-12T03:20:00", - "unit": "mg/dL", - "amount": 87.08839542920165 - }, - { - "date": "2020-08-12T03:25:00", - "unit": "mg/dL", - "amount": 86.74897873986762 - }, - { - "date": "2020-08-12T03:30:00", - "unit": "mg/dL", - "amount": 86.45098429977048 - }, - { - "date": "2020-08-12T03:35:00", - "unit": "mg/dL", - "amount": 86.1921522326048 - }, - { - "date": "2020-08-12T03:40:00", - "unit": "mg/dL", - "amount": 85.97029949014501 - }, - { - "date": "2020-08-12T03:45:00", - "unit": "mg/dL", - "amount": 85.78331981969473 - }, - { - "date": "2020-08-12T03:50:00", - "unit": "mg/dL", - "amount": 85.62918345552342 - }, - { - "date": "2020-08-12T03:55:00", - "unit": "mg/dL", - "amount": 85.50593656574566 - }, - { - "date": "2020-08-12T04:00:00", - "unit": "mg/dL", - "amount": 85.4117004834797 - }, - { - "date": "2020-08-12T04:05:00", - "unit": "mg/dL", - "amount": 85.34467074869607 - }, - { - "date": "2020-08-12T04:10:00", - "unit": "mg/dL", - "amount": 85.30311598491548 - }, - { - "date": "2020-08-12T04:15:00", - "unit": "mg/dL", - "amount": 85.28537663283302 - }, - { - "date": "2020-08-12T04:20:00", - "unit": "mg/dL", - "amount": 85.28556920063926 - }, - { - "date": "2020-08-12T04:25:00", - "unit": "mg/dL", - "amount": 85.28653120549029 - }, - { - "date": "2020-08-12T04:30:00", - "unit": "mg/dL", - "amount": 85.28677865928194 - }, - { - "date": "2020-08-12T04:35:00", - "unit": "mg/dL", - "amount": 85.2867985741656 - } -] \ No newline at end of file diff --git a/LoopTests/LoopCore/LoopCompletionFreshnessTests.swift b/LoopTests/LoopCore/LoopCompletionFreshnessTests.swift deleted file mode 100644 index 7f5d7095b0..0000000000 --- a/LoopTests/LoopCore/LoopCompletionFreshnessTests.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// LoopCompletionFreshnessTests.swift -// LoopTests -// -// Created by Nathaniel Hamming on 2020-10-28. -// Copyright © 2020 LoopKit Authors. All rights reserved. -// - -import XCTest -@testable import LoopCore - -class LoopCompletionFreshnessTests: XCTestCase { - - func testInitializationWithAge() { - let freshAge = TimeInterval(minutes: 5) - let agingAge = TimeInterval(minutes: 15) - let staleAge1 = TimeInterval(minutes: 20) - let staleAge2 = TimeInterval(hours: 20) - - XCTAssertEqual(LoopCompletionFreshness(age: nil), .stale) - XCTAssertEqual(LoopCompletionFreshness(age: freshAge), .fresh) - XCTAssertEqual(LoopCompletionFreshness(age: agingAge), .aging) - XCTAssertEqual(LoopCompletionFreshness(age: staleAge1), .stale) - XCTAssertEqual(LoopCompletionFreshness(age: staleAge2), .stale) - } - - func testInitializationWithLoopCompletion() { - let freshDate = Date().addingTimeInterval(-.minutes(1)) - let agingDate = Date().addingTimeInterval(-.minutes(7)) - let staleDate1 = Date().addingTimeInterval(-.minutes(17)) - let staleDate2 = Date().addingTimeInterval(-.hours(13)) - - XCTAssertEqual(LoopCompletionFreshness(lastCompletion: nil), .stale) - XCTAssertEqual(LoopCompletionFreshness(lastCompletion: freshDate), .fresh) - XCTAssertEqual(LoopCompletionFreshness(lastCompletion: agingDate), .aging) - XCTAssertEqual(LoopCompletionFreshness(lastCompletion: staleDate1), .stale) - XCTAssertEqual(LoopCompletionFreshness(lastCompletion: staleDate2), .stale) - } - - func testMaxAge() { - var loopCompletionFreshness: LoopCompletionFreshness = .fresh - XCTAssertEqual(loopCompletionFreshness.maxAge, TimeInterval.minutes(6)) - - loopCompletionFreshness = .aging - XCTAssertEqual(loopCompletionFreshness.maxAge, TimeInterval.minutes(16)) - - loopCompletionFreshness = .stale - XCTAssertNil(loopCompletionFreshness.maxAge) - } -} diff --git a/LoopTests/LoopSettingsTests.swift b/LoopTests/LoopSettingsTests.swift deleted file mode 100644 index a0ad8f4503..0000000000 --- a/LoopTests/LoopSettingsTests.swift +++ /dev/null @@ -1,121 +0,0 @@ -// -// LoopSettingsTests.swift -// LoopTests -// -// Created by Michael Pangburn on 3/1/20. -// Copyright © 2020 LoopKit Authors. All rights reserved. -// - -import XCTest -import LoopCore -import LoopKit - - -class LoopSettingsTests: XCTestCase { - private let preMealRange = DoubleRange(minValue: 80, maxValue: 80).quantityRange(for: .milligramsPerDeciliter) - private let targetRange = DoubleRange(minValue: 95, maxValue: 105) - - private lazy var settings: LoopSettings = { - var settings = LoopSettings() - settings.preMealTargetRange = preMealRange - settings.glucoseTargetRangeSchedule = GlucoseRangeSchedule( - unit: .milligramsPerDeciliter, - dailyItems: [.init(startTime: 0, value: targetRange)] - ) - return settings - }() - - func testPreMealOverride() { - var settings = self.settings - let preMealStart = Date() - settings.enablePreMealOverride(at: preMealStart, for: 1 /* hour */ * 60 * 60) - let actualPreMealRange = settings.effectiveGlucoseTargetRangeSchedule()?.quantityRange(at: preMealStart.addingTimeInterval(30 /* minutes */ * 60)) - XCTAssertEqual(preMealRange, actualPreMealRange) - } - - func testPreMealOverrideWithPotentialCarbEntry() { - var settings = self.settings - let preMealStart = Date() - settings.enablePreMealOverride(at: preMealStart, for: 1 /* hour */ * 60 * 60) - let actualRange = settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: true)?.value(at: preMealStart.addingTimeInterval(30 /* minutes */ * 60)) - XCTAssertEqual(targetRange, actualRange) - } - - func testScheduleOverride() { - var settings = self.settings - let overrideStart = Date() - let overrideTargetRange = DoubleRange(minValue: 130, maxValue: 150) - let override = TemporaryScheduleOverride( - context: .custom, - settings: TemporaryScheduleOverrideSettings( - unit: .milligramsPerDeciliter, - targetRange: overrideTargetRange - ), - startDate: overrideStart, - duration: .finite(3 /* hours */ * 60 * 60), - enactTrigger: .local, - syncIdentifier: UUID() - ) - settings.scheduleOverride = override - let actualOverrideRange = settings.effectiveGlucoseTargetRangeSchedule()?.value(at: overrideStart.addingTimeInterval(30 /* minutes */ * 60)) - XCTAssertEqual(actualOverrideRange, overrideTargetRange) - } - - func testBothPreMealAndScheduleOverride() { - var settings = self.settings - let preMealStart = Date() - settings.enablePreMealOverride(at: preMealStart, for: 1 /* hour */ * 60 * 60) - - let overrideStart = Date() - let overrideTargetRange = DoubleRange(minValue: 130, maxValue: 150) - let override = TemporaryScheduleOverride( - context: .custom, - settings: TemporaryScheduleOverrideSettings( - unit: .milligramsPerDeciliter, - targetRange: overrideTargetRange - ), - startDate: overrideStart, - duration: .finite(3 /* hours */ * 60 * 60), - enactTrigger: .local, - syncIdentifier: UUID() - ) - settings.scheduleOverride = override - - let actualPreMealRange = settings.effectiveGlucoseTargetRangeSchedule()?.quantityRange(at: preMealStart.addingTimeInterval(30 /* minutes */ * 60)) - XCTAssertEqual(actualPreMealRange, preMealRange) - - // The pre-meal range should be projected into the future, despite the simultaneous schedule override - let preMealRangeDuringOverride = settings.effectiveGlucoseTargetRangeSchedule()?.quantityRange(at: preMealStart.addingTimeInterval(2 /* hours */ * 60 * 60)) - XCTAssertEqual(preMealRangeDuringOverride, preMealRange) - } - - func testScheduleOverrideWithExpiredPreMealOverride() { - var settings = self.settings - settings.preMealOverride = TemporaryScheduleOverride( - context: .preMeal, - settings: TemporaryScheduleOverrideSettings(targetRange: preMealRange), - startDate: Date(timeIntervalSinceNow: -2 /* hours */ * 60 * 60), - duration: .finite(1 /* hours */ * 60 * 60), - enactTrigger: .local, - syncIdentifier: UUID() - ) - - let overrideStart = Date() - let overrideTargetRange = DoubleRange(minValue: 130, maxValue: 150) - let override = TemporaryScheduleOverride( - context: .custom, - settings: TemporaryScheduleOverrideSettings( - unit: .milligramsPerDeciliter, - targetRange: overrideTargetRange - ), - startDate: overrideStart, - duration: .finite(3 /* hours */ * 60 * 60), - enactTrigger: .local, - syncIdentifier: UUID() - ) - settings.scheduleOverride = override - - let actualOverrideRange = settings.effectiveGlucoseTargetRangeSchedule()?.value(at: overrideStart.addingTimeInterval(2 /* hours */ * 60 * 60)) - XCTAssertEqual(actualOverrideRange, overrideTargetRange) - } -} diff --git a/LoopTests/Managers/Alerts/AlertManagerTests.swift b/LoopTests/Managers/Alerts/AlertManagerTests.swift index 5eeca9cebd..44403da913 100644 --- a/LoopTests/Managers/Alerts/AlertManagerTests.swift +++ b/LoopTests/Managers/Alerts/AlertManagerTests.swift @@ -11,153 +11,9 @@ import UserNotifications import XCTest @testable import Loop +@MainActor class AlertManagerTests: XCTestCase { - class MockBluetoothProvider: BluetoothProvider { - var bluetoothAuthorization: BluetoothAuthorization = .authorized - - var bluetoothState: BluetoothState = .poweredOn - - func authorizeBluetooth(_ completion: @escaping (BluetoothAuthorization) -> Void) { - completion(bluetoothAuthorization) - } - - func addBluetoothObserver(_ observer: BluetoothObserver, queue: DispatchQueue) { - } - - func removeBluetoothObserver(_ observer: BluetoothObserver) { - } - } - - class MockModalAlertScheduler: InAppModalAlertScheduler { - var scheduledAlert: Alert? - override func scheduleAlert(_ alert: Alert) { - scheduledAlert = alert - } - var unscheduledAlertIdentifier: Alert.Identifier? - override func unscheduleAlert(identifier: Alert.Identifier) { - unscheduledAlertIdentifier = identifier - } - } - - class MockUserNotificationAlertScheduler: UserNotificationAlertScheduler { - var scheduledAlert: Alert? - var muted: Bool? - - override func scheduleAlert(_ alert: Alert, muted: Bool) { - scheduledAlert = alert - self.muted = muted - } - var unscheduledAlertIdentifier: Alert.Identifier? - override func unscheduleAlert(identifier: Alert.Identifier) { - unscheduledAlertIdentifier = identifier - } - } - - class MockResponder: AlertResponder { - var acknowledged: [Alert.AlertIdentifier: Bool] = [:] - func acknowledgeAlert(alertIdentifier: Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { - completion(nil) - acknowledged[alertIdentifier] = true - } - } - - class MockFileManager: FileManager { - - var fileExists = true - let newer = Date() - let older = Date.distantPast - - var createdDirURL: URL? - override func createDirectory(at url: URL, withIntermediateDirectories createIntermediates: Bool, attributes: [FileAttributeKey : Any]? = nil) throws { - createdDirURL = url - } - override func fileExists(atPath path: String) -> Bool { - return !path.contains("doesntExist") - } - override func attributesOfItem(atPath path: String) throws -> [FileAttributeKey : Any] { - return path.contains("Sounds") ? path.contains("existsNewer") ? [.creationDate: newer] : [.creationDate: older] : - [.creationDate: newer] - } - var removedURLs = [URL]() - override func removeItem(at URL: URL) throws { - removedURLs.append(URL) - } - var copiedSrcURLs = [URL]() - var copiedDstURLs = [URL]() - override func copyItem(at srcURL: URL, to dstURL: URL) throws { - copiedSrcURLs.append(srcURL) - copiedDstURLs.append(dstURL) - } - override func urls(for directory: FileManager.SearchPathDirectory, in domainMask: FileManager.SearchPathDomainMask) -> [URL] { - return [] - } - } - - class MockPresenter: AlertPresenter { - func present(_ viewControllerToPresent: UIViewController, animated: Bool, completion: (() -> Void)?) { completion?() } - func dismissTopMost(animated: Bool, completion: (() -> Void)?) { completion?() } - func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool, completion: (() -> Void)?) { completion?() } - } - - class MockAlertManagerResponder: AlertManagerResponder { - func acknowledgeAlert(identifier: LoopKit.Alert.Identifier) { } - } - - class MockSoundVendor: AlertSoundVendor { - func getSoundBaseURL() -> URL? { - // Hm. It's not easy to make a "fake" URL, so we'll use this one: - return Bundle.main.resourceURL - } - - func getSounds() -> [Alert.Sound] { - return [.sound(name: "doesntExist"), .sound(name: "existsNewer"), .sound(name: "existsOlder")] - } - } - - class MockAlertStore: AlertStore { - - var issuedAlert: Alert? - override public func recordIssued(alert: Alert, at date: Date = Date(), completion: ((Result) -> Void)? = nil) { - issuedAlert = alert - completion?(.success) - } - - var retractedAlert: Alert? - var retractedAlertDate: Date? - override public func recordRetractedAlert(_ alert: Alert, at date: Date, completion: ((Result) -> Void)? = nil) { - retractedAlert = alert - retractedAlertDate = date - completion?(.success) - } - - var acknowledgedAlertIdentifier: Alert.Identifier? - var acknowledgedAlertDate: Date? - override public func recordAcknowledgement(of identifier: Alert.Identifier, at date: Date = Date(), - completion: ((Result) -> Void)? = nil) { - acknowledgedAlertIdentifier = identifier - acknowledgedAlertDate = date - completion?(.success) - } - - var retractededAlertIdentifier: Alert.Identifier? - override public func recordRetraction(of identifier: Alert.Identifier, at date: Date = Date(), - completion: ((Result) -> Void)? = nil) { - retractededAlertIdentifier = identifier - retractedAlertDate = date - completion?(.success) - } - - var storedAlerts = [StoredAlert]() - override public func lookupAllUnacknowledgedUnretracted(managerIdentifier: String? = nil, filteredByTriggers triggersStoredType: [AlertTriggerStoredType]? = nil, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { - completion(.success(storedAlerts)) - } - - override public func lookupAllUnretracted(managerIdentifier: String?, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { - completion(.success(storedAlerts)) - } - } - static let mockManagerIdentifier = "mockManagerIdentifier" static let mockTypeIdentifier = "mockTypeIdentifier" static let mockIdentifier = Alert.Identifier(managerIdentifier: mockManagerIdentifier, alertIdentifier: mockTypeIdentifier) @@ -505,20 +361,9 @@ class AlertManagerTests: XCTestCase { } wait(for: [testExpectation], timeout: 1) - if #available(iOS 15.0, *) { - XCTAssertNil(loopNotRunningRequests.first(where: { $0.content.interruptionLevel == .timeSensitive })?.content.sound) - if let request = loopNotRunningRequests.first(where: { $0.content.interruptionLevel == .critical }) { - XCTAssertEqual(request.content.sound, .defaultCriticalSound(withAudioVolume: 0)) - } - } else if FeatureFlags.criticalAlertsEnabled { - for request in loopNotRunningRequests { - let sound = request.content.sound - XCTAssertTrue(sound == nil || sound == .defaultCriticalSound(withAudioVolume: 0.0)) - } - } else { - for request in loopNotRunningRequests { - XCTAssertNil(request.content.sound) - } + XCTAssertNil(loopNotRunningRequests.first(where: { $0.content.interruptionLevel == .timeSensitive })?.content.sound) + if let request = loopNotRunningRequests.first(where: { $0.content.interruptionLevel == .critical }) { + XCTAssertEqual(request.content.sound, .defaultCriticalSound(withAudioVolume: 0)) } } } @@ -531,39 +376,3 @@ extension Swift.Result { } } } - -class MockUserNotificationCenter: UserNotificationCenter { - - var pendingRequests = [UNNotificationRequest]() - var deliveredRequests = [UNNotificationRequest]() - - func add(_ request: UNNotificationRequest, withCompletionHandler completionHandler: ((Error?) -> Void)? = nil) { - pendingRequests.append(request) - } - - func removePendingNotificationRequests(withIdentifiers identifiers: [String]) { - identifiers.forEach { identifier in - pendingRequests.removeAll { $0.identifier == identifier } - } - } - - func removeDeliveredNotifications(withIdentifiers identifiers: [String]) { - identifiers.forEach { identifier in - deliveredRequests.removeAll { $0.identifier == identifier } - } - } - - func deliverAll() { - deliveredRequests = pendingRequests - pendingRequests = [] - } - - func getDeliveredNotifications(completionHandler: @escaping ([UNNotification]) -> Void) { - // Sadly, we can't create UNNotifications. - completionHandler([]) - } - - func getPendingNotificationRequests(completionHandler: @escaping ([UNNotificationRequest]) -> Void) { - completionHandler(pendingRequests) - } -} diff --git a/LoopTests/Managers/Alerts/AlertStoreTests.swift b/LoopTests/Managers/Alerts/AlertStoreTests.swift index bb9d109633..81ba581e0c 100644 --- a/LoopTests/Managers/Alerts/AlertStoreTests.swift +++ b/LoopTests/Managers/Alerts/AlertStoreTests.swift @@ -870,14 +870,13 @@ class AlertStoreLogCriticalEventLogTests: XCTestCase { endDate: dateFormatter.date(from: "2100-01-02T03:09:00Z")!, to: outputStream, progress: progress)) - XCTAssertEqual(outputStream.string, #""" - [ - {"acknowledgedDate":"2100-01-02T03:08:00.000Z","alertIdentifier":"a1","backgroundContent":"{\"acknowledgeActionButtonLabel\":\"OK\",\"body\":\"background\",\"title\":\"BACKGROUND\"}","interruptionLevel":"timeSensitive","issuedDate":"2100-01-02T03:08:00.000Z","managerIdentifier":"m1","modificationCounter":1,"syncIdentifier":"52A046F7-F449-49B2-B003-7A378D0002DE","triggerType":0}, - {"acknowledgedDate":"2100-01-02T03:04:00.000Z","alertIdentifier":"a3","backgroundContent":"{\"acknowledgeActionButtonLabel\":\"OK\",\"body\":\"background\",\"title\":\"BACKGROUND\"}","interruptionLevel":"timeSensitive","issuedDate":"2100-01-02T03:04:00.000Z","managerIdentifier":"m3","modificationCounter":3,"syncIdentifier":"285AEA4B-0DEE-41F4-8669-800E9582A6E7","triggerType":0}, - {"acknowledgedDate":"2100-01-02T03:06:00.000Z","alertIdentifier":"a4","backgroundContent":"{\"acknowledgeActionButtonLabel\":\"OK\",\"body\":\"background\",\"title\":\"BACKGROUND\"}","interruptionLevel":"timeSensitive","issuedDate":"2100-01-02T03:06:00.000Z","managerIdentifier":"m4","modificationCounter":4,"syncIdentifier":"4B3109BD-DE11-42BD-A777-D4783459C483","triggerType":0} - ] - """#) + [ + {"acknowledgedDate":"2100-01-02T03:08:00.000Z","alertIdentifier":"a1","backgroundContent":"{\"acknowledgeActionButtonLabel\":\"OK\",\"body\":\"background\",\"title\":\"BACKGROUND\"}","interruptionLevel":"timeSensitive","issuedDate":"2100-01-02T03:08:00.000Z","managerIdentifier":"m1","modificationCounter":1,"syncIdentifier":"52A046F7-F449-49B2-B003-7A378D0002DE","triggerType":0}, + {"acknowledgedDate":"2100-01-02T03:04:00.000Z","alertIdentifier":"a3","backgroundContent":"{\"acknowledgeActionButtonLabel\":\"OK\",\"body\":\"background\",\"title\":\"BACKGROUND\"}","interruptionLevel":"timeSensitive","issuedDate":"2100-01-02T03:04:00.000Z","managerIdentifier":"m3","modificationCounter":3,"syncIdentifier":"285AEA4B-0DEE-41F4-8669-800E9582A6E7","triggerType":0}, + {"acknowledgedDate":"2100-01-02T03:06:00.000Z","alertIdentifier":"a4","backgroundContent":"{\"acknowledgeActionButtonLabel\":\"OK\",\"body\":\"background\",\"title\":\"BACKGROUND\"}","interruptionLevel":"timeSensitive","issuedDate":"2100-01-02T03:06:00.000Z","managerIdentifier":"m4","modificationCounter":4,"syncIdentifier":"4B3109BD-DE11-42BD-A777-D4783459C483","triggerType":0} + ] + """#) XCTAssertEqual(progress.completedUnitCount, 3 * 1) } diff --git a/LoopTests/Managers/Alerts/StoredAlertTests.swift b/LoopTests/Managers/Alerts/StoredAlertTests.swift index 85fe753c7d..63bb93b3f3 100644 --- a/LoopTests/Managers/Alerts/StoredAlertTests.swift +++ b/LoopTests/Managers/Alerts/StoredAlertTests.swift @@ -46,33 +46,33 @@ class StoredAlertEncodableTests: XCTestCase { XCTAssertEqual(.active, storedAlert.interruptionLevel) storedAlert.issuedDate = dateFormatter.date(from: "2020-05-14T21:00:12Z")! try! assertStoredAlertEncodable(storedAlert, encodesJSON: #""" - { - "alertIdentifier" : "bar", - "backgroundContent" : "{\"acknowledgeActionButtonLabel\":\"OK\",\"body\":\"background\",\"title\":\"BACKGROUND\"}", - "interruptionLevel" : "active", - "issuedDate" : "2020-05-14T21:00:12Z", - "managerIdentifier" : "foo", - "modificationCounter" : 1, - "syncIdentifier" : "A7073F28-0322-4506-A733-CF6E0687BAF7", - "triggerType" : 0 - } - """# + { + "alertIdentifier" : "bar", + "backgroundContent" : "{\"acknowledgeActionButtonLabel\":\"OK\",\"body\":\"background\",\"title\":\"BACKGROUND\"}", + "interruptionLevel" : "active", + "issuedDate" : "2020-05-14T21:00:12Z", + "managerIdentifier" : "foo", + "modificationCounter" : 1, + "syncIdentifier" : "A7073F28-0322-4506-A733-CF6E0687BAF7", + "triggerType" : 0 + } + """# ) - + storedAlert.interruptionLevel = .critical XCTAssertEqual(.critical, storedAlert.interruptionLevel) try! assertStoredAlertEncodable(storedAlert, encodesJSON: #""" - { - "alertIdentifier" : "bar", - "backgroundContent" : "{\"acknowledgeActionButtonLabel\":\"OK\",\"body\":\"background\",\"title\":\"BACKGROUND\"}", - "interruptionLevel" : "critical", - "issuedDate" : "2020-05-14T21:00:12Z", - "managerIdentifier" : "foo", - "modificationCounter" : 1, - "syncIdentifier" : "A7073F28-0322-4506-A733-CF6E0687BAF7", - "triggerType" : 0 - } - """# + { + "alertIdentifier" : "bar", + "backgroundContent" : "{\"acknowledgeActionButtonLabel\":\"OK\",\"body\":\"background\",\"title\":\"BACKGROUND\"}", + "interruptionLevel" : "critical", + "issuedDate" : "2020-05-14T21:00:12Z", + "managerIdentifier" : "foo", + "modificationCounter" : 1, + "syncIdentifier" : "A7073F28-0322-4506-A733-CF6E0687BAF7", + "triggerType" : 0 + } + """# ) } } diff --git a/LoopTests/Managers/CGMStalenessMonitorTests.swift b/LoopTests/Managers/CGMStalenessMonitorTests.swift index 89afce784b..9da44f7f00 100644 --- a/LoopTests/Managers/CGMStalenessMonitorTests.swift +++ b/LoopTests/Managers/CGMStalenessMonitorTests.swift @@ -30,7 +30,7 @@ class CGMStalenessMonitorTests: XCTestCase { XCTAssert(monitor.cgmDataIsStale) } - func testStalenessWithRecentCMGSample() { + func testStalenessWithRecentCMGSample() async throws { let monitor = CGMStalenessMonitor() fetchExpectation = expectation(description: "Fetch latest cgm glucose") latestCGMGlucose = storedGlucoseSample @@ -46,13 +46,16 @@ class CGMStalenessMonitorTests: XCTestCase { } monitor.delegate = self - waitForExpectations(timeout: 2) - + + await monitor.checkCGMStaleness() + + await fulfillment(of: [fetchExpectation!, exp], timeout: 2) + XCTAssertNotNil(cancelable) XCTAssertEqual(receivedValues, [true, false]) } - func testStalenessWithNoRecentCGMData() { + func testStalenessWithNoRecentCGMData() async throws { let monitor = CGMStalenessMonitor() fetchExpectation = expectation(description: "Fetch latest cgm glucose") latestCGMGlucose = nil @@ -68,13 +71,16 @@ class CGMStalenessMonitorTests: XCTestCase { } monitor.delegate = self - waitForExpectations(timeout: 2) - + + await monitor.checkCGMStaleness() + + await fulfillment(of: [fetchExpectation!, exp], timeout: 2) + XCTAssertNotNil(cancelable) XCTAssertEqual(receivedValues, [true, true]) } - func testStalenessNewReadingsArriving() { + func testStalenessNewReadingsArriving() async throws { let monitor = CGMStalenessMonitor() fetchExpectation = expectation(description: "Fetch latest cgm glucose") latestCGMGlucose = nil @@ -90,19 +96,21 @@ class CGMStalenessMonitorTests: XCTestCase { } monitor.delegate = self - + + await monitor.checkCGMStaleness() + monitor.cgmGlucoseSamplesAvailable([newGlucoseSample]) - - waitForExpectations(timeout: 2) - + + await fulfillment(of: [fetchExpectation!, exp], timeout: 2) + XCTAssertNotNil(cancelable) - XCTAssertEqual(receivedValues, [true, false]) + XCTAssertEqual(receivedValues, [true, true, false]) } } extension CGMStalenessMonitorTests: CGMStalenessMonitorDelegate { - func getLatestCGMGlucose(since: Date, completion: @escaping (Result) -> Void) { - completion(.success(latestCGMGlucose)) + public func getLatestCGMGlucose(since: Date) async throws -> StoredGlucoseSample? { fetchExpectation?.fulfill() + return latestCGMGlucose } } diff --git a/LoopTests/Managers/DeviceDataManagerTests.swift b/LoopTests/Managers/DeviceDataManagerTests.swift new file mode 100644 index 0000000000..c72a955cab --- /dev/null +++ b/LoopTests/Managers/DeviceDataManagerTests.swift @@ -0,0 +1,210 @@ +// +// DeviceDataManagerTests.swift +// LoopTests +// +// Created by Pete Schwamb on 10/31/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import XCTest +import HealthKit +import LoopKit +import LoopKitUI +import LoopCore +@testable import Loop + +@MainActor +final class DeviceDataManagerTests: XCTestCase { + + var deviceDataManager: DeviceDataManager! + let mockDecisionStore = MockDosingDecisionStore() + let pumpManager: MockPumpManager = MockPumpManager() + let cgmManager: MockCGMManager = MockCGMManager() + let trustedTimeChecker = MockTrustedTimeChecker() + let loopControlMock = LoopControlMock() + var settingsManager: SettingsManager! + var uploadEventListener: MockUploadEventListener! + + + class MockAlertIssuer: AlertIssuer { + func issueAlert(_ alert: LoopKit.Alert) { + } + + func retractAlert(identifier: LoopKit.Alert.Identifier) { + } + } + + override func setUp() async throws { + let mockUserNotificationCenter = MockUserNotificationCenter() + let mockBluetoothProvider = MockBluetoothProvider() + let alertPresenter = MockPresenter() + let automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: true, isAutomaticDosingAllowed: true) + + let alertManager = AlertManager( + alertPresenter: alertPresenter, + userNotificationAlertScheduler: MockUserNotificationAlertScheduler(userNotificationCenter: mockUserNotificationCenter), + bluetoothProvider: mockBluetoothProvider, + analyticsServicesManager: AnalyticsServicesManager() + ) + + let persistenceController = PersistenceController.mock() + + let healthStore = HKHealthStore() + + let carbStore = CarbStore( + cacheStore: persistenceController, + cacheLength: .days(1) + ) + + let doseStore = await DoseStore( + cacheStore: persistenceController + ) + + let fileManager = FileManager.default + let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! + let deviceLogDirectory = documentsDirectory.appendingPathComponent("DeviceLog") + if !fileManager.fileExists(atPath: deviceLogDirectory.path) { + do { + try fileManager.createDirectory(at: deviceLogDirectory, withIntermediateDirectories: false) + } catch let error { + preconditionFailure("Could not create DeviceLog directory: \(error)") + } + } + let deviceLog = PersistentDeviceLog(storageFile: deviceLogDirectory.appendingPathComponent("Storage.sqlite")) + + let glucoseStore = await GlucoseStore(cacheStore: persistenceController) + + let cgmEventStore = CgmEventStore(cacheStore: persistenceController) + + self.settingsManager = SettingsManager(cacheStore: persistenceController, expireAfter: .days(1), alertMuter: AlertMuter()) + + self.uploadEventListener = MockUploadEventListener() + + deviceDataManager = DeviceDataManager( + pluginManager: PluginManager(), + deviceLog: deviceLog, + alertManager: alertManager, + settingsManager: settingsManager, + healthStore: healthStore, + carbStore: carbStore, + doseStore: doseStore, + glucoseStore: glucoseStore, + cgmEventStore: cgmEventStore, + uploadEventListener: uploadEventListener, + crashRecoveryManager: CrashRecoveryManager(alertIssuer: MockAlertIssuer()), + loopControl: loopControlMock, + analyticsServicesManager: AnalyticsServicesManager(), + activeServicesProvider: self, + activeStatefulPluginsProvider: self, + bluetoothProvider: mockBluetoothProvider, + alertPresenter: alertPresenter, + automaticDosingStatus: automaticDosingStatus, + cacheStore: persistenceController, + localCacheDuration: .days(1), + displayGlucosePreference: DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter), + displayGlucoseUnitBroadcaster: self + ) + + deviceDataManager.pumpManager = pumpManager + deviceDataManager.cgmManager = cgmManager + } + + func testValidateMaxTempBasalDoesntCancelTempBasalIfHigher() async throws { + let dose = DoseEntry( + type: .tempBasal, + startDate: Date(), + value: 3.0, + unit: .unitsPerHour, + automatic: true + ) + pumpManager.status.basalDeliveryState = .tempBasal(dose) + + let newLimits = DeliveryLimits( + maximumBasalRate: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: 5), + maximumBolus: nil + ) + let limits = try await deviceDataManager.syncDeliveryLimits(deliveryLimits: newLimits) + + XCTAssertNil(loopControlMock.lastCancelActiveTempBasalReason) + XCTAssertTrue(mockDecisionStore.dosingDecisions.isEmpty) + XCTAssertEqual(limits.maximumBasalRate, newLimits.maximumBasalRate) + } + + func testValidateMaxTempBasalCancelsTempBasalIfLower() async throws { + let dose = DoseEntry( + type: .tempBasal, + startDate: Date(), + endDate: nil, + value: 5.0, + unit: .unitsPerHour + ) + pumpManager.status.basalDeliveryState = .tempBasal(dose) + + let newLimits = DeliveryLimits( + maximumBasalRate: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: 3), + maximumBolus: nil + ) + let limits = try await deviceDataManager.syncDeliveryLimits(deliveryLimits: newLimits) + + XCTAssertEqual(.maximumBasalRateChanged, loopControlMock.lastCancelActiveTempBasalReason) + XCTAssertEqual(limits.maximumBasalRate, newLimits.maximumBasalRate) + } + + func testReceivedUnreliableCGMReadingCancelsTempBasal() { + let dose = DoseEntry( + type: .tempBasal, + startDate: Date(), + value: 5.0, + unit: .unitsPerHour + ) + pumpManager.status.basalDeliveryState = .tempBasal(dose) + + settingsManager.mutateLoopSettings { settings in + settings.basalRateSchedule = BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 3.0)]) + } + + loopControlMock.cancelExpectation = expectation(description: "Temp basal cancel") + + if let deviceManager = self.deviceDataManager { + cgmManager.delegateQueue.async { + deviceManager.cgmManager(self.cgmManager, hasNew: .unreliableData) + } + } + + wait(for: [loopControlMock.cancelExpectation!], timeout: 1) + + XCTAssertEqual(loopControlMock.lastCancelActiveTempBasalReason, .unreliableCGMData) + } + + func testUploadEventListener() { + let alertStore = AlertStore() + deviceDataManager.alertStoreHasUpdatedAlertData(alertStore) + XCTAssertEqual(uploadEventListener.lastUploadTriggeringType, .alert) + } + +} + +extension DeviceDataManagerTests: ActiveServicesProvider { + var activeServices: [LoopKit.Service] { + return [] + } + + +} + +extension DeviceDataManagerTests: ActiveStatefulPluginsProvider { + var activeStatefulPlugins: [LoopKit.StatefulPluggable] { + return [] + } +} + +extension DeviceDataManagerTests: DisplayGlucoseUnitBroadcaster { + func addDisplayGlucoseUnitObserver(_ observer: LoopKitUI.DisplayGlucoseUnitObserver) { + } + + func removeDisplayGlucoseUnitObserver(_ observer: LoopKitUI.DisplayGlucoseUnitObserver) { + } + + func notifyObserversOfDisplayGlucoseUnitChange(to displayGlucoseUnit: HKUnit) { + } +} diff --git a/LoopTests/Managers/DoseEnactorTests.swift b/LoopTests/Managers/DoseEnactorTests.swift index bf722ec874..08e5f4d9b5 100644 --- a/LoopTests/Managers/DoseEnactorTests.swift +++ b/LoopTests/Managers/DoseEnactorTests.swift @@ -10,6 +10,7 @@ import XCTest import Foundation import LoopKit import HealthKit +import LoopAlgorithm @testable import Loop @@ -21,136 +22,9 @@ extension MockPumpManagerError: LocalizedError { } -class MockPumpManager: PumpManager { - - var enactBolusCalled: ((Double, BolusActivationType) -> Void)? - - var enactTempBasalCalled: ((Double, TimeInterval) -> Void)? - - var enactTempBasalError: PumpManagerError? - - init() { - - } - - // PumpManager implementation - static var onboardingMaximumBasalScheduleEntryCount: Int = 24 - - static var onboardingSupportedBasalRates: [Double] = [1,2,3] - - static var onboardingSupportedBolusVolumes: [Double] = [1,2,3] - - static var onboardingSupportedMaximumBolusVolumes: [Double] = [1,2,3] - - let deliveryUnitsPerMinute = 1.5 - - var supportedBasalRates: [Double] = [1,2,3] - - var supportedBolusVolumes: [Double] = [1,2,3] - - var supportedMaximumBolusVolumes: [Double] = [1,2,3] - - var maximumBasalScheduleEntryCount: Int = 24 - - var minimumBasalScheduleEntryDuration: TimeInterval = .minutes(30) - - var pumpManagerDelegate: PumpManagerDelegate? - - var pumpRecordsBasalProfileStartEvents: Bool = false - - var pumpReservoirCapacity: Double = 50 - - var lastSync: Date? - - var status: PumpManagerStatus = - PumpManagerStatus( - timeZone: TimeZone.current, - device: HKDevice(name: "MockPumpManager", manufacturer: nil, model: nil, hardwareVersion: nil, firmwareVersion: nil, softwareVersion: nil, localIdentifier: nil, udiDeviceIdentifier: nil), - pumpBatteryChargeRemaining: nil, - basalDeliveryState: nil, - bolusState: .noBolus, - insulinType: .novolog) - - func addStatusObserver(_ observer: PumpManagerStatusObserver, queue: DispatchQueue) { - } - - func removeStatusObserver(_ observer: PumpManagerStatusObserver) { - } - - func ensureCurrentPumpData(completion: ((Date?) -> Void)?) { - completion?(Date()) - } - - func setMustProvideBLEHeartbeat(_ mustProvideBLEHeartbeat: Bool) { - } - - func createBolusProgressReporter(reportingOn dispatchQueue: DispatchQueue) -> DoseProgressReporter? { - return nil - } - - func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (PumpManagerError?) -> Void) { - enactBolusCalled?(units, activationType) - completion(nil) - } - - func cancelBolus(completion: @escaping (PumpManagerResult) -> Void) { - completion(.success(nil)) - } - - func enactTempBasal(unitsPerHour: Double, for duration: TimeInterval, completion: @escaping (PumpManagerError?) -> Void) { - enactTempBasalCalled?(unitsPerHour, duration) - completion(enactTempBasalError) - } - - func suspendDelivery(completion: @escaping (Error?) -> Void) { - completion(nil) - } - - func resumeDelivery(completion: @escaping (Error?) -> Void) { - completion(nil) - } - - func syncBasalRateSchedule(items scheduleItems: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { - } - - func syncDeliveryLimits(limits deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { - - } - - func estimatedDuration(toBolus units: Double) -> TimeInterval { - .minutes(units / deliveryUnitsPerMinute) - } - - static var pluginIdentifier: String = "MockPumpManager" - - var localizedTitle: String = "MockPumpManager" - - var delegateQueue: DispatchQueue! - - required init?(rawState: RawStateValue) { - - } - - var rawState: RawStateValue = [:] - - var isOnboarded: Bool = true - - var debugDescription: String = "MockPumpManager" - - func acknowledgeAlert(alertIdentifier: Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { - } - - func getSoundBaseURL() -> URL? { - return nil - } - - func getSounds() -> [Alert.Sound] { - return [.sound(name: "doesntExist")] - } -} class DoseEnactorTests: XCTestCase { - func testBasalAndBolusDosedSerially() { + func testBasalAndBolusDosedSerially() async throws { let enactor = DoseEnactor() let tempBasalRecommendation = TempBasalRecommendation(unitsPerHour: 0, duration: 0) // Cancel let recommendation = AutomaticDoseRecommendation(basalAdjustment: tempBasalRecommendation, bolusUnits: 1.5) @@ -165,15 +39,13 @@ class DoseEnactorTests: XCTestCase { pumpManager.enactBolusCalled = { (amount, automatic) in bolusExpectation.fulfill() } - - enactor.enact(recommendation: recommendation, with: pumpManager) { error in - XCTAssertNil(error) - } - - wait(for: [tempBasalExpectation, bolusExpectation], timeout: 5, enforceOrder: true) + + try await enactor.enact(recommendation: recommendation, with: pumpManager) + + await fulfillment(of: [tempBasalExpectation, bolusExpectation], timeout: 5, enforceOrder: true) } - func testBolusDoesNotIssueIfTempBasalAdjustmentFailed() { + func testBolusDoesNotIssueIfTempBasalAdjustmentFailed() async throws { let enactor = DoseEnactor() let tempBasalRecommendation = TempBasalRecommendation(unitsPerHour: 0, duration: 0) // Cancel let recommendation = AutomaticDoseRecommendation(basalAdjustment: tempBasalRecommendation, bolusUnits: 1.5) @@ -190,14 +62,16 @@ class DoseEnactorTests: XCTestCase { pumpManager.enactTempBasalError = .configuration(MockPumpManagerError.failed) - enactor.enact(recommendation: recommendation, with: pumpManager) { error in - XCTAssertNotNil(error) + do { + try await enactor.enact(recommendation: recommendation, with: pumpManager) + XCTFail("Expected enact to throw error on failure.") + } catch { } - - waitForExpectations(timeout: 2) + + await fulfillment(of: [tempBasalExpectation]) } - func testTempBasalOnly() { + func testTempBasalOnly() async throws { let enactor = DoseEnactor() let tempBasalRecommendation = TempBasalRecommendation(unitsPerHour: 1.2, duration: .minutes(30)) // Cancel let recommendation = AutomaticDoseRecommendation(basalAdjustment: tempBasalRecommendation, bolusUnits: 0) @@ -213,13 +87,10 @@ class DoseEnactorTests: XCTestCase { pumpManager.enactBolusCalled = { (amount, automatic) in XCTFail("Should not enact bolus") } - - enactor.enact(recommendation: recommendation, with: pumpManager) { error in - XCTAssertNil(error) - } - - waitForExpectations(timeout: 2) + try await enactor.enact(recommendation: recommendation, with: pumpManager) + + await fulfillment(of: [tempBasalExpectation]) } diff --git a/LoopTests/Managers/LoopAlgorithmTests.swift b/LoopTests/Managers/LoopAlgorithmTests.swift deleted file mode 100644 index 6c51283872..0000000000 --- a/LoopTests/Managers/LoopAlgorithmTests.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// LoopAlgorithmTests.swift -// LoopTests -// -// Created by Pete Schwamb on 8/17/23. -// Copyright © 2023 LoopKit Authors. All rights reserved. -// - -import XCTest -import LoopKit -import LoopCore -import HealthKit - -final class LoopAlgorithmTests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - public var bundle: Bundle { - return Bundle(for: type(of: self)) - } - - public func loadFixture(_ resourceName: String) -> T { - let path = bundle.path(forResource: resourceName, ofType: "json")! - return try! JSONSerialization.jsonObject(with: Data(contentsOf: URL(fileURLWithPath: path)), options: []) as! T - } - - func loadBasalRateScheduleFixture(_ resourceName: String) -> BasalRateSchedule { - let fixture: [JSONDictionary] = loadFixture(resourceName) - - let items = fixture.map { - return RepeatingScheduleValue(startTime: TimeInterval(minutes: $0["minutes"] as! Double), value: $0["rate"] as! Double) - } - - return BasalRateSchedule(dailyItems: items, timeZone: .utcTimeZone)! - } - - func loadPredictedGlucoseFixture(_ name: String) -> [PredictedGlucoseValue] { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - let url = bundle.url(forResource: name, withExtension: "json")! - return try! decoder.decode([PredictedGlucoseValue].self, from: try! Data(contentsOf: url)) - } - - - func testLiveCaptureWithFunctionalAlgorithm() throws { - // This matches the "testForecastFromLiveCaptureInputData" test of LoopDataManagerDosingTests, - // Using the same input data, but generating the forecast using the LoopAlgorithm.generatePrediction() - // function. - - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - let url = bundle.url(forResource: "live_capture_input", withExtension: "json")! - let predictionInput = try! decoder.decode(LoopPredictionInput.self, from: try! Data(contentsOf: url)) - - let prediction = try LoopAlgorithm.generatePrediction(input: predictionInput) - - let expectedPredictedGlucose = loadPredictedGlucoseFixture("live_capture_predicted_glucose") - - XCTAssertEqual(expectedPredictedGlucose.count, prediction.glucose.count) - - let defaultAccuracy = 1.0 / 40.0 - - for (expected, calculated) in zip(expectedPredictedGlucose, prediction.glucose) { - XCTAssertEqual(expected.startDate, calculated.startDate) - XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) - } - } -} diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift deleted file mode 100644 index a1f26a0e92..0000000000 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ /dev/null @@ -1,647 +0,0 @@ -// -// LoopDataManagerDosingTests.swift -// LoopTests -// -// Created by Anna Quinlan on 10/19/22. -// Copyright © 2022 LoopKit Authors. All rights reserved. -// - -import XCTest -import HealthKit -import LoopKit -@testable import LoopCore -@testable import Loop - -class MockDelegate: LoopDataManagerDelegate { - let pumpManager = MockPumpManager() - - var bolusUnits: Double? - func loopDataManager(_ manager: Loop.LoopDataManager, estimateBolusDuration units: Double) -> TimeInterval? { - self.bolusUnits = units - return pumpManager.estimatedDuration(toBolus: units) - } - - var recommendation: AutomaticDoseRecommendation? - var error: LoopError? - func loopDataManager(_ manager: LoopDataManager, didRecommend automaticDose: (recommendation: AutomaticDoseRecommendation, date: Date), completion: @escaping (LoopError?) -> Void) { - self.recommendation = automaticDose.recommendation - completion(error) - } - func roundBasalRate(unitsPerHour: Double) -> Double { Double(Int(unitsPerHour / 0.05)) * 0.05 } - func roundBolusVolume(units: Double) -> Double { Double(Int(units / 0.05)) * 0.05 } - var pumpManagerStatus: PumpManagerStatus? - var cgmManagerStatus: CGMManagerStatus? - var pumpStatusHighlight: DeviceStatusHighlight? -} - -class LoopDataManagerDosingTests: LoopDataManagerTests { - // MARK: Functions to load fixtures - func loadLocalDateGlucoseEffect(_ name: String) -> [GlucoseEffect] { - let fixture: [JSONDictionary] = loadFixture(name) - let localDateFormatter = ISO8601DateFormatter.localTimeDate() - - return fixture.map { - return GlucoseEffect(startDate: localDateFormatter.date(from: $0["date"] as! String)!, quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), doubleValue:$0["amount"] as! Double)) - } - } - - func loadPredictedGlucoseFixture(_ name: String) -> [PredictedGlucoseValue] { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - let url = bundle.url(forResource: name, withExtension: "json")! - return try! decoder.decode([PredictedGlucoseValue].self, from: try! Data(contentsOf: url)) - } - - // MARK: Tests - func testForecastFromLiveCaptureInputData() { - - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - let url = bundle.url(forResource: "live_capture_input", withExtension: "json")! - let predictionInput = try! decoder.decode(LoopPredictionInput.self, from: try! Data(contentsOf: url)) - - // Therapy settings in the "live capture" input only have one value, so we can fake some schedules - // from the first entry of each therapy setting's history. - let basalRateSchedule = BasalRateSchedule(dailyItems: [ - RepeatingScheduleValue(startTime: 0, value: predictionInput.settings.basal.first!.value) - ]) - let insulinSensitivitySchedule = InsulinSensitivitySchedule( - unit: .milligramsPerDeciliter, - dailyItems: [ - RepeatingScheduleValue(startTime: 0, value: predictionInput.settings.sensitivity.first!.value.doubleValue(for: .milligramsPerDeciliter)) - ], - timeZone: .utcTimeZone - )! - let carbRatioSchedule = CarbRatioSchedule( - unit: .gram(), - dailyItems: [ - RepeatingScheduleValue(startTime: 0.0, value: predictionInput.settings.carbRatio.first!.value) - ], - timeZone: .utcTimeZone - )! - - let settings = LoopSettings( - dosingEnabled: false, - glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, - insulinSensitivitySchedule: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - carbRatioSchedule: carbRatioSchedule, - maximumBasalRatePerHour: 10, - maximumBolus: 5, - suspendThreshold: predictionInput.settings.suspendThreshold, - automaticDosingStrategy: .automaticBolus - ) - - let glucoseStore = MockGlucoseStore() - glucoseStore.storedGlucose = predictionInput.glucoseHistory - - let currentDate = glucoseStore.latestGlucose!.startDate - now = currentDate - - let doseStore = MockDoseStore() - doseStore.basalProfile = basalRateSchedule - doseStore.basalProfileApplyingOverrideHistory = doseStore.basalProfile - doseStore.sensitivitySchedule = insulinSensitivitySchedule - doseStore.doseHistory = predictionInput.doses - doseStore.lastAddedPumpData = predictionInput.doses.last!.startDate - let carbStore = MockCarbStore() - carbStore.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivitySchedule - carbStore.carbRatioSchedule = carbRatioSchedule - carbStore.carbRatioScheduleApplyingOverrideHistory = carbRatioSchedule - carbStore.carbHistory = predictionInput.carbEntries - - - dosingDecisionStore = MockDosingDecisionStore() - automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: true, isAutomaticDosingAllowed: true) - loopDataManager = LoopDataManager( - lastLoopCompleted: currentDate, - basalDeliveryState: .active(currentDate), - settings: settings, - overrideHistory: TemporaryScheduleOverrideHistory(), - analyticsServicesManager: AnalyticsServicesManager(), - localCacheDuration: .days(1), - doseStore: doseStore, - glucoseStore: glucoseStore, - carbStore: carbStore, - dosingDecisionStore: dosingDecisionStore, - latestStoredSettingsProvider: MockLatestStoredSettingsProvider(), - now: { currentDate }, - pumpInsulinType: .novolog, - automaticDosingStatus: automaticDosingStatus, - trustedTimeOffset: { 0 } - ) - - let expectedPredictedGlucose = loadPredictedGlucoseFixture("live_capture_predicted_glucose") - - let updateGroup = DispatchGroup() - updateGroup.enter() - var predictedGlucose: [PredictedGlucoseValue]? - var recommendedBasal: TempBasalRecommendation? - self.loopDataManager.getLoopState { _, state in - predictedGlucose = state.predictedGlucoseIncludingPendingInsulin - recommendedBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment - updateGroup.leave() - } - // We need to wait until the task completes to get outputs - updateGroup.wait() - - XCTAssertNotNil(predictedGlucose) - - XCTAssertEqual(expectedPredictedGlucose.count, predictedGlucose!.count) - - for (expected, calculated) in zip(expectedPredictedGlucose, predictedGlucose!) { - XCTAssertEqual(expected.startDate, calculated.startDate) - XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) - } - } - - - func testFlatAndStable() { - setUp(for: .flatAndStable) - let predictedGlucoseOutput = loadLocalDateGlucoseEffect("flat_and_stable_predicted_glucose") - - let updateGroup = DispatchGroup() - updateGroup.enter() - var predictedGlucose: [PredictedGlucoseValue]? - var recommendedDose: AutomaticDoseRecommendation? - self.loopDataManager.getLoopState { _, state in - predictedGlucose = state.predictedGlucose - recommendedDose = state.recommendedAutomaticDose?.recommendation - updateGroup.leave() - } - // We need to wait until the task completes to get outputs - updateGroup.wait() - - XCTAssertNotNil(predictedGlucose) - XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) - - for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { - XCTAssertEqual(expected.startDate, calculated.startDate) - XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) - } - - let recommendedTempBasal = recommendedDose?.basalAdjustment - - XCTAssertEqual(1.40, recommendedTempBasal!.unitsPerHour, accuracy: defaultAccuracy) - } - - func testHighAndStable() { - setUp(for: .highAndStable) - let predictedGlucoseOutput = loadLocalDateGlucoseEffect("high_and_stable_predicted_glucose") - - let updateGroup = DispatchGroup() - updateGroup.enter() - var predictedGlucose: [PredictedGlucoseValue]? - var recommendedBasal: TempBasalRecommendation? - self.loopDataManager.getLoopState { _, state in - predictedGlucose = state.predictedGlucose - recommendedBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment - updateGroup.leave() - } - // We need to wait until the task completes to get outputs - updateGroup.wait() - - XCTAssertNotNil(predictedGlucose) - XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) - - for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { - XCTAssertEqual(expected.startDate, calculated.startDate) - XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) - } - - XCTAssertEqual(4.63, recommendedBasal!.unitsPerHour, accuracy: defaultAccuracy) - } - - func testHighAndFalling() { - setUp(for: .highAndFalling) - let predictedGlucoseOutput = loadLocalDateGlucoseEffect("high_and_falling_predicted_glucose") - - let updateGroup = DispatchGroup() - updateGroup.enter() - var predictedGlucose: [PredictedGlucoseValue]? - var recommendedTempBasal: TempBasalRecommendation? - self.loopDataManager.getLoopState { _, state in - predictedGlucose = state.predictedGlucose - recommendedTempBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment - updateGroup.leave() - } - // We need to wait until the task completes to get outputs - updateGroup.wait() - - XCTAssertNotNil(predictedGlucose) - XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) - - for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { - XCTAssertEqual(expected.startDate, calculated.startDate) - XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) - } - - XCTAssertEqual(0, recommendedTempBasal!.unitsPerHour, accuracy: defaultAccuracy) - } - - func testHighAndRisingWithCOB() { - setUp(for: .highAndRisingWithCOB) - let predictedGlucoseOutput = loadLocalDateGlucoseEffect("high_and_rising_with_cob_predicted_glucose") - - let updateGroup = DispatchGroup() - updateGroup.enter() - var predictedGlucose: [PredictedGlucoseValue]? - var recommendedBolus: ManualBolusRecommendation? - self.loopDataManager.getLoopState { _, state in - predictedGlucose = state.predictedGlucose - recommendedBolus = try? state.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: true) - updateGroup.leave() - } - // We need to wait until the task completes to get outputs - updateGroup.wait() - - XCTAssertNotNil(predictedGlucose) - XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) - - for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { - XCTAssertEqual(expected.startDate, calculated.startDate) - XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) - } - - XCTAssertEqual(1.6, recommendedBolus!.amount, accuracy: defaultAccuracy) - } - - func testLowAndFallingWithCOB() { - setUp(for: .lowAndFallingWithCOB) - let predictedGlucoseOutput = loadLocalDateGlucoseEffect("low_and_falling_predicted_glucose") - - let updateGroup = DispatchGroup() - updateGroup.enter() - var predictedGlucose: [PredictedGlucoseValue]? - var recommendedTempBasal: TempBasalRecommendation? - self.loopDataManager.getLoopState { _, state in - predictedGlucose = state.predictedGlucose - recommendedTempBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment - updateGroup.leave() - } - // We need to wait until the task completes to get outputs - updateGroup.wait() - - XCTAssertNotNil(predictedGlucose) - XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) - - for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { - XCTAssertEqual(expected.startDate, calculated.startDate) - XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) - } - - XCTAssertEqual(0, recommendedTempBasal!.unitsPerHour, accuracy: defaultAccuracy) - } - - func testLowWithLowTreatment() { - setUp(for: .lowWithLowTreatment) - let predictedGlucoseOutput = loadLocalDateGlucoseEffect("low_with_low_treatment_predicted_glucose") - - let updateGroup = DispatchGroup() - updateGroup.enter() - var predictedGlucose: [PredictedGlucoseValue]? - var recommendedTempBasal: TempBasalRecommendation? - self.loopDataManager.getLoopState { _, state in - predictedGlucose = state.predictedGlucose - recommendedTempBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment - updateGroup.leave() - } - // We need to wait until the task completes to get outputs - updateGroup.wait() - - XCTAssertNotNil(predictedGlucose) - XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) - - for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { - XCTAssertEqual(expected.startDate, calculated.startDate) - XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) - } - - XCTAssertEqual(0, recommendedTempBasal!.unitsPerHour, accuracy: defaultAccuracy) - } - - func waitOnDataQueue(timeout: TimeInterval = 1.0) { - let e = expectation(description: "dataQueue") - loopDataManager.getLoopState { _, _ in - e.fulfill() - } - wait(for: [e], timeout: timeout) - } - - func testValidateMaxTempBasalDoesntCancelTempBasalIfHigher() { - let dose = DoseEntry(type: .tempBasal, startDate: Date(), endDate: nil, value: 3.0, unit: .unitsPerHour, deliveredUnits: nil, description: nil, syncIdentifier: nil, scheduledBasalRate: nil) - setUp(for: .highAndStable, basalDeliveryState: .tempBasal(dose)) - // This wait is working around the issue presented by LoopDataManager.init(). It cancels the temp basal if - // `isClosedLoop` is false (which it is from `setUp` above). When that happens, it races with - // `maxTempBasalSavePreflight` below. This ensures only one happens at a time. - waitOnDataQueue() - let delegate = MockDelegate() - loopDataManager.delegate = delegate - var error: Error? - let exp = expectation(description: #function) - XCTAssertNil(delegate.recommendation) - loopDataManager.maxTempBasalSavePreflight(unitsPerHour: 5.0) { - error = $0 - exp.fulfill() - } - wait(for: [exp], timeout: 1.0) - XCTAssertNil(error) - XCTAssertNil(delegate.recommendation) - XCTAssertTrue(dosingDecisionStore.dosingDecisions.isEmpty) - } - - func testValidateMaxTempBasalCancelsTempBasalIfLower() { - let dose = DoseEntry(type: .tempBasal, startDate: Date(), endDate: nil, value: 5.0, unit: .unitsPerHour, deliveredUnits: nil, description: nil, syncIdentifier: nil, scheduledBasalRate: nil) - setUp(for: .highAndStable, basalDeliveryState: .tempBasal(dose)) - // This wait is working around the issue presented by LoopDataManager.init(). It cancels the temp basal if - // `isClosedLoop` is false (which it is from `setUp` above). When that happens, it races with - // `maxTempBasalSavePreflight` below. This ensures only one happens at a time. - waitOnDataQueue() - let delegate = MockDelegate() - loopDataManager.delegate = delegate - var error: Error? - let exp = expectation(description: #function) - XCTAssertNil(delegate.recommendation) - loopDataManager.maxTempBasalSavePreflight(unitsPerHour: 3.0) { - error = $0 - exp.fulfill() - } - wait(for: [exp], timeout: 1.0) - XCTAssertNil(error) - XCTAssertEqual(delegate.recommendation, AutomaticDoseRecommendation(basalAdjustment: .cancel)) - XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "maximumBasalRateChanged") - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, AutomaticDoseRecommendation(basalAdjustment: .cancel)) - } - - func testChangingMaxBasalUpdatesLoopData() { - setUp(for: .highAndStable) - waitOnDataQueue() - var loopDataUpdated = false - let exp = expectation(description: #function) - let observer = NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { _ in - loopDataUpdated = true - exp.fulfill() - } - XCTAssertFalse(loopDataUpdated) - loopDataManager.mutateSettings { $0.maximumBasalRatePerHour = 2.0 } - wait(for: [exp], timeout: 1.0) - XCTAssertTrue(loopDataUpdated) - NotificationCenter.default.removeObserver(observer) - } - - func testOpenLoopCancelsTempBasal() { - let dose = DoseEntry(type: .tempBasal, startDate: Date(), value: 1.0, unit: .unitsPerHour) - setUp(for: .highAndStable, basalDeliveryState: .tempBasal(dose)) - waitOnDataQueue() - let delegate = MockDelegate() - loopDataManager.delegate = delegate - let exp = expectation(description: #function) - let observer = NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { _ in - exp.fulfill() - } - automaticDosingStatus.automaticDosingEnabled = false - wait(for: [exp], timeout: 1.0) - let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel) - XCTAssertEqual(delegate.recommendation, expectedAutomaticDoseRecommendation) - XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "automaticDosingDisabled") - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) - NotificationCenter.default.removeObserver(observer) - } - - func testReceivedUnreliableCGMReadingCancelsTempBasal() { - let dose = DoseEntry(type: .tempBasal, startDate: Date(), value: 5.0, unit: .unitsPerHour) - setUp(for: .highAndStable, basalDeliveryState: .tempBasal(dose)) - waitOnDataQueue() - let delegate = MockDelegate() - loopDataManager.delegate = delegate - let exp = expectation(description: #function) - let observer = NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { _ in - exp.fulfill() - } - loopDataManager.receivedUnreliableCGMReading() - wait(for: [exp], timeout: 1.0) - let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel) - XCTAssertEqual(delegate.recommendation, expectedAutomaticDoseRecommendation) - XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "unreliableCGMData") - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) - NotificationCenter.default.removeObserver(observer) - } - - func testLoopEnactsTempBasalWithoutManualBolusRecommendation() { - setUp(for: .highAndStable) - waitOnDataQueue() - let delegate = MockDelegate() - loopDataManager.delegate = delegate - let exp = expectation(description: #function) - let observer = NotificationCenter.default.addObserver(forName: .LoopCompleted, object: nil, queue: nil) { _ in - exp.fulfill() - } - loopDataManager.loop() - wait(for: [exp], timeout: 1.0) - let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 4.55, duration: .minutes(30))) - XCTAssertEqual(delegate.recommendation, expectedAutomaticDoseRecommendation) - XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) - if dosingDecisionStore.dosingDecisions.count == 1 { - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "loop") - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) - XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRecommendation) - XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRequested) - } - NotificationCenter.default.removeObserver(observer) - } - - func testLoopRecommendsTempBasalWithoutEnactingIfOpenLoop() { - setUp(for: .highAndStable) - automaticDosingStatus.automaticDosingEnabled = false - waitOnDataQueue() - let delegate = MockDelegate() - loopDataManager.delegate = delegate - let exp = expectation(description: #function) - let observer = NotificationCenter.default.addObserver(forName: .LoopCompleted, object: nil, queue: nil) { _ in - exp.fulfill() - } - loopDataManager.loop() - wait(for: [exp], timeout: 1.0) - let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 4.55, duration: .minutes(30))) - XCTAssertNil(delegate.recommendation) - XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "loop") - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) - XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRecommendation) - XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRequested) - NotificationCenter.default.removeObserver(observer) - } - - func testLoopGetStateRecommendsManualBolus() { - setUp(for: .highAndStable) - let exp = expectation(description: #function) - var recommendedBolus: ManualBolusRecommendation? - loopDataManager.getLoopState { (_, loopState) in - recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: true) - exp.fulfill() - } - wait(for: [exp], timeout: 100000.0) - XCTAssertEqual(recommendedBolus!.amount, 1.82, accuracy: 0.01) - } - - func testLoopGetStateRecommendsManualBolusWithMomentum() { - setUp(for: .highAndRisingWithCOB) - let exp = expectation(description: #function) - var recommendedBolus: ManualBolusRecommendation? - loopDataManager.getLoopState { (_, loopState) in - recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: true) - exp.fulfill() - } - wait(for: [exp], timeout: 1.0) - XCTAssertEqual(recommendedBolus!.amount, 1.62, accuracy: 0.01) - } - - func testLoopGetStateRecommendsManualBolusWithoutMomentum() { - setUp(for: .highAndRisingWithCOB) - let exp = expectation(description: #function) - var recommendedBolus: ManualBolusRecommendation? - loopDataManager.getLoopState { (_, loopState) in - recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: false) - exp.fulfill() - } - wait(for: [exp], timeout: 1.0) - XCTAssertEqual(recommendedBolus!.amount, 1.52, accuracy: 0.01) - } - - func testIsClosedLoopAvoidsTriggeringTempBasalCancelOnCreation() { - let settings = LoopSettings( - dosingEnabled: false, - glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, - maximumBasalRatePerHour: 5, - maximumBolus: 10, - suspendThreshold: suspendThreshold - ) - - let doseStore = MockDoseStore() - let glucoseStore = MockGlucoseStore(for: .flatAndStable) - let carbStore = MockCarbStore() - - let currentDate = Date() - - dosingDecisionStore = MockDosingDecisionStore() - automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: false, isAutomaticDosingAllowed: true) - let existingTempBasal = DoseEntry( - type: .tempBasal, - startDate: currentDate.addingTimeInterval(-.minutes(2)), - endDate: currentDate.addingTimeInterval(.minutes(28)), - value: 1.0, - unit: .unitsPerHour, - deliveredUnits: nil, - description: "Mock Temp Basal", - syncIdentifier: "asdf", - scheduledBasalRate: nil, - insulinType: .novolog, - automatic: true, - manuallyEntered: false, - isMutable: true) - loopDataManager = LoopDataManager( - lastLoopCompleted: currentDate.addingTimeInterval(-.minutes(5)), - basalDeliveryState: .tempBasal(existingTempBasal), - settings: settings, - overrideHistory: TemporaryScheduleOverrideHistory(), - analyticsServicesManager: AnalyticsServicesManager(), - localCacheDuration: .days(1), - doseStore: doseStore, - glucoseStore: glucoseStore, - carbStore: carbStore, - dosingDecisionStore: dosingDecisionStore, - latestStoredSettingsProvider: MockLatestStoredSettingsProvider(), - now: { currentDate }, - pumpInsulinType: .novolog, - automaticDosingStatus: automaticDosingStatus, - trustedTimeOffset: { 0 } - ) - let mockDelegate = MockDelegate() - loopDataManager.delegate = mockDelegate - - // Dose enacting happens asynchronously, as does receiving isClosedLoop signals - waitOnMain(timeout: 5) - XCTAssertNil(mockDelegate.recommendation) - } - - func testAutoBolusMaxIOBClamping() { - /// `maxBolus` is set to clamp the automatic dose - /// Autobolus without clamping: 0.65 U. Clamped recommendation: 0.2 U. - setUp(for: .highAndRisingWithCOB, maxBolus: 5, dosingStrategy: .automaticBolus) - - // This sets up dose rounding - let delegate = MockDelegate() - loopDataManager.delegate = delegate - - let updateGroup = DispatchGroup() - updateGroup.enter() - - var insulinOnBoard: InsulinValue? - var recommendedBolus: Double? - self.loopDataManager.getLoopState { _, state in - insulinOnBoard = state.insulinOnBoard - recommendedBolus = state.recommendedAutomaticDose?.recommendation.bolusUnits - updateGroup.leave() - } - updateGroup.wait() - - XCTAssertEqual(recommendedBolus!, 0.5, accuracy: 0.01) - XCTAssertEqual(insulinOnBoard?.value, 9.5) - - /// Set the `maximumBolus` to 10U so there's no clamping - updateGroup.enter() - self.loopDataManager.mutateSettings { settings in settings.maximumBolus = 10 } - self.loopDataManager.getLoopState { _, state in - insulinOnBoard = state.insulinOnBoard - recommendedBolus = state.recommendedAutomaticDose?.recommendation.bolusUnits - updateGroup.leave() - } - updateGroup.wait() - - XCTAssertEqual(recommendedBolus!, 0.65, accuracy: 0.01) - XCTAssertEqual(insulinOnBoard?.value, 9.5) - } - - func testTempBasalMaxIOBClamping() { - /// `maximumBolus` is set to 5U to clamp max IOB at 10U - /// Without clamping: 4.25 U/hr. Clamped recommendation: 2.0 U/hr. - setUp(for: .highAndRisingWithCOB, maxBolus: 5) - - // This sets up dose rounding - let delegate = MockDelegate() - loopDataManager.delegate = delegate - - let updateGroup = DispatchGroup() - updateGroup.enter() - - var insulinOnBoard: InsulinValue? - var recommendedBasal: TempBasalRecommendation? - self.loopDataManager.getLoopState { _, state in - insulinOnBoard = state.insulinOnBoard - recommendedBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment - updateGroup.leave() - } - updateGroup.wait() - - XCTAssertEqual(recommendedBasal!.unitsPerHour, 2.0, accuracy: 0.01) - XCTAssertEqual(insulinOnBoard?.value, 9.5) - - /// Set the `maximumBolus` to 10U so there's no clamping - updateGroup.enter() - self.loopDataManager.mutateSettings { settings in settings.maximumBolus = 10 } - self.loopDataManager.getLoopState { _, state in - insulinOnBoard = state.insulinOnBoard - recommendedBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment - updateGroup.leave() - } - updateGroup.wait() - - XCTAssertEqual(recommendedBasal!.unitsPerHour, 4.25, accuracy: 0.01) - XCTAssertEqual(insulinOnBoard?.value, 9.5) - } - -} diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index 32c7d66f19..45d7612b7a 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -9,69 +9,14 @@ import XCTest import HealthKit import LoopKit +import HealthKit +import LoopAlgorithm + @testable import LoopCore @testable import Loop public typealias JSONDictionary = [String: Any] -enum DosingTestScenario { - case liveCapture // Includes actual dosing history, bg history, etc. - case flatAndStable - case highAndStable - case highAndRisingWithCOB - case lowAndFallingWithCOB - case lowWithLowTreatment - case highAndFalling - - var fixturePrefix: String { - switch self { - case .liveCapture: - return "live_capture_" - case .flatAndStable: - return "flat_and_stable_" - case .highAndStable: - return "high_and_stable_" - case .highAndRisingWithCOB: - return "high_rising_with_cob_" - case .lowAndFallingWithCOB: - return "low_and_falling_with_cob_" - case .lowWithLowTreatment: - return "low_with_low_treatment_" - case .highAndFalling: - return "high_and_falling_" - } - } - - static let localDateFormatter = ISO8601DateFormatter.localTimeDate() - - static var dateFormatter: ISO8601DateFormatter = { - let dateFormatter = ISO8601DateFormatter() - dateFormatter.formatOptions = [.withInternetDateTime] - return dateFormatter - }() - - - var currentDate: Date { - switch self { - case .liveCapture: - return Self.dateFormatter.date(from: "2023-07-29T19:21:00Z")! - case .flatAndStable: - return Self.localDateFormatter.date(from: "2020-08-11T20:45:02")! - case .highAndStable: - return Self.localDateFormatter.date(from: "2020-08-12T12:39:22")! - case .highAndRisingWithCOB: - return Self.localDateFormatter.date(from: "2020-08-11T21:48:17")! - case .lowAndFallingWithCOB: - return Self.localDateFormatter.date(from: "2020-08-11T22:06:06")! - case .lowWithLowTreatment: - return Self.localDateFormatter.date(from: "2020-08-11T22:23:55")! - case .highAndFalling: - return Self.localDateFormatter.date(from: "2020-08-11T22:59:45")! - } - } - -} - extension TimeZone { static var fixtureTimeZone: TimeZone { return TimeZone(secondsFromGMT: 25200)! @@ -94,6 +39,7 @@ extension ISO8601DateFormatter { } } +@MainActor class LoopDataManagerTests: XCTestCase { // MARK: Constants for testing let retrospectiveCorrectionEffectDuration = TimeInterval(hours: 1) @@ -117,18 +63,23 @@ class LoopDataManagerTests: XCTestCase { ], timeZone: .utcTimeZone)! } - // MARK: Mock stores + // MARK: Stores var now: Date! + let persistenceController = PersistenceController.mock() + var doseStore = MockDoseStore() + var glucoseStore = MockGlucoseStore() + var carbStore = MockCarbStore() var dosingDecisionStore: MockDosingDecisionStore! var automaticDosingStatus: AutomaticDosingStatus! var loopDataManager: LoopDataManager! - - func setUp(for test: DosingTestScenario, - basalDeliveryState: PumpManagerStatus.BasalDeliveryState? = nil, - maxBolus: Double = 10, - maxBasalRate: Double = 5.0, - dosingStrategy: AutomaticDosingStrategy = .tempBasalOnly) - { + var deliveryDelegate: MockDeliveryDelegate! + var settingsProvider: MockSettingsProvider! + + func d(_ interval: TimeInterval) -> Date { + return now.addingTimeInterval(interval) + } + + override func setUp() async throws { let basalRateSchedule = loadBasalRateScheduleFixture("basal_profile") let insulinSensitivitySchedule = InsulinSensitivitySchedule( unit: .milligramsPerDeciliter, @@ -146,54 +97,361 @@ class LoopDataManagerTests: XCTestCase { timeZone: .utcTimeZone )! - let settings = LoopSettings( + let settings = StoredSettings( dosingEnabled: false, glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, - insulinSensitivitySchedule: insulinSensitivitySchedule, + maximumBasalRatePerHour: 6, + maximumBolus: 5, + suspendThreshold: suspendThreshold, basalRateSchedule: basalRateSchedule, + insulinSensitivitySchedule: insulinSensitivitySchedule, carbRatioSchedule: carbRatioSchedule, - maximumBasalRatePerHour: maxBasalRate, - maximumBolus: maxBolus, - suspendThreshold: suspendThreshold, - automaticDosingStrategy: dosingStrategy + automaticDosingStrategy: .automaticBolus ) - - let doseStore = MockDoseStore(for: test) - doseStore.basalProfile = basalRateSchedule - doseStore.basalProfileApplyingOverrideHistory = doseStore.basalProfile - doseStore.sensitivitySchedule = insulinSensitivitySchedule - let glucoseStore = MockGlucoseStore(for: test) - let carbStore = MockCarbStore(for: test) - carbStore.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivitySchedule - carbStore.carbRatioSchedule = carbRatioSchedule - - let currentDate = glucoseStore.latestGlucose!.startDate - now = currentDate - + + settingsProvider = MockSettingsProvider(settings: settings) + + now = dateFormatter.date(from: "2023-07-29T19:21:00Z")! + + doseStore.lastAddedPumpData = now + dosingDecisionStore = MockDosingDecisionStore() automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: true, isAutomaticDosingAllowed: true) + + let temporaryPresetsManager = TemporaryPresetsManager(settingsProvider: settingsProvider) + loopDataManager = LoopDataManager( - lastLoopCompleted: currentDate, - basalDeliveryState: basalDeliveryState ?? .active(currentDate), - settings: settings, - overrideHistory: TemporaryScheduleOverrideHistory(), - analyticsServicesManager: AnalyticsServicesManager(), - localCacheDuration: .days(1), + lastLoopCompleted: now, + temporaryPresetsManager: temporaryPresetsManager, + settingsProvider: settingsProvider, doseStore: doseStore, glucoseStore: glucoseStore, carbStore: carbStore, dosingDecisionStore: dosingDecisionStore, - latestStoredSettingsProvider: MockLatestStoredSettingsProvider(), - now: { currentDate }, - pumpInsulinType: .novolog, + now: { [weak self] in self?.now ?? Date() }, automaticDosingStatus: automaticDosingStatus, - trustedTimeOffset: { 0 } + trustedTimeOffset: { 0 }, + analyticsServicesManager: nil, + carbAbsorptionModel: .piecewiseLinear ) + + deliveryDelegate = MockDeliveryDelegate() + loopDataManager.deliveryDelegate = deliveryDelegate + + deliveryDelegate.basalDeliveryState = .active(now.addingTimeInterval(-.hours(2))) } - + override func tearDownWithError() throws { loopDataManager = nil } + + // MARK: Functions to load fixtures + func loadLocalDateGlucoseEffect(_ name: String) -> [GlucoseEffect] { + let fixture: [JSONDictionary] = loadFixture(name) + let localDateFormatter = ISO8601DateFormatter.localTimeDate() + + return fixture.map { + return GlucoseEffect(startDate: localDateFormatter.date(from: $0["date"] as! String)!, quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), doubleValue:$0["amount"] as! Double)) + } + } + + func loadPredictedGlucoseFixture(_ name: String) -> [PredictedGlucoseValue] { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let url = bundle.url(forResource: name, withExtension: "json")! + return try! decoder.decode([PredictedGlucoseValue].self, from: try! Data(contentsOf: url)) + } + + // MARK: Tests + func testForecastFromLiveCaptureInputData() async { + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let url = bundle.url(forResource: "live_capture_input", withExtension: "json")! + let predictionInput = try! decoder.decode(LoopPredictionInput.self, from: try! Data(contentsOf: url)) + + // Therapy settings in the "live capture" input only have one value, so we can fake some schedules + // from the first entry of each therapy setting's history. + let basalRateSchedule = BasalRateSchedule(dailyItems: [ + RepeatingScheduleValue(startTime: 0, value: predictionInput.basal.first!.value) + ]) + let insulinSensitivitySchedule = InsulinSensitivitySchedule( + unit: .milligramsPerDeciliter, + dailyItems: [ + RepeatingScheduleValue(startTime: 0, value: predictionInput.sensitivity.first!.value.doubleValue(for: .milligramsPerDeciliter)) + ], + timeZone: .utcTimeZone + )! + let carbRatioSchedule = CarbRatioSchedule( + unit: .gram(), + dailyItems: [ + RepeatingScheduleValue(startTime: 0.0, value: predictionInput.carbRatio.first!.value) + ], + timeZone: .utcTimeZone + )! + + settingsProvider.settings = StoredSettings( + dosingEnabled: false, + glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, + maximumBasalRatePerHour: 10, + maximumBolus: 5, + suspendThreshold: GlucoseThreshold(unit: .milligramsPerDeciliter, value: 65), + basalRateSchedule: basalRateSchedule, + insulinSensitivitySchedule: insulinSensitivitySchedule, + carbRatioSchedule: carbRatioSchedule, + automaticDosingStrategy: .automaticBolus + ) + + glucoseStore.storedGlucose = predictionInput.glucoseHistory.map { StoredGlucoseSample.from(fixture: $0) } + + let currentDate = glucoseStore.latestGlucose!.startDate + now = currentDate + + doseStore.doseHistory = predictionInput.doses.map { DoseEntry.from(fixture: $0) } + doseStore.lastAddedPumpData = predictionInput.doses.last!.startDate + carbStore.carbHistory = predictionInput.carbEntries.map { StoredCarbEntry.from(fixture: $0) } + + let expectedPredictedGlucose = loadPredictedGlucoseFixture("live_capture_predicted_glucose") + + await loopDataManager.updateDisplayState() + + let predictedGlucose = loopDataManager.displayState.output?.predictedGlucose + + XCTAssertNotNil(predictedGlucose) + + XCTAssertEqual(expectedPredictedGlucose.count, predictedGlucose!.count) + + for (expected, calculated) in zip(expectedPredictedGlucose, predictedGlucose!) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) + } + + await loopDataManager.loop() + + XCTAssertEqual(0, deliveryDelegate.lastEnact?.bolusUnits) + XCTAssertEqual(0, deliveryDelegate.lastEnact?.basalAdjustment?.unitsPerHour) + } + + + func testHighAndStable() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-1)), quantity: .glucose(value: 120)), + ] + + await loopDataManager.updateDisplayState() + + XCTAssertEqual(120, loopDataManager.eventualBG) + XCTAssert(loopDataManager.displayState.output!.effects.momentum.isEmpty) + + await loopDataManager.loop() + + XCTAssertEqual(0.2, deliveryDelegate.lastEnact!.bolusUnits!, accuracy: defaultAccuracy) + } + + + func testHighAndFalling() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-18)), quantity: .glucose(value: 200)), + StoredGlucoseSample(startDate: d(.minutes(-13)), quantity: .glucose(value: 190)), + StoredGlucoseSample(startDate: d(.minutes(-8)), quantity: .glucose(value: 180)), + StoredGlucoseSample(startDate: d(.minutes(-3)), quantity: .glucose(value: 170)), + ] + + await loopDataManager.updateDisplayState() + + XCTAssertEqual(132, loopDataManager.eventualBG!, accuracy: 0.5) + XCTAssert(!loopDataManager.displayState.output!.effects.momentum.isEmpty) + + await loopDataManager.loop() + + // Should correct high. + XCTAssertEqual(0.25, deliveryDelegate.lastEnact!.bolusUnits!, accuracy: defaultAccuracy) + } + + func testHighAndRisingWithCOB() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-18)), quantity: .glucose(value: 200)), + StoredGlucoseSample(startDate: d(.minutes(-13)), quantity: .glucose(value: 210)), + StoredGlucoseSample(startDate: d(.minutes(-8)), quantity: .glucose(value: 220)), + StoredGlucoseSample(startDate: d(.minutes(-3)), quantity: .glucose(value: 230)), + ] + + await loopDataManager.updateDisplayState() + + XCTAssertEqual(268, loopDataManager.eventualBG!, accuracy: 0.5) + XCTAssert(!loopDataManager.displayState.output!.effects.momentum.isEmpty) + + await loopDataManager.loop() + + // Should correct high. + XCTAssertEqual(1.25, deliveryDelegate.lastEnact!.bolusUnits!, accuracy: defaultAccuracy) + } + + func testLowAndFalling() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-18)), quantity: .glucose(value: 100)), + StoredGlucoseSample(startDate: d(.minutes(-13)), quantity: .glucose(value: 95)), + StoredGlucoseSample(startDate: d(.minutes(-8)), quantity: .glucose(value: 90)), + StoredGlucoseSample(startDate: d(.minutes(-3)), quantity: .glucose(value: 85)), + ] + + await loopDataManager.updateDisplayState() + + XCTAssertEqual(66, loopDataManager.eventualBG!, accuracy: 0.5) + XCTAssert(!loopDataManager.displayState.output!.effects.momentum.isEmpty) + + await loopDataManager.loop() + + // Should not bolus, and should low temp. + XCTAssertEqual(0, deliveryDelegate.lastEnact!.bolusUnits!, accuracy: defaultAccuracy) + XCTAssertEqual(0, deliveryDelegate.lastEnact!.basalAdjustment!.unitsPerHour, accuracy: defaultAccuracy) + } + + + func testLowAndFallingWithCOB() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-18)), quantity: .glucose(value: 100)), + StoredGlucoseSample(startDate: d(.minutes(-13)), quantity: .glucose(value: 95)), + StoredGlucoseSample(startDate: d(.minutes(-8)), quantity: .glucose(value: 92)), + StoredGlucoseSample(startDate: d(.minutes(-3)), quantity: .glucose(value: 90)), + ] + + carbStore.carbHistory = [ + StoredCarbEntry(startDate: d(.minutes(-5)), quantity: .carbs(value: 20)) + ] + + await loopDataManager.updateDisplayState() + + XCTAssertEqual(192, loopDataManager.eventualBG!, accuracy: 0.5) + XCTAssert(!loopDataManager.displayState.output!.effects.momentum.isEmpty) + + await loopDataManager.loop() + + // Because eventual is high, but mid-term is low, stay neutral in delivery. + XCTAssertEqual(0, deliveryDelegate.lastEnact!.bolusUnits!, accuracy: defaultAccuracy) + XCTAssertNil(deliveryDelegate.lastEnact!.basalAdjustment) + } + + func testOpenLoopCancelsTempBasal() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-1)), quantity: .glucose(value: 150)), + ] + + let dose = DoseEntry(type: .tempBasal, startDate: Date(), value: 1.0, unit: .unitsPerHour) + deliveryDelegate.basalDeliveryState = .tempBasal(dose) + + dosingDecisionStore.storeExpectation = expectation(description: #function) + + automaticDosingStatus.automaticDosingEnabled = false + + await fulfillment(of: [dosingDecisionStore.storeExpectation!], timeout: 1.0) + + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel) + XCTAssertEqual(deliveryDelegate.lastEnact, expectedAutomaticDoseRecommendation) + XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "automaticDosingDisabled") + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) + } + + func testLoopEnactsTempBasalWithoutManualBolusRecommendation() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-1)), quantity: .glucose(value: 150)), + ] + + settingsProvider.settings.automaticDosingStrategy = .tempBasalOnly + + await loopDataManager.loop() + + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 3.0, duration: .minutes(30))) + XCTAssertEqual(deliveryDelegate.lastEnact, expectedAutomaticDoseRecommendation) + XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) + if dosingDecisionStore.dosingDecisions.count == 1 { + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "loop") + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) + XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRecommendation) + XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRequested) + } + } + + func testOngoingTempBasalIsSufficient() async { + // LoopDataManager should trim future temp basals when running the algorithm. + // and should not include effects from future delivery of the temp basal in its prediction. + + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-4)), quantity: .glucose(value: 100)), + ] + + carbStore.carbHistory = [ + StoredCarbEntry(startDate: d(.minutes(-5)), quantity: .carbs(value: 20)) + ] + + // Temp basal started one minute ago, covering carbs. + let dose = DoseEntry( + type: .tempBasal, + startDate: d(.minutes(-1)), + endDate: d(.minutes(29)), + value: 5.05, + unit: .unitsPerHour + ) + deliveryDelegate.basalDeliveryState = .tempBasal(dose) + + doseStore.doseHistory = [ dose ] + + settingsProvider.settings.automaticDosingStrategy = .tempBasalOnly + + await loopDataManager.loop() + + // Should not adjust delivery, as existing temp basal is correct. + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: nil) + XCTAssertNil(deliveryDelegate.lastEnact) + XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) + if dosingDecisionStore.dosingDecisions.count == 1 { + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "loop") + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) + XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRecommendation) + XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRequested) + } + } + + + func testLoopRecommendsTempBasalWithoutEnactingIfOpenLoop() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-1)), quantity: .glucose(value: 150)), + ] + automaticDosingStatus.automaticDosingEnabled = false + settingsProvider.settings.automaticDosingStrategy = .tempBasalOnly + + await loopDataManager.loop() + + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 3.0, duration: .minutes(30))) + XCTAssertNil(deliveryDelegate.lastEnact) + XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "loop") + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) + XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRecommendation) + XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRequested) + } + + func testLoopGetStateRecommendsManualBolusWithoutMomentum() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-18)), quantity: .glucose(value: 100)), + StoredGlucoseSample(startDate: d(.minutes(-13)), quantity: .glucose(value: 130)), + StoredGlucoseSample(startDate: d(.minutes(-8)), quantity: .glucose(value: 160)), + StoredGlucoseSample(startDate: d(.minutes(-3)), quantity: .glucose(value: 190)), + ] + + loopDataManager.usePositiveMomentumAndRCForManualBoluses = true + var recommendation = try! await loopDataManager.recommendManualBolus()! + XCTAssertEqual(recommendation.amount, 3.45, accuracy: 0.01) + + loopDataManager.usePositiveMomentumAndRCForManualBoluses = false + recommendation = try! await loopDataManager.recommendManualBolus()! + XCTAssertEqual(recommendation.amount, 1.75, accuracy: 0.01) + + } + + } extension LoopDataManagerTests { @@ -216,3 +474,56 @@ extension LoopDataManagerTests { return BasalRateSchedule(dailyItems: items, timeZone: .utcTimeZone)! } } + +extension HKQuantity { + static func glucose(value: Double) -> HKQuantity { + return .init(unit: .milligramsPerDeciliter, doubleValue: value) + } + + static func carbs(value: Double) -> HKQuantity { + return .init(unit: .gram(), doubleValue: value) + } + +} + +extension LoopDataManager { + var eventualBG: Double? { + displayState.output?.predictedGlucose.last?.quantity.doubleValue(for: .milligramsPerDeciliter) + } +} + +extension StoredGlucoseSample { + static func from(fixture: FixtureGlucoseSample) -> StoredGlucoseSample { + return StoredGlucoseSample( + startDate: fixture.startDate, + quantity: fixture.quantity, + condition: fixture.condition, + trendRate: fixture.trendRate, + isDisplayOnly: fixture.isDisplayOnly, + wasUserEntered: fixture.wasUserEntered + ) + } +} + +extension DoseEntry { + static func from(fixture: FixtureInsulinDose) -> DoseEntry { + return DoseEntry( + type: fixture.deliveryType == .bolus ? .bolus : .basal, + startDate: fixture.startDate, + endDate: fixture.endDate, + value: fixture.volume, + unit: .units + ) + } +} + +extension StoredCarbEntry { + static func from(fixture: FixtureCarbEntry) -> StoredCarbEntry { + return StoredCarbEntry( + startDate: fixture.startDate, + quantity: fixture.quantity, + foodType: fixture.foodType, + absorptionTime: fixture.absorptionTime + ) + } +} diff --git a/LoopTests/Managers/MealDetectionManagerTests.swift b/LoopTests/Managers/MealDetectionManagerTests.swift index 3db48cc7eb..7acfe1b660 100644 --- a/LoopTests/Managers/MealDetectionManagerTests.swift +++ b/LoopTests/Managers/MealDetectionManagerTests.swift @@ -10,6 +10,8 @@ import XCTest import HealthKit import LoopCore import LoopKit +import LoopAlgorithm + @testable import Loop fileprivate class MockGlucoseSample: GlucoseSampleValue { @@ -17,7 +19,7 @@ fileprivate class MockGlucoseSample: GlucoseSampleValue { let provenanceIdentifier = "" let isDisplayOnly: Bool let wasUserEntered: Bool - let condition: LoopKit.GlucoseCondition? = nil + let condition: GlucoseCondition? = nil let trendRate: HKQuantity? = nil var trend: LoopKit.GlucoseTrend? var syncIdentifier: String? @@ -180,65 +182,102 @@ extension MissedMealTestType { } } +@MainActor class MealDetectionManagerTests: XCTestCase { let dateFormatter = ISO8601DateFormatter.localTimeDate() let pumpManager = MockPumpManager() var mealDetectionManager: MealDetectionManager! - var carbStore: CarbStore! - + var now: Date { mealDetectionManager.test_currentDate! } - - var bolusUnits: Double? - var bolusDurationEstimator: ((Double) -> TimeInterval?)! - - fileprivate var glucoseSamples: [MockGlucoseSample]! - - @discardableResult func setUp(for testType: MissedMealTestType) -> [GlucoseEffectVelocity] { - carbStore = CarbStore( - cacheStore: PersistenceController(directoryURL: URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).appendingPathComponent(UUID().uuidString, isDirectory: true)), - cacheLength: .hours(24), - defaultAbsorptionTimes: (fast: .minutes(30), medium: .hours(3), slow: .hours(5)), - overrideHistory: TemporaryScheduleOverrideHistory(), - provenanceIdentifier: Bundle.main.bundleIdentifier!, - test_currentDate: testType.currentDate) - + + var algorithmInput: StoredDataAlgorithmInput! + var algorithmOutput: AlgorithmOutput! + + var mockAlgorithmState: AlgorithmDisplayState! + + var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? + + var carbRatioSchedule: CarbRatioSchedule? + + var maximumBolus: Double? = 5 + var maximumBasalRatePerHour: Double = 6 + + var bolusState: PumpManagerStatus.BolusState? = .noBolus + + func setUp(for testType: MissedMealTestType) { // Set up schedules - carbStore.carbRatioSchedule = testType.carbSchedule - carbStore.insulinSensitivitySchedule = testType.insulinSensitivitySchedule - - // Add any needed carb entries to the carb store - let updateGroup = DispatchGroup() - testType.carbEntries.forEach { carbEntry in - updateGroup.enter() - carbStore.addCarbEntry(carbEntry) { result in - if case .failure(_) = result { - XCTFail("Failed to add carb entry to carb store") - } - - updateGroup.leave() - } - } - _ = updateGroup.wait(timeout: .now() + .seconds(5)) - + + let date = testType.currentDate + let historyStart = date.addingTimeInterval(-.hours(24)) + + let glucoseTarget = GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: [.init(startTime: 0, value: DoubleRange(minValue: 100, maxValue: 110))]) + + insulinSensitivityScheduleApplyingOverrideHistory = testType.insulinSensitivitySchedule + carbRatioSchedule = testType.carbSchedule + + algorithmInput = StoredDataAlgorithmInput( + glucoseHistory: [StoredGlucoseSample(startDate: date, quantity: .init(unit: .milligramsPerDeciliter, doubleValue: 100))], + doses: [], + carbEntries: testType.carbEntries.map { $0.asStoredCarbEntry }, + predictionStart: date, + basal: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 1.0)])!.between(start: historyStart, end: date), + sensitivity: testType.insulinSensitivitySchedule.quantitiesBetween(start: historyStart, end: date), + carbRatio: testType.carbSchedule.between(start: historyStart, end: date), + target: glucoseTarget!.quantityBetween(start: historyStart, end: date), + suspendThreshold: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 65), + maxBolus: maximumBolus!, + maxBasalRate: maximumBasalRatePerHour, + useIntegralRetrospectiveCorrection: false, + includePositiveVelocityAndRC: true, + carbAbsorptionModel: .piecewiseLinear, + recommendationInsulinModel: ExponentialInsulinModelPreset.rapidActingAdult.model, + recommendationType: .automaticBolus) + + // These tests don't actually run the loop algorithm directly; they were written to take ICE from fixtures, compute carb effects, and subtract them. + let counteractionEffects = counteractionEffects(for: testType) + + let carbEntries = testType.carbEntries.map { $0.asStoredCarbEntry } + // Carb Effects + let carbStatus = carbEntries.map( + to: counteractionEffects, + carbRatio: algorithmInput.carbRatio, + insulinSensitivity: algorithmInput.sensitivity + ) + + let carbEffects = carbStatus.dynamicGlucoseEffects( + from: date.addingTimeInterval(-IntegralRetrospectiveCorrection.retrospectionInterval), + carbRatios: algorithmInput.carbRatio, + insulinSensitivities: algorithmInput.sensitivity, + absorptionModel: algorithmInput.carbAbsorptionModel.model + ) + + let effects = LoopAlgorithmEffects( + insulin: [], + carbs: carbEffects, + carbStatus: carbStatus, + retrospectiveCorrection: [], + momentum: [], + insulinCounteraction: counteractionEffects, + retrospectiveGlucoseDiscrepancies: [] + ) + + algorithmOutput = AlgorithmOutput( + recommendationResult: .success(.init()), + predictedGlucose: [], + effects: effects, + dosesRelativeToBasal: [] + ) + mealDetectionManager = MealDetectionManager( - carbRatioScheduleApplyingOverrideHistory: carbStore.carbRatioScheduleApplyingOverrideHistory, - insulinSensitivityScheduleApplyingOverrideHistory: carbStore.insulinSensitivityScheduleApplyingOverrideHistory, - maximumBolus: 5, - test_currentDate: testType.currentDate + algorithmStateProvider: self, + settingsProvider: self, + bolusStateProvider: self ) - - glucoseSamples = [MockGlucoseSample(startDate: now)] - - bolusDurationEstimator = { units in - self.bolusUnits = units - return self.pumpManager.estimatedDuration(toBolus: units) - } - - // Fetch & return the counteraction effects for the test - return counteractionEffects(for: testType) + mealDetectionManager.test_currentDate = testType.currentDate + } private func counteractionEffects(for testType: MissedMealTestType) -> [GlucoseEffectVelocity] { @@ -253,27 +292,6 @@ class MealDetectionManagerTests: XCTestCase { } } - private func mealDetectionCarbEffects(using insulinCounteractionEffects: [GlucoseEffectVelocity]) -> [GlucoseEffect] { - let carbEffectStart = now.addingTimeInterval(-MissedMealSettings.maxRecency) - - var carbEffects: [GlucoseEffect] = [] - - let updateGroup = DispatchGroup() - updateGroup.enter() - carbStore.getGlucoseEffects(start: carbEffectStart, end: now, effectVelocities: insulinCounteractionEffects) { result in - defer { updateGroup.leave() } - - guard case .success((_, let effects)) = result else { - XCTFail("Failed to fetch glucose effects to check for missed meal") - return - } - carbEffects = effects - } - _ = updateGroup.wait(timeout: .now() + .seconds(5)) - - return carbEffects - } - override func tearDown() { mealDetectionManager.lastMissedMealNotification = nil mealDetectionManager = nil @@ -282,104 +300,128 @@ class MealDetectionManagerTests: XCTestCase { // MARK: - Algorithm Tests func testNoMissedMeal() { - let counteractionEffects = setUp(for: .noMeal) + setUp(for: .noMeal) + + let status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: algorithmInput.glucoseHistory, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) - let updateGroup = DispatchGroup() - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .noMissedMeal) - updateGroup.leave() - } - updateGroup.wait() + XCTAssertEqual(status, .noMissedMeal) } func testNoMissedMeal_WithCOB() { - let counteractionEffects = setUp(for: .noMealWithCOB) + setUp(for: .noMealWithCOB) - let updateGroup = DispatchGroup() - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .noMissedMeal) - updateGroup.leave() - } - updateGroup.wait() + let status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: algorithmInput.glucoseHistory, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) + + XCTAssertEqual(status, .noMissedMeal) } func testMissedMeal_NoCarbEntry() { let testType = MissedMealTestType.missedMealNoCOB - let counteractionEffects = setUp(for: testType) + setUp(for: testType) - let updateGroup = DispatchGroup() - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 55)) - updateGroup.leave() - } - updateGroup.wait() + let status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: algorithmInput.glucoseHistory, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) + + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 55)) } func testDynamicCarbAutofill() { let testType = MissedMealTestType.dynamicCarbAutofill - let counteractionEffects = setUp(for: testType) + setUp(for: testType) - let updateGroup = DispatchGroup() - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 25)) - updateGroup.leave() - } - updateGroup.wait() + let status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: algorithmInput.glucoseHistory, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) + + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 25)) } func testMissedMeal_MissedMealAndCOB() { let testType = MissedMealTestType.missedMealWithCOB - let counteractionEffects = setUp(for: testType) + setUp(for: testType) - let updateGroup = DispatchGroup() - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 50)) - updateGroup.leave() - } - updateGroup.wait() + let status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: algorithmInput.glucoseHistory, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) + + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 50)) } func testNoisyCGM() { - let counteractionEffects = setUp(for: .noisyCGM) + setUp(for: .noisyCGM) - let updateGroup = DispatchGroup() - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .noMissedMeal) - updateGroup.leave() - } - updateGroup.wait() + let status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: algorithmInput.glucoseHistory, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) + + XCTAssertEqual(status, .noMissedMeal) } func testManyMeals() { let testType = MissedMealTestType.manyMeals - let counteractionEffects = setUp(for: testType) + setUp(for: testType) - let updateGroup = DispatchGroup() - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 40)) - updateGroup.leave() - } - updateGroup.wait() + let status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: algorithmInput.glucoseHistory, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) + + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 40)) } func testMMOLUser() { let testType = MissedMealTestType.mmolUser - let counteractionEffects = setUp(for: testType) + setUp(for: testType) - let updateGroup = DispatchGroup() - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 25)) - updateGroup.leave() - } - updateGroup.wait() + let status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: algorithmInput.glucoseHistory, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) + + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 25)) } // MARK: - Notification Tests @@ -388,8 +430,13 @@ class MealDetectionManagerTests: XCTestCase { UserDefaults.standard.missedMealNotificationsEnabled = true let status = MissedMealStatus.noMissedMeal - mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) - + mealDetectionManager.manageMealNotifications( + at: now, + for: status + ) + + mealDetectionManager.manageMealNotifications(at: now, for: status) + XCTAssertNil(mealDetectionManager.lastMissedMealNotification) } @@ -398,8 +445,8 @@ class MealDetectionManagerTests: XCTestCase { UserDefaults.standard.missedMealNotificationsEnabled = true let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 40) - mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) - + mealDetectionManager.manageMealNotifications(at: now, for: status) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, now) XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.carbAmount, 40) } @@ -409,8 +456,8 @@ class MealDetectionManagerTests: XCTestCase { UserDefaults.standard.missedMealNotificationsEnabled = false let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 40) - mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) - + mealDetectionManager.manageMealNotifications(at: now, for: status) + XCTAssertNil(mealDetectionManager.lastMissedMealNotification) } @@ -423,8 +470,8 @@ class MealDetectionManagerTests: XCTestCase { mealDetectionManager.lastMissedMealNotification = oldNotification let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: MissedMealSettings.minCarbThreshold) - mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) - + mealDetectionManager.manageMealNotifications(at: now, for: status) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification, oldNotification) } @@ -433,8 +480,8 @@ class MealDetectionManagerTests: XCTestCase { UserDefaults.standard.missedMealNotificationsEnabled = true let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 120) - mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) - + mealDetectionManager.manageMealNotifications(at: now, for: status) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, now) XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.carbAmount, 75) } @@ -444,10 +491,9 @@ class MealDetectionManagerTests: XCTestCase { UserDefaults.standard.missedMealNotificationsEnabled = true let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 40) - mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 0, bolusDurationEstimator: bolusDurationEstimator) - + mealDetectionManager.manageMealNotifications(at: now, for: status) + /// The bolus units time delegate should never be called if there are 0 pending units - XCTAssertNil(bolusUnits) XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, now) XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.carbAmount, 40) } @@ -455,11 +501,21 @@ class MealDetectionManagerTests: XCTestCase { func testMissedMealLongPendingBolus() { setUp(for: .notificationTest) UserDefaults.standard.missedMealNotificationsEnabled = true - + + bolusState = .inProgress( + DoseEntry( + type: .bolus, + startDate: now.addingTimeInterval(-.seconds(10)), + endDate: now.addingTimeInterval(.minutes(10)), + value: 20, + unit: .units, + automatic: true + ) + ) + let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 40) - mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 10, bolusDurationEstimator: bolusDurationEstimator) - - XCTAssertEqual(bolusUnits, 10) + mealDetectionManager.manageMealNotifications(at: now, for: status) + /// There shouldn't be a delay in delivering notification, since the autobolus will take the length of the notification window to deliver XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, now) XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.carbAmount, 40) @@ -468,61 +524,104 @@ class MealDetectionManagerTests: XCTestCase { func testNoMissedMealShortPendingBolus_DelaysNotificationTime() { setUp(for: .notificationTest) UserDefaults.standard.missedMealNotificationsEnabled = true - + + bolusState = .inProgress( + DoseEntry( + type: .bolus, + startDate: now.addingTimeInterval(-.seconds(10)), + endDate: now.addingTimeInterval(20), + value: 2, + unit: .units, + automatic: true + ) + ) + let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 30) - mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 2, bolusDurationEstimator: bolusDurationEstimator) - - let expectedDeliveryTime = now.addingTimeInterval(TimeInterval(80)) - XCTAssertEqual(bolusUnits, 2) + mealDetectionManager.manageMealNotifications(at: now, for: status) + + let expectedDeliveryTime = now.addingTimeInterval(TimeInterval(20)) XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, expectedDeliveryTime) - + + bolusState = .inProgress( + DoseEntry( + type: .bolus, + startDate: now.addingTimeInterval(-.seconds(10)), + endDate: now.addingTimeInterval(.minutes(3)), + value: 4.5, + unit: .units, + automatic: true + ) + ) + mealDetectionManager.lastMissedMealNotification = nil - mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 4.5, bolusDurationEstimator: bolusDurationEstimator) - + mealDetectionManager.manageMealNotifications(at: now, for: status) + let expectedDeliveryTime2 = now.addingTimeInterval(TimeInterval(minutes: 3)) - XCTAssertEqual(bolusUnits, 4.5) XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, expectedDeliveryTime2) } func testHasCalibrationPoints_NoNotification() { let testType = MissedMealTestType.manyMeals - let counteractionEffects = setUp(for: testType) + setUp(for: testType) let calibratedGlucoseSamples = [MockGlucoseSample(startDate: now), MockGlucoseSample(startDate: now, isDisplayOnly: true)] - let updateGroup = DispatchGroup() - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: calibratedGlucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .noMissedMeal) - updateGroup.leave() - } - updateGroup.wait() - + var status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: calibratedGlucoseSamples, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) + + XCTAssertEqual(status, .noMissedMeal) + let manualGlucoseSamples = [MockGlucoseSample(startDate: now), MockGlucoseSample(startDate: now, wasUserEntered: true)] - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: manualGlucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .noMissedMeal) - updateGroup.leave() - } - updateGroup.wait() + + status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: manualGlucoseSamples, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) + + XCTAssertEqual(status, .noMissedMeal) } func testHasTooOldCalibrationPoint_NoImpactOnNotificationDelivery() { let testType = MissedMealTestType.manyMeals - let counteractionEffects = setUp(for: testType) + setUp(for: testType) let tooOldCalibratedGlucoseSamples = [MockGlucoseSample(startDate: now, isDisplayOnly: false), MockGlucoseSample(startDate: now.addingTimeInterval(-MissedMealSettings.maxRecency-1), isDisplayOnly: true)] - let updateGroup = DispatchGroup() - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: tooOldCalibratedGlucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 40)) - updateGroup.leave() + let status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: tooOldCalibratedGlucoseSamples, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) + + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 40)) + } +} + +extension MealDetectionManagerTests: AlgorithmDisplayStateProvider { + var algorithmState: AlgorithmDisplayState { + get async { + return mockAlgorithmState } - updateGroup.wait() } } +extension MealDetectionManagerTests: BolusStateProvider { } + +extension MealDetectionManagerTests: SettingsWithOverridesProvider { } + extension MealDetectionManagerTests { public var bundle: Bundle { return Bundle(for: type(of: self)) diff --git a/LoopTests/Managers/SettingsManagerTests.swift b/LoopTests/Managers/SettingsManagerTests.swift new file mode 100644 index 0000000000..a4768bcd28 --- /dev/null +++ b/LoopTests/Managers/SettingsManagerTests.swift @@ -0,0 +1,35 @@ +// +// SettingsManager.swift +// LoopTests +// +// Created by Pete Schwamb on 12/1/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import XCTest +import LoopKit +@testable import Loop + +@MainActor +final class SettingsManagerTests: XCTestCase { + + + func testChangingMaxBasalUpdatesLoopData() async { + + let persistenceController = PersistenceController.mock() + + let settingsManager = SettingsManager(cacheStore: persistenceController, expireAfter: .days(1), alertMuter: AlertMuter()) + + let exp = expectation(description: #function) + let observer = NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { _ in + exp.fulfill() + } + + settingsManager.mutateLoopSettings { $0.maximumBasalRatePerHour = 2.0 } + + await fulfillment(of: [exp], timeout: 1.0) + NotificationCenter.default.removeObserver(observer) + } + + +} diff --git a/LoopTests/Managers/SupportManagerTests.swift b/LoopTests/Managers/SupportManagerTests.swift index 48fa42e4d8..54471521ae 100644 --- a/LoopTests/Managers/SupportManagerTests.swift +++ b/LoopTests/Managers/SupportManagerTests.swift @@ -12,6 +12,7 @@ import LoopKitUI import SwiftUI @testable import Loop +@MainActor class SupportManagerTests: XCTestCase { enum MockError: Error { case nothing } @@ -34,7 +35,7 @@ class SupportManagerTests: XCTestCase { weak var delegate: SupportUIDelegate? } class MockSupport: Mixin, SupportUI { - static var pluginIdentifier: String { "SupportManagerTestsMockSupport" } + var pluginIdentifier: String { "SupportManagerTestsMockSupport" } override init() { super.init() } required init?(rawState: RawStateValue) { super.init() } var rawState: RawStateValue = [:] @@ -46,7 +47,7 @@ class SupportManagerTests: XCTestCase { } class AnotherMockSupport: Mixin, SupportUI { - static var pluginIdentifier: String { "SupportManagerTestsAnotherMockSupport" } + var pluginIdentifier: String { "SupportManagerTestsAnotherMockSupport" } override init() { super.init() } required init?(rawState: RawStateValue) { super.init() } var rawState: RawStateValue = [:] @@ -66,14 +67,15 @@ class SupportManagerTests: XCTestCase { } class MockDeviceSupportDelegate: DeviceSupportDelegate { + var availableSupports: [LoopKitUI.SupportUI] = [] var pumpManagerStatus: LoopKit.PumpManagerStatus? var cgmManagerStatus: LoopKit.CGMManagerStatus? - func generateDiagnosticReport(_ completion: @escaping (String) -> Void) { - completion("Mock Issue Report") + func generateDiagnosticReport() async -> String { + "Mock Issue Report" } } @@ -86,7 +88,7 @@ class SupportManagerTests: XCTestCase { override func setUp() { mockAlertIssuer = MockAlertIssuer() - supportManager = SupportManager(pluginManager: pluginManager, deviceSupportDelegate: mocKDeviceSupportDelegate, staticSupportTypes: [], alertIssuer: mockAlertIssuer) + supportManager = SupportManager(pluginManager: pluginManager, deviceSupportDelegate: mocKDeviceSupportDelegate, alertIssuer: mockAlertIssuer) mockSupport = SupportManagerTests.MockSupport() supportManager.addSupport(mockSupport) } diff --git a/LoopTests/Managers/TemporaryPresetsManagerTests.swift b/LoopTests/Managers/TemporaryPresetsManagerTests.swift new file mode 100644 index 0000000000..492762864f --- /dev/null +++ b/LoopTests/Managers/TemporaryPresetsManagerTests.swift @@ -0,0 +1,97 @@ +// +// TemporaryPresetsManagerTests.swift +// LoopTests +// +// Created by Pete Schwamb on 12/11/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import XCTest +import LoopKit + +@testable import Loop + + +class TemporaryPresetsManagerTests: XCTestCase { + private let preMealRange = DoubleRange(minValue: 80, maxValue: 80).quantityRange(for: .milligramsPerDeciliter) + private let targetRange = DoubleRange(minValue: 95, maxValue: 105) + + private lazy var settings: StoredSettings = { + var settings = StoredSettings() + settings.preMealTargetRange = preMealRange + settings.glucoseTargetRangeSchedule = GlucoseRangeSchedule( + unit: .milligramsPerDeciliter, + dailyItems: [.init(startTime: 0, value: targetRange)] + ) + return settings + }() + + var manager: TemporaryPresetsManager! + + override func setUp() async throws { + let settingsProvider = MockSettingsProvider(settings: settings) + manager = TemporaryPresetsManager(settingsProvider: settingsProvider) + } + + func testPreMealOverride() { + let preMealStart = Date() + manager.enablePreMealOverride(at: preMealStart, for: 1 /* hour */ * 60 * 60) + let actualPreMealRange = manager.effectiveGlucoseTargetRangeSchedule()?.quantityRange(at: preMealStart.addingTimeInterval(30 /* minutes */ * 60)) + XCTAssertEqual(preMealRange, actualPreMealRange) + } + + func testPreMealOverrideWithPotentialCarbEntry() { + let preMealStart = Date() + manager.enablePreMealOverride(at: preMealStart, for: 1 /* hour */ * 60 * 60) + let actualRange = manager.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: true)?.value(at: preMealStart.addingTimeInterval(30 /* minutes */ * 60)) + XCTAssertEqual(targetRange, actualRange) + } + + func testScheduleOverride() { + let overrideStart = Date() + let overrideTargetRange = DoubleRange(minValue: 130, maxValue: 150) + let override = TemporaryScheduleOverride( + context: .custom, + settings: TemporaryScheduleOverrideSettings( + unit: .milligramsPerDeciliter, + targetRange: overrideTargetRange + ), + startDate: overrideStart, + duration: .finite(3 /* hours */ * 60 * 60), + enactTrigger: .local, + syncIdentifier: UUID() + ) + manager.scheduleOverride = override + let actualOverrideRange = manager.effectiveGlucoseTargetRangeSchedule()?.value(at: overrideStart.addingTimeInterval(30 /* minutes */ * 60)) + XCTAssertEqual(actualOverrideRange, overrideTargetRange) + } + + func testScheduleOverrideWithExpiredPreMealOverride() { + manager.preMealOverride = TemporaryScheduleOverride( + context: .preMeal, + settings: TemporaryScheduleOverrideSettings(targetRange: preMealRange), + startDate: Date(timeIntervalSinceNow: -2 /* hours */ * 60 * 60), + duration: .finite(1 /* hours */ * 60 * 60), + enactTrigger: .local, + syncIdentifier: UUID() + ) + + let overrideStart = Date() + let overrideTargetRange = DoubleRange(minValue: 130, maxValue: 150) + let override = TemporaryScheduleOverride( + context: .custom, + settings: TemporaryScheduleOverrideSettings( + unit: .milligramsPerDeciliter, + targetRange: overrideTargetRange + ), + startDate: overrideStart, + duration: .finite(3 /* hours */ * 60 * 60), + enactTrigger: .local, + syncIdentifier: UUID() + ) + manager.scheduleOverride = override + + let actualOverrideRange = manager.effectiveGlucoseTargetRangeSchedule()?.value(at: overrideStart.addingTimeInterval(2 /* hours */ * 60 * 60)) + XCTAssertEqual(actualOverrideRange, overrideTargetRange) + } +} diff --git a/LoopTests/Mock Stores/MockCarbStore.swift b/LoopTests/Mock Stores/MockCarbStore.swift index 4a5c016eb5..df0f7d8b21 100644 --- a/LoopTests/Mock Stores/MockCarbStore.swift +++ b/LoopTests/Mock Stores/MockCarbStore.swift @@ -8,170 +8,38 @@ import HealthKit import LoopKit +import LoopCore @testable import Loop class MockCarbStore: CarbStoreProtocol { - var carbHistory: [StoredCarbEntry]? + var defaultAbsorptionTimes = LoopCoreConstants.defaultCarbAbsorptionTimes - init(for scenario: DosingTestScenario = .flatAndStable) { - self.scenario = scenario // The store returns different effect values based on the scenario - self.carbHistory = loadHistoricCarbEntries(scenario: scenario) - } - - var scenario: DosingTestScenario - - var sampleType: HKSampleType = HKSampleType.quantityType(forIdentifier: HKQuantityTypeIdentifier.dietaryCarbohydrates)! - - var preferredUnit: HKUnit! = .gram() - - var delegate: CarbStoreDelegate? - - var carbRatioSchedule: CarbRatioSchedule? - - var insulinSensitivitySchedule: InsulinSensitivitySchedule? - - var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? = InsulinSensitivitySchedule( - unit: HKUnit.milligramsPerDeciliter, - dailyItems: [ - RepeatingScheduleValue(startTime: 0.0, value: 45.0), - RepeatingScheduleValue(startTime: 32400.0, value: 55.0) - ], - timeZone: .utcTimeZone - )! - - var carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule? = CarbRatioSchedule( - unit: .gram(), - dailyItems: [ - RepeatingScheduleValue(startTime: 0.0, value: 10.0), - RepeatingScheduleValue(startTime: 32400.0, value: 12.0) - ], - timeZone: .utcTimeZone - )! - - var maximumAbsorptionTimeInterval: TimeInterval { - return defaultAbsorptionTimes.slow * 2 - } - - var delta: TimeInterval = .minutes(5) - - var defaultAbsorptionTimes: CarbStore.DefaultAbsorptionTimes = (fast: .minutes(30), medium: .hours(3), slow: .hours(5)) - - var authorizationRequired: Bool = false - - var sharingDenied: Bool = false - - func authorize(toShare: Bool, read: Bool, _ completion: @escaping (HealthKitSampleStoreResult) -> Void) { - completion(.success(true)) - } - - func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: NewCarbEntry, completion: @escaping (CarbStoreResult) -> Void) { - completion(.failure(.notConfigured)) - } - - func addCarbEntry(_ entry: NewCarbEntry, completion: @escaping (CarbStoreResult) -> Void) { - completion(.failure(.notConfigured)) - } - - func getCarbStatus(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult<[CarbStatus]>) -> Void) { - completion(.failure(.notConfigured)) - } - - func generateDiagnosticReport(_ completion: @escaping (String) -> Void) { - completion("") - } - - func glucoseEffects(of samples: [Sample], startingAt start: Date, endingAt end: Date?, effectVelocities: [LoopKit.GlucoseEffectVelocity]) throws -> [LoopKit.GlucoseEffect] where Sample : LoopKit.CarbEntry { - return [] - } - - func getCarbsOnBoardValues(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult<[CarbValue]>) -> Void) { - completion(.success([])) - } - - func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult) -> Void) { - completion(.failure(.notConfigured)) - } - - func getTotalCarbs(since start: Date, completion: @escaping (CarbStoreResult) -> Void) { - completion(.failure(.notConfigured)) - } - - func deleteCarbEntry(_ entry: StoredCarbEntry, completion: @escaping (CarbStoreResult) -> Void) { - completion(.failure(.notConfigured)) - } - - func getGlucoseEffects(start: Date, end: Date?, effectVelocities: [LoopKit.GlucoseEffectVelocity], completion: @escaping (LoopKit.CarbStoreResult<(entries: [LoopKit.StoredCarbEntry], effects: [LoopKit.GlucoseEffect])>) -> Void) - { - if let carbHistory, let carbRatioScheduleApplyingOverrideHistory, let insulinSensitivityScheduleApplyingOverrideHistory { - let foodStart = start.addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval) - let samples = carbHistory.filterDateRange(foodStart, end) - let carbDates = samples.map { $0.startDate } - let maxCarbDate = carbDates.max()! - let minCarbDate = carbDates.min()! - let carbRatio = carbRatioScheduleApplyingOverrideHistory.between(start: minCarbDate, end: maxCarbDate) - let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory.quantitiesBetween(start: minCarbDate, end: maxCarbDate) - let effects = samples.map( - to: effectVelocities, - carbRatio: carbRatio, - insulinSensitivity: insulinSensitivity - ).dynamicGlucoseEffects( - from: start, - to: end, - carbRatios: carbRatio, - insulinSensitivities: insulinSensitivity - ) - completion(.success((entries: samples, effects: effects))) + var carbHistory: [StoredCarbEntry] = [] - } else { - let fixture: [JSONDictionary] = loadFixture(fixtureToLoad) - - let dateFormatter = ISO8601DateFormatter.localTimeDate() - - return completion(.success(([], fixture.map { - return GlucoseEffect(startDate: dateFormatter.date(from: $0["date"] as! String)!, quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), doubleValue:$0["amount"] as! Double)) - }))) - } + func getCarbEntries(start: Date?, end: Date?, dateAscending: Bool, fetchLimit: Int?, with favoriteFoodID: String?) async throws -> [StoredCarbEntry] { + return carbHistory.filterDateRange(start, end) } -} -extension MockCarbStore { - public var bundle: Bundle { - return Bundle(for: type(of: self)) + func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: NewCarbEntry) async throws -> StoredCarbEntry { + let stored = newEntry.asStoredCarbEntry + carbHistory = carbHistory.map({ entry in + if entry.syncIdentifier == oldEntry.syncIdentifier { + return stored + } else { + return entry + } + }) + return stored } - public func loadFixture(_ resourceName: String) -> T { - let path = bundle.path(forResource: resourceName, ofType: "json")! - return try! JSONSerialization.jsonObject(with: Data(contentsOf: URL(fileURLWithPath: path)), options: []) as! T - } - - var fixtureToLoad: String { - switch scenario { - case .liveCapture: - fatalError("live capture scenario computes effects from carb entries, does not used pre-canned effects") - case .flatAndStable: - return "flat_and_stable_carb_effect" - case .highAndStable: - return "high_and_stable_carb_effect" - case .highAndRisingWithCOB: - return "high_and_rising_with_cob_carb_effect" - case .lowAndFallingWithCOB: - return "low_and_falling_carb_effect" - case .lowWithLowTreatment: - return "low_with_low_treatment_carb_effect" - case .highAndFalling: - return "high_and_falling_carb_effect" - } + func addCarbEntry(_ entry: NewCarbEntry) async throws -> StoredCarbEntry { + let stored = entry.asStoredCarbEntry + carbHistory.append(stored) + return stored } - public func loadHistoricCarbEntries(scenario: DosingTestScenario) -> [StoredCarbEntry]? { - if let url = bundle.url(forResource: scenario.fixturePrefix + "carb_entries", withExtension: "json"), - let data = try? Data(contentsOf: url) - { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try? decoder.decode([StoredCarbEntry].self, from: data) - } else { - return nil - } + func deleteCarbEntry(_ oldEntry: StoredCarbEntry) async throws -> Bool { + carbHistory = carbHistory.filter { $0.syncIdentifier == oldEntry.syncIdentifier } + return true } } diff --git a/LoopTests/Mock Stores/MockDoseStore.swift b/LoopTests/Mock Stores/MockDoseStore.swift index 207596f31b..93aaa73068 100644 --- a/LoopTests/Mock Stores/MockDoseStore.swift +++ b/LoopTests/Mock Stores/MockDoseStore.swift @@ -8,164 +8,30 @@ import HealthKit import LoopKit +import LoopAlgorithm @testable import Loop class MockDoseStore: DoseStoreProtocol { - var doseHistory: [DoseEntry]? - var sensitivitySchedule: InsulinSensitivitySchedule? - - init(for scenario: DosingTestScenario = .flatAndStable) { - self.scenario = scenario // The store returns different effect values based on the scenario - self.pumpEventQueryAfterDate = scenario.currentDate - self.lastAddedPumpData = scenario.currentDate - self.doseHistory = loadHistoricDoses(scenario: scenario) + func getNormalizedDoseEntries(start: Date, end: Date?) async throws -> [LoopKit.DoseEntry] { + return doseHistory ?? [] + addedDoses } - static let dateFormatter = ISO8601DateFormatter.localTimeDate() - - var scenario: DosingTestScenario - - var basalProfileApplyingOverrideHistory: BasalRateSchedule? - - var delegate: DoseStoreDelegate? - - var device: HKDevice? - - var pumpRecordsBasalProfileStartEvents: Bool = false - - var pumpEventQueryAfterDate: Date - - var basalProfile: BasalRateSchedule? - - // Default to the adult exponential insulin model - var insulinModelProvider: InsulinModelProvider = StaticInsulinModelProvider(ExponentialInsulinModelPreset.rapidActingAdult) - - var longestEffectDuration: TimeInterval = ExponentialInsulinModelPreset.rapidActingAdult.effectDuration - - var insulinSensitivitySchedule: InsulinSensitivitySchedule? - - var sampleType: HKSampleType = HKQuantityType.quantityType(forIdentifier: .insulinDelivery)! - - var authorizationRequired: Bool = false - - var sharingDenied: Bool = false - - var lastReservoirValue: ReservoirValue? - - var lastAddedPumpData: Date + var addedDoses: [DoseEntry] = [] - func addPumpEvents(_ events: [NewPumpEvent], lastReconciliation: Date?, replacePendingEvents: Bool, completion: @escaping (DoseStore.DoseStoreError?) -> Void) { - completion(nil) - } - - func addReservoirValue(_ unitVolume: Double, at date: Date, completion: @escaping (ReservoirValue?, ReservoirValue?, Bool, DoseStore.DoseStoreError?) -> Void) { - completion(nil, nil, false, nil) - } - - func insulinOnBoard(at date: Date, completion: @escaping (DoseStoreResult) -> Void) { - completion(.success(.init(startDate: scenario.currentDate, value: 9.5))) - } - - func generateDiagnosticReport(_ completion: @escaping (String) -> Void) { - completion("") + func addDoses(_ doses: [DoseEntry], from device: HKDevice?) async throws { + addedDoses = doses } - func addDoses(_ doses: [DoseEntry], from device: HKDevice?, completion: @escaping (Error?) -> Void) { - completion(nil) - } - - func getInsulinOnBoardValues(start: Date, end: Date?, basalDosingEnd: Date?, completion: @escaping (DoseStoreResult<[InsulinValue]>) -> Void) { - completion(.failure(.configurationError)) - } - - func getNormalizedDoseEntries(start: Date, end: Date?, completion: @escaping (DoseStoreResult<[DoseEntry]>) -> Void) { - completion(.failure(.configurationError)) - } - - func executePumpEventQuery(fromQueryAnchor queryAnchor: DoseStore.QueryAnchor?, limit: Int, completion: @escaping (DoseStore.PumpEventQueryResult) -> Void) { - completion(.failure(DoseStore.DoseStoreError.configurationError)) - } - - func getTotalUnitsDelivered(since startDate: Date, completion: @escaping (DoseStoreResult) -> Void) { - completion(.failure(.configurationError)) - } - - func getGlucoseEffects(start: Date, end: Date? = nil, basalDosingEnd: Date? = Date(), completion: @escaping (_ result: DoseStoreResult<[GlucoseEffect]>) -> Void) { - if let doseHistory, let sensitivitySchedule, let basalProfile = basalProfileApplyingOverrideHistory { - // To properly know glucose effects at startDate, we need to go back another DIA hours - let doseStart = start.addingTimeInterval(-longestEffectDuration) - let doses = doseHistory.filterDateRange(doseStart, end) - let trimmedDoses = doses.map { (dose) -> DoseEntry in - guard dose.type != .bolus else { - return dose - } - return dose.trimmed(to: basalDosingEnd) - } - - let annotatedDoses = trimmedDoses.annotated(with: basalProfile) - - let glucoseEffects = annotatedDoses.glucoseEffects(insulinModelProvider: self.insulinModelProvider, longestEffectDuration: self.longestEffectDuration, insulinSensitivity: sensitivitySchedule, from: start, to: end) - completion(.success(glucoseEffects.filterDateRange(start, end))) - } else { - return completion(.success(getCannedGlucoseEffects())) - } - } - - func getCannedGlucoseEffects() -> [GlucoseEffect] { - let fixture: [JSONDictionary] = loadFixture(fixtureToLoad) - let dateFormatter = ISO8601DateFormatter.localTimeDate() - - return fixture.map { - return GlucoseEffect( - startDate: dateFormatter.date(from: $0["date"] as! String)!, - quantity: HKQuantity( - unit: HKUnit(from: $0["unit"] as! String), - doubleValue: $0["amount"] as! Double - ) - ) - } - } -} - -extension MockDoseStore { - public var bundle: Bundle { - return Bundle(for: type(of: self)) - } + var lastReservoirValue: LoopKit.ReservoirValue? - public func loadFixture(_ resourceName: String) -> T { - let path = bundle.path(forResource: resourceName, ofType: "json")! - return try! JSONSerialization.jsonObject(with: Data(contentsOf: URL(fileURLWithPath: path)), options: []) as! T + func getTotalUnitsDelivered(since startDate: Date) async throws -> InsulinValue { + return InsulinValue(startDate: lastAddedPumpData, value: 0) } - var fixtureToLoad: String { - switch scenario { - case .liveCapture: - fatalError("live capture scenario computes effects from doses, does not used pre-canned effects") - case .flatAndStable: - return "flat_and_stable_insulin_effect" - case .highAndStable: - return "high_and_stable_insulin_effect" - case .highAndRisingWithCOB: - return "high_and_rising_with_cob_insulin_effect" - case .lowAndFallingWithCOB: - return "low_and_falling_insulin_effect" - case .lowWithLowTreatment: - return "low_with_low_treatment_insulin_effect" - case .highAndFalling: - return "high_and_falling_insulin_effect" - } - } + var lastAddedPumpData = Date.distantPast - public func loadHistoricDoses(scenario: DosingTestScenario) -> [DoseEntry]? { - if let url = bundle.url(forResource: scenario.fixturePrefix + "doses", withExtension: "json"), - let data = try? Data(contentsOf: url) - { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try? decoder.decode([DoseEntry].self, from: data) - } else { - return nil - } - } + var doseHistory: [DoseEntry]? + static let dateFormatter = ISO8601DateFormatter.localTimeDate() + } diff --git a/LoopTests/Mock Stores/MockDosingDecisionStore.swift b/LoopTests/Mock Stores/MockDosingDecisionStore.swift index f8e4191d8e..f13734326a 100644 --- a/LoopTests/Mock Stores/MockDosingDecisionStore.swift +++ b/LoopTests/Mock Stores/MockDosingDecisionStore.swift @@ -7,13 +7,34 @@ // import LoopKit +import XCTest @testable import Loop class MockDosingDecisionStore: DosingDecisionStoreProtocol { + var delegate: LoopKit.DosingDecisionStoreDelegate? + + var exportName: String = "MockDosingDecision" + + func exportProgressTotalUnitCount(startDate: Date, endDate: Date?) -> Result { + return .success(1) + } + + func export(startDate: Date, endDate: Date, to stream: LoopKit.DataOutputStream, progress: Progress) -> Error? { + return nil + } + var dosingDecisions: [StoredDosingDecision] = [] - func storeDosingDecision(_ dosingDecision: StoredDosingDecision, completion: @escaping () -> Void) { + var storeExpectation: XCTestExpectation? + + func storeDosingDecision(_ dosingDecision: StoredDosingDecision) async { dosingDecisions.append(dosingDecision) - completion() + storeExpectation?.fulfill() + } + + func executeDosingDecisionQuery(fromQueryAnchor queryAnchor: LoopKit.DosingDecisionStore.QueryAnchor?, limit: Int, completion: @escaping (LoopKit.DosingDecisionStore.DosingDecisionQueryResult) -> Void) { + if let queryAnchor { + completion(.success(queryAnchor, [])) + } } } diff --git a/LoopTests/Mock Stores/MockGlucoseStore.swift b/LoopTests/Mock Stores/MockGlucoseStore.swift index 19a6bc22e8..ea6c3f118d 100644 --- a/LoopTests/Mock Stores/MockGlucoseStore.swift +++ b/LoopTests/Mock Stores/MockGlucoseStore.swift @@ -8,108 +8,26 @@ import HealthKit import LoopKit +import LoopAlgorithm @testable import Loop class MockGlucoseStore: GlucoseStoreProtocol { - - init(for scenario: DosingTestScenario = .flatAndStable) { - self.scenario = scenario // The store returns different effect values based on the scenario - storedGlucose = loadHistoricGlucose(scenario: scenario) - } - - let dateFormatter = ISO8601DateFormatter.localTimeDate() - - var scenario: DosingTestScenario - var storedGlucose: [StoredGlucoseSample]? - - var latestGlucose: GlucoseSampleValue? { - if let storedGlucose { - return storedGlucose.last - } else { - return StoredGlucoseSample( - sample: HKQuantitySample( - type: HKQuantityType.quantityType(forIdentifier: .bloodGlucose)!, - quantity: HKQuantity(unit: HKUnit.milligramsPerDeciliter, doubleValue: latestGlucoseValue), - start: glucoseStartDate, - end: glucoseStartDate - ) - ) - } + func getGlucoseSamples(start: Date?, end: Date?) async throws -> [StoredGlucoseSample] { + storedGlucose?.filterDateRange(start, end) ?? [] } - - var preferredUnit: HKUnit? - - var sampleType: HKSampleType = HKSampleType.quantityType(forIdentifier: HKQuantityTypeIdentifier.bloodGlucose)! - - var delegate: GlucoseStoreDelegate? - - var managedDataInterval: TimeInterval? - - var healthKitStorageDelay = TimeInterval(0) - var authorizationRequired: Bool = false - - var sharingDenied: Bool = false - - func authorize(toShare: Bool, read: Bool, _ completion: @escaping (HealthKitSampleStoreResult) -> Void) { - completion(.success(true)) - } - - func addGlucoseSamples(_ values: [NewGlucoseSample], completion: @escaping (Result<[StoredGlucoseSample], Error>) -> Void) { + func addGlucoseSamples(_ samples: [NewGlucoseSample]) async throws -> [StoredGlucoseSample] { // Using the dose store error because we don't need to create GlucoseStore errors just for the mock store - completion(.failure(DoseStore.DoseStoreError.configurationError)) + throw DoseStore.DoseStoreError.configurationError } - - func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (Result<[StoredGlucoseSample], Error>) -> Void) { - completion(.success([latestGlucose as! StoredGlucoseSample])) - } - - func generateDiagnosticReport(_ completion: @escaping (String) -> Void) { - completion("") - } - - func purgeAllGlucoseSamples(healthKitPredicate: NSPredicate, completion: @escaping (Error?) -> Void) { - // Using the dose store error because we don't need to create GlucoseStore errors just for the mock store - completion(DoseStore.DoseStoreError.configurationError) - } - - func executeGlucoseQuery(fromQueryAnchor queryAnchor: GlucoseStore.QueryAnchor?, limit: Int, completion: @escaping (GlucoseStore.GlucoseQueryResult) -> Void) { - // Using the dose store error because we don't need to create GlucoseStore errors just for the mock store - completion(.failure(DoseStore.DoseStoreError.configurationError)) - } - - func counteractionEffects(for samples: [Sample], to effects: [GlucoseEffect]) -> [GlucoseEffectVelocity] where Sample : GlucoseSampleValue { - samples.counteractionEffects(to: effects) - } - - func getRecentMomentumEffect(for date: Date? = nil, _ completion: @escaping (_ effects: Result<[GlucoseEffect], Error>) -> Void) { - if let storedGlucose { - let samples = storedGlucose.filterDateRange((date ?? Date()).addingTimeInterval(-GlucoseMath.momentumDataInterval), nil) - completion(.success(samples.linearMomentumEffect())) - } else { - let fixture: [JSONDictionary] = loadFixture(momentumEffectToLoad) - let dateFormatter = ISO8601DateFormatter.localTimeDate() - return completion(.success(fixture.map { - return GlucoseEffect(startDate: dateFormatter.date(from: $0["date"] as! String)!, quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), doubleValue: $0["amount"] as! Double)) - } - )) - } - } + let dateFormatter = ISO8601DateFormatter.localTimeDate() - func getCounteractionEffects(start: Date, end: Date? = nil, to effects: [GlucoseEffect], _ completion: @escaping (_ effects: Result<[GlucoseEffectVelocity], Error>) -> Void) { - if let storedGlucose { - let samples = storedGlucose.filterDateRange(start, end) - completion(.success(self.counteractionEffects(for: samples, to: effects))) - } else { - let fixture: [JSONDictionary] = loadFixture(counteractionEffectToLoad) - let dateFormatter = ISO8601DateFormatter.localTimeDate() - - completion(.success(fixture.map { - return GlucoseEffectVelocity(startDate: dateFormatter.date(from: $0["startDate"] as! String)!, endDate: dateFormatter.date(from: $0["endDate"] as! String)!, quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), doubleValue:$0["value"] as! Double)) - })) - } + var storedGlucose: [StoredGlucoseSample]? + + var latestGlucose: GlucoseSampleValue? { + return storedGlucose?.last } } @@ -123,92 +41,5 @@ extension MockGlucoseStore { return try! JSONSerialization.jsonObject(with: Data(contentsOf: URL(fileURLWithPath: path)), options: []) as! T } - public func loadHistoricGlucose(scenario: DosingTestScenario) -> [StoredGlucoseSample]? { - if let url = bundle.url(forResource: scenario.fixturePrefix + "historic_glucose", withExtension: "json"), - let data = try? Data(contentsOf: url) - { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try? decoder.decode([StoredGlucoseSample].self, from: data) - } else { - return nil - } - } - - var counteractionEffectToLoad: String { - switch scenario { - case .liveCapture: - fatalError("live capture scenario computes counteraction effects from input data, does not used pre-canned effects") - case .flatAndStable: - return "flat_and_stable_counteraction_effect" - case .highAndStable: - return "high_and_stable_counteraction_effect" - case .highAndRisingWithCOB: - return "high_and_rising_with_cob_counteraction_effect" - case .lowAndFallingWithCOB: - return "low_and_falling_counteraction_effect" - case .lowWithLowTreatment: - return "low_with_low_treatment_counteraction_effect" - case .highAndFalling: - return "high_and_falling_counteraction_effect" - } - } - - var momentumEffectToLoad: String { - switch scenario { - case .liveCapture: - fatalError("live capture scenario computes momentu effects from input data, does not used pre-canned effects") - case .flatAndStable: - return "flat_and_stable_momentum_effect" - case .highAndStable: - return "high_and_stable_momentum_effect" - case .highAndRisingWithCOB: - return "high_and_rising_with_cob_momentum_effect" - case .lowAndFallingWithCOB: - return "low_and_falling_momentum_effect" - case .lowWithLowTreatment: - return "low_with_low_treatment_momentum_effect" - case .highAndFalling: - return "high_and_falling_momentum_effect" - } - } - - var glucoseStartDate: Date { - switch scenario { - case .liveCapture: - fatalError("live capture scenario uses actual glucose input data") - case .flatAndStable: - return dateFormatter.date(from: "2020-08-11T20:45:02")! - case .highAndStable: - return dateFormatter.date(from: "2020-08-12T12:39:22")! - case .highAndRisingWithCOB: - return dateFormatter.date(from: "2020-08-11T21:48:17")! - case .lowAndFallingWithCOB: - return dateFormatter.date(from: "2020-08-11T22:06:06")! - case .lowWithLowTreatment: - return dateFormatter.date(from: "2020-08-11T22:23:55")! - case .highAndFalling: - return dateFormatter.date(from: "2020-08-11T22:59:45")! - } - } - - var latestGlucoseValue: Double { - switch scenario { - case .liveCapture: - fatalError("live capture scenario uses actual glucose input data") - case .flatAndStable: - return 123.42849966275706 - case .highAndStable: - return 200.0 - case .highAndRisingWithCOB: - return 129.93174411197853 - case .lowAndFallingWithCOB: - return 75.10768374646841 - case .lowWithLowTreatment: - return 81.22399763523448 - case .highAndFalling: - return 200.0 - } - } } diff --git a/LoopTests/Mock Stores/MockSettingsStore.swift b/LoopTests/Mock Stores/MockSettingsStore.swift index 7e21268236..0113596810 100644 --- a/LoopTests/Mock Stores/MockSettingsStore.swift +++ b/LoopTests/Mock Stores/MockSettingsStore.swift @@ -10,7 +10,7 @@ import LoopKit @testable import Loop class MockLatestStoredSettingsProvider: LatestStoredSettingsProvider { - var latestSettings: StoredSettings { StoredSettings() } + var settings: StoredSettings { StoredSettings() } func storeSettings(_ settings: StoredSettings, completion: @escaping () -> Void) { completion() } diff --git a/LoopTests/Mocks/AlertMocks.swift b/LoopTests/Mocks/AlertMocks.swift new file mode 100644 index 0000000000..d13c0663db --- /dev/null +++ b/LoopTests/Mocks/AlertMocks.swift @@ -0,0 +1,192 @@ +// +// AlertMocks.swift +// LoopTests +// +// Created by Pete Schwamb on 10/31/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import UIKit +import LoopKit +@testable import Loop + +class MockBluetoothProvider: BluetoothProvider { + var bluetoothAuthorization: BluetoothAuthorization = .authorized + + var bluetoothState: BluetoothState = .poweredOn + + func authorizeBluetooth(_ completion: @escaping (BluetoothAuthorization) -> Void) { + completion(bluetoothAuthorization) + } + + func addBluetoothObserver(_ observer: BluetoothObserver, queue: DispatchQueue) { + } + + func removeBluetoothObserver(_ observer: BluetoothObserver) { + } +} + +class MockModalAlertScheduler: InAppModalAlertScheduler { + var scheduledAlert: Alert? + override func scheduleAlert(_ alert: Alert) { + scheduledAlert = alert + } + var unscheduledAlertIdentifier: Alert.Identifier? + override func unscheduleAlert(identifier: Alert.Identifier) { + unscheduledAlertIdentifier = identifier + } +} + +class MockUserNotificationAlertScheduler: UserNotificationAlertScheduler { + var scheduledAlert: Alert? + var muted: Bool? + + override func scheduleAlert(_ alert: Alert, muted: Bool) { + scheduledAlert = alert + self.muted = muted + } + var unscheduledAlertIdentifier: Alert.Identifier? + override func unscheduleAlert(identifier: Alert.Identifier) { + unscheduledAlertIdentifier = identifier + } +} + +class MockResponder: AlertResponder { + var acknowledged: [Alert.AlertIdentifier: Bool] = [:] + func acknowledgeAlert(alertIdentifier: Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { + completion(nil) + acknowledged[alertIdentifier] = true + } +} + +class MockFileManager: FileManager { + + var fileExists = true + let newer = Date() + let older = Date.distantPast + + var createdDirURL: URL? + override func createDirectory(at url: URL, withIntermediateDirectories createIntermediates: Bool, attributes: [FileAttributeKey : Any]? = nil) throws { + createdDirURL = url + } + override func fileExists(atPath path: String) -> Bool { + return !path.contains("doesntExist") + } + override func attributesOfItem(atPath path: String) throws -> [FileAttributeKey : Any] { + return path.contains("Sounds") ? path.contains("existsNewer") ? [.creationDate: newer] : [.creationDate: older] : + [.creationDate: newer] + } + var removedURLs = [URL]() + override func removeItem(at URL: URL) throws { + removedURLs.append(URL) + } + var copiedSrcURLs = [URL]() + var copiedDstURLs = [URL]() + override func copyItem(at srcURL: URL, to dstURL: URL) throws { + copiedSrcURLs.append(srcURL) + copiedDstURLs.append(dstURL) + } + override func urls(for directory: FileManager.SearchPathDirectory, in domainMask: FileManager.SearchPathDomainMask) -> [URL] { + return [] + } +} + +class MockPresenter: AlertPresenter { + func present(_ viewControllerToPresent: UIViewController, animated: Bool, completion: (() -> Void)?) { completion?() } + func dismissTopMost(animated: Bool, completion: (() -> Void)?) { completion?() } + func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool, completion: (() -> Void)?) { completion?() } +} + +class MockAlertManagerResponder: AlertManagerResponder { + func acknowledgeAlert(identifier: LoopKit.Alert.Identifier) { } +} + +class MockSoundVendor: AlertSoundVendor { + func getSoundBaseURL() -> URL? { + // Hm. It's not easy to make a "fake" URL, so we'll use this one: + return Bundle.main.resourceURL + } + + func getSounds() -> [Alert.Sound] { + return [.sound(name: "doesntExist"), .sound(name: "existsNewer"), .sound(name: "existsOlder")] + } +} + +class MockAlertStore: AlertStore { + + var issuedAlert: Alert? + override public func recordIssued(alert: Alert, at date: Date = Date(), completion: ((Result) -> Void)? = nil) { + issuedAlert = alert + completion?(.success) + } + + var retractedAlert: Alert? + var retractedAlertDate: Date? + override public func recordRetractedAlert(_ alert: Alert, at date: Date, completion: ((Result) -> Void)? = nil) { + retractedAlert = alert + retractedAlertDate = date + completion?(.success) + } + + var acknowledgedAlertIdentifier: Alert.Identifier? + var acknowledgedAlertDate: Date? + override public func recordAcknowledgement(of identifier: Alert.Identifier, at date: Date = Date(), + completion: ((Result) -> Void)? = nil) { + acknowledgedAlertIdentifier = identifier + acknowledgedAlertDate = date + completion?(.success) + } + + var retractededAlertIdentifier: Alert.Identifier? + override public func recordRetraction(of identifier: Alert.Identifier, at date: Date = Date(), + completion: ((Result) -> Void)? = nil) { + retractededAlertIdentifier = identifier + retractedAlertDate = date + completion?(.success) + } + + var storedAlerts = [StoredAlert]() + override public func lookupAllUnacknowledgedUnretracted(managerIdentifier: String? = nil, filteredByTriggers triggersStoredType: [AlertTriggerStoredType]? = nil, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { + completion(.success(storedAlerts)) + } + + override public func lookupAllUnretracted(managerIdentifier: String?, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { + completion(.success(storedAlerts)) + } +} + +class MockUserNotificationCenter: UserNotificationCenter { + + var pendingRequests = [UNNotificationRequest]() + var deliveredRequests = [UNNotificationRequest]() + + func add(_ request: UNNotificationRequest, withCompletionHandler completionHandler: ((Error?) -> Void)? = nil) { + pendingRequests.append(request) + } + + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) { + identifiers.forEach { identifier in + pendingRequests.removeAll { $0.identifier == identifier } + } + } + + func removeDeliveredNotifications(withIdentifiers identifiers: [String]) { + identifiers.forEach { identifier in + deliveredRequests.removeAll { $0.identifier == identifier } + } + } + + func deliverAll() { + deliveredRequests = pendingRequests + pendingRequests = [] + } + + func getDeliveredNotifications(completionHandler: @escaping ([UNNotification]) -> Void) { + // Sadly, we can't create UNNotifications. + completionHandler([]) + } + + func getPendingNotificationRequests(completionHandler: @escaping ([UNNotificationRequest]) -> Void) { + completionHandler(pendingRequests) + } +} diff --git a/LoopTests/Mocks/LoopControlMock.swift b/LoopTests/Mocks/LoopControlMock.swift new file mode 100644 index 0000000000..ef5847651c --- /dev/null +++ b/LoopTests/Mocks/LoopControlMock.swift @@ -0,0 +1,30 @@ +// +// LoopControlMock.swift +// LoopTests +// +// Created by Pete Schwamb on 11/30/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import XCTest +import Foundation +import LoopAlgorithm +@testable import Loop + + +class LoopControlMock: LoopControl { + var lastLoopCompleted: Date? + + var lastCancelActiveTempBasalReason: CancelActiveTempBasalReason? + + var cancelExpectation: XCTestExpectation? + + func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason) async { + lastCancelActiveTempBasalReason = reason + cancelExpectation?.fulfill() + } + + func loop() async { + } + +} diff --git a/LoopTests/Mocks/MockCGMManager.swift b/LoopTests/Mocks/MockCGMManager.swift new file mode 100644 index 0000000000..38e6d6a140 --- /dev/null +++ b/LoopTests/Mocks/MockCGMManager.swift @@ -0,0 +1,63 @@ +// +// MockCGMManager.swift +// LoopTests +// +// Created by Pete Schwamb on 10/31/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +class MockCGMManager: CGMManager { + var cgmManagerDelegate: LoopKit.CGMManagerDelegate? + + var providesBLEHeartbeat: Bool = false + + var managedDataInterval: TimeInterval? + + var shouldSyncToRemoteService: Bool = true + + var glucoseDisplay: LoopKit.GlucoseDisplayable? + + var cgmManagerStatus: LoopKit.CGMManagerStatus { + return CGMManagerStatus(hasValidSensorSession: true, device: nil) + } + + var delegateQueue: DispatchQueue! + + func fetchNewDataIfNeeded(_ completion: @escaping (LoopKit.CGMReadingResult) -> Void) { + completion(.noData) + } + + var localizedTitle: String = "MockCGMManager" + + init() { + } + + required init?(rawState: RawStateValue) { + } + + var rawState: RawStateValue { + return [:] + } + + var isOnboarded: Bool = true + + var debugDescription: String = "MockCGMManager" + + func acknowledgeAlert(alertIdentifier: LoopKit.Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { + completion(nil) + } + + func getSoundBaseURL() -> URL? { + return nil + } + + func getSounds() -> [LoopKit.Alert.Sound] { + return [] + } + + var pluginIdentifier: String = "MockCGMManager" + +} diff --git a/LoopTests/Mocks/MockDeliveryDelegate.swift b/LoopTests/Mocks/MockDeliveryDelegate.swift new file mode 100644 index 0000000000..c3bd8e911b --- /dev/null +++ b/LoopTests/Mocks/MockDeliveryDelegate.swift @@ -0,0 +1,46 @@ +// +// MockDeliveryDelegate.swift +// LoopTests +// +// Created by Pete Schwamb on 12/1/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import LoopAlgorithm +@testable import Loop + +class MockDeliveryDelegate: DeliveryDelegate { + var isSuspended: Bool = false + + var pumpInsulinType: InsulinType? + + var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? + + var isPumpConfigured: Bool = true + + var lastEnact: AutomaticDoseRecommendation? + + func enact(_ recommendation: AutomaticDoseRecommendation) async throws { + lastEnact = recommendation + } + + var lastBolus: Double? + var lastBolusActivationType: BolusActivationType? + + func enactBolus(units: Double, activationType: BolusActivationType) async throws { + lastBolus = units + lastBolusActivationType = activationType + } + + func roundBasalRate(unitsPerHour: Double) -> Double { + (unitsPerHour * 20).rounded() / 20.0 + } + + func roundBolusVolume(units: Double) -> Double { + (units * 20).rounded() / 20.0 + } + + +} diff --git a/LoopTests/Mocks/MockPumpManager.swift b/LoopTests/Mocks/MockPumpManager.swift new file mode 100644 index 0000000000..70131ab674 --- /dev/null +++ b/LoopTests/Mocks/MockPumpManager.swift @@ -0,0 +1,141 @@ +// +// MockPumpManager.swift +// LoopTests +// +// Created by Pete Schwamb on 10/31/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import LoopKitUI +import HealthKit +@testable import Loop + +class MockPumpManager: PumpManager { + + var enactBolusCalled: ((Double, BolusActivationType) -> Void)? + + var enactTempBasalCalled: ((Double, TimeInterval) -> Void)? + + var enactTempBasalError: PumpManagerError? + + init() { + + } + + // PumpManager implementation + static var onboardingMaximumBasalScheduleEntryCount: Int = 24 + + static var onboardingSupportedBasalRates: [Double] = [1,2,3] + + static var onboardingSupportedBolusVolumes: [Double] = [1,2,3] + + static var onboardingSupportedMaximumBolusVolumes: [Double] = [1,2,3] + + let deliveryUnitsPerMinute = 1.5 + + var supportedBasalRates: [Double] = [1,2,3] + + var supportedBolusVolumes: [Double] = [1,2,3] + + var supportedMaximumBolusVolumes: [Double] = [1,2,3] + + var maximumBasalScheduleEntryCount: Int = 24 + + var minimumBasalScheduleEntryDuration: TimeInterval = .minutes(30) + + var pumpManagerDelegate: PumpManagerDelegate? + + var pumpRecordsBasalProfileStartEvents: Bool = false + + var pumpReservoirCapacity: Double = 50 + + var lastSync: Date? + + var status: PumpManagerStatus = + PumpManagerStatus( + timeZone: TimeZone.current, + device: HKDevice(name: "MockPumpManager", manufacturer: nil, model: nil, hardwareVersion: nil, firmwareVersion: nil, softwareVersion: nil, localIdentifier: nil, udiDeviceIdentifier: nil), + pumpBatteryChargeRemaining: nil, + basalDeliveryState: nil, + bolusState: .noBolus, + insulinType: .novolog) + + func addStatusObserver(_ observer: PumpManagerStatusObserver, queue: DispatchQueue) { + } + + func removeStatusObserver(_ observer: PumpManagerStatusObserver) { + } + + func ensureCurrentPumpData(completion: ((Date?) -> Void)?) { + completion?(Date()) + } + + func setMustProvideBLEHeartbeat(_ mustProvideBLEHeartbeat: Bool) { + } + + func createBolusProgressReporter(reportingOn dispatchQueue: DispatchQueue) -> DoseProgressReporter? { + return nil + } + + func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (PumpManagerError?) -> Void) { + enactBolusCalled?(units, activationType) + completion(nil) + } + + func cancelBolus(completion: @escaping (PumpManagerResult) -> Void) { + completion(.success(nil)) + } + + func enactTempBasal(unitsPerHour: Double, for duration: TimeInterval, completion: @escaping (PumpManagerError?) -> Void) { + enactTempBasalCalled?(unitsPerHour, duration) + completion(enactTempBasalError) + } + + func suspendDelivery(completion: @escaping (Error?) -> Void) { + completion(nil) + } + + func resumeDelivery(completion: @escaping (Error?) -> Void) { + completion(nil) + } + + func syncBasalRateSchedule(items scheduleItems: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { + } + + func syncDeliveryLimits(limits deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { + completion(.success(deliveryLimits)) + } + + func estimatedDuration(toBolus units: Double) -> TimeInterval { + .minutes(units / deliveryUnitsPerMinute) + } + + var pluginIdentifier: String = "MockPumpManager" + + var localizedTitle: String = "MockPumpManager" + + var delegateQueue: DispatchQueue! + + required init?(rawState: RawStateValue) { + + } + + var rawState: RawStateValue = [:] + + var isOnboarded: Bool = true + + var debugDescription: String = "MockPumpManager" + + func acknowledgeAlert(alertIdentifier: Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { + } + + func getSoundBaseURL() -> URL? { + return nil + } + + func getSounds() -> [Alert.Sound] { + return [.sound(name: "doesntExist")] + } +} diff --git a/LoopTests/Mocks/MockSettingsProvider.swift b/LoopTests/Mocks/MockSettingsProvider.swift new file mode 100644 index 0000000000..823f0901f8 --- /dev/null +++ b/LoopTests/Mocks/MockSettingsProvider.swift @@ -0,0 +1,53 @@ +// +// MockSettingsProvider.swift +// LoopTests +// +// Created by Pete Schwamb on 11/28/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import HealthKit +import LoopAlgorithm +@testable import Loop + +class MockSettingsProvider: SettingsProvider { + var basalHistory: [AbsoluteScheduleValue]? + func getBasalHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + return basalHistory ?? settings.basalRateSchedule?.between(start: startDate, end: endDate) ?? [] + } + + var carbRatioHistory: [AbsoluteScheduleValue]? + func getCarbRatioHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + return carbRatioHistory ?? settings.carbRatioSchedule?.between(start: startDate, end: endDate) ?? [] + } + + var insulinSensitivityHistory: [AbsoluteScheduleValue]? + func getInsulinSensitivityHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + return insulinSensitivityHistory ?? settings.insulinSensitivitySchedule?.quantitiesBetween(start: startDate, end: endDate) ?? [] + } + + var targetRangeHistory: [AbsoluteScheduleValue>]? + func getTargetRangeHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue>] { + return targetRangeHistory ?? settings.glucoseTargetRangeSchedule?.quantityBetween(start: startDate, end: endDate) ?? [] + } + + func getDosingLimits(at date: Date) async throws -> DosingLimits { + return DosingLimits( + suspendThreshold: settings.suspendThreshold?.quantity, + maxBolus: settings.maximumBolus, + maxBasalRate: settings.maximumBasalRatePerHour + ) + } + + func executeSettingsQuery(fromQueryAnchor queryAnchor: SettingsStore.QueryAnchor?, limit: Int, completion: @escaping (SettingsStore.SettingsQueryResult) -> Void) { + completion(.success(SettingsStore.QueryAnchor(), [])) + } + + var settings: StoredSettings + + init(settings: StoredSettings) { + self.settings = settings + } +} diff --git a/LoopTests/Mocks/MockTrustedTimeChecker.swift b/LoopTests/Mocks/MockTrustedTimeChecker.swift new file mode 100644 index 0000000000..137de2eede --- /dev/null +++ b/LoopTests/Mocks/MockTrustedTimeChecker.swift @@ -0,0 +1,14 @@ +// +// MockTrustedTimeChecker.swift +// LoopTests +// +// Created by Pete Schwamb on 11/1/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +@testable import Loop + +class MockTrustedTimeChecker: TrustedTimeChecker { + var detectedSystemTimeOffset: TimeInterval = 0 +} diff --git a/LoopTests/Mocks/MockUploadEventListener.swift b/LoopTests/Mocks/MockUploadEventListener.swift new file mode 100644 index 0000000000..75de952dd6 --- /dev/null +++ b/LoopTests/Mocks/MockUploadEventListener.swift @@ -0,0 +1,17 @@ +// +// MockUploadEventListener.swift +// LoopTests +// +// Created by Pete Schwamb on 11/30/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +@testable import Loop + +class MockUploadEventListener: UploadEventListener { + var lastUploadTriggeringType: RemoteDataType? + func triggerUpload(for triggeringType: RemoteDataType) { + self.lastUploadTriggeringType = triggeringType + } +} diff --git a/LoopTests/Mocks/PersistenceController.swift b/LoopTests/Mocks/PersistenceController.swift new file mode 100644 index 0000000000..43fca07c60 --- /dev/null +++ b/LoopTests/Mocks/PersistenceController.swift @@ -0,0 +1,16 @@ +// +// PersistenceController.swift +// LoopTests +// +// Created by Pete Schwamb on 10/31/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +extension PersistenceController { + static func mock() -> PersistenceController { + return PersistenceController(directoryURL: URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).appendingPathComponent(UUID().uuidString, isDirectory: true)) + } +} diff --git a/LoopTests/Models/AutomationHistoryEntryTests.swift b/LoopTests/Models/AutomationHistoryEntryTests.swift new file mode 100644 index 0000000000..ffa7967aa8 --- /dev/null +++ b/LoopTests/Models/AutomationHistoryEntryTests.swift @@ -0,0 +1,97 @@ +// +// AutomationHistoryEntryTests.swift +// LoopTests +// +// Created by Pete Schwamb on 9/19/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import XCTest + +@testable import Loop + +class TimelineTests: XCTestCase { + + func testEmptyArray() { + let entries: [AutomationHistoryEntry] = [] + let start = Date() + let end = start.addingTimeInterval(3600) // 1 hour later + + let timeline = entries.toTimeline(from: start, to: end) + + XCTAssertTrue(timeline.isEmpty, "Timeline should be empty for an empty array of entries") + } + + func testSingleEntry() { + let start = Date() + let end = start.addingTimeInterval(3600) // 1 hour later + let entries = [AutomationHistoryEntry(startDate: start, enabled: true)] + + let timeline = entries.toTimeline(from: start, to: end) + + XCTAssertEqual(timeline.count, 1, "Timeline should have one entry") + XCTAssertEqual(timeline[0].startDate, start) + XCTAssertEqual(timeline[0].endDate, end) + XCTAssertEqual(timeline[0].value, true) + } + + func testMultipleEntries() { + let start = Date() + let middleDate = start.addingTimeInterval(1800) // 30 minutes later + let end = start.addingTimeInterval(3600) // 1 hour later + let entries = [ + AutomationHistoryEntry(startDate: start, enabled: true), + AutomationHistoryEntry(startDate: middleDate, enabled: false) + ] + + let timeline = entries.toTimeline(from: start, to: end) + + XCTAssertEqual(timeline.count, 2, "Timeline should have two entries") + XCTAssertEqual(timeline[0].startDate, start) + XCTAssertEqual(timeline[0].endDate, middleDate) + XCTAssertEqual(timeline[0].value, true) + XCTAssertEqual(timeline[1].startDate, middleDate) + XCTAssertEqual(timeline[1].endDate, end) + XCTAssertEqual(timeline[1].value, false) + } + + func testEntriesOutsideRange() { + let start = Date() + let end = start.addingTimeInterval(3600) // 1 hour later + let beforeStart = start.addingTimeInterval(-1800) // 30 minutes before start + let afterEnd = end.addingTimeInterval(1800) // 30 minutes after end + let entries = [ + AutomationHistoryEntry(startDate: beforeStart, enabled: true), + AutomationHistoryEntry(startDate: afterEnd, enabled: false) + ] + + let timeline = entries.toTimeline(from: start, to: end) + + XCTAssertEqual(timeline.count, 1, "Timeline should have one entry") + XCTAssertEqual(timeline[0].startDate, start) + XCTAssertEqual(timeline[0].endDate, end) + XCTAssertEqual(timeline[0].value, true) + } + + func testConsecutiveEntriesWithSameValue() { + let start = Date() + let middle1 = start.addingTimeInterval(1200) // 20 minutes later + let middle2 = start.addingTimeInterval(2400) // 40 minutes later + let end = start.addingTimeInterval(3600) // 1 hour later + let entries = [ + AutomationHistoryEntry(startDate: start, enabled: true), + AutomationHistoryEntry(startDate: middle1, enabled: true), + AutomationHistoryEntry(startDate: middle2, enabled: false) + ] + + let timeline = entries.toTimeline(from: start, to: end) + + XCTAssertEqual(timeline.count, 2, "Timeline should have two entries") + XCTAssertEqual(timeline[0].startDate, start) + XCTAssertEqual(timeline[0].endDate, middle2) + XCTAssertEqual(timeline[0].value, true) + XCTAssertEqual(timeline[1].startDate, middle2) + XCTAssertEqual(timeline[1].endDate, end) + XCTAssertEqual(timeline[1].value, false) + } +} diff --git a/LoopTests/Models/TempBasalRecommendationTests.swift b/LoopTests/Models/TempBasalRecommendationTests.swift new file mode 100644 index 0000000000..8c0c7ab1f4 --- /dev/null +++ b/LoopTests/Models/TempBasalRecommendationTests.swift @@ -0,0 +1,26 @@ +// +// TempBasalRecommendationTests.swift +// LoopTests +// +// Created by Pete Schwamb on 2/21/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import XCTest +import LoopAlgorithm +@testable import Loop + +class TempBasalRecommendationTests: XCTestCase { + + func testCancel() { + let cancel = TempBasalRecommendation.cancel + XCTAssertEqual(cancel.unitsPerHour, 0) + XCTAssertEqual(cancel.duration, 0) + } + + func testInitializer() { + let tempBasalRecommendation = TempBasalRecommendation(unitsPerHour: 1.23, duration: 4.56) + XCTAssertEqual(tempBasalRecommendation.unitsPerHour, 1.23) + XCTAssertEqual(tempBasalRecommendation.duration, 4.56) + } +} diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index 7f2c421ebf..275b4c3743 100644 --- a/LoopTests/ViewModels/BolusEntryViewModelTests.swift +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -12,6 +12,8 @@ import LoopKit import LoopKitUI import SwiftUI import XCTest +import LoopAlgorithm + @testable import Loop @MainActor @@ -21,7 +23,7 @@ class BolusEntryViewModelTests: XCTestCase { static let now = ISO8601DateFormatter().date(from: "2020-03-11T07:00:00-0700")! static let exampleStartDate = now - .hours(2) static let exampleEndDate = now - .hours(1) - static fileprivate let exampleGlucoseValue = MockGlucoseValue(quantity: exampleManualGlucoseQuantity, startDate: exampleStartDate) + static fileprivate let exampleGlucoseValue = SimpleGlucoseValue(startDate: exampleStartDate, quantity: exampleManualGlucoseQuantity) static let exampleManualGlucoseQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 123.4) static let exampleManualGlucoseSample = HKQuantitySample(type: HKQuantityType.quantityType(forIdentifier: .bloodGlucose)!, @@ -57,7 +59,7 @@ class BolusEntryViewModelTests: XCTestCase { var bolusEntryViewModel: BolusEntryViewModel! fileprivate var delegate: MockBolusEntryViewModelDelegate! var now: Date = BolusEntryViewModelTests.now - + let mockOriginalCarbEntry = StoredCarbEntry( startDate: BolusEntryViewModelTests.exampleStartDate, quantity: BolusEntryViewModelTests.exampleCarbQuantity, @@ -87,6 +89,8 @@ class BolusEntryViewModelTests: XCTestCase { let queue = DispatchQueue(label: "BolusEntryViewModelTests") var saveAndDeliverSuccess = false + var mockDeliveryDelegate = MockDeliveryDelegate() + override func setUp(completion: @escaping (Error?) -> Void) { now = Self.now delegate = MockBolusEntryViewModelDelegate() @@ -113,6 +117,8 @@ class BolusEntryViewModelTests: XCTestCase { bolusEntryViewModel.maximumBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 10) + bolusEntryViewModel.deliveryDelegate = mockDeliveryDelegate + await bolusEntryViewModel.generateRecommendationAndStartObserving() } @@ -144,16 +150,7 @@ class BolusEntryViewModelTests: XCTestCase { } // MARK: updating state - - func testUpdateDisableManualGlucoseEntryIfNecessary() async throws { - bolusEntryViewModel.isManualGlucoseEntryEnabled = true - bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity - await bolusEntryViewModel.update() - XCTAssertFalse(bolusEntryViewModel.isManualGlucoseEntryEnabled) - XCTAssertNil(bolusEntryViewModel.manualGlucoseQuantity) - XCTAssertEqual(.glucoseNoLongerStale, bolusEntryViewModel.activeAlert) - } - + func testUpdateDisableManualGlucoseEntryIfNecessaryStaleGlucose() async throws { delegate.mostRecentGlucoseDataDate = Date.distantPast bolusEntryViewModel.isManualGlucoseEntryEnabled = true @@ -166,7 +163,7 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateGlucoseValues() async throws { XCTAssertEqual(0, bolusEntryViewModel.glucoseValues.count) - delegate.getGlucoseSamplesResponse = [StoredGlucoseSample(sample: Self.exampleCGMGlucoseSample)] + delegate.loopStateInput.glucoseHistory = [StoredGlucoseSample(sample: Self.exampleCGMGlucoseSample)] await bolusEntryViewModel.update() XCTAssertEqual(1, bolusEntryViewModel.glucoseValues.count) XCTAssertEqual([100.4], bolusEntryViewModel.glucoseValues.map { @@ -176,10 +173,10 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateGlucoseValuesWithManual() async throws { XCTAssertEqual(0, bolusEntryViewModel.glucoseValues.count) - bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity - delegate.getGlucoseSamplesResponse = [StoredGlucoseSample(sample: Self.exampleCGMGlucoseSample)] + bolusEntryViewModel.manualGlucoseQuantity = .glucose(value: 123) + delegate.loopStateInput.glucoseHistory = [.mock(100, at: now.addingTimeInterval(-.minutes(5)))] await bolusEntryViewModel.update() - XCTAssertEqual([100.4, 123.4], bolusEntryViewModel.glucoseValues.map { + XCTAssertEqual([100, 123], bolusEntryViewModel.glucoseValues.map { return $0.quantity.doubleValue(for: .milligramsPerDeciliter) }) } @@ -191,22 +188,26 @@ class BolusEntryViewModelTests: XCTestCase { } func testUpdatePredictedGlucoseValues() async throws { - let prediction = [PredictedGlucoseValue(startDate: Self.exampleStartDate, quantity: Self.exampleCGMGlucoseQuantity)] - delegate.loopState.predictGlucoseValueResult = prediction - await bolusEntryViewModel.update() - XCTAssertEqual(prediction, bolusEntryViewModel.predictedGlucoseValues.map { PredictedGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) }) + do { + let input = try await delegate.fetchData(for: Self.exampleStartDate, disablingPreMeal: false, ensureDosingCoverageStart: nil) + let prediction = try input.predictGlucose() + await bolusEntryViewModel.update() + XCTAssertEqual(prediction, bolusEntryViewModel.predictedGlucoseValues.map { PredictedGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) }) + } catch { + XCTFail("Unable to generate prediction") + } } func testUpdatePredictedGlucoseValuesWithManual() async throws { - let prediction = [PredictedGlucoseValue(startDate: Self.exampleStartDate, quantity: Self.exampleCGMGlucoseQuantity)] - delegate.loopState.predictGlucoseValueResult = prediction - await bolusEntryViewModel.update() - - bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity - XCTAssertEqual(prediction, - bolusEntryViewModel.predictedGlucoseValues.map { - PredictedGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) - }) + do { + let input = try await delegate.fetchData(for: Self.exampleStartDate, disablingPreMeal: false, ensureDosingCoverageStart: nil) + let prediction = try input.predictGlucose() + await bolusEntryViewModel.update() + bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity + XCTAssertEqual(prediction, bolusEntryViewModel.predictedGlucoseValues.map { PredictedGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) }) + } catch { + XCTFail("Unable to generate prediction") + } } func testUpdateSettings() async throws { @@ -218,20 +219,20 @@ class BolusEntryViewModelTests: XCTestCase { RepeatingScheduleValue(startTime: TimeInterval(28800), value: DoubleRange(minValue: 90, maxValue: 100)), RepeatingScheduleValue(startTime: TimeInterval(75600), value: DoubleRange(minValue: 100, maxValue: 110)) ], timeZone: .utcTimeZone)! - var newSettings = LoopSettings(dosingEnabled: true, + let newSettings = StoredSettings(dosingEnabled: true, glucoseTargetRangeSchedule: newGlucoseTargetRangeSchedule, maximumBasalRatePerHour: 1.0, maximumBolus: 10.0, suspendThreshold: GlucoseThreshold(unit: .milligramsPerDeciliter, value: 100.0)) let settings = TemporaryScheduleOverrideSettings(unit: .millimolesPerLiter, targetRange: nil, insulinNeedsScaleFactor: nil) - newSettings.preMealOverride = TemporaryScheduleOverride(context: .preMeal, settings: settings, startDate: Self.exampleStartDate, duration: .indefinite, enactTrigger: .local, syncIdentifier: UUID()) - newSettings.scheduleOverride = TemporaryScheduleOverride(context: .custom, settings: settings, startDate: Self.exampleStartDate, duration: .indefinite, enactTrigger: .local, syncIdentifier: UUID()) + delegate.preMealOverride = TemporaryScheduleOverride(context: .preMeal, settings: settings, startDate: Self.exampleStartDate, duration: .indefinite, enactTrigger: .local, syncIdentifier: UUID()) + delegate.scheduleOverride = TemporaryScheduleOverride(context: .custom, settings: settings, startDate: Self.exampleStartDate, duration: .indefinite, enactTrigger: .local, syncIdentifier: UUID()) delegate.settings = newSettings bolusEntryViewModel.updateSettings() await bolusEntryViewModel.update() - XCTAssertEqual(newSettings.preMealOverride, bolusEntryViewModel.preMealOverride) - XCTAssertEqual(newSettings.scheduleOverride, bolusEntryViewModel.scheduleOverride) + XCTAssertEqual(delegate.preMealOverride, bolusEntryViewModel.preMealOverride) + XCTAssertEqual(delegate.scheduleOverride, bolusEntryViewModel.scheduleOverride) XCTAssertEqual(newGlucoseTargetRangeSchedule, bolusEntryViewModel.targetGlucoseSchedule) } @@ -245,78 +246,85 @@ class BolusEntryViewModelTests: XCTestCase { RepeatingScheduleValue(startTime: TimeInterval(28800), value: DoubleRange(minValue: 90, maxValue: 100)), RepeatingScheduleValue(startTime: TimeInterval(75600), value: DoubleRange(minValue: 100, maxValue: 110)) ], timeZone: .utcTimeZone)! - var newSettings = LoopSettings(dosingEnabled: true, + let newSettings = StoredSettings(dosingEnabled: true, glucoseTargetRangeSchedule: newGlucoseTargetRangeSchedule, maximumBasalRatePerHour: 1.0, maximumBolus: 10.0, suspendThreshold: GlucoseThreshold(unit: .milligramsPerDeciliter, value: 100.0)) - newSettings.preMealOverride = Self.examplePreMealOverride - newSettings.scheduleOverride = Self.exampleCustomScheduleOverride + delegate.preMealOverride = Self.examplePreMealOverride + delegate.scheduleOverride = Self.exampleCustomScheduleOverride delegate.settings = newSettings bolusEntryViewModel.updateSettings() // Pre-meal override should be ignored if we have carbs (LOOP-1964), and cleared in settings - XCTAssertEqual(newSettings.scheduleOverride, bolusEntryViewModel.scheduleOverride) + XCTAssertEqual(delegate.scheduleOverride, bolusEntryViewModel.scheduleOverride) XCTAssertEqual(newGlucoseTargetRangeSchedule, bolusEntryViewModel.targetGlucoseSchedule) // ... but restored if we cancel without bolusing bolusEntryViewModel = nil } - func testManualGlucoseChangesPredictedGlucoseValues() async throws { - bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity - let prediction = [PredictedGlucoseValue(startDate: Self.exampleStartDate, quantity: Self.exampleCGMGlucoseQuantity)] - delegate.loopState.predictGlucoseValueResult = prediction + func testManualGlucoseIncludedInAlgorithmRun() async throws { + bolusEntryViewModel.manualGlucoseQuantity = .glucose(value: 123) await bolusEntryViewModel.update() - XCTAssertEqual(prediction, - bolusEntryViewModel.predictedGlucoseValues.map { - PredictedGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) - }) + XCTAssertEqual(123, delegate.manualGlucoseSampleForBolusRecommendation?.quantity.doubleValue(for: .milligramsPerDeciliter)) } func testUpdateInsulinOnBoard() async throws { - delegate.insulinOnBoardResult = .success(InsulinValue(startDate: Self.exampleStartDate, value: 1.5)) + delegate.activeInsulin = InsulinValue(startDate: Self.exampleStartDate, value: 1.5) XCTAssertNil(bolusEntryViewModel.activeInsulin) await bolusEntryViewModel.update() XCTAssertEqual(HKQuantity(unit: .internationalUnit(), doubleValue: 1.5), bolusEntryViewModel.activeInsulin) } func testUpdateCarbsOnBoard() async throws { - delegate.carbsOnBoardResult = .success(CarbValue(startDate: Self.exampleStartDate, endDate: Self.exampleEndDate, value: Self.exampleCarbQuantity.doubleValue(for: .gram()))) + delegate.activeCarbs = CarbValue(startDate: Self.exampleStartDate, endDate: Self.exampleEndDate, value: Self.exampleCarbQuantity.doubleValue(for: .gram())) XCTAssertNil(bolusEntryViewModel.activeCarbs) await bolusEntryViewModel.update() XCTAssertEqual(Self.exampleCarbQuantity, bolusEntryViewModel.activeCarbs) } func testUpdateCarbsOnBoardFailure() async throws { - delegate.carbsOnBoardResult = .failure(CarbStore.CarbStoreError.notConfigured) + delegate.activeCarbs = nil await bolusEntryViewModel.update() XCTAssertNil(bolusEntryViewModel.activeCarbs) } func testUpdateRecommendedBolusNoNotice() async throws { - await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) + let originalCarbEntry = StoredCarbEntry.mock(50, at: now.addingTimeInterval(-.minutes(5))) + let editedCarbEntry = NewCarbEntry.mock(40, at: now.addingTimeInterval(-.minutes(5))) + + delegate.loopStateInput.carbEntries = [originalCarbEntry] + + await setUpViewModel(originalCarbEntry: originalCarbEntry, potentialCarbEntry: editedCarbEntry) + XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) - let recommendation = ManualBolusRecommendation(amount: 1.25, pendingInsulin: 4.321) - delegate.loopState.bolusRecommendationResult = recommendation + let recommendation = ManualBolusRecommendation(amount: 1.25) + delegate.algorithmOutput.recommendationResult = .success(.init(manual: recommendation)) + await bolusEntryViewModel.update() + XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus XCTAssertNotNil(recommendedBolus) XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit())) - let consideringPotentialCarbEntryPassed = try XCTUnwrap(delegate.loopState.consideringPotentialCarbEntryPassed) - XCTAssertEqual(mockPotentialCarbEntry, consideringPotentialCarbEntryPassed) - let replacingCarbEntryPassed = try XCTUnwrap(delegate.loopState.replacingCarbEntryPassed) - XCTAssertEqual(mockOriginalCarbEntry, replacingCarbEntryPassed) + + XCTAssertEqual(delegate.originalCarbEntryForBolusRecommendation?.quantity, originalCarbEntry.quantity) + XCTAssertEqual(delegate.potentialCarbEntryForBolusRecommendation?.quantity, editedCarbEntry.quantity) + XCTAssertNil(delegate.manualGlucoseSampleForBolusRecommendation) + XCTAssertNil(bolusEntryViewModel.activeNotice) } func testUpdateRecommendedBolusWithNotice() async throws { delegate.settings.suspendThreshold = GlucoseThreshold(unit: .milligramsPerDeciliter, value: Self.exampleCGMGlucoseQuantity.doubleValue(for: .milligramsPerDeciliter)) XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) - let recommendation = ManualBolusRecommendation(amount: 1.25, pendingInsulin: 4.321, notice: BolusRecommendationNotice.glucoseBelowSuspendThreshold(minGlucose: Self.exampleGlucoseValue)) - delegate.loopState.bolusRecommendationResult = recommendation + let recommendation = ManualBolusRecommendation( + amount: 1.25, + notice: BolusRecommendationNotice.glucoseBelowSuspendThreshold(minGlucose: Self.exampleGlucoseValue) + ) + delegate.algorithmOutput.recommendationResult = .success(.init(manual: recommendation)) await bolusEntryViewModel.update() XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus @@ -328,8 +336,8 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateRecommendedBolusWithNoticeMissingSuspendThreshold() async throws { XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) delegate.settings.suspendThreshold = nil - let recommendation = ManualBolusRecommendation(amount: 1.25, pendingInsulin: 4.321, notice: BolusRecommendationNotice.glucoseBelowSuspendThreshold(minGlucose: Self.exampleGlucoseValue)) - delegate.loopState.bolusRecommendationResult = recommendation + let recommendation = ManualBolusRecommendation(amount: 1.25, notice: BolusRecommendationNotice.glucoseBelowSuspendThreshold(minGlucose: Self.exampleGlucoseValue)) + delegate.algorithmOutput.recommendationResult = .success(.init(manual: recommendation)) await bolusEntryViewModel.update() XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus @@ -340,8 +348,8 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateRecommendedBolusWithOtherNotice() async throws { XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) - let recommendation = ManualBolusRecommendation(amount: 1.25, pendingInsulin: 4.321, notice: BolusRecommendationNotice.currentGlucoseBelowTarget(glucose: Self.exampleGlucoseValue)) - delegate.loopState.bolusRecommendationResult = recommendation + let recommendation = ManualBolusRecommendation(amount: 1.25, notice: BolusRecommendationNotice.currentGlucoseBelowTarget(glucose: Self.exampleGlucoseValue)) + delegate.algorithmOutput.recommendationResult = .success(.init(manual: recommendation)) await bolusEntryViewModel.update() XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus @@ -352,7 +360,7 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateRecommendedBolusThrowsMissingDataError() async throws { XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) - delegate.loopState.bolusRecommendationError = LoopError.missingDataError(.glucose) + delegate.algorithmOutput.recommendationResult = .failure(LoopError.missingDataError(.glucose)) await bolusEntryViewModel.update() XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus @@ -362,7 +370,7 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateRecommendedBolusThrowsPumpDataTooOld() async throws { XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) - delegate.loopState.bolusRecommendationError = LoopError.pumpDataTooOld(date: now) + delegate.algorithmOutput.recommendationResult = .failure(LoopError.pumpDataTooOld(date: now)) await bolusEntryViewModel.update() XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus @@ -372,7 +380,7 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateRecommendedBolusThrowsGlucoseTooOld() async throws { XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) - delegate.loopState.bolusRecommendationError = LoopError.glucoseTooOld(date: now) + delegate.algorithmOutput.recommendationResult = .failure(LoopError.glucoseTooOld(date: now)) await bolusEntryViewModel.update() XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus @@ -382,7 +390,7 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateRecommendedBolusThrowsInvalidFutureGlucose() async throws { XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) - delegate.loopState.bolusRecommendationError = LoopError.invalidFutureGlucose(date: now) + delegate.algorithmOutput.recommendationResult = .failure(LoopError.invalidFutureGlucose(date: now)) await bolusEntryViewModel.update() XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus @@ -392,7 +400,7 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateRecommendedBolusThrowsOtherError() async throws { XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) - delegate.loopState.bolusRecommendationError = LoopError.pumpSuspended + delegate.algorithmOutput.recommendationResult = .failure(LoopError.pumpSuspended) await bolusEntryViewModel.update() XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus @@ -401,20 +409,31 @@ class BolusEntryViewModelTests: XCTestCase { } func testUpdateRecommendedBolusWithManual() async throws { - await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) - bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity + let originalCarbEntry = StoredCarbEntry.mock(50, at: now.addingTimeInterval(-.minutes(5))) + let editedCarbEntry = NewCarbEntry.mock(40, at: now.addingTimeInterval(-.minutes(5))) + + delegate.loopStateInput.carbEntries = [originalCarbEntry] + + await setUpViewModel(originalCarbEntry: originalCarbEntry, potentialCarbEntry: editedCarbEntry) + + let manualGlucoseQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 123) + + bolusEntryViewModel.manualGlucoseQuantity = manualGlucoseQuantity XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) - let recommendation = ManualBolusRecommendation(amount: 1.25, pendingInsulin: 4.321) - delegate.loopState.bolusRecommendationResult = recommendation + + let recommendation = ManualBolusRecommendation(amount: 1.25) + delegate.algorithmOutput.recommendationResult = .success(.init(manual: recommendation)) await bolusEntryViewModel.update() + XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus XCTAssertNotNil(recommendedBolus) XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit())) - let consideringPotentialCarbEntryPassed = try XCTUnwrap(delegate.loopState.consideringPotentialCarbEntryPassed) - XCTAssertEqual(mockPotentialCarbEntry, consideringPotentialCarbEntryPassed) - let replacingCarbEntryPassed = try XCTUnwrap(delegate.loopState.replacingCarbEntryPassed) - XCTAssertEqual(mockOriginalCarbEntry, replacingCarbEntryPassed) + + XCTAssertEqual(delegate.potentialCarbEntryForBolusRecommendation, editedCarbEntry) + XCTAssertEqual(delegate.originalCarbEntryForBolusRecommendation, originalCarbEntry) + XCTAssertEqual(delegate.manualGlucoseSampleForBolusRecommendation?.quantity, manualGlucoseQuantity) + XCTAssertNil(bolusEntryViewModel.activeNotice) } @@ -508,8 +527,6 @@ class BolusEntryViewModelTests: XCTestCase { bolusEntryViewModel.enteredBolus = BolusEntryViewModelTests.noBolus - delegate.addGlucoseSamplesResult = .success([Self.exampleManualStoredGlucoseSample]) - let saveAndDeliverSuccess = await bolusEntryViewModel.saveAndDeliver() let expectedGlucoseSample = NewGlucoseSample(date: now, quantity: Self.exampleManualGlucoseQuantity, condition: nil, trend: nil, trendRate: nil, isDisplayOnly: false, wasUserEntered: true, syncIdentifier: mockUUID) @@ -534,7 +551,6 @@ class BolusEntryViewModelTests: XCTestCase { func testSaveCarbGlucoseNoBolus() async throws { await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) - delegate.addGlucoseSamplesResult = .success([Self.exampleManualStoredGlucoseSample]) delegate.addCarbEntryResult = .success(mockFinalCarbEntry) try await saveAndDeliver(BolusEntryViewModelTests.noBolus) @@ -557,8 +573,6 @@ class BolusEntryViewModelTests: XCTestCase { func testSaveManualGlucoseAndBolus() async throws { bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity - delegate.addGlucoseSamplesResult = .success([Self.exampleManualStoredGlucoseSample]) - try await saveAndDeliver(BolusEntryViewModelTests.exampleBolusQuantity) let expectedGlucoseSample = NewGlucoseSample(date: now, quantity: Self.exampleManualGlucoseQuantity, condition: nil, trend: nil, trendRate: nil, isDisplayOnly: false, wasUserEntered: true, syncIdentifier: mockUUID) @@ -609,13 +623,14 @@ class BolusEntryViewModelTests: XCTestCase { RepeatingScheduleValue(startTime: TimeInterval(28800), value: DoubleRange(minValue: 90, maxValue: 100)), RepeatingScheduleValue(startTime: TimeInterval(75600), value: DoubleRange(minValue: 100, maxValue: 110)) ], timeZone: .utcTimeZone)! - var newSettings = LoopSettings(dosingEnabled: true, + let newSettings = StoredSettings(dosingEnabled: true, glucoseTargetRangeSchedule: newGlucoseTargetRangeSchedule, maximumBasalRatePerHour: 1.0, maximumBolus: 10.0, suspendThreshold: GlucoseThreshold(unit: .milligramsPerDeciliter, value: 100.0)) - newSettings.preMealOverride = Self.examplePreMealOverride - newSettings.scheduleOverride = Self.exampleCustomScheduleOverride + + delegate.preMealOverride = Self.examplePreMealOverride + delegate.scheduleOverride = Self.exampleCustomScheduleOverride delegate.settings = newSettings bolusEntryViewModel.updateSettings() @@ -633,7 +648,6 @@ class BolusEntryViewModelTests: XCTestCase { await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity - delegate.addGlucoseSamplesResult = .success([Self.exampleManualStoredGlucoseSample]) delegate.addCarbEntryResult = .success(mockFinalCarbEntry) try await saveAndDeliver(BolusEntryViewModelTests.exampleBolusQuantity) @@ -798,173 +812,154 @@ class BolusEntryViewModelTests: XCTestCase { bolusEntryViewModel.enteredBolus = Self.exampleBolusQuantity XCTAssertEqual(.saveAndDeliver, bolusEntryViewModel.actionButtonAction) } -} - -// MARK: utilities - -fileprivate class MockLoopState: LoopState { - - var carbsOnBoard: CarbValue? - - var insulinOnBoard: InsulinValue? - - var error: LoopError? - - var insulinCounteractionEffects: [GlucoseEffectVelocity] = [] - - var predictedGlucose: [PredictedGlucoseValue]? - - var predictedGlucoseIncludingPendingInsulin: [PredictedGlucoseValue]? - - var recommendedAutomaticDose: (recommendation: AutomaticDoseRecommendation, date: Date)? - - var retrospectiveGlucoseDiscrepancies: [GlucoseChange]? - - var totalRetrospectiveCorrection: HKQuantity? - - var predictGlucoseValueResult: [PredictedGlucoseValue] = [] - func predictGlucose(using inputs: PredictionInputEffect, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, includingPendingInsulin: Bool, considerPositiveVelocityAndRC: Bool) throws -> [PredictedGlucoseValue] { - return predictGlucoseValueResult - } - func predictGlucoseFromManualGlucose(_ glucose: NewGlucoseSample, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, includingPendingInsulin: Bool, considerPositiveVelocityAndRC: Bool) throws -> [PredictedGlucoseValue] { - return predictGlucoseValueResult - } - - var bolusRecommendationResult: ManualBolusRecommendation? - var bolusRecommendationError: Error? - var consideringPotentialCarbEntryPassed: NewCarbEntry?? - var replacingCarbEntryPassed: StoredCarbEntry?? - func recommendBolus(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { - consideringPotentialCarbEntryPassed = potentialCarbEntry - replacingCarbEntryPassed = replacedCarbEntry - if let error = bolusRecommendationError { throw error } - return bolusRecommendationResult - } - - func recommendBolusForManualGlucose(_ glucose: NewGlucoseSample, consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { - consideringPotentialCarbEntryPassed = potentialCarbEntry - replacingCarbEntryPassed = replacedCarbEntry - if let error = bolusRecommendationError { throw error } - return bolusRecommendationResult - } } + public enum BolusEntryViewTestError: Error { case responseUndefined } fileprivate class MockBolusEntryViewModelDelegate: BolusEntryViewModelDelegate { - - fileprivate var loopState = MockLoopState() - - private let dataAccessQueue = DispatchQueue(label: "com.loopKit.tests.dataAccessQueue", qos: .utility) - - - func updateRemoteRecommendation() { + func insulinModel(for type: LoopKit.InsulinType?) -> InsulinModel { + return ExponentialInsulinModelPreset.rapidActingAdult } - func roundBolusVolume(units: Double) -> Double { - // 0.05 units for rates between 0.05-30U/hr - // 0 is not a supported bolus volume - let supportedBolusVolumes = (1...600).map { Double($0) / 20.0 } - return ([0.0] + supportedBolusVolumes).enumerated().min( by: { abs($0.1 - units) < abs($1.1 - units) } )!.1 + var settings = StoredSettings( + dosingEnabled: true, + glucoseTargetRangeSchedule: BolusEntryViewModelTests.exampleGlucoseRangeSchedule, + maximumBasalRatePerHour: 3.0, + maximumBolus: 10.0, + suspendThreshold: GlucoseThreshold(unit: .internationalUnit(), value: 75)) + { + didSet { + NotificationCenter.default.post(name: .LoopDataUpdated, object: nil, userInfo: [ + LoopDataManager.LoopUpdateContextKey: LoopUpdateContext.preferences.rawValue + ]) + } } - func insulinActivityDuration(for type: InsulinType?) -> TimeInterval { - return .hours(6) + .minutes(10) - } + + var scheduleOverride: LoopKit.TemporaryScheduleOverride? + + var preMealOverride: LoopKit.TemporaryScheduleOverride? var pumpInsulinType: InsulinType? + + var mostRecentGlucoseDataDate: Date? + + var mostRecentPumpDataDate: Date? - var displayGlucosePreference: DisplayGlucosePreference = DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) - - func withLoopState(do block: @escaping (LoopState) -> Void) { - dataAccessQueue.async { - block(self.loopState) - } - } - - func saveGlucose(sample: LoopKit.NewGlucoseSample) async -> LoopKit.StoredGlucoseSample? { - glucoseSamplesAdded.append(sample) - return StoredGlucoseSample(sample: sample.quantitySample) + var loopStateInput = StoredDataAlgorithmInput( + glucoseHistory: [], + doses: [], + carbEntries: [], + predictionStart: Date(), + basal: [], + sensitivity: [], + carbRatio: [], + target: [], + suspendThreshold: nil, + maxBolus: 3, + maxBasalRate: 6, + useIntegralRetrospectiveCorrection: false, + includePositiveVelocityAndRC: true, + carbAbsorptionModel: .piecewiseLinear, + recommendationInsulinModel: ExponentialInsulinModelPreset.rapidActingAdult, + recommendationType: .manualBolus, + automaticBolusApplicationFactor: 0.4 + ) + + func fetchData(for baseTime: Date, disablingPreMeal: Bool, ensureDosingCoverageStart: Date?) async throws -> StoredDataAlgorithmInput { + loopStateInput.predictionStart = baseTime + return loopStateInput + } + + func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool) -> GlucoseRangeSchedule? { + return nil } - - var glucoseSamplesAdded = [NewGlucoseSample]() - var addGlucoseSamplesResult: Swift.Result<[StoredGlucoseSample], Error> = .failure(BolusEntryViewTestError.responseUndefined) - func addGlucoseSamples(_ samples: [NewGlucoseSample], completion: ((Swift.Result<[StoredGlucoseSample], Error>) -> Void)?) { - glucoseSamplesAdded.append(contentsOf: samples) - completion?(addGlucoseSamplesResult) + + func insulinActivityDuration(for type: InsulinType?) -> TimeInterval { + return InsulinMath.defaultInsulinActivityDuration } var carbEntriesAdded = [(NewCarbEntry, StoredCarbEntry?)]() - var addCarbEntryResult: Result = .failure(BolusEntryViewTestError.responseUndefined) - func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?, completion: @escaping (Result) -> Void) { + var addCarbEntryResult: Result = .failure(BolusEntryViewTestError.responseUndefined) + func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?) async throws -> StoredCarbEntry { carbEntriesAdded.append((carbEntry, replacingEntry)) - completion(addCarbEntryResult) + switch addCarbEntryResult { + case .success(let success): + return success + case .failure(let failure): + throw failure + } + } + + var glucoseSamplesAdded = [NewGlucoseSample]() + var saveGlucoseError: Error? + func saveGlucose(sample: NewGlucoseSample) async throws -> StoredGlucoseSample { + glucoseSamplesAdded.append(sample) + if let saveGlucoseError { + throw saveGlucoseError + } else { + return sample.asStoredGlucoseStample + } } var bolusDosingDecisionsAdded = [(BolusDosingDecision, Date)]() - func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) { + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) async { bolusDosingDecisionsAdded.append((bolusDosingDecision, date)) } - + var enactedBolusUnits: Double? var enactedBolusActivationType: BolusActivationType? - func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (Error?) -> Void) { + func enactBolus(units: Double, activationType: BolusActivationType) async throws { enactedBolusUnits = units enactedBolusActivationType = activationType } - - var getGlucoseSamplesResponse: [StoredGlucoseSample] = [] - func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) { - completion(.success(getGlucoseSamplesResponse)) - } - - var insulinOnBoardResult: DoseStoreResult? - func insulinOnBoard(at date: Date, completion: @escaping (DoseStoreResult) -> Void) { - if let insulinOnBoardResult = insulinOnBoardResult { - completion(insulinOnBoardResult) - } else { - completion(.failure(.configurationError)) - } + + var activeInsulin: InsulinValue? + + var activeCarbs: CarbValue? + + var prediction: [PredictedGlucoseValue] = [] + var lastGeneratePredictionInput: StoredDataAlgorithmInput? + + func generatePrediction(input: StoredDataAlgorithmInput) throws -> [PredictedGlucoseValue] { + lastGeneratePredictionInput = input + return prediction } - - var carbsOnBoardResult: CarbStoreResult? - func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult) -> Void) { - if let carbsOnBoardResult = carbsOnBoardResult { - completion(carbsOnBoardResult) + + var algorithmOutput: AlgorithmOutput = AlgorithmOutput( + recommendationResult: .success(.init()), + predictedGlucose: [], + effects: LoopAlgorithmEffects.emptyMock, + dosesRelativeToBasal: [], + activeInsulin: nil, + activeCarbs: nil + ) + + var manualGlucoseSampleForBolusRecommendation: NewGlucoseSample? + var potentialCarbEntryForBolusRecommendation: NewCarbEntry? + var originalCarbEntryForBolusRecommendation: StoredCarbEntry? + + func recommendManualBolus( + manualGlucoseSample: NewGlucoseSample?, + potentialCarbEntry: NewCarbEntry?, + originalCarbEntry: StoredCarbEntry? + ) async throws -> ManualBolusRecommendation? { + + manualGlucoseSampleForBolusRecommendation = manualGlucoseSample + potentialCarbEntryForBolusRecommendation = potentialCarbEntry + originalCarbEntryForBolusRecommendation = originalCarbEntry + + switch algorithmOutput.recommendationResult { + case .success(let recommendation): + return recommendation.manual + case .failure(let error): + throw error } } - - var ensureCurrentPumpDataCompletion: ((Date?) -> Void)? - func ensureCurrentPumpData(completion: @escaping (Date?) -> Void) { - ensureCurrentPumpDataCompletion = completion - } - - var mostRecentGlucoseDataDate: Date? - - var mostRecentPumpDataDate: Date? - - var isPumpConfigured: Bool = true - - var preferredGlucoseUnit: HKUnit = .milligramsPerDeciliter - - var insulinModel: InsulinModel? = MockInsulinModel() - - var settings: LoopSettings = LoopSettings( - dosingEnabled: true, - glucoseTargetRangeSchedule: BolusEntryViewModelTests.exampleGlucoseRangeSchedule, - maximumBasalRatePerHour: 3.0, - maximumBolus: 10.0, - suspendThreshold: GlucoseThreshold(unit: .internationalUnit(), value: 75)) { - didSet { - NotificationCenter.default.post(name: .LoopDataUpdated, object: nil, userInfo: [ - LoopDataManager.LoopUpdateContextKey: LoopDataManager.LoopUpdateContext.preferences.rawValue - ]) - } - } - } fileprivate struct MockInsulinModel: InsulinModel { @@ -1012,3 +1007,40 @@ extension ManualBolusRecommendationWithDate: Equatable { return lhs.recommendation == rhs.recommendation && lhs.date == rhs.date } } + +extension LoopAlgorithmEffects { + public static var emptyMock: LoopAlgorithmEffects { + return LoopAlgorithmEffects( + insulin: [], + carbs: [], + carbStatus: [], + retrospectiveCorrection: [], + momentum: [], + insulinCounteraction: [], + retrospectiveGlucoseDiscrepancies: [] + ) + } +} + +extension NewCarbEntry { + static func mock(_ grams: Double, at date: Date) -> NewCarbEntry { + NewCarbEntry( + quantity: .init(unit: .gram(), doubleValue: grams), + startDate: date, + foodType: nil, + absorptionTime: nil + ) + } +} + +extension StoredCarbEntry { + static func mock(_ grams: Double, at date: Date) -> StoredCarbEntry { + StoredCarbEntry(startDate: date, quantity: .init(unit: .gram(), doubleValue: grams)) + } +} + +extension StoredGlucoseSample { + static func mock(_ value: Double, at date: Date) -> StoredGlucoseSample { + StoredGlucoseSample(startDate: date, quantity: .glucose(value: value)) + } +} diff --git a/LoopTests/ViewModels/CGMStatusHUDViewModelTests.swift b/LoopTests/ViewModels/CGMStatusHUDViewModelTests.swift index ea743eb008..9728578c0c 100644 --- a/LoopTests/ViewModels/CGMStatusHUDViewModelTests.swift +++ b/LoopTests/ViewModels/CGMStatusHUDViewModelTests.swift @@ -14,12 +14,9 @@ import LoopKit class CGMStatusHUDViewModelTests: XCTestCase { private var viewModel: CGMStatusHUDViewModel! - private var staleGlucoseValueHandlerWasCalled = false - private var testExpect: XCTestExpectation! override func setUpWithError() throws { - staleGlucoseValueHandlerWasCalled = false - viewModel = CGMStatusHUDViewModel(staleGlucoseValueHandler: staleGlucoseValueHandler) + viewModel = CGMStatusHUDViewModel() } override func tearDownWithError() throws { @@ -45,14 +42,13 @@ class CGMStatusHUDViewModelTests: XCTestCase { isLocal: true, glucoseRangeCategory: .urgentLow) let glucoseStartDate = Date() - let staleGlucoseAge: TimeInterval = .minutes(15) viewModel.setGlucoseQuantity(90, at: glucoseStartDate, unit: .milligramsPerDeciliter, - staleGlucoseAge: staleGlucoseAge, glucoseDisplay: glucoseDisplay, wasUserEntered: false, - isDisplayOnly: false) + isDisplayOnly: false, + isGlucoseValueStale: false) XCTAssertNil(viewModel.manualGlucoseTrendIconOverride) XCTAssertNil(viewModel.statusHighlight) @@ -70,14 +66,13 @@ class CGMStatusHUDViewModelTests: XCTestCase { isLocal: true, glucoseRangeCategory: .urgentLow) let glucoseStartDate = Date() - let staleGlucoseAge: TimeInterval = .minutes(-1) viewModel.setGlucoseQuantity(90, at: glucoseStartDate, unit: .milligramsPerDeciliter, - staleGlucoseAge: staleGlucoseAge, glucoseDisplay: glucoseDisplay, wasUserEntered: false, - isDisplayOnly: false) + isDisplayOnly: false, + isGlucoseValueStale: true) XCTAssertNil(viewModel.manualGlucoseTrendIconOverride) XCTAssertNil(viewModel.statusHighlight) @@ -90,35 +85,6 @@ class CGMStatusHUDViewModelTests: XCTestCase { XCTAssertEqual(viewModel.unitsString, HKUnit.milligramsPerDeciliter.localizedShortUnitString) } - func testSetGlucoseQuantityCGMStaleDelayed() { - testExpect = self.expectation(description: #function) - let glucoseDisplay = TestGlucoseDisplay(isStateValid: true, - trendType: .down, - trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), - isLocal: true, - glucoseRangeCategory: .urgentLow) - let glucoseStartDate = Date() - let staleGlucoseAge: TimeInterval = .seconds(0.01) - viewModel.setGlucoseQuantity(90, - at: glucoseStartDate, - unit: .milligramsPerDeciliter, - staleGlucoseAge: staleGlucoseAge, - glucoseDisplay: glucoseDisplay, - wasUserEntered: false, - isDisplayOnly: false) - wait(for: [testExpect], timeout: 1.0) - XCTAssertTrue(staleGlucoseValueHandlerWasCalled) - XCTAssertNil(viewModel.manualGlucoseTrendIconOverride) - XCTAssertNil(viewModel.statusHighlight) - XCTAssertEqual(viewModel.glucoseValueString, "– – –") - XCTAssertNil(viewModel.trend) - XCTAssertNotEqual(viewModel.glucoseTrendTintColor, glucoseDisplay.glucoseRangeCategory?.trendColor) - XCTAssertEqual(viewModel.glucoseTrendTintColor, .glucoseTintColor) - XCTAssertNotEqual(viewModel.glucoseValueTintColor, glucoseDisplay.glucoseRangeCategory?.glucoseColor) - XCTAssertEqual(viewModel.glucoseValueTintColor, .label) - XCTAssertEqual(viewModel.unitsString, HKUnit.milligramsPerDeciliter.localizedShortUnitString) - } - func testSetGlucoseQuantityManualGlucose() { let glucoseDisplay = TestGlucoseDisplay(isStateValid: true, trendType: .down, @@ -126,14 +92,13 @@ class CGMStatusHUDViewModelTests: XCTestCase { isLocal: true, glucoseRangeCategory: .urgentLow) let glucoseStartDate = Date() - let staleGlucoseAge: TimeInterval = .minutes(15) viewModel.setGlucoseQuantity(90, at: glucoseStartDate, unit: .milligramsPerDeciliter, - staleGlucoseAge: staleGlucoseAge, glucoseDisplay: glucoseDisplay, wasUserEntered: true, - isDisplayOnly: false) + isDisplayOnly: false, + isGlucoseValueStale: false) XCTAssertNil(viewModel.manualGlucoseTrendIconOverride) XCTAssertNil(viewModel.statusHighlight) @@ -152,14 +117,13 @@ class CGMStatusHUDViewModelTests: XCTestCase { isLocal: true, glucoseRangeCategory: .urgentLow) let glucoseStartDate = Date() - let staleGlucoseAge: TimeInterval = .minutes(15) viewModel.setGlucoseQuantity(90, at: glucoseStartDate, unit: .milligramsPerDeciliter, - staleGlucoseAge: staleGlucoseAge, glucoseDisplay: glucoseDisplay, wasUserEntered: true, - isDisplayOnly: true) + isDisplayOnly: true, + isGlucoseValueStale: false) XCTAssertNil(viewModel.manualGlucoseTrendIconOverride) XCTAssertEqual(viewModel.glucoseValueString, "90") @@ -191,14 +155,13 @@ class CGMStatusHUDViewModelTests: XCTestCase { isLocal: true, glucoseRangeCategory: .urgentLow) let glucoseStartDate = Date() - let staleGlucoseAge: TimeInterval = .minutes(15) viewModel.setGlucoseQuantity(90, at: glucoseStartDate, unit: .milligramsPerDeciliter, - staleGlucoseAge: staleGlucoseAge, glucoseDisplay: glucoseDisplay, wasUserEntered: true, - isDisplayOnly: false) + isDisplayOnly: false, + isGlucoseValueStale: false) XCTAssertEqual(viewModel.glucoseValueString, "90") XCTAssertNil(viewModel.trend) @@ -222,14 +185,13 @@ class CGMStatusHUDViewModelTests: XCTestCase { trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), isLocal: true, glucoseRangeCategory: .urgentLow) - let staleGlucoseAge: TimeInterval = .minutes(15) viewModel.setGlucoseQuantity(90, at: Date(), unit: .milligramsPerDeciliter, - staleGlucoseAge: staleGlucoseAge, glucoseDisplay: glucoseDisplay, wasUserEntered: true, - isDisplayOnly: false) + isDisplayOnly: false, + isGlucoseValueStale: false) // check that manual glucose is displayed XCTAssertEqual(viewModel.glucoseValueString, "90") @@ -255,10 +217,10 @@ class CGMStatusHUDViewModelTests: XCTestCase { viewModel.setGlucoseQuantity(95, at: Date(), unit: .milligramsPerDeciliter, - staleGlucoseAge: staleGlucoseAge, glucoseDisplay: glucoseDisplay, wasUserEntered: false, - isDisplayOnly: false) + isDisplayOnly: false, + isGlucoseValueStale: false) // check that status highlight is displayed XCTAssertEqual(viewModel.glucoseValueString, "95") @@ -291,10 +253,10 @@ class CGMStatusHUDViewModelTests: XCTestCase { viewModel.setGlucoseQuantity(100, at: Date(), unit: .milligramsPerDeciliter, - staleGlucoseAge: staleGlucoseAge, glucoseDisplay: glucoseDisplay, wasUserEntered: true, - isDisplayOnly: false) + isDisplayOnly: false, + isGlucoseValueStale: false) // check that manual glucose is still displayed (again with status highlight icon) XCTAssertEqual(viewModel.glucoseValueString, "100") @@ -307,10 +269,10 @@ class CGMStatusHUDViewModelTests: XCTestCase { viewModel.setGlucoseQuantity(100, at: Date(), unit: .milligramsPerDeciliter, - staleGlucoseAge: .minutes(-1), glucoseDisplay: glucoseDisplay, wasUserEntered: true, - isDisplayOnly: false) + isDisplayOnly: false, + isGlucoseValueStale: true) // check that the status highlight is displayed XCTAssertEqual(viewModel.statusHighlight as! TestStatusHighlight, statusHighlight2) @@ -319,11 +281,6 @@ class CGMStatusHUDViewModelTests: XCTestCase { } extension CGMStatusHUDViewModelTests { - func staleGlucoseValueHandler() { - self.staleGlucoseValueHandlerWasCalled = true - testExpect.fulfill() - } - struct TestStatusHighlight: DeviceStatusHighlight, Equatable { var localizedMessage: String diff --git a/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift b/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift index 55104e5a1b..d21c3f9e43 100644 --- a/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift +++ b/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift @@ -10,8 +10,11 @@ import HealthKit import LoopCore import LoopKit import XCTest +import LoopAlgorithm + @testable import Loop +@MainActor class ManualEntryDoseViewModelTests: XCTestCase { static let now = Date.distantFuture @@ -24,13 +27,6 @@ class ManualEntryDoseViewModelTests: XCTestCase { static let noBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0) - var authenticateOverrideCompletion: ((Swift.Result) -> Void)? - private func authenticateOverride(_ message: String, _ completion: @escaping (Swift.Result) -> Void) { - authenticateOverrideCompletion = completion - } - - var saveAndDeliverSuccess = false - fileprivate var delegate: MockManualEntryDoseViewModelDelegate! static let mockUUID = UUID() @@ -39,100 +35,70 @@ class ManualEntryDoseViewModelTests: XCTestCase { override func setUpWithError() throws { now = Self.now delegate = MockManualEntryDoseViewModelDelegate() - delegate.mostRecentGlucoseDataDate = now - delegate.mostRecentPumpDataDate = now - saveAndDeliverSuccess = false setUpViewModel() } func setUpViewModel() { manualEntryDoseViewModel = ManualEntryDoseViewModel(delegate: delegate, now: { self.now }, - screenWidth: 512, debounceIntervalMilliseconds: 0, uuidProvider: { self.mockUUID }, timeZone: TimeZone(abbreviation: "GMT")!) - manualEntryDoseViewModel.authenticate = authenticateOverride + manualEntryDoseViewModel.authenticationHandler = { _ in return true } } - func testDoseLogging() throws { + func testDoseLogging() async throws { XCTAssertEqual(.novolog, manualEntryDoseViewModel.selectedInsulinType) manualEntryDoseViewModel.enteredBolus = Self.exampleBolusQuantity - try saveAndDeliver(ManualEntryDoseViewModelTests.exampleBolusQuantity) + try await manualEntryDoseViewModel.saveManualDose() + XCTAssertEqual(delegate.manualEntryBolusUnits, Self.exampleBolusQuantity.doubleValue(for: .internationalUnit())) XCTAssertEqual(delegate.manuallyEnteredDoseInsulinType, .novolog) } - - private func saveAndDeliver(_ bolus: HKQuantity, file: StaticString = #file, line: UInt = #line) throws { - manualEntryDoseViewModel.enteredBolus = bolus - manualEntryDoseViewModel.saveManualDose { self.saveAndDeliverSuccess = true } - if bolus != ManualEntryDoseViewModelTests.noBolus { - let authenticateOverrideCompletion = try XCTUnwrap(self.authenticateOverrideCompletion, file: file, line: line) - authenticateOverrideCompletion(.success(())) - } + + func testDoseNotSavedIfNotAuthenticated() async throws { + XCTAssertEqual(.novolog, manualEntryDoseViewModel.selectedInsulinType) + manualEntryDoseViewModel.enteredBolus = Self.exampleBolusQuantity + + manualEntryDoseViewModel.authenticationHandler = { _ in return false } + + do { + try await manualEntryDoseViewModel.saveManualDose() + XCTFail("Saving should fail if not authenticated.") + } catch { } + + XCTAssertNil(delegate.manualEntryBolusUnits) + XCTAssertNil(delegate.manuallyEnteredDoseInsulinType) } + } fileprivate class MockManualEntryDoseViewModelDelegate: ManualDoseViewModelDelegate { - - func insulinActivityDuration(for type: InsulinType?) -> TimeInterval { - return .hours(6) + .minutes(10) - } - var pumpInsulinType: InsulinType? - + var manualEntryBolusUnits: Double? var manualEntryDoseStartDate: Date? var manuallyEnteredDoseInsulinType: InsulinType? + func addManuallyEnteredDose(startDate: Date, units: Double, insulinType: InsulinType?) { manualEntryBolusUnits = units manualEntryDoseStartDate = startDate manuallyEnteredDoseInsulinType = insulinType } - var loopStateCallBlock: ((LoopState) -> Void)? - func withLoopState(do block: @escaping (LoopState) -> Void) { - loopStateCallBlock = block - } - - var enactedBolusUnits: Double? - func enactBolus(units: Double, automatic: Bool, completion: @escaping (Error?) -> Void) { - enactedBolusUnits = units - } - - var getGlucoseSamplesResponse: [StoredGlucoseSample] = [] - func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) { - completion(.success(getGlucoseSamplesResponse)) - } - - var insulinOnBoardResult: DoseStoreResult? - func insulinOnBoard(at date: Date, completion: @escaping (DoseStoreResult) -> Void) { - if let insulinOnBoardResult = insulinOnBoardResult { - completion(insulinOnBoardResult) - } - } - - var carbsOnBoardResult: CarbStoreResult? - func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult) -> Void) { - if let carbsOnBoardResult = carbsOnBoardResult { - completion(carbsOnBoardResult) - } + func insulinActivityDuration(for type: InsulinType?) -> TimeInterval { + return InsulinMath.defaultInsulinActivityDuration } - var ensureCurrentPumpDataCompletion: (() -> Void)? - func ensureCurrentPumpData(completion: @escaping () -> Void) { - ensureCurrentPumpDataCompletion = completion + var algorithmDisplayState = AlgorithmDisplayState() + + var settings = StoredSettings() + + var scheduleOverride: TemporaryScheduleOverride? + + func insulinModel(for type: LoopKit.InsulinType?) -> InsulinModel { + return ExponentialInsulinModelPreset.rapidActingAdult } - - var mostRecentGlucoseDataDate: Date? - - var mostRecentPumpDataDate: Date? - - var isPumpConfigured: Bool = true - - var preferredGlucoseUnit: HKUnit = .milligramsPerDeciliter - - var settings: LoopSettings = LoopSettings() } diff --git a/LoopTests/ViewModels/SimpleBolusViewModelTests.swift b/LoopTests/ViewModels/SimpleBolusViewModelTests.swift index 94c1fd8661..d2425abd0b 100644 --- a/LoopTests/ViewModels/SimpleBolusViewModelTests.swift +++ b/LoopTests/ViewModels/SimpleBolusViewModelTests.swift @@ -11,9 +11,11 @@ import HealthKit import LoopKit import LoopKitUI import LoopCore +import LoopAlgorithm @testable import Loop +@MainActor class SimpleBolusViewModelTests: XCTestCase { enum MockError: Error { @@ -37,44 +39,31 @@ class SimpleBolusViewModelTests: XCTestCase { enactedBolus = nil currentRecommendation = 0 } - - func testFailedAuthenticationShouldNotSaveDataOrBolus() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) - viewModel.authenticate = { (description, completion) in + + func testFailedAuthenticationShouldNotSaveDataOrBolus() async { + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false, displayGlucosePreference: displayGlucosePreference) + viewModel.setAuthenticationMethdod { description, completion in completion(.failure(MockError.authentication)) } - + viewModel.enteredBolusString = "3" - let saveExpectation = expectation(description: "Save completion callback") - - viewModel.saveAndDeliver { (success) in - saveExpectation.fulfill() - } - - waitForExpectations(timeout: 2) - + let _ = await viewModel.saveAndDeliver() + XCTAssertNil(enactedBolus) XCTAssertNil(addedCarbEntry) XCTAssert(addedGlucose.isEmpty) - } - func testIssuingBolus() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + func testIssuingBolus() async { + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false, displayGlucosePreference: displayGlucosePreference) viewModel.authenticate = { (description, completion) in completion(.success) } viewModel.enteredBolusString = "3" - let saveExpectation = expectation(description: "Save completion callback") - - viewModel.saveAndDeliver { (success) in - saveExpectation.fulfill() - } - - waitForExpectations(timeout: 2) + let _ = await viewModel.saveAndDeliver() XCTAssertNil(addedCarbEntry) XCTAssert(addedGlucose.isEmpty) @@ -83,8 +72,8 @@ class SimpleBolusViewModelTests: XCTestCase { } - func testMealCarbsAndManualGlucoseWithRecommendation() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + func testMealCarbsAndManualGlucoseWithRecommendation() async { + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false, displayGlucosePreference: displayGlucosePreference) viewModel.authenticate = { (description, completion) in completion(.success) } @@ -94,13 +83,7 @@ class SimpleBolusViewModelTests: XCTestCase { viewModel.enteredCarbString = "20" viewModel.manualGlucoseString = "180" - let saveExpectation = expectation(description: "Save completion callback") - - viewModel.saveAndDeliver { (success) in - saveExpectation.fulfill() - } - - waitForExpectations(timeout: 2) + let _ = await viewModel.saveAndDeliver() XCTAssertEqual(20, addedCarbEntry?.quantity.doubleValue(for: .gram())) XCTAssertEqual(180, addedGlucose.first?.quantity.doubleValue(for: .milligramsPerDeciliter)) @@ -111,8 +94,8 @@ class SimpleBolusViewModelTests: XCTestCase { XCTAssertEqual(storedBolusDecision?.carbEntry?.quantity, addedCarbEntry?.quantity) } - func testMealCarbsWithUserOverridingRecommendation() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + func testMealCarbsWithUserOverridingRecommendation() async { + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false, displayGlucosePreference: displayGlucosePreference) viewModel.authenticate = { (description, completion) in completion(.success) } @@ -127,13 +110,7 @@ class SimpleBolusViewModelTests: XCTestCase { viewModel.enteredBolusString = "0.1" - let saveExpectation = expectation(description: "Save completion callback") - - viewModel.saveAndDeliver { (success) in - saveExpectation.fulfill() - } - - waitForExpectations(timeout: 2) + let _ = await viewModel.saveAndDeliver() XCTAssertEqual(20, addedCarbEntry?.quantity.doubleValue(for: .gram())) @@ -145,7 +122,7 @@ class SimpleBolusViewModelTests: XCTestCase { } func testDeleteCarbsRemovesRecommendation() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false, displayGlucosePreference: displayGlucosePreference) viewModel.authenticate = { (description, completion) in completion(.success) } @@ -160,11 +137,11 @@ class SimpleBolusViewModelTests: XCTestCase { viewModel.enteredCarbString = "" XCTAssertEqual("–", viewModel.recommendedBolus) - XCTAssertEqual("0", viewModel.enteredBolusString) + XCTAssertEqual("", viewModel.enteredBolusString) } func testDeleteCurrentGlucoseRemovesRecommendation() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false, displayGlucosePreference: displayGlucosePreference) viewModel.authenticate = { (description, completion) in completion(.success) } @@ -179,11 +156,11 @@ class SimpleBolusViewModelTests: XCTestCase { viewModel.manualGlucoseString = "" XCTAssertEqual("–", viewModel.recommendedBolus) - XCTAssertEqual("0", viewModel.enteredBolusString) + XCTAssertEqual("", viewModel.enteredBolusString) } func testDeleteCurrentGlucoseRemovesActiveInsulin() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false, displayGlucosePreference: displayGlucosePreference) viewModel.authenticate = { (description, completion) in completion(.success) } @@ -201,7 +178,7 @@ class SimpleBolusViewModelTests: XCTestCase { func testManualGlucoseStringMatchesDisplayGlucoseUnit() { // used "260" mg/dL ("14.4" mmol/L) since 14.40 mmol/L -> 259 mg/dL and 14.43 mmol/L -> 260 mg/dL - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false, displayGlucosePreference: displayGlucosePreference) XCTAssertEqual(viewModel.manualGlucoseString, "") viewModel.manualGlucoseString = "260" XCTAssertEqual(viewModel.manualGlucoseString, "260") @@ -221,8 +198,8 @@ class SimpleBolusViewModelTests: XCTestCase { } func testGlucoseEntryWarnings() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) - + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false, displayGlucosePreference: displayGlucosePreference) + currentRecommendation = 2 viewModel.manualGlucoseString = "180" XCTAssertNil(viewModel.activeNotice) @@ -252,26 +229,26 @@ class SimpleBolusViewModelTests: XCTestCase { } func testGlucoseEntryWarningsForMealBolus() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: true) + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: true, displayGlucosePreference: displayGlucosePreference) viewModel.manualGlucoseString = "69" viewModel.enteredCarbString = "25" XCTAssertEqual(viewModel.activeNotice, .glucoseWarning) } func testOutOfBoundsGlucoseShowsNoRecommendation() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: true) + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: true, displayGlucosePreference: displayGlucosePreference) viewModel.manualGlucoseString = "699" XCTAssert(!viewModel.bolusRecommended) } func testOutOfBoundsCarbsShowsNoRecommendation() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: true) + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: true, displayGlucosePreference: displayGlucosePreference) viewModel.enteredCarbString = "400" XCTAssert(!viewModel.bolusRecommended) } func testMaxBolusWarnings() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false, displayGlucosePreference: displayGlucosePreference) viewModel.enteredBolusString = "20" XCTAssertEqual(viewModel.activeNotice, .maxBolusExceeded) @@ -285,13 +262,12 @@ class SimpleBolusViewModelTests: XCTestCase { } extension SimpleBolusViewModelTests: SimpleBolusViewModelDelegate { - func addGlucose(_ samples: [NewGlucoseSample], completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) { - addedGlucose = samples - completion(.success([])) + func saveGlucose(sample: LoopKit.NewGlucoseSample) async throws -> StoredGlucoseSample { + addedGlucose.append(sample) + return sample.asStoredGlucoseStample } - - func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?, completion: @escaping (Result) -> Void) { - + + func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?) async throws -> StoredCarbEntry { addedCarbEntry = carbEntry let storedCarbEntry = StoredCarbEntry( startDate: carbEntry.startDate, @@ -305,35 +281,38 @@ extension SimpleBolusViewModelTests: SimpleBolusViewModelDelegate { createdByCurrentApp: true, userCreatedDate: Date(), userUpdatedDate: nil) - completion(.success(storedCarbEntry)) + return storedCarbEntry } - func enactBolus(units: Double, activationType: BolusActivationType) { + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) async { + storedBolusDecision = bolusDosingDecision + } + + + func enactBolus(units: Double, activationType: BolusActivationType) async throws { enactedBolus = (units: units, activationType: activationType) } - - func insulinOnBoard(at date: Date, completion: @escaping (DoseStoreResult) -> Void) { - completion(.success(currentIOB)) + + + func insulinOnBoard(at date: Date) async -> InsulinValue? { + return currentIOB } - + + func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? { - var decision = BolusDosingDecision(for: .simpleBolus) - decision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: currentRecommendation, pendingInsulin: 0, notice: .none), + decision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: currentRecommendation, notice: .none), date: date) decision.insulinOnBoard = currentIOB return decision } - - func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) { - storedBolusDecision = bolusDosingDecision - } - var maximumBolus: Double { + + var maximumBolus: Double? { return 3.0 } - var suspendThreshold: HKQuantity { + var suspendThreshold: HKQuantity? { return HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 80) } } diff --git a/LoopUI/ViewModel/CGMStatusHUDViewModel.swift b/LoopUI/ViewModel/CGMStatusHUDViewModel.swift index a26834e4b3..06dc1d456d 100644 --- a/LoopUI/ViewModel/CGMStatusHUDViewModel.swift +++ b/LoopUI/ViewModel/CGMStatusHUDViewModel.swift @@ -32,15 +32,12 @@ public class CGMStatusHUDViewModel { return manualGlucoseTrendIconOverride } - private var glucoseValueCurrent: Bool { - guard let isStaleAt = isStaleAt else { return true } - return Date() < isStaleAt - } + var isGlucoseValueStale: Bool = false private var isManualGlucose: Bool = false private var isManualGlucoseCurrent: Bool { - return isManualGlucose && glucoseValueCurrent + return isManualGlucose && !isGlucoseValueStale } var manualGlucoseTrendIconOverride: UIImage? @@ -70,58 +67,17 @@ public class CGMStatusHUDViewModel { } } - var isVisible: Bool = true { - didSet { - if oldValue != isVisible { - if !isVisible { - stalenessTimer?.invalidate() - stalenessTimer = nil - } else { - startStalenessTimerIfNeeded() - } - } - } - } - - private var stalenessTimer: Timer? - - private var isStaleAt: Date? { - didSet { - if oldValue != isStaleAt { - stalenessTimer?.invalidate() - stalenessTimer = nil - } - } - } + var isVisible: Bool = true - private func startStalenessTimerIfNeeded() { - if let fireDate = isStaleAt, - isVisible, - stalenessTimer == nil - { - stalenessTimer = Timer(fire: fireDate, interval: 0, repeats: false) { (_) in - self.displayStaleGlucoseValue() - self.staleGlucoseValueHandler() - } - RunLoop.main.add(stalenessTimer!, forMode: .default) - } - } - private lazy var timeFormatter = DateFormatter(timeStyle: .short) - - var staleGlucoseValueHandler: () -> Void - - init(staleGlucoseValueHandler: @escaping () -> Void) { - self.staleGlucoseValueHandler = staleGlucoseValueHandler - } func setGlucoseQuantity(_ glucoseQuantity: Double, at glucoseStartDate: Date, unit: HKUnit, - staleGlucoseAge: TimeInterval, glucoseDisplay: GlucoseDisplayable?, wasUserEntered: Bool, - isDisplayOnly: Bool) + isDisplayOnly: Bool, + isGlucoseValueStale: Bool) { var accessibilityStrings = [String]() @@ -131,14 +87,12 @@ public class CGMStatusHUDViewModel { let time = timeFormatter.string(from: glucoseStartDate) - isStaleAt = glucoseStartDate.addingTimeInterval(staleGlucoseAge) - glucoseValueTintColor = glucoseDisplay?.glucoseRangeCategory?.glucoseColor ?? .label + self.isGlucoseValueStale = isGlucoseValueStale let numberFormatter = NumberFormatter.glucoseFormatter(for: unit) if let valueString = numberFormatter.string(from: glucoseQuantity) { - if glucoseValueCurrent { - startStalenessTimerIfNeeded() + if !isGlucoseValueStale { switch glucoseDisplay?.glucoseRangeCategory { case .some(.belowRange): glucoseValueString = LocalizedString("LOW", comment: "String displayed instead of a glucose value below the CGM range") @@ -158,7 +112,7 @@ public class CGMStatusHUDViewModel { if isManualGlucoseCurrent { // a manual glucose value presents any status highlight icon instead of a trend icon setManualGlucoseTrendIconOverride() - } else if let trend = glucoseDisplay?.trendType, glucoseValueCurrent { + } else if let trend = glucoseDisplay?.trendType, !isGlucoseValueStale { self.trend = trend glucoseTrendTintColor = glucoseDisplay?.glucoseRangeCategory?.trendColor ?? .glucoseTintColor accessibilityStrings.append(trend.localizedDescription) diff --git a/LoopUI/Views/CGMStatusHUDView.swift b/LoopUI/Views/CGMStatusHUDView.swift index ad659445fd..5a40459c3c 100644 --- a/LoopUI/Views/CGMStatusHUDView.swift +++ b/LoopUI/Views/CGMStatusHUDView.swift @@ -23,6 +23,15 @@ public final class CGMStatusHUDView: DeviceStatusHUDView, NibLoadable { return 1 } + public var isGlucoseValueStale: Bool { + get { + viewModel.isGlucoseValueStale + } + set { + viewModel.isGlucoseValueStale = newValue + } + } + public var isVisible: Bool { get { viewModel.isVisible @@ -47,9 +56,7 @@ public final class CGMStatusHUDView: DeviceStatusHUDView, NibLoadable { override func setup() { super.setup() statusHighlightView.setIconPosition(.right) - viewModel = CGMStatusHUDViewModel(staleGlucoseValueHandler: { [weak self] in - self?.updateDisplay() - }) + viewModel = CGMStatusHUDViewModel() } override public func tintColorDidChange() { @@ -110,18 +117,18 @@ public final class CGMStatusHUDView: DeviceStatusHUDView, NibLoadable { public func setGlucoseQuantity(_ glucoseQuantity: Double, at glucoseStartDate: Date, unit: HKUnit, - staleGlucoseAge: TimeInterval, glucoseDisplay: GlucoseDisplayable?, wasUserEntered: Bool, - isDisplayOnly: Bool) + isDisplayOnly: Bool, + isGlucoseValueStale: Bool) { viewModel.setGlucoseQuantity(glucoseQuantity, at: glucoseStartDate, unit: unit, - staleGlucoseAge: staleGlucoseAge, glucoseDisplay: glucoseDisplay, wasUserEntered: wasUserEntered, - isDisplayOnly: isDisplayOnly) + isDisplayOnly: isDisplayOnly, + isGlucoseValueStale: isGlucoseValueStale) updateDisplay() } diff --git a/LoopUI/Views/LoopCompletionHUDView.swift b/LoopUI/Views/LoopCompletionHUDView.swift index b0e6b1387b..d9c920bc79 100644 --- a/LoopUI/Views/LoopCompletionHUDView.swift +++ b/LoopUI/Views/LoopCompletionHUDView.swift @@ -7,8 +7,8 @@ // import UIKit +import LoopKit import LoopKitUI -import LoopCore public final class LoopCompletionHUDView: BaseHUDView { @@ -20,7 +20,7 @@ public final class LoopCompletionHUDView: BaseHUDView { private(set) var freshness = LoopCompletionFreshness.stale { didSet { - updateTintColor() + loopStateView.freshness = freshness } } @@ -30,6 +30,12 @@ public final class LoopCompletionHUDView: BaseHUDView { updateDisplay(nil) } + public var loopStatusColors: StateColorPalette = StateColorPalette(unknown: .black, normal: .black, warning: .black, error: .black) { + didSet { + loopStateView.loopStatusColors = loopStatusColors + } + } + public var loopIconClosed = false { didSet { loopStateView.open = !loopIconClosed @@ -43,6 +49,9 @@ public final class LoopCompletionHUDView: BaseHUDView { } } } + + public var mostRecentGlucoseDataDate: Date? + public var mostRecentPumpDataDate: Date? public var loopInProgress = false { didSet { @@ -65,26 +74,6 @@ public final class LoopCompletionHUDView: BaseHUDView { } } - override public func stateColorsDidUpdate() { - super.stateColorsDidUpdate() - updateTintColor() - } - - private func updateTintColor() { - let tintColor: UIColor? - - switch freshness { - case .fresh: - tintColor = stateColors?.normal - case .aging: - tintColor = stateColors?.warning - case .stale: - tintColor = stateColors?.error - } - - self.tintColor = tintColor - } - private func initTimer(_ startDate: Date) { let updateInterval = TimeInterval(minutes: 1) @@ -150,7 +139,7 @@ public final class LoopCompletionHUDView: BaseHUDView { lastLoopMessage = "" let timeAgoToIncludeTimeStamp: TimeInterval = .minutes(20) let timeAgoToIncludeDate: TimeInterval = .hours(4) - if let date = lastLoopCompleted { + if loopIconClosed, let date = lastLoopCompleted { let ago = abs(min(0, date.timeIntervalSinceNow)) freshness = LoopCompletionFreshness(age: ago) @@ -184,6 +173,28 @@ public final class LoopCompletionHUDView: BaseHUDView { caption?.text = "–" accessibilityLabel = nil } + } else if !loopIconClosed, let mostRecentPumpDataDate, let mostRecentGlucoseDataDate { + let ago = max(abs(min(0, mostRecentPumpDataDate.timeIntervalSinceNow)), abs(min(0, mostRecentGlucoseDataDate.timeIntervalSinceNow))) + + freshness = LoopCompletionFreshness(age: ago) + + if let timeString = timeAgoFormatter.string(from: ago) { + switch traitCollection.preferredContentSizeCategory { + case UIContentSizeCategory.extraSmall, + UIContentSizeCategory.small, + UIContentSizeCategory.medium, + UIContentSizeCategory.large: + // Use a longer form only for smaller text sizes + caption?.text = String(format: LocalizedString("%@ ago", comment: "Format string describing the time interval since the last cgm or pump communication date. (1: The localized date components"), timeString) + default: + caption?.text = timeString + } + + accessibilityLabel = String(format: LocalizedString("Last device communication ran %@ ago", comment: "Accessbility format label describing the time interval since the last device communication date. (1: The localized date components)"), timeString) + } else { + caption?.text = "–" + accessibilityLabel = nil + } } else { caption?.text = "–" accessibilityLabel = LocalizedString("Waiting for first run", comment: "Accessibility label describing completion HUD waiting for first run") @@ -191,8 +202,10 @@ public final class LoopCompletionHUDView: BaseHUDView { if loopIconClosed { accessibilityHint = LocalizedString("Closed loop", comment: "Accessibility hint describing completion HUD for a closed loop") + accessibilityIdentifier = "loopCompletionHUDLoopStatusClosed" } else { accessibilityHint = LocalizedString("Open loop", comment: "Accessbility hint describing completion HUD for an open loop") + accessibilityIdentifier = "loopCompletionHUDLoopStatusOpen" } } @@ -207,20 +220,98 @@ extension LoopCompletionHUDView { public var loopCompletionMessage: (title: String, message: String) { switch freshness { case .fresh: - if loopStateView.open { - let reason = closedLoopDisallowedLocalizedDescription ?? LocalizedString("Tap Settings to toggle Closed Loop ON if you wish for the app to automate your insulin.", comment: "Instructions for user to close loop if it is allowed.") - return (title: LocalizedString("Closed Loop OFF", comment: "Title of green open loop OFF message"), - message: String(format: LocalizedString("\n%1$@ is operating with Closed Loop in the OFF position. Your pump and CGM will continue operating, but the app will not adjust dosing automatically.\n\n%2$@", comment: "Green closed loop OFF message (1: app name)(2: reason for open loop)"), Bundle.main.bundleDisplayName, reason)) + if !loopIconClosed { + let reason = closedLoopDisallowedLocalizedDescription ?? LocalizedString( + "Tap Settings to toggle Closed Loop ON if you wish for the app to automate your insulin.", + comment: "Instructions for user to close loop if it is allowed." + ) + + return ( + title: LocalizedString( + "Closed Loop OFF", + comment: "Title of fresh loop OFF message" + ), + message: String( + format: LocalizedString( + "\n%1$@ is operating with Closed Loop in the OFF position. Your pump and CGM will continue operating, but the app will not adjust dosing automatically.\n\n%2$@", + comment: "Fresh closed loop OFF message (1: app name)(2: reason for open loop)" + ), + Bundle.main.bundleDisplayName, + reason + ) + ) } else { - return (title: LocalizedString("Closed Loop ON", comment: "Title of green closed loop ON message"), - message: String(format: LocalizedString("\n%1$@\n\n%2$@ is operating with Closed Loop in the ON position.", comment: "Green closed loop ON message (1: last loop string) (2: app name)"), lastLoopMessage, Bundle.main.bundleDisplayName)) + return ( + title: LocalizedString( + "Closed Loop ON", + comment: "Title of fresh closed loop ON message" + ), + message: String( + format: LocalizedString( + "\n%1$@\n\n%2$@ is operating with Closed Loop in the ON position.", + comment: "Fresh closed loop ON message (1: last loop string) (2: app name)" + ), + lastLoopMessage, + Bundle.main.bundleDisplayName + ) + ) } case .aging: - return (title: LocalizedString("Loop Warning", comment: "Title of yellow loop message"), - message: String(format: LocalizedString("\n%1$@\n\nTap your CGM and insulin pump status icons for more information. %2$@ will continue trying to complete a loop, but watch for potential communication issues with your pump and CGM.", comment: "Yellow loop message (1: last loop string) (2: app name)"), lastLoopMessage, Bundle.main.bundleDisplayName)) + if !loopIconClosed { + return ( + title: LocalizedString( + "Caution", + comment: "Title of aging open loop message" + ), + message: LocalizedString( + "Tap your CGM and insulin pump status icons for more information. Check for potential communication issues with your pump and CGM.", + comment: "Aging open loop message" + ) + ) + } else { + return ( + title: LocalizedString( + "Loop Warning", + comment: "Title of aging closed loop message" + ), + message: String( + format: LocalizedString( + "\n%1$@\n\nTap your CGM and insulin pump status icons for more information. %2$@ will continue trying to complete a loop, but watch for potential communication issues with your pump and CGM.", + comment: "Aging loop message (1: last loop string) (2: app name)" + ), + lastLoopMessage, + Bundle.main.bundleDisplayName + ) + ) + } case .stale: - return (title: LocalizedString("Loop Failure", comment: "Title of red loop message"), - message: String(format: LocalizedString("\n%1$@\n\nTap your CGM and insulin pump status icons for more information. %2$@ will continue trying to complete a loop, but check for potential communication issues with your pump and CGM.", comment: "Red loop message (1: last loop string) (2: app name)"), lastLoopMessage, Bundle.main.bundleDisplayName)) + if !loopIconClosed { + return ( + title: LocalizedString( + "Device Error", + comment: "Title of stale loop message" + ), + message: LocalizedString( + "Tap your CGM and insulin pump status icons for more information. Check for potential communication issues with your pump and CGM.", + comment: "Stale open loop message" + ) + ) + } else { + return ( + title: LocalizedString( + "Loop Failure", + comment: "Title of red loop message" + ), + message: String( + format: LocalizedString( + "\n%1$@\n\nTap your CGM and insulin pump status icons for more information. %2$@ will continue trying to complete a loop, but check for potential communication issues with your pump and CGM.", + comment: "Red loop message (1: last loop string) (2: app name)" + ), + lastLoopMessage, + Bundle.main.bundleDisplayName + ) + ) + } } } } diff --git a/LoopUI/Views/LoopStateView.swift b/LoopUI/Views/LoopStateView.swift index eedc483de4..0b4a64670b 100644 --- a/LoopUI/Views/LoopStateView.swift +++ b/LoopUI/Views/LoopStateView.swift @@ -6,110 +6,109 @@ // Copyright © 2016 Nathan Racklyeft. All rights reserved. // +import LoopKit +import LoopKitUI +import SwiftUI import UIKit -final class LoopStateView: UIView { - var firstDataUpdate = true +class WrappedLoopStateViewModel: ObservableObject { + @Published var loopStatusColors: StateColorPalette + @Published var closedLoop: Bool + @Published var freshness: LoopCompletionFreshness + @Published var animating: Bool - override func tintColorDidChange() { - super.tintColorDidChange() - - updateTintColor() + init( + loopStatusColors: StateColorPalette = StateColorPalette(unknown: .black, normal: .black, warning: .black, error: .black), + closedLoop: Bool = true, + freshness: LoopCompletionFreshness = .stale, + animating: Bool = false + ) { + self.loopStatusColors = loopStatusColors + self.closedLoop = closedLoop + self.freshness = freshness + self.animating = animating } +} - private func updateTintColor() { - shapeLayer.strokeColor = tintColor.cgColor +struct WrappedLoopCircleView: View { + + @StateObject var viewModel: WrappedLoopStateViewModel + + var body: some View { + LoopCircleView(closedLoop: viewModel.closedLoop, freshness: viewModel.freshness, animating: viewModel.animating) + .environment(\.loopStatusColorPalette, viewModel.loopStatusColors) } +} - var open = false { - didSet { - if open != oldValue { - shapeLayer.path = drawPath() - } - } +class LoopCircleHostingController: UIHostingController { + init(viewModel: WrappedLoopStateViewModel) { + super.init( + rootView: WrappedLoopCircleView( + viewModel: viewModel + ) + ) } - - override class var layerClass : AnyClass { - return CAShapeLayer.self + + required init?(coder aDecoder: NSCoder) { + fatalError() } +} - private var shapeLayer: CAShapeLayer { - return layer as! CAShapeLayer - } +final class LoopStateView: UIView { + override init(frame: CGRect) { super.init(frame: frame) - - shapeLayer.lineWidth = 8 - shapeLayer.fillColor = UIColor.clear.cgColor - updateTintColor() - - shapeLayer.path = drawPath() + + setupViews() } - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - - shapeLayer.lineWidth = 8 - shapeLayer.fillColor = UIColor.clear.cgColor - updateTintColor() - - shapeLayer.path = drawPath() + + required init?(coder: NSCoder) { + super.init(coder: coder) + + setupViews() } - - override func layoutSubviews() { - super.layoutSubviews() - - shapeLayer.path = drawPath() + + var loopStatusColors: StateColorPalette = StateColorPalette(unknown: .black, normal: .black, warning: .black, error: .black) { + didSet { + viewModel.loopStatusColors = loopStatusColors + } } - private func drawPath(lineWidth: CGFloat? = nil) -> CGPath { - let center = CGPoint(x: bounds.midX, y: bounds.midY) - let lineWidth = lineWidth ?? shapeLayer.lineWidth - let radius = min(bounds.width / 2, bounds.height / 2) - lineWidth / 2 - - let startAngle = open ? -CGFloat.pi / 4 : 0 - let endAngle = open ? 5 * CGFloat.pi / 4 : 2 * CGFloat.pi - - let path = UIBezierPath( - arcCenter: center, - radius: radius, - startAngle: startAngle, - endAngle: endAngle, - clockwise: true - ) - - return path.cgPath + var freshness: LoopCompletionFreshness = .stale { + didSet { + viewModel.freshness = freshness + } + } + + var open = false { + didSet { + viewModel.closedLoop = !open + } } - - private static let AnimationKey = "com.loudnate.Naterade.breatheAnimation" var animated: Bool = false { didSet { - if animated != oldValue { - if animated { - let path = CABasicAnimation(keyPath: "path") - path.fromValue = shapeLayer.path ?? drawPath() - path.toValue = drawPath(lineWidth: 16) - - let width = CABasicAnimation(keyPath: "lineWidth") - width.fromValue = shapeLayer.lineWidth - width.toValue = 10 - - let group = CAAnimationGroup() - group.animations = [path, width] - group.duration = firstDataUpdate ? 0 : 1 - group.repeatCount = HUGE - group.autoreverses = true - group.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) - - shapeLayer.add(group, forKey: type(of: self).AnimationKey) - } else { - shapeLayer.removeAnimation(forKey: type(of: self).AnimationKey) - } - } - firstDataUpdate = false + viewModel.animating = animated } } + + private let viewModel = WrappedLoopStateViewModel() + + private func setupViews() { + let hostingController = LoopCircleHostingController(viewModel: viewModel) + + hostingController.view.backgroundColor = .clear + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + + addSubview(hostingController.view) + + NSLayoutConstraint.activate([ + hostingController.view.leadingAnchor.constraint(equalTo: leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: trailingAnchor), + hostingController.view.topAnchor.constraint(equalTo: topAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } } diff --git a/LoopUI/Views/PumpStatusHUDView.swift b/LoopUI/Views/PumpStatusHUDView.swift index 7b6aaeb889..754aa6e7fe 100644 --- a/LoopUI/Views/PumpStatusHUDView.swift +++ b/LoopUI/Views/PumpStatusHUDView.swift @@ -43,7 +43,11 @@ public final class PumpStatusHUDView: DeviceStatusHUDView, NibLoadable { } override public func presentStatusHighlight() { - guard !statusStackView.arrangedSubviews.contains(statusHighlightView) else { + defer { + accessibilityValue = statusHighlightView.messageLabel.text + } + + guard !isStatusHighlightDisplayed else { return } @@ -60,6 +64,18 @@ public final class PumpStatusHUDView: DeviceStatusHUDView, NibLoadable { } override public func dismissStatusHighlight() { + defer { + var parts = [String]() + if let basalRateAccessibilityValue = basalRateHUD.accessibilityValue { + parts.append(basalRateAccessibilityValue) + } + + if let pumpManagerProvidedAccessibilityValue = pumpManagerProvidedHUD.accessibilityValue { + parts.append(pumpManagerProvidedAccessibilityValue) + } + accessibilityValue = parts.joined(separator: ", ") + } + guard statusStackView.arrangedSubviews.contains(statusHighlightView) else { return } @@ -86,7 +102,14 @@ public final class PumpStatusHUDView: DeviceStatusHUDView, NibLoadable { public func addPumpManagerProvidedHUDView(_ pumpManagerProvidedHUD: BaseHUDView) { self.pumpManagerProvidedHUD = pumpManagerProvidedHUD + guard !isStatusHighlightDisplayed else { + self.pumpManagerProvidedHUD.isHidden = true + return + } statusStackView.addArrangedSubview(self.pumpManagerProvidedHUD) } + private var isStatusHighlightDisplayed: Bool { + statusStackView.arrangedSubviews.contains(statusHighlightView) + } } diff --git a/LoopUI/Views/StatusBarHUDView.swift b/LoopUI/Views/StatusBarHUDView.swift index 3bd851e2e2..1d348e4fbb 100644 --- a/LoopUI/Views/StatusBarHUDView.swift +++ b/LoopUI/Views/StatusBarHUDView.swift @@ -59,6 +59,9 @@ public class StatusBarHUDView: UIView, NibLoadable { containerView.heightAnchor.constraint(equalTo: heightAnchor), ]) + self.cgmStatusHUD.accessibilityIdentifier = "glucoseHUDView" + self.pumpStatusHUD.accessibilityIdentifier = "pumpHUDView" + self.backgroundColor = UIColor.secondarySystemBackground } diff --git a/LoopUI/Views/StatusHighlightHUDView.swift b/LoopUI/Views/StatusHighlightHUDView.swift index 564b6ca0c0..7ab08b7c61 100644 --- a/LoopUI/Views/StatusHighlightHUDView.swift +++ b/LoopUI/Views/StatusHighlightHUDView.swift @@ -64,6 +64,8 @@ public class StatusHighlightHUDView: UIView, NibLoadable { stackView.widthAnchor.constraint(equalTo: widthAnchor), stackView.heightAnchor.constraint(equalTo: heightAnchor), ]) + + accessibilityValue = messageLabel.text } public func setIconPosition(_ iconPosition: IconPosition) { diff --git a/WatchApp Extension/ComplicationController.swift b/WatchApp Extension/ComplicationController.swift index 9f79aad280..a79ec10924 100644 --- a/WatchApp Extension/ComplicationController.swift +++ b/WatchApp Extension/ComplicationController.swift @@ -8,8 +8,9 @@ import ClockKit import WatchKit -import LoopCore +import LoopKit import os.log +import LoopAlgorithm final class ComplicationController: NSObject, CLKComplicationDataSource { @@ -88,7 +89,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { let template = CLKComplicationTemplate.templateForFamily(complication.family, from: context, at: timelineDate, - recencyInterval: LoopCoreConstants.inputDataRecencyInterval, + recencyInterval: LoopAlgorithm.inputDataRecencyInterval, chartGenerator: self.makeChart) { switch complication.family { @@ -119,7 +120,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { var futureChangeDates: [Date] = [ // Stale glucose date: just a second after glucose expires - glucoseDate + LoopCoreConstants.inputDataRecencyInterval + 1, + glucoseDate + LoopAlgorithm.inputDataRecencyInterval + 1, ] if let loopLastRunDate = context.loopLastRunDate { @@ -135,7 +136,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { if let template = CLKComplicationTemplate.templateForFamily(complication.family, from: context, at: futureChangeDate, - recencyInterval: LoopCoreConstants.inputDataRecencyInterval, + recencyInterval: LoopAlgorithm.inputDataRecencyInterval, chartGenerator: self.makeChart) { template.tintColor = UIColor.tintColor diff --git a/WatchApp Extension/Controllers/ActionHUDController.swift b/WatchApp Extension/Controllers/ActionHUDController.swift index dce285b9d9..bb2df24563 100644 --- a/WatchApp Extension/Controllers/ActionHUDController.swift +++ b/WatchApp Extension/Controllers/ActionHUDController.swift @@ -60,13 +60,13 @@ final class ActionHUDController: HUDInterfaceController { super.update() let activeOverrideContext: TemporaryScheduleOverride.Context? - if let override = loopManager.settings.scheduleOverride, override.isActive() { + if let override = loopManager.watchInfo.scheduleOverride, override.isActive() { activeOverrideContext = override.context } else { activeOverrideContext = nil } - updateForPreMeal(enabled: loopManager.settings.preMealOverride?.isActive() == true) + updateForPreMeal(enabled: loopManager.watchInfo.preMealOverride?.isActive() == true) updateForOverrideContext(activeOverrideContext) let isClosedLoop = loopManager.activeContext?.isClosedLoop ?? false @@ -80,7 +80,7 @@ final class ActionHUDController: HUDInterfaceController { carbsButtonGroup.state = .off bolusButtonGroup.state = .off - if loopManager.settings.preMealTargetRange == nil { + if loopManager.watchInfo.loopSettings.preMealTargetRange == nil { preMealButtonGroup.state = .disabled } else if preMealButtonGroup.state == .disabled { preMealButtonGroup.state = .off @@ -98,9 +98,9 @@ final class ActionHUDController: HUDInterfaceController { private var canEnableOverride: Bool { if FeatureFlags.sensitivityOverridesEnabled { - return !loopManager.settings.overridePresets.isEmpty + return !loopManager.watchInfo.loopSettings.overridePresets.isEmpty } else { - return loopManager.settings.legacyWorkoutTargetRange != nil + return loopManager.watchInfo.loopSettings.legacyWorkoutTargetRange != nil } } @@ -133,11 +133,11 @@ final class ActionHUDController: HUDInterfaceController { private let glucoseFormatter = QuantityFormatter(for: .milligramsPerDeciliter) @IBAction func togglePreMealMode() { - guard let range = loopManager.settings.preMealTargetRange else { + guard let range = loopManager.watchInfo.loopSettings.preMealTargetRange else { return } - let buttonToSelect = loopManager.settings.preMealOverride?.isActive() == true ? SelectedButton.on : SelectedButton.off + let buttonToSelect = loopManager.watchInfo.preMealOverride?.isActive() == true ? SelectedButton.on : SelectedButton.off let viewModel = OnOffSelectionViewModel( title: NSLocalizedString("Pre-Meal", comment: "Title for sheet to enable/disable pre-meal on watch"), message: formattedGlucoseRangeString(from: range), @@ -152,30 +152,29 @@ final class ActionHUDController: HUDInterfaceController { updateForPreMeal(enabled: isPreMealEnabled) pendingMessageResponses += 1 - var settings = loopManager.settings - let overrideContext = settings.scheduleOverride?.context + var watchInfo = loopManager.watchInfo + let overrideContext = watchInfo.scheduleOverride?.context if isPreMealEnabled { - settings.enablePreMealOverride(for: .hours(1)) + watchInfo.enablePreMealOverride(for: .hours(1)) if !FeatureFlags.sensitivityOverridesEnabled { - settings.clearOverride(matching: .legacyWorkout) + watchInfo.clearOverride(matching: .legacyWorkout) updateForOverrideContext(nil) } } else { - settings.clearOverride(matching: .preMeal) + watchInfo.clearOverride(matching: .preMeal) } - let userInfo = LoopSettingsUserInfo(settings: settings) do { - try WCSession.default.sendSettingsUpdateMessage(userInfo, completionHandler: { (result) in + try WCSession.default.sendSettingsUpdateMessage(watchInfo, completionHandler: { (result) in DispatchQueue.main.async { self.pendingMessageResponses -= 1 switch result { case .success(let context): if self.pendingMessageResponses == 0 { - self.loopManager.settings.preMealOverride = settings.preMealOverride - self.loopManager.settings.scheduleOverride = settings.scheduleOverride + self.loopManager.watchInfo.preMealOverride = watchInfo.preMealOverride + self.loopManager.watchInfo.scheduleOverride = watchInfo.scheduleOverride } ExtensionDelegate.shared().loopManager.updateContext(context) @@ -208,14 +207,14 @@ final class ActionHUDController: HUDInterfaceController { overrideButtonGroup.state == .on ? sendOverride(nil) : presentController(withName: OverrideSelectionController.className, context: self as OverrideSelectionControllerDelegate) - } else if let range = loopManager.settings.legacyWorkoutTargetRange { - let buttonToSelect = loopManager.settings.nonPreMealOverrideEnabled() == true ? SelectedButton.on : SelectedButton.off - + } else if let range = loopManager.watchInfo.loopSettings.legacyWorkoutTargetRange { + let buttonToSelect = loopManager.watchInfo.nonPreMealOverrideEnabled() == true ? SelectedButton.on : SelectedButton.off + let viewModel = OnOffSelectionViewModel( title: NSLocalizedString("Workout", comment: "Title for sheet to enable/disable workout mode on watch"), message: formattedGlucoseRangeString(from: range), onSelection: { isWorkoutEnabled in - let override = isWorkoutEnabled ? self.loopManager.settings.legacyWorkoutOverride(for: .infinity) : nil + let override = isWorkoutEnabled ? self.loopManager.watchInfo.legacyWorkoutOverride(for: .infinity) : nil self.sendOverride(override) }, selectedButton: buttonToSelect, @@ -244,24 +243,23 @@ final class ActionHUDController: HUDInterfaceController { updateForOverrideContext(override?.context) pendingMessageResponses += 1 - var settings = loopManager.settings - let isPreMealEnabled = settings.preMealOverride?.isActive() == true + var watchInfo = loopManager.watchInfo + let isPreMealEnabled = watchInfo.preMealOverride?.isActive() == true if override?.context == .legacyWorkout { - settings.preMealOverride = nil + watchInfo.preMealOverride = nil } - settings.scheduleOverride = override + watchInfo.scheduleOverride = override - let userInfo = LoopSettingsUserInfo(settings: settings) do { - try WCSession.default.sendSettingsUpdateMessage(userInfo, completionHandler: { (result) in + try WCSession.default.sendSettingsUpdateMessage(watchInfo, completionHandler: { (result) in DispatchQueue.main.async { self.pendingMessageResponses -= 1 switch result { case .success(let context): if self.pendingMessageResponses == 0 { - self.loopManager.settings.scheduleOverride = override - self.loopManager.settings.preMealOverride = settings.preMealOverride + self.loopManager.watchInfo.scheduleOverride = override + self.loopManager.watchInfo.preMealOverride = watchInfo.preMealOverride } ExtensionDelegate.shared().loopManager.updateContext(context) diff --git a/WatchApp Extension/Controllers/CarbEntryListController.swift b/WatchApp Extension/Controllers/CarbEntryListController.swift index a704a942cd..8a2b74a420 100644 --- a/WatchApp Extension/Controllers/CarbEntryListController.swift +++ b/WatchApp Extension/Controllers/CarbEntryListController.swift @@ -10,6 +10,7 @@ import LoopCore import LoopKit import os.log import WatchKit +import LoopAlgorithm class CarbEntryListController: WKInterfaceController, IdentifiableClass { @IBOutlet private var table: WKInterfaceTable! @@ -79,7 +80,7 @@ extension CarbEntryListController { } private func reloadCarbEntries() { - let start = min(Calendar.current.startOfDay(for: Date()), Date(timeIntervalSinceNow: -loopManager.carbStore.maximumAbsorptionTimeInterval)) + let start = min(Calendar.current.startOfDay(for: Date()), Date(timeIntervalSinceNow: -CarbMath.maximumAbsorptionTimeInterval)) loopManager.carbStore.getCarbEntries(start: start) { (result) in switch result { diff --git a/WatchApp Extension/Controllers/ChartHUDController.swift b/WatchApp Extension/Controllers/ChartHUDController.swift index f7aa0b0231..d093bca3c9 100644 --- a/WatchApp Extension/Controllers/ChartHUDController.swift +++ b/WatchApp Extension/Controllers/ChartHUDController.swift @@ -13,6 +13,7 @@ import HealthKit import SpriteKit import os.log import LoopCore +import LoopAlgorithm final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { private enum TableRow: Int, CaseIterable { @@ -162,7 +163,7 @@ final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { cell.setIsLastRow(row.isLast) cell.setContentInset(systemMinimumLayoutMargins) - let isActiveContextStale = Date().timeIntervalSince(activeContext.creationDate) > LoopCoreConstants.inputDataRecencyInterval + let isActiveContextStale = Date().timeIntervalSince(activeContext.creationDate) > LoopAlgorithm.inputDataRecencyInterval switch row { case .iob: diff --git a/WatchApp Extension/Controllers/HUDInterfaceController.swift b/WatchApp Extension/Controllers/HUDInterfaceController.swift index b23dc56680..eca7b0424a 100644 --- a/WatchApp Extension/Controllers/HUDInterfaceController.swift +++ b/WatchApp Extension/Controllers/HUDInterfaceController.swift @@ -9,6 +9,7 @@ import WatchKit import LoopCore import LoopKit +import LoopAlgorithm class HUDInterfaceController: WKInterfaceController { private var activeContextObserver: NSObjectProtocol? @@ -79,14 +80,22 @@ class HUDInterfaceController: WKInterfaceController { eventualGlucoseLabel.setHidden(true) } - if let glucose = activeContext.glucose, let glucoseDate = activeContext.glucoseDate, let unit = activeContext.displayGlucoseUnit, glucoseDate.timeIntervalSinceNow > -LoopCoreConstants.inputDataRecencyInterval { + if let glucose = activeContext.glucose, let glucoseDate = activeContext.glucoseDate, let unit = activeContext.displayGlucoseUnit, glucoseDate.timeIntervalSinceNow > -LoopAlgorithm.inputDataRecencyInterval { let formatter = NumberFormatter.glucoseFormatter(for: unit) - - if let glucoseValue = formatter.string(from: glucose.doubleValue(for: unit)) { + + var glucoseValue: String? + + if let glucoseCondition = activeContext.glucoseCondition { + glucoseValue = glucoseCondition.localizedDescription + } else { + glucoseValue = formatter.string(from: glucose.doubleValue(for: unit)) + } + + if let glucoseValue { let trend = activeContext.glucoseTrend?.symbol ?? "" glucoseLabel.setText(glucoseValue + trend) } - + if showEventualGlucose, let eventualGlucose = activeContext.eventualGlucose, let eventualGlucoseValue = formatter.string(from: eventualGlucose.doubleValue(for: unit)) { eventualGlucoseLabel.setText(eventualGlucoseValue) eventualGlucoseLabel.setHidden(false) diff --git a/WatchApp Extension/Controllers/OverrideSelectionController.swift b/WatchApp Extension/Controllers/OverrideSelectionController.swift index ba79776138..93537cd987 100644 --- a/WatchApp Extension/Controllers/OverrideSelectionController.swift +++ b/WatchApp Extension/Controllers/OverrideSelectionController.swift @@ -23,7 +23,7 @@ final class OverrideSelectionController: WKInterfaceController, IdentifiableClas @IBOutlet private var table: WKInterfaceTable! private let loopManager = ExtensionDelegate.shared().loopManager - private lazy var presets = loopManager.settings.overridePresets + private lazy var presets = loopManager.watchInfo.loopSettings.overridePresets weak var delegate: OverrideSelectionControllerDelegate? diff --git a/WatchApp Extension/ExtensionDelegate.swift b/WatchApp Extension/ExtensionDelegate.swift index 1ef1d13d75..946669adf4 100644 --- a/WatchApp Extension/ExtensionDelegate.swift +++ b/WatchApp Extension/ExtensionDelegate.swift @@ -219,9 +219,9 @@ extension ExtensionDelegate: WCSessionDelegate { switch name { case LoopSettingsUserInfo.name: - if let settings = LoopSettingsUserInfo(rawValue: userInfo)?.settings { + if let loopSettings = LoopSettingsUserInfo(rawValue: userInfo) { DispatchQueue.main.async { - self.loopManager.settings = settings + self.loopManager.watchInfo = loopSettings } } else { log.error("Could not decode LoopSettingsUserInfo: %{public}@", userInfo) diff --git a/WatchApp Extension/Extensions/CLKComplicationTemplate.swift b/WatchApp Extension/Extensions/CLKComplicationTemplate.swift index f49a9f2db0..2518644375 100644 --- a/WatchApp Extension/Extensions/CLKComplicationTemplate.swift +++ b/WatchApp Extension/Extensions/CLKComplicationTemplate.swift @@ -11,6 +11,7 @@ import HealthKit import LoopKit import Foundation import LoopCore +import LoopAlgorithm extension CLKComplicationTemplate { @@ -25,16 +26,19 @@ extension CLKComplicationTemplate { return nil } - return templateForFamily(family, + return templateForFamily( + family, glucose: glucose, unit: unit, glucoseDate: context.glucoseDate, trend: context.glucoseTrend, + glucoseCondition: context.glucoseCondition, eventualGlucose: context.eventualGlucose, at: date, loopLastRunDate: context.loopLastRunDate, recencyInterval: recencyInterval, - chartGenerator: makeChart) + chartGenerator: makeChart + ) } static func templateForFamily( @@ -43,6 +47,7 @@ extension CLKComplicationTemplate { unit: HKUnit, glucoseDate: Date?, trend: GlucoseTrend?, + glucoseCondition: GlucoseCondition?, eventualGlucose: HKQuantity?, at date: Date, loopLastRunDate: Date?, @@ -65,7 +70,15 @@ extension CLKComplicationTemplate { glucoseString = NSLocalizedString("---", comment: "No glucose value representation (3 dashes for mg/dL; no spaces as this will get truncated in the watch complication)") trendString = "" } else { - guard let formattedGlucose = formatter.string(from: glucose.doubleValue(for: unit)) else { + var formattedGlucose: String? + + if let glucoseCondition { + formattedGlucose = glucoseCondition.localizedDescription + } else { + formattedGlucose = formatter.string(from: glucose.doubleValue(for: unit)) + } + + guard let formattedGlucose else { return nil } glucoseString = formattedGlucose @@ -161,6 +174,7 @@ extension CLKComplicationTemplate { unit: unit, glucoseDate: glucoseDate, trend: trend, + glucoseCondition: glucoseCondition, eventualGlucose: eventualGlucose, at: date, loopLastRunDate: loopLastRunDate, diff --git a/WatchApp Extension/Extensions/GlucoseCondition.swift b/WatchApp Extension/Extensions/GlucoseCondition.swift new file mode 100644 index 0000000000..5dae0e1a7a --- /dev/null +++ b/WatchApp Extension/Extensions/GlucoseCondition.swift @@ -0,0 +1,21 @@ +// +// GlucoseCondition.swift +// WatchApp Extension +// +// Created by Pete Schwamb on 6/13/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopAlgorithm + +extension GlucoseCondition { + var localizedDescription: String { + switch self { + case .aboveRange: + return NSLocalizedString("HIGH", comment: "String displayed instead of a glucose value above the CGM range") + case .belowRange: + return NSLocalizedString("LOW", comment: "String displayed instead of a glucose value below the CGM range") + } + } +} diff --git a/WatchApp Extension/Extensions/WCSession.swift b/WatchApp Extension/Extensions/WCSession.swift index 246eff2b2c..6eb309309f 100644 --- a/WatchApp Extension/Extensions/WCSession.swift +++ b/WatchApp Extension/Extensions/WCSession.swift @@ -73,7 +73,7 @@ extension WCSession { ) } - func sendSettingsUpdateMessage(_ userInfo: LoopSettingsUserInfo, completionHandler: @escaping (Result) -> Void) throws { + func sendSettingsUpdateMessage(_ userInfo: LoopSettingsUserInfo, completionHandler: @escaping (Result) -> Void) throws { guard activationState == .activated else { throw MessageError.activation } @@ -159,7 +159,7 @@ extension WCSession { ) } - func sendContextRequestMessage(_ userInfo: WatchContextRequestUserInfo, completionHandler: @escaping (Result) -> Void) throws { + func sendContextRequestMessage(_ userInfo: WatchContextRequestUserInfo, completionHandler: @escaping (Result) -> Void) throws { guard activationState == .activated else { throw MessageError.activation } diff --git a/WatchApp Extension/Managers/ComplicationChartManager.swift b/WatchApp Extension/Managers/ComplicationChartManager.swift index bfca19ea24..1d71f66446 100644 --- a/WatchApp Extension/Managers/ComplicationChartManager.swift +++ b/WatchApp Extension/Managers/ComplicationChartManager.swift @@ -11,6 +11,7 @@ import UIKit import HealthKit import WatchKit import LoopKit +import LoopAlgorithm private let textInsets = UIEdgeInsets(top: 2, left: 2, bottom: 2, right: 2) diff --git a/WatchApp Extension/Managers/LoopDataManager.swift b/WatchApp Extension/Managers/LoopDataManager.swift index 579b6a2148..b8b2d4a50f 100644 --- a/WatchApp Extension/Managers/LoopDataManager.swift +++ b/WatchApp Extension/Managers/LoopDataManager.swift @@ -1,5 +1,5 @@ // -// LoopDataManager.swift +// LoopDosingManager.swift // WatchApp Extension // // Created by Bharat Mediratta on 6/21/18. @@ -12,22 +12,23 @@ import LoopKit import LoopCore import WatchConnectivity import os.log +import LoopAlgorithm class LoopDataManager { let carbStore: CarbStore - let glucoseStore: GlucoseStore + var glucoseStore: GlucoseStore! @PersistedProperty(key: "Settings") - private var rawSettings: LoopSettings.RawValue? + private var rawWatchInfo: LoopSettingsUserInfo.RawValue? // Main queue only - var settings: LoopSettings { + var watchInfo: LoopSettingsUserInfo { didSet { needsDidUpdateContextNotification = true sendDidUpdateContextNotificationIfNecessary() - rawSettings = settings.rawValue + rawWatchInfo = watchInfo.rawValue } } @@ -40,7 +41,7 @@ class LoopDataManager { } } - private let log = OSLog(category: "LoopDataManager") + private let log = OSLog(category: "LoopDosingManager") // Main queue only private(set) var activeContext: WatchContext? { @@ -66,20 +67,24 @@ class LoopDataManager { carbStore = CarbStore( cacheStore: cacheStore, cacheLength: .hours(24), // Require 24 hours to store recent carbs "since midnight" for CarbEntryListController - defaultAbsorptionTimes: LoopCoreConstants.defaultCarbAbsorptionTimes, - syncVersion: 0, - provenanceIdentifier: HKSource.default().bundleIdentifier - ) - glucoseStore = GlucoseStore( - cacheStore: cacheStore, - cacheLength: .hours(4), - provenanceIdentifier: HKSource.default().bundleIdentifier + syncVersion: 0 ) - settings = LoopSettings() + self.watchInfo = LoopSettingsUserInfo( + loopSettings: LoopSettings(), + scheduleOverride: nil, + preMealOverride: nil + ) + + Task { + glucoseStore = await GlucoseStore( + cacheStore: cacheStore, + cacheLength: .hours(4) + ) + } - if let rawSettings = rawSettings, let storedSettings = LoopSettings(rawValue: rawSettings) { - self.settings = storedSettings + if let rawWatchInfo = rawWatchInfo, let watchInfo = LoopSettingsUserInfo(rawValue: rawWatchInfo) { + self.watchInfo = watchInfo } } } @@ -94,7 +99,9 @@ extension LoopDataManager { if activeContext == nil || context.shouldReplace(activeContext!) { if let newGlucoseSample = context.newGlucoseSample { - self.glucoseStore.addGlucoseSamples([newGlucoseSample]) { (_) in } + Task { + try? await self.glucoseStore.addGlucoseSamples([newGlucoseSample]) + } } activeContext = context } @@ -112,7 +119,7 @@ extension LoopDataManager { func requestCarbBackfill() { dispatchPrecondition(condition: .onQueue(.main)) - let start = min(Calendar.current.startOfDay(for: Date()), Date(timeIntervalSinceNow: -carbStore.maximumAbsorptionTimeInterval)) + let start = min(Calendar.current.startOfDay(for: Date()), Date(timeIntervalSinceNow: -CarbMath.maximumAbsorptionTimeInterval)) let userInfo = CarbBackfillRequestUserInfo(startDate: start) WCSession.default.sendCarbBackfillRequestMessage(userInfo) { (result) in switch result { @@ -151,8 +158,10 @@ extension LoopDataManager { WCSession.default.sendGlucoseBackfillRequestMessage(userInfo) { (result) in switch result { case .success(let context): - self.glucoseStore.setSyncGlucoseSamples(context.samples) { (error) in - if let error = error { + Task { + do { + try await self.glucoseStore.setSyncGlucoseSamples(context.samples) + } catch { self.log.error("Failure setting sync glucose samples: %{public}@", String(describing: error)) } } @@ -196,20 +205,18 @@ extension LoopDataManager { return } - glucoseStore.getGlucoseSamples(start: .earliestGlucoseCutoff) { result in + Task { var historicalGlucose: [StoredGlucoseSample]? - switch result { - case .failure(let error): + do { + historicalGlucose = try await glucoseStore.getGlucoseSamples(start: .earliestGlucoseCutoff) + } catch { self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) - historicalGlucose = nil - case .success(let samples): - historicalGlucose = samples } let chartData = GlucoseChartData( unit: activeContext.displayGlucoseUnit, - correctionRange: self.settings.glucoseTargetRangeSchedule, - preMealOverride: self.settings.preMealOverride, - scheduleOverride: self.settings.scheduleOverride, + correctionRange: self.watchInfo.loopSettings.glucoseTargetRangeSchedule, + preMealOverride: self.watchInfo.preMealOverride, + scheduleOverride: self.watchInfo.scheduleOverride, historicalGlucose: historicalGlucose, predictedGlucose: (activeContext.isClosedLoop ?? false) ? activeContext.predictedGlucose?.values : nil ) diff --git a/WatchApp Extension/Models/GlucoseChartData.swift b/WatchApp Extension/Models/GlucoseChartData.swift index 4ed6bd7ee8..4bf9a2b2c8 100644 --- a/WatchApp Extension/Models/GlucoseChartData.swift +++ b/WatchApp Extension/Models/GlucoseChartData.swift @@ -9,6 +9,7 @@ import Foundation import HealthKit import LoopKit +import LoopAlgorithm struct GlucoseChartData { diff --git a/WatchApp Extension/Models/GlucoseChartScaler.swift b/WatchApp Extension/Models/GlucoseChartScaler.swift index cb03f8380b..953f5bf1ea 100644 --- a/WatchApp Extension/Models/GlucoseChartScaler.swift +++ b/WatchApp Extension/Models/GlucoseChartScaler.swift @@ -11,6 +11,7 @@ import CoreGraphics import HealthKit import LoopKit import WatchKit +import LoopAlgorithm enum CoordinateSystem { diff --git a/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift b/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift index 4737a2336f..5060e5d372 100644 --- a/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift +++ b/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift @@ -7,6 +7,7 @@ import LoopKit import HealthKit +import LoopAlgorithm protocol GlucoseChartValueHashable { diff --git a/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift b/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift index 396da33e6b..8241fab62a 100644 --- a/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift +++ b/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift @@ -59,7 +59,7 @@ final class CarbAndBolusFlowViewModel: ObservableObject { self._bolusPickerValues = Published( initialValue: BolusPickerValues( supportedVolumes: loopManager.supportedBolusVolumes ?? Self.defaultSupportedBolusVolumes, - maxBolus: loopManager.settings.maximumBolus ?? Self.defaultMaxBolus + maxBolus: loopManager.watchInfo.loopSettings.maximumBolus ?? Self.defaultMaxBolus ) ) @@ -80,7 +80,7 @@ final class CarbAndBolusFlowViewModel: ObservableObject { self.bolusPickerValues = BolusPickerValues( supportedVolumes: loopManager.supportedBolusVolumes ?? Self.defaultSupportedBolusVolumes, - maxBolus: loopManager.settings.maximumBolus ?? Self.defaultMaxBolus + maxBolus: loopManager.watchInfo.loopSettings.maximumBolus ?? Self.defaultMaxBolus ) switch self.configuration {