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/GlucoseRangeSchedule.swift b/Common/Extensions/GlucoseRangeSchedule.swift index 9b092d6ad3..a7bfef414f 100644 --- a/Common/Extensions/GlucoseRangeSchedule.swift +++ b/Common/Extensions/GlucoseRangeSchedule.swift @@ -6,14 +6,14 @@ // import LoopKit -import HealthKit +import LoopAlgorithm extension GlucoseRangeSchedule { - func minQuantity(at date: Date) -> HKQuantity { - return HKQuantity(unit: unit, doubleValue: value(at: date).minValue) + func minQuantity(at date: Date) -> LoopQuantity { + return LoopQuantity(unit: unit, doubleValue: value(at: date).minValue) } - func maxQuantity(at date: Date) -> HKQuantity { - return HKQuantity(unit: unit, doubleValue: value(at: date).maxValue) + func maxQuantity(at date: Date) -> LoopQuantity { + return LoopQuantity(unit: unit, doubleValue: value(at: date).maxValue) } } diff --git a/Common/Extensions/HKUnit.swift b/Common/Extensions/HKUnit.swift index 36f7576d80..69cf8fb40c 100644 --- a/Common/Extensions/HKUnit.swift +++ b/Common/Extensions/HKUnit.swift @@ -6,13 +6,13 @@ // Copyright © 2016 LoopKit Authors. All rights reserved. // -import HealthKit +import LoopAlgorithm import LoopCore // Code in this extension is duplicated from: // https://github.com/LoopKit/LoopKit/blob/master/LoopKit/HKUnit.swift // to avoid pulling in the LoopKit extension since it's not extension-API safe. -extension HKUnit { +extension LoopUnit { // A formatting helper for determining the preferred decimal style for a given unit var preferredFractionDigits: Int { if self == .milligramsPerDeciliter { @@ -22,20 +22,6 @@ extension HKUnit { } } - var localizedShortUnitString: String { - if self == HKUnit.millimolesPerLiter { - return NSLocalizedString("mmol/L", comment: "The short unit display string for millimoles of glucose per liter") - } else if self == .milligramsPerDeciliter { - return NSLocalizedString("mg/dL", comment: "The short unit display string for milligrams of glucose per decilter") - } else if self == .internationalUnit() { - return NSLocalizedString("U", comment: "The short unit display string for international units of insulin") - } else if self == .gram() { - return NSLocalizedString("g", comment: "The short unit display string for grams") - } else { - return String(describing: self) - } - } - /// The smallest value expected to be visible on a chart var chartableIncrement: Double { if self == .milligramsPerDeciliter { 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/NumberFormatter.swift b/Common/Extensions/NumberFormatter.swift index 51f411ae7d..c0f8c3addb 100644 --- a/Common/Extensions/NumberFormatter.swift +++ b/Common/Extensions/NumberFormatter.swift @@ -7,11 +7,11 @@ // import Foundation -import HealthKit +import LoopAlgorithm extension NumberFormatter { - static func glucoseFormatter(for unit: HKUnit) -> NumberFormatter { + static func glucoseFormatter(for unit: LoopUnit) -> NumberFormatter { let numberFormatter = NumberFormatter() numberFormatter.numberStyle = .decimal @@ -24,11 +24,11 @@ extension NumberFormatter { return string(from: NSNumber(value: number)) } - func string(from quantity: HKQuantity, unit: HKUnit) -> String? { + func string(from quantity: LoopQuantity, unit: LoopUnit) -> String? { return string(from: quantity.doubleValue(for: unit), unit: unit) } - func string(from number: Double, unit: HKUnit) -> String? { + func string(from number: Double, unit: LoopUnit) -> String? { return string(from: number, unit: unit.localizedShortUnitString) } diff --git a/Common/Extensions/SampleValue.swift b/Common/Extensions/SampleValue.swift index 39dcb16e9c..b85fec0145 100644 --- a/Common/Extensions/SampleValue.swift +++ b/Common/Extensions/SampleValue.swift @@ -5,15 +5,14 @@ // Copyright © 2018 LoopKit Authors. All rights reserved. // -import HealthKit import LoopKit - +import LoopAlgorithm extension Collection where Element == SampleValue { /// O(n) - var quantityRange: ClosedRange? { - var lowest: HKQuantity? - var highest: HKQuantity? + var quantityRange: ClosedRange? { + var lowest: LoopQuantity? + var highest: LoopQuantity? for sample in self { if let l = lowest { diff --git a/Common/Extensions/UserDefaults+LoopIntents.swift b/Common/Extensions/UserDefaults+LoopIntents.swift index 07082d0615..7d04a47681 100644 --- a/Common/Extensions/UserDefaults+LoopIntents.swift +++ b/Common/Extensions/UserDefaults+LoopIntents.swift @@ -7,6 +7,7 @@ // import Foundation +import LoopCore extension UserDefaults { diff --git a/Common/FeatureFlags.swift b/Common/FeatureFlags.swift index 0c6440b564..d44a9203c2 100644 --- a/Common/FeatureFlags.swift +++ b/Common/FeatureFlags.swift @@ -11,42 +11,41 @@ import Foundation let FeatureFlags = FeatureFlagConfiguration() struct FeatureFlagConfiguration: Decodable { - let automaticBolusEnabled: Bool + let dosingStrategySelectionEnabled: Bool let cgmManagerCategorizeManualGlucoseRangeEnabled: Bool let criticalAlertsEnabled: Bool let entryDeletionEnabled: Bool let fiaspInsulinModelEnabled: Bool let lyumjevInsulinModelEnabled: Bool let afrezzaInsulinModelEnabled: Bool + let apidraInsulinModelEnabled: Bool let includeServicesInSettingsEnabled: Bool let manualDoseEntryEnabled: Bool + let doseDeletionEnabled: Bool let insulinDeliveryReservoirViewEnabled: Bool let mockTherapySettingsEnabled: Bool - let nonlinearCarbModelEnabled: Bool let observeHealthKitCarbSamplesFromOtherApps: Bool let observeHealthKitDoseSamplesFromOtherApps: Bool let observeHealthKitGlucoseSamplesFromOtherApps: Bool let remoteCommandsEnabled: Bool let predictedGlucoseChartClampEnabled: Bool let scenariosEnabled: Bool - let sensitivityOverridesEnabled: Bool let showEventualBloodGlucoseOnWatchEnabled: Bool let simulatedCoreDataEnabled: Bool let siriEnabled: Bool let simpleBolusCalculatorEnabled: Bool let usePositiveMomentumAndRCForManualBoluses: Bool - let adultChildInsulinModelSelectionEnabled: Bool 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. - #if AUTOMATIC_BOLUS_DISABLED - self.automaticBolusEnabled = false + #if DOSING_STRATEGY_SELECTION_DISABLED + self.dosingStrategySelectionEnabled = false #else - self.automaticBolusEnabled = true + self.dosingStrategySelectionEnabled = true #endif #if CGM_MANAGER_CATEGORIZE_GLUCOSE_RANGE_ENABLED @@ -67,13 +66,6 @@ struct FeatureFlagConfiguration: Decodable { #else self.entryDeletionEnabled = true #endif - - // Swift compiler config is inverse, since the default state is enabled. - #if FEATURE_OVERRIDES_DISABLED - self.sensitivityOverridesEnabled = false - #else - self.sensitivityOverridesEnabled = true - #endif // Swift compiler config is inverse, since the default state is enabled. #if FIASP_INSULIN_MODEL_DISABLED @@ -82,6 +74,13 @@ struct FeatureFlagConfiguration: Decodable { self.fiaspInsulinModelEnabled = true #endif + // Swift compiler config is inverse, since the default state is enabled. + #if APIDRA_INSULIN_MODEL_DISABLED + self.apidraInsulinModelEnabled = false + #else + self.apidraInsulinModelEnabled = true + #endif + // Swift compiler config is inverse, since the default state is enabled. #if LYUMJEV_INSULIN_MODEL_DISABLED self.lyumjevInsulinModelEnabled = false @@ -110,6 +109,13 @@ struct FeatureFlagConfiguration: Decodable { self.manualDoseEntryEnabled = true #endif + // Swift compiler config is inverse, since the default state is enabled. + #if DOSE_DELETION_DISABLED + self.doseDeletionEnabled = false + #else + self.doseDeletionEnabled = true + #endif + // Swift compiler config is inverse, since the default state is enabled. #if INSULIN_DELIVERY_RESERVOIR_VIEW_DISABLED self.insulinDeliveryReservoirViewEnabled = false @@ -123,13 +129,6 @@ struct FeatureFlagConfiguration: Decodable { self.mockTherapySettingsEnabled = false #endif - // Swift compiler config is inverse, since the default state is enabled. - #if NONLINEAR_CARB_MODEL_DISABLED - self.nonlinearCarbModelEnabled = false - #else - self.nonlinearCarbModelEnabled = true - #endif - #if OBSERVE_HEALTH_KIT_CARB_SAMPLES_FROM_OTHER_APPS_ENABLED self.observeHealthKitCarbSamplesFromOtherApps = true #else @@ -207,12 +206,6 @@ struct FeatureFlagConfiguration: Decodable { self.usePositiveMomentumAndRCForManualBoluses = true #endif - #if ADULT_CHILD_INSULIN_MODEL_SELECTION_ENABLED - self.adultChildInsulinModelSelectionEnabled = true - #else - self.adultChildInsulinModelSelectionEnabled = false - #endif - // ProfileExpirationSettingsView is inverse, since the default state is enabled. #if PROFILE_EXPIRATION_SETTINGS_VIEW_DISABLED self.profileExpirationSettingsViewEnabled = false @@ -232,6 +225,12 @@ struct FeatureFlagConfiguration: Decodable { #else self.allowAlgorithmExperiments = false #endif + + #if INVESTIGATIONAL_DEVICE + self.isInvestigationalDevice = true + #else + self.isInvestigationalDevice = false + #endif } } @@ -245,29 +244,29 @@ extension FeatureFlagConfiguration : CustomDebugStringConvertible { "* fiaspInsulinModelEnabled: \(fiaspInsulinModelEnabled)", "* lyumjevInsulinModelEnabled: \(lyumjevInsulinModelEnabled)", "* afrezzaInsulinModelEnabled: \(afrezzaInsulinModelEnabled)", + "* apidraInsulinModelEnabled: \(apidraInsulinModelEnabled)", "* includeServicesInSettingsEnabled: \(includeServicesInSettingsEnabled)", "* mockTherapySettingsEnabled: \(mockTherapySettingsEnabled)", - "* nonlinearCarbModelEnabled: \(nonlinearCarbModelEnabled)", "* observeHealthKitCarbSamplesFromOtherApps: \(observeHealthKitCarbSamplesFromOtherApps)", "* observeHealthKitDoseSamplesFromOtherApps: \(observeHealthKitDoseSamplesFromOtherApps)", "* observeHealthKitGlucoseSamplesFromOtherApps: \(observeHealthKitGlucoseSamplesFromOtherApps)", "* predictedGlucoseChartClampEnabled: \(predictedGlucoseChartClampEnabled)", "* remoteCommandsEnabled: \(remoteCommandsEnabled)", "* scenariosEnabled: \(scenariosEnabled)", - "* sensitivityOverridesEnabled: \(sensitivityOverridesEnabled)", "* showEventualBloodGlucoseOnWatchEnabled: \(showEventualBloodGlucoseOnWatchEnabled)", "* simulatedCoreDataEnabled: \(simulatedCoreDataEnabled)", "* siriEnabled: \(siriEnabled)", - "* automaticBolusEnabled: \(automaticBolusEnabled)", + "* dosingStrategySelectionEnabled: \(dosingStrategySelectionEnabled)", "* manualDoseEntryEnabled: \(manualDoseEntryEnabled)", + "* doseDeletionEnabled: \(doseDeletionEnabled)", "* allowDebugFeatures: \(allowDebugFeatures)", "* simpleBolusCalculatorEnabled: \(simpleBolusCalculatorEnabled)", "* usePositiveMomentumAndRCForManualBoluses: \(usePositiveMomentumAndRCForManualBoluses)", - "* adultChildInsulinModelSelectionEnabled: \(adultChildInsulinModelSelectionEnabled)", "* 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 deleted file mode 100644 index a6123825d8..0000000000 --- a/Common/Models/LoopSettingsUserInfo.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// LoopSettingsUserInfo.swift -// Loop -// -// Copyright © 2018 LoopKit Authors. All rights reserved. -// - -import LoopCore - - -struct LoopSettingsUserInfo { - let settings: LoopSettings -} - - -extension LoopSettingsUserInfo: RawRepresentable { - typealias RawValue = [String: Any] - - static let name = "LoopSettingsUserInfo" - static let version = 1 - - init?(rawValue: RawValue) { - 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) - else { - return nil - } - - self.settings = settings - } - - var rawValue: RawValue { - return [ - "v": LoopSettingsUserInfo.version, - "name": LoopSettingsUserInfo.name, - "s": settings.rawValue - ] - } -} 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/PumpManagerUI.swift b/Common/Models/PumpManagerUI.swift index e9250d4939..465d2dfd7c 100644 --- a/Common/Models/PumpManagerUI.swift +++ b/Common/Models/PumpManagerUI.swift @@ -12,6 +12,7 @@ import LoopKitUI typealias PumpManagerHUDViewRawValue = [String: Any] +@MainActor func PumpManagerHUDViewFromRawValue(_ rawValue: PumpManagerHUDViewRawValue, pluginManager: PluginManager) -> BaseHUDView? { guard let identifier = rawValue["managerIdentifier"] as? String, diff --git a/Common/Models/StatusExtensionContext.swift b/Common/Models/StatusExtensionContext.swift index bee1f32894..114f2a416f 100644 --- a/Common/Models/StatusExtensionContext.swift +++ b/Common/Models/StatusExtensionContext.swift @@ -8,9 +8,10 @@ // This class allows Loop to pass context data to the Loop Status Extension. import Foundation -import HealthKit +import LoopAlgorithm import LoopKit import LoopKitUI +import LoopAlgorithm struct NetBasalContext { @@ -24,24 +25,24 @@ struct GlucoseDisplayableContext: GlucoseDisplayable { let isStateValid: Bool let stateDescription: String let trendType: GlucoseTrend? - let trendRate: HKQuantity? + let trendRate: LoopQuantity? let isLocal: Bool let glucoseRangeCategory: GlucoseRangeCategory? } struct GlucoseContext: GlucoseValue { let value: Double - let unit: HKUnit + let unit: LoopUnit let startDate: Date - var quantity: HKQuantity { - return HKQuantity(unit: unit, doubleValue: value) + var quantity: LoopQuantity { + return LoopQuantity(unit: unit, doubleValue: value) } } struct PredictedGlucoseContext { let values: [Double] - let unit: HKUnit + let unit: LoopUnit let startDate: Date let interval: TimeInterval @@ -161,7 +162,7 @@ extension GlucoseDisplayableContext: RawRepresentable { } if let trendRateValue = rawValue["trendRateValue"] as? Double { - trendRate = HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: trendRateValue) + trendRate = LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: trendRateValue) } else { trendRate = nil } @@ -181,7 +182,7 @@ extension GlucoseDisplayableContext: RawRepresentable { ] raw["trendType"] = trendType?.rawValue if let trendRate = trendRate { - raw["trendRateValue"] = trendRate.doubleValue(for: HKUnit.milligramsPerDeciliterPerMinute) + raw["trendRateValue"] = trendRate.doubleValue(for: LoopUnit.milligramsPerDeciliterPerMinute) } raw["glucoseRangeCategory"] = glucoseRangeCategory?.rawValue @@ -203,7 +204,7 @@ extension PredictedGlucoseContext: RawRepresentable { } self.values = values - self.unit = HKUnit(from: unitString) + self.unit = LoopUnit(from: unitString) self.startDate = startDate self.interval = interval } @@ -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 deleted file mode 100644 index 3ce3adebf1..0000000000 --- a/Common/Models/WatchContext.swift +++ /dev/null @@ -1,174 +0,0 @@ -// -// WatchContext.swift -// Naterade -// -// Created by Nathan Racklyeft on 11/25/15. -// Copyright © 2015 Nathan Racklyeft. All rights reserved. -// - -import Foundation -import HealthKit -import LoopKit - - -final class WatchContext: RawRepresentable { - typealias RawValue = [String: Any] - - private let version = 5 - - var creationDate = Date() - - var displayGlucoseUnit: HKUnit? - - var glucose: HKQuantity? - var glucoseCondition: GlucoseCondition? - var glucoseTrend: GlucoseTrend? - var glucoseTrendRate: HKQuantity? - var glucoseDate: Date? - var glucoseIsDisplayOnly: Bool? - var glucoseWasUserEntered: Bool? - var glucoseSyncIdentifier: String? - - var predictedGlucose: WatchPredictedGlucose? - var eventualGlucose: HKQuantity? { - return predictedGlucose?.values.last?.quantity - } - - var loopLastRunDate: Date? - var lastNetTempBasalDose: Double? - var lastNetTempBasalDate: Date? - var recommendedBolusDose: Double? - - var potentialCarbEntry: NewCarbEntry? - - var cob: Double? - var iob: Double? - var reservoir: Double? - var reservoirPercentage: Double? - var batteryPercentage: Double? - - var cgmManagerState: CGMManager.RawStateValue? - - var isClosedLoop: Bool? - - init() {} - - required init?(rawValue: RawValue) { - guard rawValue["v"] as? Int == version, let creationDate = rawValue["cd"] as? Date else { - return nil - } - - self.creationDate = creationDate - isClosedLoop = rawValue["cl"] as? Bool - - if let unitString = rawValue["gu"] as? String { - displayGlucoseUnit = HKUnit(from: unitString) - } - let unit = displayGlucoseUnit ?? .milligramsPerDeciliter - if let glucoseValue = rawValue["gv"] as? Double { - glucose = HKQuantity(unit: unit, doubleValue: glucoseValue) - } - - if let rawGlucoseCondition = rawValue["gc"] as? GlucoseCondition.RawValue { - glucoseCondition = GlucoseCondition(rawValue: rawGlucoseCondition) - } - if let rawGlucoseTrend = rawValue["gt"] as? GlucoseTrend.RawValue { - glucoseTrend = GlucoseTrend(rawValue: rawGlucoseTrend) - } - if let glucoseTrendRateValue = rawValue["gtrv"] as? Double { - glucoseTrendRate = HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: glucoseTrendRateValue) - } - glucoseDate = rawValue["gd"] as? Date - glucoseIsDisplayOnly = rawValue["gdo"] as? Bool - glucoseWasUserEntered = rawValue["gue"] as? Bool - glucoseSyncIdentifier = rawValue["gs"] as? String - iob = rawValue["iob"] as? Double - reservoir = rawValue["r"] as? Double - reservoirPercentage = rawValue["rp"] as? Double - batteryPercentage = rawValue["bp"] as? Double - - loopLastRunDate = rawValue["ld"] as? Date - lastNetTempBasalDose = rawValue["ba"] as? Double - lastNetTempBasalDate = rawValue["bad"] as? Date - recommendedBolusDose = rawValue["rbo"] as? Double - if let rawPotentialCarbEntry = rawValue["pce"] as? NewCarbEntry.RawValue { - potentialCarbEntry = NewCarbEntry(rawValue: rawPotentialCarbEntry) - } - cob = rawValue["cob"] as? Double - - cgmManagerState = rawValue["cgmManagerState"] as? CGMManager.RawStateValue - - if let rawValue = rawValue["pg"] as? WatchPredictedGlucose.RawValue { - predictedGlucose = WatchPredictedGlucose(rawValue: rawValue) - } - } - - var rawValue: RawValue { - var raw: [String: Any] = [ - "v": version, - "cd": creationDate - ] - - raw["ba"] = lastNetTempBasalDose - raw["bad"] = lastNetTempBasalDate - raw["bp"] = batteryPercentage - raw["cl"] = isClosedLoop - - raw["cgmManagerState"] = cgmManagerState - - raw["cob"] = cob - - let unit = displayGlucoseUnit ?? .milligramsPerDeciliter - raw["gu"] = displayGlucoseUnit?.unitString - raw["gv"] = glucose?.doubleValue(for: unit) - - raw["gc"] = glucoseCondition?.rawValue - raw["gt"] = glucoseTrend?.rawValue - if let glucoseTrendRate = glucoseTrendRate { - let unitPerMinute = unit.unitDivided(by: .minute()) - raw["gtru"] = unitPerMinute.unitString - raw["gtrv"] = glucoseTrendRate.doubleValue(for: unitPerMinute) - } - raw["gd"] = glucoseDate - raw["gdo"] = glucoseIsDisplayOnly - raw["gue"] = glucoseWasUserEntered - raw["gs"] = glucoseSyncIdentifier - raw["iob"] = iob - raw["ld"] = loopLastRunDate - raw["r"] = reservoir - raw["rbo"] = recommendedBolusDose - raw["pce"] = potentialCarbEntry?.rawValue - raw["rp"] = reservoirPercentage - - raw["pg"] = predictedGlucose?.rawValue - - return raw - } -} - - -extension WatchContext { - func shouldReplace(_ other: WatchContext) -> Bool { - if let date = self.glucoseDate, let otherDate = other.glucoseDate { - return date >= otherDate - } else { - return true - } - } -} - -extension WatchContext { - var newGlucoseSample: NewGlucoseSample? { - if let quantity = glucose, let date = glucoseDate, let syncIdentifier = glucoseSyncIdentifier { - return NewGlucoseSample(date: date, - quantity: quantity, - condition: glucoseCondition, - trend: glucoseTrend, - trendRate: glucoseTrendRate, - isDisplayOnly: glucoseIsDisplayOnly ?? false, - wasUserEntered: glucoseWasUserEntered ?? false, - syncIdentifier: syncIdentifier, syncVersion: 0) - } - return nil - } -} diff --git a/Common/Models/WatchHistoricalGlucose.swift b/Common/Models/WatchHistoricalGlucose.swift index 13fda34816..71209d428d 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] @@ -72,10 +73,10 @@ extension WatchHistoricalGlucose: RawRepresentable { syncIdentifier: syncIdentifiers[$0], syncVersion: syncVersions[$0], startDate: startDates[$0], - quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: quantities[$0]), + quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: quantities[$0]), condition: conditions[$0], trend: trends[$0], - trendRate: trendRates[$0].flatMap { HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: $0) }, + trendRate: trendRates[$0].flatMap { LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: $0) }, isDisplayOnly: isDisplayOnlys[$0], wasUserEntered: wasUserEntereds[$0], device: devices[$0].flatMap { try? HKDevice(from: $0) }, diff --git a/Learn/Configuration/QuantityRangeEntry.swift b/Learn/Configuration/QuantityRangeEntry.swift index abb7ac344e..a549730af9 100644 --- a/Learn/Configuration/QuantityRangeEntry.swift +++ b/Learn/Configuration/QuantityRangeEntry.swift @@ -5,7 +5,6 @@ // Copyright © 2019 LoopKit Authors. All rights reserved. // -import HealthKit import LoopKit import UIKit diff --git a/Learn/Lessons/ModalDayLesson.swift b/Learn/Lessons/ModalDayLesson.swift index 612e55e41a..0b40c283d0 100644 --- a/Learn/Lessons/ModalDayLesson.swift +++ b/Learn/Lessons/ModalDayLesson.swift @@ -6,7 +6,6 @@ // import Foundation -import HealthKit import LoopCore import LoopKit import os.log diff --git a/Learn/Lessons/TimeInRangeLesson.swift b/Learn/Lessons/TimeInRangeLesson.swift index f01b3967cb..6f02fb8720 100644 --- a/Learn/Lessons/TimeInRangeLesson.swift +++ b/Learn/Lessons/TimeInRangeLesson.swift @@ -10,7 +10,6 @@ import LoopCore import LoopKit import LoopKitUI import LoopUI -import HealthKit import os.log diff --git a/Learn/Managers/DataManager.swift b/Learn/Managers/DataManager.swift index 80e958a02f..15e3220f1b 100644 --- a/Learn/Managers/DataManager.swift +++ b/Learn/Managers/DataManager.swift @@ -6,7 +6,6 @@ // import Foundation -import HealthKit import LoopKit import LoopCore @@ -47,7 +46,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/LocalizablePlural.xcstrings b/LocalizablePlural.xcstrings new file mode 100644 index 0000000000..ef7bb6d41d --- /dev/null +++ b/LocalizablePlural.xcstrings @@ -0,0 +1,75 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "%d day" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d day" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d days" + } + } + } + } + } + } + }, + "%d hr" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d hr" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d hrs" + } + } + } + } + } + } + }, + "%d min" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d min" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d mins" + } + } + } + } + } + } + } + }, + "version" : "1.1" +} \ No newline at end of file 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 Widget Extension/Bootstrap/Localizable.xcstrings b/Loop Widget Extension/Bootstrap/Localizable.xcstrings index b6b231104c..91590110de 100644 --- a/Loop Widget Extension/Bootstrap/Localizable.xcstrings +++ b/Loop Widget Extension/Bootstrap/Localizable.xcstrings @@ -154,6 +154,7 @@ } }, "??" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -867,6 +868,7 @@ }, "g" : { "comment" : "The short unit display string for grams", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -1118,6 +1120,7 @@ }, "mg/dL" : { "comment" : "The short unit display string for milligrams of glucose per decilter", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -1243,6 +1246,7 @@ }, "mmol/L" : { "comment" : "The short unit display string for millimoles of glucose per liter", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -1643,6 +1647,7 @@ }, "U" : { "comment" : "The short unit display string for international units of insulin", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { 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..86880f572e --- /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://\(host.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..b865ce0e02 100644 --- a/Loop Widget Extension/Components/GlucoseView.swift +++ b/Loop Widget Extension/Components/GlucoseView.swift @@ -8,82 +8,48 @@ import SwiftUI import LoopKit -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 7962ec1a9f..0000000000 --- a/Loop Widget Extension/Components/SystemActionLink.swift +++ /dev/null @@ -1,88 +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 - -@available(iOS 16.1, *) -struct SystemActionLink: View { - - @Environment(\.widgetRenderingMode) private var widgetRenderingMode - - 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 ? Color("WidgetBackground") : Color("fresh") - case .customPreset: - return active ? Color("WidgetBackground") : Color("glucose") - } - } - - private func backgroundColor(active: Bool) -> Color { - if widgetRenderingMode == .accented { - Color(UIColor.systemBackground).opacity(active ? 0.45 : 0.15) - } else { - switch destination { - case .carbEntry: - active ? Color("fresh") : Color("WidgetSecondaryBackground") - case .bolus: - active ? Color("insulin") : Color("WidgetSecondaryBackground") - case .preMeal: - active ? Color("fresh") : Color("WidgetSecondaryBackground") - case .customPreset: - 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(backgroundColor(active: active)) - .clipShape(ContainerRelativeShape()) - } - } -} 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/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/Live Activity/ChartView.swift b/Loop Widget Extension/Live Activity/ChartView.swift index 241c1f4667..3773b5c1b2 100644 --- a/Loop Widget Extension/Live Activity/ChartView.swift +++ b/Loop Widget Extension/Live Activity/ChartView.swift @@ -98,8 +98,7 @@ struct ChartView: View { } var body: some View { - ZStack(alignment: Alignment(horizontal: .trailing, vertical: .top)){ - Chart { + Chart { if let preset = self.preset, (preset.minValue > 0 || preset.maxValue > 0), predicatedData.count > 0, preset.endDate > Date.now.addingTimeInterval(.hours(-6)) { let (presetMin, presetMax) = adjustedRange(min: preset.minValue, max: preset.maxValue) RectangleMark( @@ -146,9 +145,24 @@ struct ChartView: View { "Low": Self.colorBelowRange, "Default": Color("glucose") ]) - .chartPlotStyle { plotContent in - plotContent.background(.cyan.opacity(0.15)) - } + .chartPlotStyle { plotContent in + plotContent + .background(.cyan.opacity(0.15)) + .overlay(alignment: .topTrailing) { + if let preset = self.preset, preset.endDate > Date.now { + Group { + if let symbolName = preset.iconSystemSymbolName { + Text(Image(systemName: symbolName)) + Text(" ") + Text(preset.title) + } else { + Text(preset.title) + } + } + .font(.footnote) + .padding(.trailing, 4) + .padding(.top, 2) + } + } + } .chartLegend(.hidden) .chartYScale(domain: [yAxisMarks.first ?? 0, yAxisMarks.last ?? 0]) .chartYAxis { @@ -161,20 +175,12 @@ struct ChartView: View { .foregroundStyle(Color.primary) } } - .chartXAxis { - AxisMarks(position: .automatic, values: .stride(by: .hour)) { _ in - AxisValueLabel(format: .dateTime.hour(.twoDigits(amPM: .narrow)), anchor: .top) - .foregroundStyle(Color.primary) - AxisGridLine(stroke: .init(lineWidth: 0.1, dash: [2, 3])) - .foregroundStyle(Color.primary) - } - } - - if let preset = self.preset, preset.endDate > Date.now { - Text(preset.title) - .font(.footnote) - .padding(.trailing, 5) - .padding(.top, 2) + .chartXAxis { + AxisMarks(position: .automatic, values: .stride(by: .hour)) { _ in + AxisValueLabel(format: .dateTime.hour(.twoDigits(amPM: .narrow)), anchor: .top) + .foregroundStyle(Color.primary) + AxisGridLine(stroke: .init(lineWidth: 0.1, dash: [2, 3])) + .foregroundStyle(Color.primary) } } } diff --git a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift index b288c71458..314d0de950 100644 --- a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift +++ b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift @@ -9,6 +9,7 @@ import ActivityKit import Charts import HealthKit +import LoopAlgorithm import LoopCore import LoopKit import SwiftUI @@ -205,12 +206,12 @@ struct GlucoseLiveActivityConfiguration: Widget { ) -> some View { let glucoseFormatter = NumberFormatter.glucoseFormatter( for: context.state.isMmol - ? HKUnit.millimolesPerLiter - : HKUnit.milligramsPerDeciliter + ? LoopUnit.millimolesPerLiter + : LoopUnit.milligramsPerDeciliter ) let unit = context.state.isMmol - ? HKUnit.millimolesPerLiter.localizedShortUnitString - : HKUnit.milligramsPerDeciliter.localizedShortUnitString + ? LoopUnit.millimolesPerLiter.localizedShortUnitString + : LoopUnit.milligramsPerDeciliter.localizedShortUnitString let glucoseColor = !context.attributes.useLimits ? .primary : getGlucoseColor(context: context) let currentBG = (glucoseFormatter.string(from: context.state.currentGlucose) ?? "??") + getArrowImage(context.state.trendType) @@ -263,7 +264,7 @@ struct GlucoseLiveActivityConfiguration: Widget { ) -> DynamicIsland { let glucoseFormatter = NumberFormatter.glucoseFormatter( for: context.state.isMmol - ? HKUnit.millimolesPerLiter : HKUnit.milligramsPerDeciliter + ? LoopUnit.millimolesPerLiter : LoopUnit.milligramsPerDeciliter ) return DynamicIsland { @@ -287,8 +288,8 @@ struct GlucoseLiveActivityConfiguration: Widget { .font(.headline) Text( context.state.isMmol - ? HKUnit.millimolesPerLiter.localizedShortUnitString - : HKUnit.milligramsPerDeciliter + ? LoopUnit.millimolesPerLiter.localizedShortUnitString + : LoopUnit.milligramsPerDeciliter .localizedShortUnitString ) .foregroundStyle(Color(white: 0.7)) diff --git a/Loop Widget Extension/LoopWidgets.swift b/Loop Widget Extension/LoopWidgets.swift index f87c2b89ff..a7fa5fcff6 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 { if #available(iOS 16.1, *) { diff --git a/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift b/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift index 45271bbe14..4fe09ba12f 100644 --- a/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift +++ b/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift @@ -6,10 +6,11 @@ // Copyright © 2023 LoopKit Authors. All rights reserved. // -import HealthKit import LoopCore import LoopKit import WidgetKit +import LoopAlgorithm + struct StatusWidgetTimelimeEntry: TimelineEntry { var date: Date @@ -17,12 +18,14 @@ struct StatusWidgetTimelimeEntry: TimelineEntry { let contextUpdatedAt: Date let lastLoopCompleted: Date? + let mostRecentGlucoseDataDate: Date? + let mostRecentPumpDataDate: Date? let closeLoop: Bool let currentGlucose: GlucoseValue? let glucoseFetchedAt: Date? - let delta: HKQuantity? - let unit: HKUnit? + let delta: LoopQuantity? + let unit: LoopUnit? let sensor: GlucoseDisplayableContext? let pumpHighlight: DeviceStatusHighlightContext? @@ -53,6 +56,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..04939d6761 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,37 +96,31 @@ 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, + guard let hkUnit = await healthStore.cachedPreferredUnits(for: .bloodGlucose), + let defaults = self.defaults, let context = defaults.statusExtensionContext, - let contextUpdatedAt = context.createdAt, - let unit = await healthStore.cachedPreferredUnits(for: .bloodGlucose) + let contextUpdatedAt = context.createdAt else { return } + let unit = LoopUnit(from: hkUnit) let lastCompleted = context.lastLoopCompleted let closeLoop = context.isClosedLoop ?? false @@ -137,7 +138,7 @@ class StatusWidgetTimelineProvider: TimelineProvider { previousGlucose = finalGlucose[finalGlucose.count - 2] } - var delta: HKQuantity? + var delta: LoopQuantity? // Making sure that previous glucose is within 6 mins of last glucose to avoid large deltas on sensor changes, missed readings, etc. if let prevGlucose = previousGlucose, @@ -145,7 +146,7 @@ class StatusWidgetTimelineProvider: TimelineProvider { currGlucose.startDate.timeIntervalSince(prevGlucose.startDate).minutes < 6 { let deltaMGDL = currGlucose.quantity.doubleValue(for: .milligramsPerDeciliter) - prevGlucose.quantity.doubleValue(for: .milligramsPerDeciliter) - delta = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: deltaMGDL) + delta = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: deltaMGDL) } let predictedGlucose = context.predictedGlucose?.samples @@ -158,6 +159,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 015f6c5d50..fbe95f5384 100644 --- a/Loop Widget Extension/Widgets/SystemStatusWidget.swift +++ b/Loop Widget Extension/Widgets/SystemStatusWidget.swift @@ -6,67 +6,82 @@ // Copyright © 2022 LoopKit Authors. All rights reserved. // +import LoopKit +import LoopKitUI import LoopUI import SwiftUI import WidgetKit -@available(iOS 16.1, *) -struct SystemStatusWidgetEntryView : View { - +struct SystemStatusWidgetEntryView: View { @Environment(\.widgetFamily) private var widgetFamily @Environment(\.widgetRenderingMode) private var widgetRenderingMode - 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( - widgetRenderingMode == .accented - ? Color(UIColor.systemBackground).opacity(0.15) - : Color("WidgetSecondaryBackground") - ) - .clipShape(ContainerRelativeShape()) + .frame(maxHeight: .infinity, alignment: .center) + .padding(.vertical, 5) + .containerRelativeBackground() - PumpView(entry: entry) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .padding(5) - .background( - widgetRenderingMode == .accented - ? Color(UIColor.systemBackground).opacity(0.15) - : Color("WidgetSecondaryBackground") - ) - .clipShape(ContainerRelativeShape()) + 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(nil)) - 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.xcconfig b/Loop.xcconfig index 7827f574d5..3039afd41c 100644 --- a/Loop.xcconfig +++ b/Loop.xcconfig @@ -29,18 +29,7 @@ LOOP_LOCAL_CACHE_DURATION_DAYS = 7 LOOP_ENTITLEMENTS = Loop/Loop.entitlements // Code signing and provisioning [DEFAULT] -LOOP_CODE_SIGN_IDENTITY_DEBUG = Apple Development -LOOP_CODE_SIGN_IDENTITY_RELEASE = Apple Development -LOOP_CODE_SIGN_STYLE = Automatic LOOP_DEVELOPMENT_TEAM = -LOOP_PROVISIONING_PROFILE_SPECIFIER_DEBUG = -LOOP_PROVISIONING_PROFILE_SPECIFIER_STATUS_EXTENSION_DEBUG = -LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_DEBUG = -LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_EXTENSION_DEBUG = -LOOP_PROVISIONING_PROFILE_SPECIFIER_RELEASE = -LOOP_PROVISIONING_PROFILE_SPECIFIER_STATUS_EXTENSION_RELEASE = -LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_RELEASE = -LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_EXTENSION_RELEASE = // Min iOS Version [DEFAULT] IPHONEOS_DEPLOYMENT_TARGET = 16.2 diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 069edf83ea..5d12d694f3 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -7,14 +7,19 @@ objects = { /* Begin PBXBuildFile section */ - 1419606428D9550400BA86E0 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; }; 1419606928D9554E00BA86E0 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; }; 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 */; }; @@ -24,11 +29,10 @@ 149A28E42A8A63A700052EDF /* FavoriteFoodDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149A28E32A8A63A700052EDF /* FavoriteFoodDetailView.swift */; }; 14B1735E28AED9EC006CCD7C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 14B1735D28AED9EC006CCD7C /* WidgetKit.framework */; }; 14B1736028AED9EC006CCD7C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 14B1735F28AED9EC006CCD7C /* SwiftUI.framework */; }; - 14B1736928AED9EE006CCD7C /* Loop Widget Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 14B1735C28AED9EC006CCD7C /* Loop Widget Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 14B1736928AED9EE006CCD7C /* Loop Widget Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 14B1735C28AED9EC006CCD7C /* Loop Widget Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 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 +45,24 @@ 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 */; }; + 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 +71,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 */; }; @@ -69,6 +80,7 @@ 1DB619AC270BAD3D006C9D07 /* VersionUpdateViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB619AB270BAD3D006C9D07 /* VersionUpdateViewModel.swift */; }; 1DDE273D24AEA4B000796622 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB1CA4E24A56D7600B3B94C /* SettingsViewModel.swift */; }; 1DDE273E24AEA4B000796622 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */; }; + BAF043BF0BC70EC99AE79F71 /* HealthAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4854073AAE5C2CB97167F63 /* HealthAccessView.swift */; }; 1DDE274024AEA4F200796622 /* NotificationsCriticalAlertPermissionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA46B5F2492E2E300D71A63 /* NotificationsCriticalAlertPermissionsView.swift */; }; 1DFE9E172447B6270082C280 /* UserNotificationAlertSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DFE9E162447B6270082C280 /* UserNotificationAlertSchedulerTests.swift */; }; 3ED3198A2EB659E600820BCF /* GlucoseLiveActivityConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED319882EB659E600820BCF /* GlucoseLiveActivityConfiguration.swift */; }; @@ -94,8 +106,6 @@ 4311FB9B1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4311FB9A1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift */; }; 431A8C401EC6E8AB00823B9C /* CircleMaskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431A8C3F1EC6E8AB00823B9C /* CircleMaskView.swift */; }; 4326BA641F3A44D9007CCAD4 /* ChartLineModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4326BA631F3A44D9007CCAD4 /* ChartLineModel.swift */; }; - 4328E01A1CFBE1DA00E199AA /* ActionHUDController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0151CFBE1DA00E199AA /* ActionHUDController.swift */; }; - 4328E01E1CFBE25F00E199AA /* CarbAndBolusFlowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E01D1CFBE25F00E199AA /* CarbAndBolusFlowController.swift */; }; 4328E0281CFBE2C500E199AA /* CLKComplicationTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0221CFBE2C500E199AA /* CLKComplicationTemplate.swift */; }; 4328E02A1CFBE2C500E199AA /* UIColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0241CFBE2C500E199AA /* UIColor.swift */; }; 4328E02B1CFBE2C500E199AA /* WKAlertAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0251CFBE2C500E199AA /* WKAlertAction.swift */; }; @@ -108,27 +118,16 @@ 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 */; }; - 4345E40421F68AD9009E00E5 /* TextRowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4345E40321F68AD9009E00E5 /* TextRowController.swift */; }; - 4345E40621F68E18009E00E5 /* CarbEntryListController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4345E40521F68E18009E00E5 /* CarbEntryListController.swift */; }; + 4345E40121F67300009E00E5 /* GetBolusRecommendationUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DE92581C5479E4001FFDE1 /* GetBolusRecommendationUserInfo.swift */; }; + 4345E40221F67300009E00E5 /* GetBolusRecommendationUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DE92581C5479E4001FFDE1 /* GetBolusRecommendationUserInfo.swift */; }; 434FF1EE1CF27EEF000DB779 /* UITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434FF1ED1CF27EEF000DB779 /* UITableViewCell.swift */; }; - 43511CEE220FC61700566C63 /* HUDRowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43511CED220FC61700566C63 /* HUDRowController.swift */; }; 43517917230A0E1A0072ECC0 /* WKInterfaceLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43517916230A0E1A0072ECC0 /* WKInterfaceLabel.swift */; }; - 435400341C9F878D00D5819C /* SetBolusUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */; }; - 435400351C9F878D00D5819C /* SetBolusUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */; }; 436A0DA51D236A2A00104B24 /* LoopError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436A0DA41D236A2A00104B24 /* LoopError.swift */; }; - 4372E484213A63FB0068E043 /* ChartHUDController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FFEDFBE20E5CF22000BFC58 /* ChartHUDController.swift */; }; 4372E487213C86240068E043 /* SampleValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E486213C86240068E043 /* SampleValue.swift */; }; 4372E488213C862B0068E043 /* SampleValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E486213C86240068E043 /* SampleValue.swift */; }; 4372E48B213CB5F00068E043 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E48A213CB5F00068E043 /* Double.swift */; }; 4372E48C213CB6750068E043 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E48A213CB5F00068E043 /* Double.swift */; }; - 4372E490213CFCE70068E043 /* LoopSettingsUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E48F213CFCE70068E043 /* LoopSettingsUserInfo.swift */; }; - 4372E491213D05F90068E043 /* LoopSettingsUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E48F213CFCE70068E043 /* LoopSettingsUserInfo.swift */; }; 4372E492213D956C0068E043 /* GlucoseRangeSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C513181E864C4E001547C7 /* GlucoseRangeSchedule.swift */; }; 4372E496213DCDD30068E043 /* GlucoseChartValueHashable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E495213DCDD30068E043 /* GlucoseChartValueHashable.swift */; }; 4374B5EF209D84BF00D17AA8 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4374B5EE209D84BE00D17AA8 /* OSLog.swift */; }; @@ -153,10 +152,7 @@ 43A51E1F1EB6D62A000736CC /* CarbAbsorptionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A51E1E1EB6D62A000736CC /* CarbAbsorptionViewController.swift */; }; 43A51E211EB6DBDD000736CC /* LoopChartsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A51E201EB6DBDD000736CC /* LoopChartsTableViewController.swift */; }; 43A567691C94880B00334FAC /* LoopDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A567681C94880B00334FAC /* LoopDataManager.swift */; }; - 43A943761B926B7B0051FA24 /* Interface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 43A943741B926B7B0051FA24 /* Interface.storyboard */; }; - 43A9437F1B926B7B0051FA24 /* WatchApp Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 43A9437E1B926B7B0051FA24 /* WatchApp Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 43A943881B926B7B0051FA24 /* ExtensionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A943871B926B7B0051FA24 /* ExtensionDelegate.swift */; }; - 43A9438A1B926B7B0051FA24 /* NotificationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A943891B926B7B0051FA24 /* NotificationController.swift */; }; 43A9438E1B926B7B0051FA24 /* ComplicationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A9438D1B926B7B0051FA24 /* ComplicationController.swift */; }; 43A943901B926B7B0051FA24 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 43A9438F1B926B7B0051FA24 /* Assets.xcassets */; }; 43A943941B926B7B0051FA24 /* WatchApp.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 43A943721B926B7B0051FA24 /* WatchApp.app */; }; @@ -164,12 +160,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, ); }; }; @@ -195,7 +189,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 */; }; @@ -207,14 +200,11 @@ 43FCEEAD221A66780013DD30 /* DateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43FCEEAC221A66780013DD30 /* DateFormatter.swift */; }; 43FCEEB1221A863E0013DD30 /* StatusChartsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43FCEEB0221A863E0013DD30 /* StatusChartsManager.swift */; }; 4F08DE8F1E7BB871006741EA /* CollectionType+Loop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F08DE8E1E7BB871006741EA /* CollectionType+Loop.swift */; }; - 4F11D3C020DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */; }; 4F11D3C220DD80B3006E072C /* WatchHistoricalGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F11D3C120DD80B3006E072C /* WatchHistoricalGlucose.swift */; }; - 4F11D3C320DD84DB006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */; }; 4F11D3C420DD881A006E072C /* WatchHistoricalGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F11D3C120DD80B3006E072C /* WatchHistoricalGlucose.swift */; }; 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 */; }; @@ -222,48 +212,74 @@ 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 */; }; 4F75289C1DFE1F6000C322D6 /* GlucoseHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4337615E1D52F487004A3647 /* GlucoseHUDView.swift */; }; 4F75289E1DFE1F6000C322D6 /* LoopCompletionHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CEEBD1CD6E0CB003C8C80 /* LoopCompletionHUDView.swift */; }; 4F7528A01DFE1F9D00C322D6 /* LoopStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438DADC71CDE8F8B007697A5 /* LoopStateView.swift */; }; - 4F7528A11DFE200B00C322D6 /* BasalStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B371851CE583890013C5A6 /* BasalStateView.swift */; }; + 4F7528A11DFE200B00C322D6 /* TreatmentArrowStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B371851CE583890013C5A6 /* TreatmentArrowStateView.swift */; }; 4F7528A51DFE208C00C322D6 /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; 4F7528AA1DFE215100C322D6 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F526D5E1DF2459000A04910 /* HKUnit.swift */; }; 4F75F00220FCFE8C00B5570E /* GlucoseChartScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F75F00120FCFE8C00B5570E /* GlucoseChartScene.swift */; }; 4F7E8AC520E2AB9600AEA65E /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7E8AC420E2AB9600AEA65E /* Date.swift */; }; - 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 */; }; 7D23667D21250C7E0028B67D /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D23667C21250C7E0028B67D /* LocalizedString.swift */; }; + 7D23667D21250C7E0028B67D /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D23667C21250C7E0028B67D /* LocalizedString.swift */; }; 7E69CFFC2A16A77E00203CBD /* ResetLoopManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */; }; + 841306BB2F7F0D9C00AF0320 /* ReferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841306BA2F7F0D9C00AF0320 /* ReferencesView.swift */; }; + 84213C752D932F0F00642E78 /* InsulinDeliveryLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C742D932F0F00642E78 /* InsulinDeliveryLog.swift */; }; + 84213C892D94941000642E78 /* InsulinDeliveryLogEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C882D94941000642E78 /* InsulinDeliveryLogEvent.swift */; }; + 84213C8B2D94948900642E78 /* InsulinDeliveryOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C8A2D94948900642E78 /* InsulinDeliveryOverview.swift */; }; + 84213C8D2D949E8100642E78 /* InsulinDeliveryLogEventRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C8C2D949E8100642E78 /* InsulinDeliveryLogEventRow.swift */; }; + 842E40A72F22F7E2000CCCE0 /* TintedContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842E40A02F22F7E2000CCCE0 /* TintedContent.swift */; }; + 842E40A92F22F7E2000CCCE0 /* EstimatedReadTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842E40982F22F7E2000CCCE0 /* EstimatedReadTime.swift */; }; + 842E40AA2F22F7E2000CCCE0 /* PresetsTrainingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842E40A32F22F7E2000CCCE0 /* PresetsTrainingView.swift */; }; + 842E40AB2F22F7E2000CCCE0 /* IntensitySlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842E409F2F22F7E2000CCCE0 /* IntensitySlider.swift */; }; + 842E40AD2F22F7E2000CCCE0 /* PresetsTrainingContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842E40A52F22F7E2000CCCE0 /* PresetsTrainingContent.swift */; }; + 842E40AE2F22F7E2000CCCE0 /* PlayMediaButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842E409E2F22F7E2000CCCE0 /* PlayMediaButton.swift */; }; + 842E40AF2F22F7E2000CCCE0 /* PresetsTrainingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842E409A2F22F7E2000CCCE0 /* PresetsTrainingCard.swift */; }; + 842E40B12F22F7E2000CCCE0 /* CommonUseStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842E409B2F22F7E2000CCCE0 /* CommonUseStep.swift */; }; + 842E40B22F22F7E2000CCCE0 /* TherapySettingsExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842E409D2F22F7E2000CCCE0 /* TherapySettingsExampleView.swift */; }; + 843F32EA2E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843F32E92E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift */; }; + 8446319F2F5A2AB3003825AE /* PresetsPerformanceHistoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8446319E2F5A2AA9003825AE /* PresetsPerformanceHistoryViewModel.swift */; }; + 847F23432E4543140035C864 /* ActivePresetBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847F23422E4543140035C864 /* ActivePresetBanner.swift */; }; + 849466D02EF1EAD300A90718 /* LocalizablePlural.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B4C6D2412EA7E38C006F5755 /* LocalizablePlural.xcstrings */; }; + 8496F7312B5711C4003E672C /* ContentMargin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8496F7302B5711C4003E672C /* ContentMargin.swift */; }; + 84A7B5502D2D972C00B6D202 /* Image+Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A7B54F2D2D972C00B6D202 /* Image+Optional.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 */; }; + 84B67A8D2F63558A004C783B /* PresetPerformanceHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B67A8C2F63558A004C783B /* PresetPerformanceHistoryView.swift */; }; 84D2879F2AC756C8007ED283 /* ContentMargin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D2879E2AC756C8007ED283 /* ContentMargin.swift */; }; + 84BC5BDF2E1C4A7300432000 /* InsulinDeliveryEventDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BC5BDE2E1C4A7300432000 /* InsulinDeliveryEventDetailsView.swift */; }; + 84D1F1A72D09053A00CB271F /* StatusTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D1F1A62D09053A00CB271F /* StatusTableView.swift */; }; + 84D1F1A92D09800700CB271F /* PresetDetentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D1F1A82D09800700CB271F /* PresetDetentView.swift */; }; + 84DEB10D2C18FABA00170734 /* IOSFocusModesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */; }; + 84DF48B52F6A0AD400BEDB40 /* AudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48B42F6A0AD400BEDB40 /* AudioPlayer.swift */; }; + 84DF48B72F6A0AD700BEDB40 /* CaptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48B62F6A0AD700BEDB40 /* CaptionsView.swift */; }; + 84DF48B92F6A0ADB00BEDB40 /* MediaPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48B82F6A0ADB00BEDB40 /* MediaPlayerView.swift */; }; + 84DF48BB2F6A0ADD00BEDB40 /* PlayerControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48BA2F6A0ADD00BEDB40 /* PlayerControls.swift */; }; + 84DF48BD2F6A0AE200BEDB40 /* TranscriptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48BC2F6A0AE200BEDB40 /* TranscriptView.swift */; }; + 84DF48BF2F6A0AE500BEDB40 /* VideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48BE2F6A0AE500BEDB40 /* VideoView.swift */; }; + 84DF48C12F6A0AED00BEDB40 /* Double+Closest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48C02F6A0AED00BEDB40 /* Double+Closest.swift */; }; + 84DF48C32F6A0AF600BEDB40 /* Image+Crop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48C22F6A0AF600BEDB40 /* Image+Crop.swift */; }; + 84E8BBCA2CCA16290078E6CF /* PresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBC92CCA16290078E6CF /* PresetsView.swift */; }; + 84EC162E2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */; }; + 84F20DFD2D0B9C3A0089DF02 /* EditPresetDurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F20DFC2D0B9C3A0089DF02 /* EditPresetDurationView.swift */; }; + 84FA9D332CF7FD0D004162B4 /* PresetsHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FA9D322CF7FD0D004162B4 /* PresetsHistoryView.swift */; }; 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 */; }; 892FB4CD22040104005293EC /* OverridePresetRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892FB4CC22040104005293EC /* OverridePresetRow.swift */; }; - 892FB4CF220402C0005293EC /* OverrideSelectionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892FB4CE220402C0005293EC /* OverrideSelectionController.swift */; }; 894F6DD3243BCBDB00CCE676 /* Environment+SizeClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 894F6DD2243BCBDB00CCE676 /* Environment+SizeClass.swift */; }; 894F6DD7243C047300CCE676 /* View+Position.swift in Sources */ = {isa = PBXBuildFile; fileRef = 894F6DD6243C047300CCE676 /* View+Position.swift */; }; 894F6DD9243C060600CCE676 /* ScalablePositionedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 894F6DD8243C060600CCE676 /* ScalablePositionedText.swift */; }; @@ -275,9 +291,7 @@ 895788B1242E69A2002CB114 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895788A9242E69A1002CB114 /* Color.swift */; }; 895788B2242E69A2002CB114 /* CircularAccessoryButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895788AA242E69A1002CB114 /* CircularAccessoryButtonStyle.swift */; }; 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 */; }; @@ -286,8 +300,6 @@ 898ECA65218ABD9B001E9D35 /* CGRect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 898ECA64218ABD9A001E9D35 /* CGRect.swift */; }; 898ECA69218ABDA9001E9D35 /* CLKTextProvider+Compound.m in Sources */ = {isa = PBXBuildFile; fileRef = 898ECA67218ABDA8001E9D35 /* CLKTextProvider+Compound.m */; }; 899433B823FE129800FA4BEA /* OverrideBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 899433B723FE129700FA4BEA /* OverrideBadgeView.swift */; }; - 89A1B66E24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A1B66D24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift */; }; - 89A1B66F24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A1B66D24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift */; }; 89A605E324327DFE009C1096 /* CarbAmountInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A605E224327DFE009C1096 /* CarbAmountInput.swift */; }; 89A605E524327F45009C1096 /* DoseVolumeInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A605E424327F45009C1096 /* DoseVolumeInput.swift */; }; 89A605E72432860C009C1096 /* PeriodicPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A605E62432860C009C1096 /* PeriodicPublisher.swift */; }; @@ -299,7 +311,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 */; }; @@ -308,7 +319,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 */; }; @@ -319,20 +329,15 @@ 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 */; }; A9347F2F24E7508A00C99C34 /* WatchHistoricalCarbs.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9347F2E24E7508A00C99C34 /* WatchHistoricalCarbs.swift */; }; - A9347F3124E7521800C99C34 /* CarbBackfillRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9347F3024E7521800C99C34 /* CarbBackfillRequestUserInfo.swift */; }; - A9347F3224E7522400C99C34 /* CarbBackfillRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9347F3024E7521800C99C34 /* CarbBackfillRequestUserInfo.swift */; }; A9347F3324E7522900C99C34 /* WatchHistoricalCarbs.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9347F2E24E7508A00C99C34 /* WatchHistoricalCarbs.swift */; }; A963B27A252CEBAE0062AA12 /* SetBolusUserInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A963B279252CEBAE0062AA12 /* SetBolusUserInfoTests.swift */; }; A966152623EA5A26005D8B29 /* DefaultAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A966152423EA5A25005D8B29 /* DefaultAssets.xcassets */; }; A966152723EA5A26005D8B29 /* DerivedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A966152523EA5A25005D8B29 /* DerivedAssets.xcassets */; }; A966152A23EA5A37005D8B29 /* DefaultAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A966152823EA5A37005D8B29 /* DefaultAssets.xcassets */; }; - A966152B23EA5A37005D8B29 /* DerivedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A966152923EA5A37005D8B29 /* DerivedAssets.xcassets */; }; A967D94C24F99B9300CDDF8A /* OutputStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = A967D94B24F99B9300CDDF8A /* OutputStream.swift */; }; A96DAC242838325900D94E38 /* DiagnosticLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = A96DAC232838325900D94E38 /* DiagnosticLog.swift */; }; A96DAC2A2838EF8A00D94E38 /* DiagnosticLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A96DAC292838EF8A00D94E38 /* DiagnosticLogTests.swift */; }; @@ -372,30 +377,36 @@ A9F703772489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F703762489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift */; }; A9FB75F1252BE320004C7D3F /* BolusDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FB75F0252BE320004C7D3F /* BolusDosingDecision.swift */; }; 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 */; }; + B429CAB42E97C97000FA988E /* LoopStatusModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B429CAB22E97C7F300FA988E /* LoopStatusModalView.swift */; }; B42C951424A3C76000857C73 /* CGMStatusHUDViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42C951324A3C76000857C73 /* CGMStatusHUDViewModel.swift */; }; B42D124328D371C400E43D22 /* AlertMuter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42D124228D371C400E43D22 /* AlertMuter.swift */; }; + B43B5C502EAFB17D0096A6AE /* InsulinSuspendedTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = B43B5C4F2EAFB17D0096A6AE /* InsulinSuspendedTableViewCell.xib */; }; + B43B5C522EAFB1BE0096A6AE /* InsulinSuspendedTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43B5C512EAFB1B60096A6AE /* InsulinSuspendedTableViewCell.swift */; }; + B43B5C542EAFBF110096A6AE /* RecentGlucoseTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = B43B5C532EAFBF110096A6AE /* RecentGlucoseTableViewCell.xib */; }; + B43B5C562EAFBF230096A6AE /* RecentGlucoseTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43B5C552EAFBF170096A6AE /* RecentGlucoseTableViewCell.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 */; }; + B47BA4282F506D58006BAAB3 /* CompleteOnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B47BA4272F506D50006BAAB3 /* CompleteOnboardingView.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 */; }; + B4C6D2422EA7E38C006F5755 /* LocalizablePlural.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B4C6D2412EA7E38C006F5755 /* LocalizablePlural.xcstrings */; }; + B4C6D2442EAA2AC2006F5755 /* TimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C6D2432EAA2AB4006F5755 /* TimeInterval.swift */; }; + B4C6D2452EAA2C83006F5755 /* TimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C6D2432EAA2AB4006F5755 /* TimeInterval.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 */; }; - B4E202302661063E009421B5 /* AutomaticDosingStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E2022F2661063E009421B5 /* AutomaticDosingStatus.swift */; }; B4E96D4B248A6B6E002DABAD /* DeviceStatusHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E96D4A248A6B6E002DABAD /* DeviceStatusHUDView.swift */; }; B4E96D4F248A6E20002DABAD /* CGMStatusHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E96D4E248A6E20002DABAD /* CGMStatusHUDView.swift */; }; B4E96D53248A7386002DABAD /* GlucoseValueHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E96D52248A7386002DABAD /* GlucoseValueHUDView.swift */; }; @@ -423,6 +434,17 @@ B66D1F3E2E6A5D6600471149 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F3D2E6A5D6600471149 /* Localizable.xcstrings */; }; B66D1F402E6A5D6600471149 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F3F2E6A5D6600471149 /* InfoPlist.xcstrings */; }; C110888D2A3913C600BA4898 /* BuildDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = C110888C2A3913C600BA4898 /* BuildDetails.swift */; }; + C10509752D8B309400118A37 /* Environment+SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10509742D8B308E00118A37 /* Environment+SettingsManager.swift */; }; + C10509772D8B591200118A37 /* StatusTableViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10509762D8B590D00118A37 /* StatusTableViewModel.swift */; }; + C10509792D8B636900118A37 /* Environment+TemporaryPresetsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10509782D8B635F00118A37 /* Environment+TemporaryPresetsManager.swift */; }; + C10C57E52E6F767A00A4825C /* CircleTintedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57E42E6F767500A4825C /* CircleTintedButton.swift */; }; + C10C57EC2E7070FF00A4825C /* PresetsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57EB2E7070FB00A4825C /* PresetsListView.swift */; }; + C10C57EE2E7081D200A4825C /* PresetDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57ED2E7081C900A4825C /* PresetDetailView.swift */; }; + C10C57FA2E708B2D00A4825C /* PresetWatchCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57F92E708B1D00A4825C /* PresetWatchCard.swift */; }; + C10C57FC2E70B8B900A4825C /* EnvironmentValues+GlucoseDisplayUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57FB2E70B87500A4825C /* EnvironmentValues+GlucoseDisplayUnit.swift */; }; + C10C57FE2E71E87D00A4825C /* ActiveOverrideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57FD2E71E87400A4825C /* ActiveOverrideView.swift */; }; + C110888D2A3913C600BA4898 /* BuildDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = C110888C2A3913C600BA4898 /* BuildDetails.swift */; }; + C11445B22DA98A3400034864 /* CorrectionRangeInformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11445B12DA98A2D00034864 /* CorrectionRangeInformationView.swift */; }; C11B9D5B286778A800500CF8 /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C11B9D5A286778A800500CF8 /* SwiftCharts */; }; C11B9D5E286778D000500CF8 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C11B9D5D286778D000500CF8 /* LoopKitUI.framework */; }; C11B9D62286779C000500CF8 /* MockKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C11B9D60286779C000500CF8 /* MockKit.framework */; }; @@ -430,17 +452,27 @@ C11B9D64286779C000500CF8 /* MockKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C11B9D61286779C000500CF8 /* MockKitUI.framework */; }; C11B9D65286779C000500CF8 /* MockKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C11B9D61286779C000500CF8 /* MockKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 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 */; }; + C120CECC2D8CD6990050944B /* Publisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C120CECB2D8CD6970050944B /* Publisher.swift */; }; + C1275DD62E808E2F0013B99D /* LoopWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1275DD52E808E2C0013B99D /* LoopWatchApp.swift */; }; + C1275DD82E808E520013B99D /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1275DD72E808E480013B99D /* ContentView.swift */; }; + C1275DDB2E8175B40013B99D /* PresetConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1275DDA2E8175AF0013B99D /* PresetConfirmationView.swift */; }; + C1275DDD2E8185990013B99D /* PresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1275DDC2E8185960013B99D /* PresetsView.swift */; }; + C1275DDE2E81FD470013B99D /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43F78D4B1C914197002152D1 /* LoopKit.framework */; }; + C1275DE22E81FD530013B99D /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C1275DE12E81FD530013B99D /* LoopKit.framework */; }; + C1275E1C2E82269A0013B99D /* LoopKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C1275E1A2E82269A0013B99D /* LoopKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + C1275E1F2E822CEE0013B99D /* DerivedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A966152923EA5A37005D8B29 /* DerivedAssets.xcassets */; }; + C129BF4A2B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C129BF492B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift */; }; + C129D3BF2B8697F100FEA6A9 /* TempBasalRecommendationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C129D3BE2B8697F100FEA6A9 /* TempBasalRecommendationTests.swift */; }; + C12D72402E4FBC5F00BD628A /* WatchActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12D723F2E4FBC5D00BD628A /* WatchActionsView.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 */; }; - C159C82F286787EF00A86EC0 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C159C82E286787EF00A86EC0 /* LoopKit.framework */; }; + C151634E2FA2C9C800FEECE8 /* RequiredVersionUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C151634D2FA2C9C800FEECE8 /* RequiredVersionUpdateView.swift */; }; + C152B9F52C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */; }; + C152B9F72C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C152B9F62C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift */; }; + C1550B0C2E6F249A009369DC /* LoopCircleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1550B0B2E6F249A009369DC /* LoopCircleView.swift */; }; C15A8C492A7305B1009D736B /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C1E3DC4628595FAA00CA19FF /* SwiftCharts */; }; C165756F2534C468004AE16E /* SimpleBolusViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C165756E2534C468004AE16E /* SimpleBolusViewModelTests.swift */; }; C16575712538A36B004AE16E /* CGMStalenessMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */; }; @@ -450,6 +482,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 */; }; @@ -458,9 +492,18 @@ C178249A1E1999FA00D9D25C /* CaseCountable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17824991E1999FA00D9D25C /* CaseCountable.swift */; }; C17824A01E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C178249F1E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift */; }; C17824A51E1AD4D100D9D25C /* ManualBolusRecommendation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17824A41E1AD4D100D9D25C /* ManualBolusRecommendation.swift */; }; + C17D52022E7F03D0001D2AD2 /* LoopHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17D52012E7F03CF001D2AD2 /* LoopHeader.swift */; }; + C17D52042E7F0578001D2AD2 /* LabelValueRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17D52032E7F0568001D2AD2 /* LabelValueRow.swift */; }; + C17D52082E7F0E1B001D2AD2 /* CarbList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17D52072E7F0E18001D2AD2 /* CarbList.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 */; }; @@ -472,83 +515,90 @@ C19C8BC428651EAE0056D5E4 /* LoopTestingKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C19C8BC228651EAE0056D5E4 /* LoopTestingKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; C19C8BCE28651F520056D5E4 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; }; C19C8BCF28651F520056D5E4 /* LoopKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 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 */; }; + C19E23B42E8350D700C20D83 /* PresetActivateButtonConfirm.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19E23B32E8350B900C20D83 /* PresetActivateButtonConfirm.swift */; }; + C19E23B62E83513400C20D83 /* PresetActivateCrownConfirm.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19E23B52E83512A00C20D83 /* PresetActivateCrownConfirm.swift */; }; + C19E23B82E83566700C20D83 /* CircularProgressWithCheckmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19E23B72E83566300C20D83 /* CircularProgressWithCheckmark.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 */; }; 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 */; }; + C1DCEDDD2E983A22001A7BB0 /* AutomatedTreatmentState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DCEDDC2E983A1B001A7BB0 /* AutomatedTreatmentState.swift */; }; + C1DCEDF42E999D5E001A7BB0 /* LastManualBolus.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DCEDF32E999D5A001A7BB0 /* LastManualBolus.swift */; }; + C1DCEDF52E999D5E001A7BB0 /* LastManualBolus.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DCEDF32E999D5A001A7BB0 /* LastManualBolus.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 */; }; C1E3DC4928595FAA00CA19FF /* SwiftCharts in Embed Frameworks */ = {isa = PBXBuildFile; productRef = C1E3DC4628595FAA00CA19FF /* SwiftCharts */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + C1ED6C612E79BBA5002F91C2 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ED6C602E79BB9C002F91C2 /* NotificationManager.swift */; }; + C1ED6C622E79BBA5002F91C2 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ED6C602E79BB9C002F91C2 /* NotificationManager.swift */; }; + C1ED6C642E7C6DB9002F91C2 /* SettingsRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57E82E705CCB00A4825C /* SettingsRequestUserInfo.swift */; }; + C1ED6C652E7C6E2F002F91C2 /* CarbBackfillRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9347F3024E7521800C99C34 /* CarbBackfillRequestUserInfo.swift */; }; + C1ED6C662E7C6E2F002F91C2 /* CarbBackfillRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9347F3024E7521800C99C34 /* CarbBackfillRequestUserInfo.swift */; }; + C1ED6C672E7C6E35002F91C2 /* SettingsRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57E82E705CCB00A4825C /* SettingsRequestUserInfo.swift */; }; + C1ED6C682E7C6E41002F91C2 /* GlucoseBackfillRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */; }; + C1ED6C692E7C6E41002F91C2 /* GlucoseBackfillRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */; }; + C1ED6C6A2E7C6E58002F91C2 /* LoopSettingsUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E48F213CFCE70068E043 /* LoopSettingsUserInfo.swift */; }; + C1ED6C6B2E7C6E58002F91C2 /* LoopSettingsUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E48F213CFCE70068E043 /* LoopSettingsUserInfo.swift */; }; + C1ED6C6C2E7C6EC1002F91C2 /* NotificationActionSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ABA1602E281D330049DF41 /* NotificationActionSelection.swift */; }; + C1ED6C6D2E7C6EC1002F91C2 /* NotificationActionSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ABA1602E281D330049DF41 /* NotificationActionSelection.swift */; }; + C1ED6C6E2E7C6EDE002F91C2 /* IntentExtensionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */; }; + E9B08021253BBDE900BAD8F8 /* IntentExtensionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */; }; + C1ED6C6F2E7C6EDE002F91C2 /* IntentExtensionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */; }; + C1ED6C702E7C6F07002F91C2 /* SetBolusUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */; }; + C1ED6C712E7C6F07002F91C2 /* SetBolusUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */; }; + C1ED6C722E7C6F23002F91C2 /* SupportedBolusVolumesUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A1B66D24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift */; }; + C1ED6C732E7C6F23002F91C2 /* SupportedBolusVolumesUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A1B66D24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift */; }; + C1ED6C742E7C6F36002F91C2 /* WatchContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF4D0FF1E18374700846527 /* WatchContext.swift */; }; + C1ED6C752E7C6F36002F91C2 /* WatchContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF4D0FF1E18374700846527 /* WatchContext.swift */; }; + C1ED6C762E7C6F58002F91C2 /* WatchPredictedGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */; }; + C1ED6C772E7C6F58002F91C2 /* WatchPredictedGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */; }; + C1ED6C782E7C6FC7002F91C2 /* Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B455C7322BD14E25002B847E /* Comparable.swift */; }; + C1ED6C792E7C6FC7002F91C2 /* Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B455C7322BD14E25002B847E /* Comparable.swift */; }; + C1ED6C7A2E7C6FE5002F91C2 /* WatchContextRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1201E2B23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift */; }; + C1ED6C7B2E7C6FE6002F91C2 /* WatchContextRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1201E2B23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift */; }; + C1ED6C7D2E7C811A002F91C2 /* SetPresetUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ED6C7C2E7C8113002F91C2 /* SetPresetUserInfo.swift */; }; + C1ED6C7E2E7C811A002F91C2 /* SetPresetUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ED6C7C2E7C8113002F91C2 /* SetPresetUserInfo.swift */; }; + C1ED6C802E7C9C86002F91C2 /* PendingPresetReminder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ED6C7F2E7C9C7A002F91C2 /* PendingPresetReminder.swift */; }; + C1ED6C822E7D8E3D002F91C2 /* AcknowledgeAlertUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ED6C812E7D8E36002F91C2 /* AcknowledgeAlertUserInfo.swift */; }; + C1ED6C832E7D8E3D002F91C2 /* AcknowledgeAlertUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ED6C812E7D8E36002F91C2 /* AcknowledgeAlertUserInfo.swift */; }; C1EE9E812A38D0FB0064784A /* BuildDetails.plist in Resources */ = {isa = PBXBuildFile; fileRef = C1EE9E802A38D0FB0064784A /* BuildDetails.plist */; }; C1EF747228D6A44A00C8C083 /* CrashRecoveryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1EF747128D6A44A00C8C083 /* CrashRecoveryManager.swift */; }; 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 */; }; + C1FAD5192E7E0C3400F7FAD9 /* ChartPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FAD5182E7E0C3100F7FAD9 /* ChartPageView.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 */; }; @@ -556,15 +606,10 @@ E98A55EF24EDD6E60008715D /* DosingDecisionStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E98A55EE24EDD6E60008715D /* DosingDecisionStoreProtocol.swift */; }; E98A55F124EDD85E0008715D /* MockDosingDecisionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E98A55F024EDD85E0008715D /* MockDosingDecisionStore.swift */; }; E98A55F324EDD9530008715D /* MockSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E98A55F224EDD9530008715D /* MockSettingsStore.swift */; }; - E98A55F524EEE15A0008715D /* OnOffSelectionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E98A55F424EEE15A0008715D /* OnOffSelectionController.swift */; }; - E98A55F724EEE1E10008715D /* OnOffSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E98A55F624EEE1E10008715D /* OnOffSelectionView.swift */; }; - E98A55F924EEFC200008715D /* OnOffSelectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E98A55F824EEFC200008715D /* OnOffSelectionViewModel.swift */; }; E9B07F7F253BBA6500BAD8F8 /* IntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B07F7E253BBA6500BAD8F8 /* IntentHandler.swift */; }; - E9B07F94253BBA6500BAD8F8 /* Loop Intent Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = E9B07F7C253BBA6500BAD8F8 /* Loop Intent Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + E9B07F94253BBA6500BAD8F8 /* Loop Intent Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E9B07F7C253BBA6500BAD8F8 /* Loop Intent Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; E9B07FEE253BBC7100BAD8F8 /* OverrideIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B07FED253BBC7100BAD8F8 /* OverrideIntentHandler.swift */; }; E9B08016253BBD7300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08015253BBD7300BAD8F8 /* UserDefaults+LoopIntents.swift */; }; - E9B08021253BBDE900BAD8F8 /* IntentExtensionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */; }; - E9B0802B253BBDFF00BAD8F8 /* IntentExtensionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */; }; E9B080B1253BDA6300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08015253BBD7300BAD8F8 /* UserDefaults+LoopIntents.swift */; }; E9B3552229358C440076AB04 /* MealDetectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3552129358C440076AB04 /* MealDetectionManager.swift */; }; E9B355292935919E0076AB04 /* MissedMealSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B35525293590980076AB04 /* MissedMealSettings.swift */; }; @@ -605,13 +650,6 @@ remoteGlobalIDString = 14B1735B28AED9EC006CCD7C; remoteInfo = SmallStatusWidgetExtension; }; - 43A943801B926B7B0051FA24 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 43776F841B8022E90074EA36 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 43A9437D1B926B7B0051FA24; - remoteInfo = "WatchApp Extension"; - }; 43A943921B926B7B0051FA24 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 43776F841B8022E90074EA36 /* Project object */; @@ -633,13 +671,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 */; @@ -647,20 +678,13 @@ remoteGlobalIDString = 4F75288A1DFE1DC600C322D6; remoteInfo = LoopUI; }; - C117ED70232EDB3200DA57CD /* PBXContainerItemProxy */ = { + C16E94F82E7DBBA600AA4E6E /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 43776F841B8022E90074EA36 /* Project object */; proxyType = 1; 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 */; @@ -678,17 +702,6 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ - 43A943981B926B7B0051FA24 /* Embed App Extensions */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 13; - files = ( - 43A9437F1B926B7B0051FA24 /* WatchApp Extension.appex in Embed App Extensions */, - ); - name = "Embed App Extensions"; - runOnlyForDeploymentPostprocessing = 0; - }; 43A9439C1B926B7B0051FA24 /* Embed Watch Content */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -726,22 +739,21 @@ dstSubfolderSpec = 10; files = ( 43C05CB221EBD88A006FB252 /* LoopCore.framework in Embed Frameworks */, - C19C8C1F28663B040056D5E4 /* LoopKit.framework in Embed Frameworks */, + C1275E1C2E82269A0013B99D /* LoopKit.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; - 4F70C1EC1DE8DCA8006380B7 /* Embed App Extensions */ = { + 4F70C1EC1DE8DCA8006380B7 /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 13; 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 */, + 14B1736928AED9EE006CCD7C /* Loop Widget Extension.appex in Embed Foundation Extensions */, + E9B07F94253BBA6500BAD8F8 /* Loop Intent Extension.appex in Embed Foundation Extensions */, ); - name = "Embed App Extensions"; + name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; C1E3DC4828595FAA00CA19FF /* Embed Frameworks */ = { @@ -760,9 +772,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 = ""; }; @@ -777,8 +793,17 @@ 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 = ""; }; + 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 = ""; }; @@ -792,7 +817,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 = ""; }; @@ -804,6 +828,7 @@ 1DB619AB270BAD3D006C9D07 /* VersionUpdateViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionUpdateViewModel.swift; sourceTree = ""; }; 1DC63E7325351BDF004605DA /* TrueTime.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = TrueTime.framework; path = Carthage/Build/iOS/TrueTime.framework; sourceTree = ""; }; 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + E4854073AAE5C2CB97167F63 /* HealthAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthAccessView.swift; sourceTree = ""; }; 1DFE9E162447B6270082C280 /* UserNotificationAlertSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationAlertSchedulerTests.swift; sourceTree = ""; }; 3ED319862EB659E600820BCF /* BasalViewActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasalViewActivity.swift; sourceTree = ""; }; 3ED319872EB659E600820BCF /* ChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartView.swift; sourceTree = ""; }; @@ -828,8 +853,6 @@ 431A8C3F1EC6E8AB00823B9C /* CircleMaskView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircleMaskView.swift; sourceTree = ""; }; 431E73471FF95A900069B5F7 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; 4326BA631F3A44D9007CCAD4 /* ChartLineModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartLineModel.swift; sourceTree = ""; }; - 4328E0151CFBE1DA00E199AA /* ActionHUDController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionHUDController.swift; sourceTree = ""; }; - 4328E01D1CFBE25F00E199AA /* CarbAndBolusFlowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbAndBolusFlowController.swift; sourceTree = ""; }; 4328E0221CFBE2C500E199AA /* CLKComplicationTemplate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CLKComplicationTemplate.swift; sourceTree = ""; }; 4328E0231CFBE2C500E199AA /* NSUserDefaults+WatchApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSUserDefaults+WatchApp.swift"; sourceTree = ""; }; 4328E0241CFBE2C500E199AA /* UIColor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; @@ -847,12 +870,9 @@ 4345E3F721F03D2A009E00E5 /* DatesAndNumberCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatesAndNumberCell.swift; sourceTree = ""; }; 4345E3F921F0473B009E00E5 /* TextCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextCell.swift; sourceTree = ""; }; 4345E3FD21F04A50009E00E5 /* DateIntervalFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateIntervalFormatter.swift; sourceTree = ""; }; - 4345E40321F68AD9009E00E5 /* TextRowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRowController.swift; sourceTree = ""; }; - 4345E40521F68E18009E00E5 /* CarbEntryListController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbEntryListController.swift; sourceTree = ""; }; 434F54561D287FDB002A9274 /* NibLoadable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NibLoadable.swift; sourceTree = ""; }; 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentifiableClass.swift; sourceTree = ""; }; 434FF1ED1CF27EEF000DB779 /* UITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UITableViewCell.swift; sourceTree = ""; }; - 43511CED220FC61700566C63 /* HUDRowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HUDRowController.swift; sourceTree = ""; }; 43517916230A0E1A0072ECC0 /* WKInterfaceLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WKInterfaceLabel.swift; sourceTree = ""; }; 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetBolusUserInfo.swift; sourceTree = ""; }; 436A0DA41D236A2A00104B24 /* LoopError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoopError.swift; sourceTree = ""; }; @@ -869,8 +889,6 @@ 43785E922120A01B0057DED1 /* NewCarbEntryIntent+Loop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NewCarbEntryIntent+Loop.swift"; sourceTree = ""; }; 43785E952120E4010057DED1 /* INRelevantShortcutStore+Loop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "INRelevantShortcutStore+Loop.swift"; sourceTree = ""; }; 43785E9A2120E7060057DED1 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; name = Base; path = Base.lproj/Intents.intentdefinition; sourceTree = ""; }; - 43785E9F2122774A0057DED1 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Intents.strings; sourceTree = ""; }; - 43785EA12122774B0057DED1 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Intents.strings; sourceTree = ""; }; 4379CFEF21112CF700AADC79 /* ShareClientUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = ShareClientUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 437AFEE6203688CF008C4892 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 437CEEBD1CD6E0CB003C8C80 /* LoopCompletionHUDView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoopCompletionHUDView.swift; sourceTree = ""; }; @@ -894,21 +912,16 @@ 43A567681C94880B00334FAC /* LoopDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = LoopDataManager.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 43A8EC6E210E622600A81379 /* CGMBLEKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = CGMBLEKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 43A943721B926B7B0051FA24 /* WatchApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WatchApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 43A943751B926B7B0051FA24 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Interface.storyboard; sourceTree = ""; }; - 43A9437E1B926B7B0051FA24 /* WatchApp Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "WatchApp Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 43A943841B926B7B0051FA24 /* PushNotificationPayload.apns */ = {isa = PBXFileReference; lastKnownFileType = text; path = PushNotificationPayload.apns; sourceTree = ""; }; 43A943871B926B7B0051FA24 /* ExtensionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionDelegate.swift; sourceTree = ""; }; - 43A943891B926B7B0051FA24 /* NotificationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationController.swift; sourceTree = ""; }; 43A9438D1B926B7B0051FA24 /* ComplicationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComplicationController.swift; sourceTree = ""; }; 43A9438F1B926B7B0051FA24 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 43A943911B926B7B0051FA24 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 43B260481ED248FB008CAA77 /* CarbEntryTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEntryTableViewCell.swift; sourceTree = ""; }; - 43B371851CE583890013C5A6 /* BasalStateView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BasalStateView.swift; sourceTree = ""; }; + 43B371851CE583890013C5A6 /* TreatmentArrowStateView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TreatmentArrowStateView.swift; sourceTree = ""; }; 43B371871CE597D10013C5A6 /* ShareClient.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = ShareClient.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = ""; }; @@ -926,12 +939,10 @@ 43C728F4222266F000C62969 /* ModalDayLesson.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalDayLesson.swift; sourceTree = ""; }; 43C728F62222700000C62969 /* DateIntervalEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateIntervalEntry.swift; sourceTree = ""; }; 43C728F8222A448700C62969 /* DayCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayCalculator.swift; sourceTree = ""; }; - 43C98058212A799E003B5D17 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Intents.strings; sourceTree = ""; }; 43CB2B2A1D924D450079823D /* WCSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WCSession.swift; sourceTree = ""; }; 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 = ""; }; @@ -950,7 +961,7 @@ 43D9FFD121EAE05D00AF44BF /* LoopCore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = LoopCore.h; sourceTree = ""; }; 43D9FFD221EAE05D00AF44BF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 43DBF0521C93EC8200B3C386 /* DeviceDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = DeviceDataManager.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; - 43DE92581C5479E4001FFDE1 /* PotentialCarbEntryUserInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PotentialCarbEntryUserInfo.swift; sourceTree = ""; }; + 43DE92581C5479E4001FFDE1 /* GetBolusRecommendationUserInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GetBolusRecommendationUserInfo.swift; sourceTree = ""; }; 43E2D90B1D20C581004DA55F /* LoopTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LoopTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 43E2D90F1D20C581004DA55F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 43E3449E1B9D68E900C85C07 /* StatusTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = StatusTableViewController.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; @@ -976,12 +987,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; }; @@ -990,81 +996,57 @@ 4F75F00120FCFE8C00B5570E /* GlucoseChartScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseChartScene.swift; sourceTree = ""; }; 4F7E8AC420E2AB9600AEA65E /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = ""; }; 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchPredictedGlucose.swift; sourceTree = ""; }; - 4F82654F20E69F9A0031A8F5 /* HUDInterfaceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HUDInterfaceController.swift; sourceTree = ""; }; 4FC8C8001DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSUserDefaults+StatusExtension.swift"; sourceTree = ""; }; 4FDDD23620DC51DF00D04B16 /* LoopDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopDataManager.swift; sourceTree = ""; }; 4FF4D0FF1E18374700846527 /* WatchContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WatchContext.swift; sourceTree = ""; }; 4FFEDFBE20E5CF22000BFC58 /* ChartHUDController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartHUDController.swift; sourceTree = ""; }; 7D23667C21250C7E0028B67D /* LocalizedString.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = LocalizedString.swift; path = LoopUI/Common/LocalizedString.swift; sourceTree = SOURCE_ROOT; }; - 7D9BEEE62335A6B3005DCFD6 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BEEE82335A6B9005DCFD6 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; - 7D9BEEE92335A6BB005DCFD6 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BEEEA2335A6BC005DCFD6 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BEEEB2335A6BD005DCFD6 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BEEEC2335A6BE005DCFD6 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BEEED2335A6BF005DCFD6 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BEEEE2335A6BF005DCFD6 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BEEEF2335A6C0005DCFD6 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BEEF02335A6C1005DCFD6 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BEEF42335CF8D005DCFD6 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BEEF62335CF90005DCFD6 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; - 7D9BEEF72335CF91005DCFD6 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BEEF82335CF93005DCFD6 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BEEF92335CF93005DCFD6 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BEEFA2335CF94005DCFD6 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BEEFB2335CF95005DCFD6 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BEEFC2335CF96005DCFD6 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BEEFD2335CF97005DCFD6 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BEEFE2335CF97005DCFD6 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BEF002335D67D005DCFD6 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Main.strings; sourceTree = ""; }; - 7D9BEF022335D687005DCFD6 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Main.strings"; sourceTree = ""; }; - 7D9BEF042335D68A005DCFD6 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Main.strings; sourceTree = ""; }; - 7D9BEF062335D68C005DCFD6 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Main.strings; sourceTree = ""; }; - 7D9BEF082335D68D005DCFD6 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Main.strings; sourceTree = ""; }; - 7D9BEF0A2335D68F005DCFD6 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Main.strings; sourceTree = ""; }; - 7D9BEF0C2335D690005DCFD6 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Main.strings; sourceTree = ""; }; - 7D9BEF0E2335D691005DCFD6 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Main.strings; sourceTree = ""; }; - 7D9BEF102335D693005DCFD6 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Main.strings; sourceTree = ""; }; - 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - 7D9BEF962335EC8D005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BF13A23370E8B005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Intents.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 = ""; }; - 7D9BF14623370E8D005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; + 7D23667C21250C7E0028B67D /* LocalizedString.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = LocalizedString.swift; path = LoopUI/Common/LocalizedString.swift; sourceTree = SOURCE_ROOT; }; 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; }; + 841306BA2F7F0D9C00AF0320 /* ReferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferencesView.swift; sourceTree = ""; }; + 84213C742D932F0F00642E78 /* InsulinDeliveryLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryLog.swift; sourceTree = ""; }; + 84213C882D94941000642E78 /* InsulinDeliveryLogEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryLogEvent.swift; sourceTree = ""; }; + 84213C8A2D94948900642E78 /* InsulinDeliveryOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryOverview.swift; sourceTree = ""; }; + 84213C8C2D949E8100642E78 /* InsulinDeliveryLogEventRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryLogEventRow.swift; sourceTree = ""; }; + 842E40982F22F7E2000CCCE0 /* EstimatedReadTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EstimatedReadTime.swift; sourceTree = ""; }; + 842E409A2F22F7E2000CCCE0 /* PresetsTrainingCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingCard.swift; sourceTree = ""; }; + 842E409B2F22F7E2000CCCE0 /* CommonUseStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonUseStep.swift; sourceTree = ""; }; + 842E409D2F22F7E2000CCCE0 /* TherapySettingsExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TherapySettingsExampleView.swift; sourceTree = ""; }; + 842E409E2F22F7E2000CCCE0 /* PlayMediaButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayMediaButton.swift; sourceTree = ""; }; + 842E409F2F22F7E2000CCCE0 /* IntensitySlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntensitySlider.swift; sourceTree = ""; }; + 842E40A02F22F7E2000CCCE0 /* TintedContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TintedContent.swift; sourceTree = ""; }; + 842E40A32F22F7E2000CCCE0 /* PresetsTrainingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingView.swift; sourceTree = ""; }; + 842E40A52F22F7E2000CCCE0 /* PresetsTrainingContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingContent.swift; sourceTree = ""; }; + 843F32E92E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryLogViewModel.swift; sourceTree = ""; }; + 8446319E2F5A2AA9003825AE /* PresetsPerformanceHistoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsPerformanceHistoryViewModel.swift; sourceTree = ""; }; + 847F23422E4543140035C864 /* ActivePresetBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivePresetBanner.swift; sourceTree = ""; }; + 8496F7302B5711C4003E672C /* ContentMargin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentMargin.swift; sourceTree = ""; }; + 84A7B54F2D2D972C00B6D202 /* Image+Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Optional.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 = ""; }; + 84B67A8C2F63558A004C783B /* PresetPerformanceHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetPerformanceHistoryView.swift; sourceTree = ""; }; 84D2879E2AC756C8007ED283 /* ContentMargin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentMargin.swift; sourceTree = ""; }; + 84BC5BDE2E1C4A7300432000 /* InsulinDeliveryEventDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryEventDetailsView.swift; sourceTree = ""; }; + 84D1F1A62D09053A00CB271F /* StatusTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableView.swift; sourceTree = ""; }; + 84D1F1A82D09800700CB271F /* PresetDetentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetDetentView.swift; sourceTree = ""; }; + 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSFocusModesView.swift; sourceTree = ""; }; + 84DF48B42F6A0AD400BEDB40 /* AudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayer.swift; sourceTree = ""; }; + 84DF48B62F6A0AD700BEDB40 /* CaptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptionsView.swift; sourceTree = ""; }; + 84DF48B82F6A0ADB00BEDB40 /* MediaPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerView.swift; sourceTree = ""; }; + 84DF48BA2F6A0ADD00BEDB40 /* PlayerControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControls.swift; sourceTree = ""; }; + 84DF48BC2F6A0AE200BEDB40 /* TranscriptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptView.swift; sourceTree = ""; }; + 84DF48BE2F6A0AE500BEDB40 /* VideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoView.swift; sourceTree = ""; }; + 84DF48C02F6A0AED00BEDB40 /* Double+Closest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Closest.swift"; sourceTree = ""; }; + 84DF48C22F6A0AF600BEDB40 /* Image+Crop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Crop.swift"; sourceTree = ""; }; + 84E8BBC92CCA16290078E6CF /* PresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsView.swift; sourceTree = ""; }; + 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = DIYLoopUnitTestPlan.xctestplan; sourceTree = ""; }; + 84F20DFC2D0B9C3A0089DF02 /* EditPresetDurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPresetDurationView.swift; sourceTree = ""; }; + 84FA9D322CF7FD0D004162B4 /* PresetsHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsHistoryView.swift; 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; }; @@ -1072,7 +1054,6 @@ 892A5D5A222F0D7C008961AB /* LoopTestingKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; name = LoopTestingKit.framework; path = Carthage/Build/iOS/LoopTestingKit.framework; sourceTree = SOURCE_ROOT; }; 892A5D682230C41D008961AB /* RangeReplaceableCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RangeReplaceableCollection.swift; sourceTree = ""; }; 892FB4CC22040104005293EC /* OverridePresetRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetRow.swift; sourceTree = ""; }; - 892FB4CE220402C0005293EC /* OverrideSelectionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideSelectionController.swift; sourceTree = ""; }; 894F6DD2243BCBDB00CCE676 /* Environment+SizeClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+SizeClass.swift"; sourceTree = ""; }; 894F6DD6243C047300CCE676 /* View+Position.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "View+Position.swift"; path = "WatchApp Extension/Views/View+Position.swift"; sourceTree = SOURCE_ROOT; }; 894F6DD8243C060600CCE676 /* ScalablePositionedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScalablePositionedText.swift; sourceTree = ""; }; @@ -1084,9 +1065,7 @@ 895788A9242E69A1002CB114 /* Color.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = ""; }; 895788AA242E69A1002CB114 /* CircularAccessoryButtonStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularAccessoryButtonStyle.swift; sourceTree = ""; }; 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 = ""; }; @@ -1109,7 +1088,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 = ""; }; @@ -1118,7 +1096,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 = ""; }; @@ -1179,23 +1156,30 @@ A9FB75F0252BE320004C7D3F /* BolusDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusDosingDecision.swift; sourceTree = ""; }; B4001CED28CBBC82002FB414 /* AlertManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertManagementView.swift; sourceTree = ""; }; B40D07C6251A89D500C1C6D7 /* GlucoseDisplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseDisplay.swift; sourceTree = ""; }; + B429CAB22E97C7F300FA988E /* LoopStatusModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopStatusModalView.swift; sourceTree = ""; }; B42C951324A3C76000857C73 /* CGMStatusHUDViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGMStatusHUDViewModel.swift; sourceTree = ""; }; B42D124228D371C400E43D22 /* AlertMuter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlertMuter.swift; sourceTree = ""; }; + B43B5C4F2EAFB17D0096A6AE /* InsulinSuspendedTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = InsulinSuspendedTableViewCell.xib; sourceTree = ""; }; + B43B5C512EAFB1B60096A6AE /* InsulinSuspendedTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinSuspendedTableViewCell.swift; sourceTree = ""; }; + B43B5C532EAFBF110096A6AE /* RecentGlucoseTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = RecentGlucoseTableViewCell.xib; sourceTree = ""; }; + B43B5C552EAFBF170096A6AE /* RecentGlucoseTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentGlucoseTableViewCell.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 = ""; }; + B47BA4272F506D50006BAAB3 /* CompleteOnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompleteOnboardingView.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 = ""; }; B490A03E24D0550F00F509FA /* GlucoseRangeCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseRangeCategory.swift; sourceTree = ""; }; B490A04024D0559D00F509FA /* DeviceLifecycleProgressState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLifecycleProgressState.swift; sourceTree = ""; }; B490A04224D055D900F509FA /* DeviceStatusHighlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusHighlight.swift; sourceTree = ""; }; B4BC56372518DEA900373647 /* CGMStatusHUDViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStatusHUDViewModelTests.swift; sourceTree = ""; }; + B4C6D2412EA7E38C006F5755 /* LocalizablePlural.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = LocalizablePlural.xcstrings; sourceTree = ""; }; + B4C6D2432EAA2AB4006F5755 /* TimeInterval.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeInterval.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 = ""; }; - B4E2022F2661063E009421B5 /* AutomaticDosingStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomaticDosingStatus.swift; sourceTree = ""; }; B4E96D4A248A6B6E002DABAD /* DeviceStatusHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusHUDView.swift; sourceTree = ""; }; B4E96D4E248A6E20002DABAD /* CGMStatusHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStatusHUDView.swift; sourceTree = ""; }; B4E96D52248A7386002DABAD /* GlucoseValueHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseValueHUDView.swift; sourceTree = ""; }; @@ -1231,6 +1215,28 @@ B66D1F422E6A5D6600471149 /* mul */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = mul; path = mul.lproj/Main.xcstrings; sourceTree = ""; }; B6F22EF72E95A03800CCA05F /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Intents.strings; sourceTree = ""; }; B6F22EF92E95A03C00CCA05F /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Intents.strings; sourceTree = ""; }; + 43785E9F2122774A0057DED1 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Intents.strings; sourceTree = ""; }; + 43785EA12122774B0057DED1 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Intents.strings; sourceTree = ""; }; + 43C98058212A799E003B5D17 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Intents.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 = ""; }; + C12CB9B023106A5F00F84978 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Intents.strings; sourceTree = ""; }; + C12CB9B223106A6000F84978 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Intents.strings"; sourceTree = ""; }; + C12CB9B423106A6100F84978 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Intents.strings; sourceTree = ""; }; + 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 = ""; }; + 7D9BEF132335EC4B005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Intents.strings; sourceTree = ""; }; + 7D9BEF292335EC58005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Intents.strings"; sourceTree = ""; }; + 7D9BEF3F2335EC62005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Intents.strings; sourceTree = ""; }; + 7D9BEF552335EC6E005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Intents.strings; sourceTree = ""; }; + 7D9BEF6B2335EC7D005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Intents.strings; sourceTree = ""; }; + 7D9BEF812335EC8B005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Intents.strings; sourceTree = ""; }; + 7D9BF13A23370E8B005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Intents.strings; sourceTree = ""; }; + F5D9C01727DABBE0002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Intents.strings; sourceTree = ""; }; + F5E0BDD327E1D71C0033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Intents.strings; sourceTree = ""; }; + C1C3127F297E4C0400296DA4 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Intents.strings; sourceTree = ""; }; + C1C247882995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Intents.strings; sourceTree = ""; }; + C1C5357529C6346A00E32DF9 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Intents.strings; sourceTree = ""; }; C1004DEF2981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; C1004DFD2981F67A00B8CF94 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E052981F6A100B8CF94 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1240,25 +1246,50 @@ C1004E2C2981F75B00B8CF94 /* 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 = ""; }; C101947127DD473C004E7EB8 /* MockKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MockKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C10509742D8B308E00118A37 /* Environment+SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+SettingsManager.swift"; sourceTree = ""; }; + C10509762D8B590D00118A37 /* StatusTableViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewModel.swift; sourceTree = ""; }; + C10509782D8B635F00118A37 /* Environment+TemporaryPresetsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+TemporaryPresetsManager.swift"; sourceTree = ""; }; C1092BFD29F8116700AE3D1C /* apply-info-customizations.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "apply-info-customizations.sh"; sourceTree = ""; }; + C10C57E42E6F767500A4825C /* CircleTintedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleTintedButton.swift; sourceTree = ""; }; + C10C57E82E705CCB00A4825C /* SettingsRequestUserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRequestUserInfo.swift; sourceTree = ""; }; + C10C57EB2E7070FB00A4825C /* PresetsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsListView.swift; sourceTree = ""; }; + C10C57ED2E7081C900A4825C /* PresetDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetDetailView.swift; sourceTree = ""; }; + C10C57F92E708B1D00A4825C /* PresetWatchCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetWatchCard.swift; sourceTree = ""; }; + C10C57FB2E70B87500A4825C /* EnvironmentValues+GlucoseDisplayUnit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EnvironmentValues+GlucoseDisplayUnit.swift"; sourceTree = ""; }; + C10C57FD2E71E87400A4825C /* ActiveOverrideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveOverrideView.swift; sourceTree = ""; }; C110888C2A3913C600BA4898 /* BuildDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildDetails.swift; sourceTree = ""; }; + C11445B12DA98A2D00034864 /* CorrectionRangeInformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionRangeInformationView.swift; sourceTree = ""; }; C11AA5C7258736CF00BDE12F /* DerivedAssetsBase.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DerivedAssetsBase.xcassets; sourceTree = ""; }; C11B9D5D286778D000500CF8 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C11B9D60286779C000500CF8 /* MockKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MockKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C11B9D61286779C000500CF8 /* MockKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MockKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C11BD0542523CFED00236B08 /* SimpleBolusViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleBolusViewModel.swift; sourceTree = ""; }; C1201E2B23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchContextRequestUserInfo.swift; 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 = ""; }; - C12CB9B023106A5F00F84978 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Intents.strings; sourceTree = ""; }; - C12CB9B223106A6000F84978 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Intents.strings"; sourceTree = ""; }; - C12CB9B423106A6100F84978 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Intents.strings; sourceTree = ""; }; - 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 = ""; }; + C120CECB2D8CD6970050944B /* Publisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Publisher.swift; sourceTree = ""; }; + C1275DD52E808E2C0013B99D /* LoopWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopWatchApp.swift; sourceTree = ""; }; + C1275DD72E808E480013B99D /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + C1275DDA2E8175AF0013B99D /* PresetConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetConfirmationView.swift; sourceTree = ""; }; + C1275DDC2E8185960013B99D /* PresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsView.swift; sourceTree = ""; }; + C1275DE12E81FD530013B99D /* LoopKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C1275E1A2E82269A0013B99D /* LoopKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C1275E1D2E8227260013B99D /* LoopKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C129BF492B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryPresetsManagerTests.swift; sourceTree = ""; }; + C129D3BE2B8697F100FEA6A9 /* TempBasalRecommendationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempBasalRecommendationTests.swift; sourceTree = ""; }; + C12D723F2E4FBC5D00BD628A /* WatchActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchActionsView.swift; 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 = ""; }; + C151634D2FA2C9C800FEECE8 /* RequiredVersionUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequiredVersionUpdateView.swift; 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 = ""; }; + C1550B0B2E6F249A009369DC /* LoopCircleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopCircleView.swift; sourceTree = ""; }; + C159C8192867857000A86EC0 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 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; }; + C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationHistoryEntry.swift; sourceTree = ""; }; + C152B9F62C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationHistoryEntryTests.swift; sourceTree = ""; }; + C1550B0B2E6F249A009369DC /* LoopCircleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopCircleView.swift; sourceTree = ""; }; C159C8192867857000A86EC0 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 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; }; @@ -1269,6 +1300,8 @@ 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 = ""; }; @@ -1277,8 +1310,18 @@ C17824991E1999FA00D9D25C /* CaseCountable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CaseCountable.swift; sourceTree = ""; }; C178249F1E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseThresholdTableViewController.swift; sourceTree = ""; }; C17824A41E1AD4D100D9D25C /* ManualBolusRecommendation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ManualBolusRecommendation.swift; sourceTree = ""; }; + C17D52012E7F03CF001D2AD2 /* LoopHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopHeader.swift; sourceTree = ""; }; + C17D52032E7F0568001D2AD2 /* LabelValueRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelValueRow.swift; sourceTree = ""; }; + C17D52072E7F0E18001D2AD2 /* CarbList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbList.swift; sourceTree = ""; }; C1814B85225E507C008D2D8E /* Sequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sequence.swift; sourceTree = ""; }; C18913B42524F24C007B0683 /* DeviceDataManager+SimpleBolusViewModelDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceDataManager+SimpleBolusViewModelDelegate.swift"; 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 = ""; }; 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 = ""; }; @@ -1289,37 +1332,53 @@ C19C8BC228651EAE0056D5E4 /* LoopTestingKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopTestingKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C19C8BC728651F0A0056D5E4 /* MockKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MockKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C19C8C20286776C20056D5E4 /* LoopKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - C19E387B298638CE00851444 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; + C19E23B32E8350B900C20D83 /* PresetActivateButtonConfirm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetActivateButtonConfirm.swift; sourceTree = ""; }; + C19E23B52E83512A00C20D83 /* PresetActivateCrownConfirm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetActivateCrownConfirm.swift; sourceTree = ""; }; + C19E23B72E83566300C20D83 /* CircularProgressWithCheckmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularProgressWithCheckmark.swift; sourceTree = ""; }; + C19E23B92E83607C00C20D83 /* DerivedAssetsBase.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DerivedAssetsBase.xcassets; 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; }; + C1ABA1602E281D330049DF41 /* NotificationActionSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationActionSelection.swift; sourceTree = ""; }; C1AD41FF256D61E500164DDD /* Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Comparable.swift; sourceTree = ""; }; C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualGlucoseEntryRow.swift; sourceTree = ""; }; - C1BCB5AF298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; - C1C247882995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Intents.strings; sourceTree = ""; }; - 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 = ""; }; - 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 = ""; }; - C1C3127F297E4C0400296DA4 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Intents.strings; sourceTree = ""; }; - C1C5357529C6346A00E32DF9 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Intents.strings; sourceTree = ""; }; + C1B80D622AF97D7200AB7705 /* LoopDataManager+CarbAbsorption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoopDataManager+CarbAbsorption.swift"; sourceTree = ""; }; C1C660D0252E4DD5009B5C32 /* LoopConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopConstants.swift; sourceTree = ""; }; 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 = ""; }; + 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 = ""; }; + C1DCEDDC2E983A1B001A7BB0 /* AutomatedTreatmentState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomatedTreatmentState.swift; sourceTree = ""; }; + C1DCEDF32E999D5A001A7BB0 /* LastManualBolus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastManualBolus.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; }; C1E2774722433D7A00354103 /* MKRingProgressView.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MKRingProgressView.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C1E3862428247B7100F561A4 /* StoredLoopNotRunningNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredLoopNotRunningNotification.swift; sourceTree = ""; }; C1E9CB5A295101570022387B /* install-scenarios.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "install-scenarios.sh"; sourceTree = ""; }; + C1E9CB5A295101570022387B /* install-scenarios.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "install-scenarios.sh"; sourceTree = ""; }; + C1ED6C602E79BB9C002F91C2 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; + C1ED6C7C2E7C8113002F91C2 /* SetPresetUserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetPresetUserInfo.swift; sourceTree = ""; }; + C1ED6C7F2E7C9C7A002F91C2 /* PendingPresetReminder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingPresetReminder.swift; sourceTree = ""; }; + C1ED6C812E7D8E36002F91C2 /* AcknowledgeAlertUserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcknowledgeAlertUserInfo.swift; sourceTree = ""; }; 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 = ""; }; - C1F48FF62995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; 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 = ""; }; + C1F7822527CC056900C0919A /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = ""; }; + C1F8B1D122375E4200DD66CF /* BolusProgressTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusProgressTableViewCell.swift; sourceTree = ""; }; + C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BolusProgressTableViewCell.xib; sourceTree = ""; }; + C1FB428B217806A300FAB378 /* StateColorPalette.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateColorPalette.swift; sourceTree = ""; }; + C1FB428E217921D600FAB378 /* PumpManagerUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpManagerUI.swift; sourceTree = ""; }; C1F7822527CC056900C0919A /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = ""; }; C1F8B1D122375E4200DD66CF /* BolusProgressTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusProgressTableViewCell.swift; sourceTree = ""; }; C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BolusProgressTableViewCell.xib; sourceTree = ""; }; + C1FAD5182E7E0C3100F7FAD9 /* ChartPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartPageView.swift; sourceTree = ""; }; C1FB428B217806A300FAB378 /* StateColorPalette.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateColorPalette.swift; sourceTree = ""; }; C1FB428E217921D600FAB378 /* PumpManagerUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpManagerUI.swift; sourceTree = ""; }; DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegralRetrospectiveCorrectionSelectionView.swift; sourceTree = ""; }; @@ -1328,44 +1387,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 = ""; }; @@ -1373,9 +1401,6 @@ E98A55EE24EDD6E60008715D /* DosingDecisionStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DosingDecisionStoreProtocol.swift; sourceTree = ""; }; E98A55F024EDD85E0008715D /* MockDosingDecisionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDosingDecisionStore.swift; sourceTree = ""; }; E98A55F224EDD9530008715D /* MockSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSettingsStore.swift; sourceTree = ""; }; - E98A55F424EEE15A0008715D /* OnOffSelectionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnOffSelectionController.swift; sourceTree = ""; }; - E98A55F624EEE1E10008715D /* OnOffSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnOffSelectionView.swift; sourceTree = ""; }; - E98A55F824EEFC200008715D /* OnOffSelectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnOffSelectionViewModel.swift; sourceTree = ""; }; E9B07F7C253BBA6500BAD8F8 /* Loop Intent Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Loop Intent Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; E9B07F7E253BBA6500BAD8F8 /* IntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentHandler.swift; sourceTree = ""; }; E9B07F80253BBA6500BAD8F8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -1403,14 +1428,6 @@ E9C58A7924DB529A00487A17 /* dynamic_glucose_effect_partially_observed.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = dynamic_glucose_effect_partially_observed.json; sourceTree = ""; }; E9C58A7A24DB529A00487A17 /* counteraction_effect_falling_glucose.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = counteraction_effect_falling_glucose.json; sourceTree = ""; }; 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - F5E0BDE327E1D7230033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1423,7 +1440,6 @@ 1419606928D9554E00BA86E0 /* LoopKitUI.framework in Frameworks */, 1419606A28D955BC00BA86E0 /* MockKitUI.framework in Frameworks */, 1481F9BB28DA26F4004C5AEB /* LoopUI.framework in Frameworks */, - 1419606428D9550400BA86E0 /* LoopKitUI.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1431,6 +1447,10 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C1E2773E224177C000354103 /* ClockKit.framework in Frameworks */, + 4344628220A7A37F00C4BE6F /* CoreBluetooth.framework in Frameworks */, + 43D9002F21EB234400AF44BF /* LoopCore.framework in Frameworks */, + 4396BD50225159C0005AA4D3 /* HealthKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1453,24 +1473,12 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 43A9437B1B926B7B0051FA24 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 43D9002F21EB234400AF44BF /* LoopCore.framework in Frameworks */, - C1E2773E224177C000354103 /* ClockKit.framework in Frameworks */, - 4344628220A7A37F00C4BE6F /* CoreBluetooth.framework in Frameworks */, - C19C8C1E28663B040056D5E4 /* LoopKit.framework in Frameworks */, - 4396BD50225159C0005AA4D3 /* HealthKit.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 43D9002321EB209400AF44BF /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 43D9002D21EB225D00AF44BF /* HealthKit.framework in Frameworks */, - C159C82F286787EF00A86EC0 /* LoopKit.framework in Frameworks */, + C1275DE22E81FD530013B99D /* LoopKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1478,7 +1486,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - C19C8C21286776C20056D5E4 /* LoopKit.framework in Frameworks */, + C1275DDE2E81FD470013B99D /* LoopKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1490,18 +1498,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; @@ -1539,6 +1535,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 = ( @@ -1560,13 +1582,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 = ""; @@ -1625,18 +1648,19 @@ 4328E01F1CFBE2B100E199AA /* Extensions */ = { isa = PBXGroup; children = ( - 898ECA68218ABDA9001E9D35 /* CLKTextProvider+Compound.h */, - 898ECA66218ABDA8001E9D35 /* WatchApp Extension-Bridging-Header.h */, - 898ECA67218ABDA8001E9D35 /* CLKTextProvider+Compound.m */, + C10C57FB2E70B87500A4825C /* EnvironmentValues+GlucoseDisplayUnit.swift */, 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 */, @@ -1659,10 +1683,10 @@ isa = PBXGroup; children = ( DDC389F52A2B61750066E2E8 /* ApplicationFactorStrategy.swift */, - B4E2022F2661063E009421B5 /* AutomaticDosingStatus.swift */, A9FB75F0252BE320004C7D3F /* BolusDosingDecision.swift */, DDC389F92A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift */, C1EF747128D6A44A00C8C083 /* CrashRecoveryManager.swift */, + 1455ACB12C667BEE004F44F2 /* Deeplink.swift */, DDC389F72A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift */, B40D07C6251A89D500C1C6D7 /* GlucoseDisplay.swift */, 43C2FAE01EB656A500364AFF /* GlucoseEffectVelocity.swift */, @@ -1677,6 +1701,10 @@ C1E3862428247B7100F561A4 /* StoredLoopNotRunningNotification.swift */, 4328E0311CFC068900E199AA /* WatchContext+LoopKit.swift */, A987CD4824A58A0100439ADC /* ZipArchive.swift */, + C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */, + C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */, + C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */, + 84213C882D94941000642E78 /* InsulinDeliveryLogEvent.swift */, ); path = Models; sourceTree = ""; @@ -1687,7 +1715,6 @@ C18A491122FCC20B00FDA733 /* Scripts */, 4FF4D0FA1E1834BD00846527 /* Common */, 43776F8E1B8022E90074EA36 /* Loop */, - 4F70C1DF1DE8DCA7006380B7 /* Loop Status Extension */, 43D9FFD021EAE05D00AF44BF /* LoopCore */, 4F75288C1DFE1DC600C322D6 /* LoopUI */, 43A943731B926B7B0051FA24 /* WatchApp */, @@ -1699,6 +1726,7 @@ A900531928D60852000BC15B /* Shortcuts */, 968DCD53F724DE56FFE51920 /* Frameworks */, 43776F8D1B8022E90074EA36 /* Products */, + B4C6D2412EA7E38C006F5755 /* LocalizablePlural.xcstrings */, 437D9BA11D7B5203007245E8 /* Loop.xcconfig */, A951C5FF23E8AB51003E26DC /* Version.xcconfig */, ); @@ -1709,9 +1737,7 @@ children = ( 43776F8C1B8022E90074EA36 /* Loop.app */, 43A943721B926B7B0051FA24 /* WatchApp.app */, - 43A9437E1B926B7B0051FA24 /* WatchApp Extension.appex */, 43E2D90B1D20C581004DA55F /* LoopTests.xctest */, - 4F70C1DC1DE8DCA7006380B7 /* Loop Status Extension.appex */, 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */, 43D9FFCF21EAE05D00AF44BF /* LoopCore.framework */, 43D9002A21EB209400AF44BF /* LoopCore.framework */, @@ -1751,8 +1777,10 @@ isa = PBXGroup; children = ( B66D1F222E6A5D6500471149 /* InfoPlist.xcstrings */, + C19E23B92E83607C00C20D83 /* DerivedAssetsBase.xcassets */, + C1275DD72E808E480013B99D /* ContentView.swift */, + C1275DD52E808E2C0013B99D /* LoopWatchApp.swift */, 43F5C2D61B92A4DC003EB13D /* Info.plist */, - 43A943741B926B7B0051FA24 /* Interface.storyboard */, A966152823EA5A37005D8B29 /* DefaultAssets.xcassets */, A966152923EA5A37005D8B29 /* DerivedAssets.xcassets */, ); @@ -1767,10 +1795,10 @@ 43D533BB1CFD1DD7009E3085 /* WatchApp Extension.entitlements */, 43A943911B926B7B0051FA24 /* Info.plist */, B66D1F242E6A5D6500471149 /* InfoPlist.xcstrings */, + 4B67E2C6289B4EDB002D92AF /* InfoPlist.strings */, 43A9438D1B926B7B0051FA24 /* ComplicationController.swift */, 43A943871B926B7B0051FA24 /* ExtensionDelegate.swift */, 43A9438F1B926B7B0051FA24 /* Assets.xcassets */, - 4328E0121CFBE1B700E199AA /* Controllers */, 4328E01F1CFBE2B100E199AA /* Extensions */, 4FE3475F20D5D7FA00A86D03 /* Managers */, 898ECA5D218ABD17001E9D35 /* Models */, @@ -1877,14 +1905,14 @@ isa = PBXGroup; children = ( 3ED3199B2EB65A9B00820BCF /* LiveActivitySettings.swift */, + C1ED6C632E7C6DA6002F91C2 /* Models */, + C1ED6C602E79BB9C002F91C2 /* NotificationManager.swift */, C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */, - 43DE92581C5479E4001FFDE1 /* PotentialCarbEntryUserInfo.swift */, + 43DE92581C5479E4001FFDE1 /* GetBolusRecommendationUserInfo.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 */, B66D1F3A2E6A5D6600471149 /* Localizable.xcstrings */, @@ -1899,8 +1927,14 @@ 43E344A01B9E144300C85C07 /* Extensions */ = { isa = PBXGroup; children = ( + 84DF48C22F6A0AF600BEDB40 /* Image+Crop.swift */, + 84DF48C02F6A0AED00BEDB40 /* Double+Closest.swift */, + C120CECB2D8CD6970050944B /* Publisher.swift */, + C10509782D8B635F00118A37 /* Environment+TemporaryPresetsManager.swift */, + C10509742D8B308E00118A37 /* Environment+SettingsManager.swift */, A98556842493F901000FD662 /* AlertStore+SimulatedCoreData.swift */, C1D289B422F90A52003FFBD9 /* BasalDeliveryState.swift */, + C1F2CAAB2B7A980600D7F581 /* BasalRelativeDose.swift */, A9F703722489BC8500C98AD8 /* CarbStore+SimulatedCoreData.swift */, C17824991E1999FA00D9D25C /* CaseCountable.swift */, 4F6663931E905FD2009E74FC /* ChartColorPalette+Loop.swift */, @@ -1908,10 +1942,8 @@ 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 */, @@ -1923,12 +1955,13 @@ A9DCF2D525B0F3C500C89088 /* LoopUIColorPalette+Default.swift */, 89E267FE229267DF00A3F2AF /* Optional.swift */, A967D94B24F99B9300CDDF8A /* OutputStream.swift */, - 895FE0942201234000FCF18A /* OverrideSelectionViewController.swift */, A9F703762489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift */, A999D40524663D18004C89D4 /* PumpManagerError.swift */, 892A5D682230C41D008961AB /* RangeReplaceableCollection.swift */, A9CBE45B248ACC03008E7BA2 /* SettingsStore+SimulatedCoreData.swift */, C1FB428B217806A300FAB378 /* StateColorPalette.swift */, + C1F2CAA92B76B3EE00D7F581 /* TempBasalRecommendation.swift */, + B4C6D2432EAA2AB4006F5755 /* TimeInterval.swift */, 43F89CA222BDFBBC006BB54E /* UIActivityIndicatorView.swift */, 43F41C361D3BF32400C11ED6 /* UIAlertController.swift */, A9F66FC2247F451500096EA7 /* UIDevice+Loop.swift */, @@ -1938,6 +1971,7 @@ C13DA2AF24F6C7690098BB29 /* UIViewController.swift */, 430B29922041F5B200BA9F93 /* UserDefaults+Loop.swift */, A9B607AF247F000F00792BE4 /* UserNotifications+Loop.swift */, + 84A7B54F2D2D972C00B6D202 /* Image+Optional.swift */, ); path = Extensions; sourceTree = ""; @@ -1962,33 +1996,40 @@ 43F5C2CF1B92A2ED003EB13D /* Views */ = { isa = PBXGroup; children = ( - 1452F4AA2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift */, + 84213C732D932EF400642E78 /* Insulin Delivery Log */, 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 */, 3ED319972EB65A6900820BCF /* LiveActivityBottomRowManagerView.swift */, 3ED319982EB65A6900820BCF /* LiveActivityManagementView.swift */, + B43B5C512EAFB1B60096A6AE /* InsulinSuspendedTableViewCell.swift */, + B43B5C4F2EAFB17D0096A6AE /* InsulinSuspendedTableViewCell.xib */, C1742331259BEADC00399C9D /* ManualEntryDoseView.swift */, 1DA46B5F2492E2E300D71A63 /* NotificationsCriticalAlertPermissionsView.swift */, 899433B723FE129700FA4BEA /* OverrideBadgeView.swift */, 89D6953D23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift */, - 89CAB36224C8FE95009EE3CE /* PredictedGlucoseChartView.swift */, 438D42FA1D7D11A4003244B0 /* PredictionInputEffectTableViewCell.swift */, 439706E522D2E84900C81566 /* PredictionSettingTableViewCell.swift */, + 84E8BBAF2CC979300078E6CF /* Presets */, + B43B5C552EAFBF170096A6AE /* RecentGlucoseTableViewCell.swift */, + B43B5C532EAFBF110096A6AE /* RecentGlucoseTableViewCell.xib */, + C151634D2FA2C9C800FEECE8 /* RequiredVersionUpdateView.swift */, 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */, + E4854073AAE5C2CB97167F63 /* HealthAccessView.swift */, DDC389FB2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift */, C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */, 43F64DD81D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift */, @@ -1996,6 +2037,9 @@ C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */, DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */, DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */, + 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */, + 84D1F1A62D09053A00CB271F /* StatusTableView.swift */, + B429CAB22E97C7F300FA988E /* LoopStatusModalView.swift */, ); path = Views; sourceTree = ""; @@ -2005,7 +2049,9 @@ children = ( B42D124228D371C400E43D22 /* AlertMuter.swift */, 1D6B1B6626866D89009AC446 /* AlertPermissionsChecker.swift */, + 1DA6499D2441266400F61E75 /* Alerts */, 439897361CD2F80600223065 /* AnalyticsServicesManager.swift */, + C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */, B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */, 439BED291E76093C00B0AED5 /* CGMManager.swift */, C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */, @@ -2013,33 +2059,32 @@ 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 */, 3ED319902EB65A2D00820BCF /* Live Activity */, - C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */, - A96DAC2B2838F31200D94E38 /* SharedLogging.swift */, - 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */, 84AA81E42A4A3981000B658B /* DeeplinkManager.swift */, + C1B80D622AF97D7200AB7705 /* LoopDataManager+CarbAbsorption.swift */, ); path = Managers; sourceTree = ""; @@ -2047,17 +2092,17 @@ 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 = ""; @@ -2101,7 +2146,7 @@ isa = PBXGroup; children = ( 437CEEBF1CD6FCD8003C8C80 /* BasalRateHUDView.swift */, - 43B371851CE583890013C5A6 /* BasalStateView.swift */, + 43B371851CE583890013C5A6 /* TreatmentArrowStateView.swift */, B4E96D4E248A6E20002DABAD /* CGMStatusHUDView.swift */, B4C9859325D5A3BB009FD9CA /* StatusBadgeHUDView.swift */, B4E96D4A248A6B6E002DABAD /* DeviceStatusHUDView.swift */, @@ -2178,22 +2223,12 @@ 4FF4D0FB1E1834C400846527 /* Models */ = { isa = PBXGroup; children = ( - 89F9119324358E4500ECCAF3 /* CarbAbsorptionTime.swift */, - A9347F3024E7521800C99C34 /* CarbBackfillRequestUserInfo.swift */, - 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */, - 4372E48F213CFCE70068E043 /* LoopSettingsUserInfo.swift */, + C110888C2A3913C600BA4898 /* BuildDetails.swift */, 43C3B6F620BBCAA30026CAFA /* PumpManager.swift */, C1FB428E217921D600FAB378 /* PumpManagerUI.swift */, - 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */, 4F70C2111DE900EA006380B7 /* StatusExtensionContext.swift */, - 89A1B66D24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift */, - 4FF4D0FF1E18374700846527 /* WatchContext.swift */, - C1201E2B23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift */, A9347F2E24E7508A00C99C34 /* WatchHistoricalCarbs.swift */, 4F11D3C120DD80B3006E072C /* WatchHistoricalGlucose.swift */, - 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */, - E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */, - C110888C2A3913C600BA4898 /* BuildDetails.swift */, ); path = Models; sourceTree = ""; @@ -2201,9 +2236,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 */, @@ -2227,13 +2263,49 @@ path = Common; sourceTree = ""; }; + 84213C732D932EF400642E78 /* Insulin Delivery Log */ = { + isa = PBXGroup; + children = ( + 84213C742D932F0F00642E78 /* InsulinDeliveryLog.swift */, + 843F32E92E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift */, + 84BC5BDE2E1C4A7300432000 /* InsulinDeliveryEventDetailsView.swift */, + 84213C8C2D949E8100642E78 /* InsulinDeliveryLogEventRow.swift */, + 84213C8A2D94948900642E78 /* InsulinDeliveryOverview.swift */, + ); + path = "Insulin Delivery Log"; + sourceTree = ""; + }; + 842E40A12F22F7E2000CCCE0 /* Components */ = { + isa = PBXGroup; + children = ( + 842E40982F22F7E2000CCCE0 /* EstimatedReadTime.swift */, + 842E409A2F22F7E2000CCCE0 /* PresetsTrainingCard.swift */, + 842E409B2F22F7E2000CCCE0 /* CommonUseStep.swift */, + 842E409D2F22F7E2000CCCE0 /* TherapySettingsExampleView.swift */, + 842E409F2F22F7E2000CCCE0 /* IntensitySlider.swift */, + 842E40A02F22F7E2000CCCE0 /* TintedContent.swift */, + 841306BA2F7F0D9C00AF0320 /* ReferencesView.swift */, + ); + path = Components; + sourceTree = ""; + }; + 842E40A62F22F7E2000CCCE0 /* Training */ = { + isa = PBXGroup; + children = ( + 842E40A12F22F7E2000CCCE0 /* Components */, + 842E40A32F22F7E2000CCCE0 /* PresetsTrainingView.swift */, + 842E40A52F22F7E2000CCCE0 /* PresetsTrainingContent.swift */, + ); + path = Training; + sourceTree = ""; + }; 84AA81D12A4A2778000B658B /* Components */ = { 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; @@ -2254,6 +2326,8 @@ 84AA81D92A4A2966000B658B /* Helpers */ = { isa = PBXGroup; children = ( + 1455ACAC2C6675DF004F44F2 /* Color.swift */, + 8496F7302B5711C4003E672C /* ContentMargin.swift */, 84AA81DA2A4A2973000B658B /* Date.swift */, 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */, 3ED3199E2EB65AFE00820BCF /* LocalizedString.swift */, @@ -2279,11 +2353,49 @@ path = Widgets; sourceTree = ""; }; + 84DF48A42F6A0A6E00BEDB40 /* Media Player */ = { + isa = PBXGroup; + children = ( + 84DF48BE2F6A0AE500BEDB40 /* VideoView.swift */, + 84DF48BC2F6A0AE200BEDB40 /* TranscriptView.swift */, + 84DF48BA2F6A0ADD00BEDB40 /* PlayerControls.swift */, + 84DF48B82F6A0ADB00BEDB40 /* MediaPlayerView.swift */, + 84DF48B62F6A0AD700BEDB40 /* CaptionsView.swift */, + 84DF48B42F6A0AD400BEDB40 /* AudioPlayer.swift */, + 842E409E2F22F7E2000CCCE0 /* PlayMediaButton.swift */, + ); + path = "Media Player"; + sourceTree = ""; + }; + 84E8BBAF2CC979300078E6CF /* Presets */ = { + isa = PBXGroup; + children = ( + C11445B12DA98A2D00034864 /* CorrectionRangeInformationView.swift */, + 84FA9D322CF7FD0D004162B4 /* PresetsHistoryView.swift */, + 84B67A8C2F63558A004C783B /* PresetPerformanceHistoryView.swift */, + 8446319E2F5A2AA9003825AE /* PresetsPerformanceHistoryViewModel.swift */, + 84E8BBC92CCA16290078E6CF /* PresetsView.swift */, + 84DF48A42F6A0A6E00BEDB40 /* Media Player */, + 842E40A62F22F7E2000CCCE0 /* Training */, + 84E8BBC22CC9B9780078E6CF /* Components */, + ); + path = Presets; + sourceTree = ""; + }; + 84E8BBC22CC9B9780078E6CF /* Components */ = { + isa = PBXGroup; + children = ( + 847F23422E4543140035C864 /* ActivePresetBanner.swift */, + 84F20DFC2D0B9C3A0089DF02 /* EditPresetDurationView.swift */, + 84D1F1A82D09800700CB271F /* PresetDetentView.swift */, + ); + path = Components; + sourceTree = ""; + }; 891B508324342BCA005DA578 /* View Models */ = { isa = PBXGroup; children = ( 891B508424342BE1005DA578 /* CarbAndBolusFlowViewModel.swift */, - E98A55F824EEFC200008715D /* OnOffSelectionViewModel.swift */, ); path = "View Models"; sourceTree = ""; @@ -2291,15 +2403,31 @@ 895788A3242E6947002CB114 /* Views */ = { isa = PBXGroup; children = ( - 895788B5242E6A25002CB114 /* Carb Entry & Bolus */, - 895788B4242E69C8002CB114 /* Extensions */, 895788AB242E69A2002CB114 /* ActionButton.swift */, - 895788AA242E69A1002CB114 /* CircularAccessoryButtonStyle.swift */, + C10C57FD2E71E87400A4825C /* ActiveOverrideView.swift */, + 895788B5242E6A25002CB114 /* Carb Entry & Bolus */, + C17D52072E7F0E18001D2AD2 /* CarbList.swift */, + C1FAD5182E7E0C3100F7FAD9 /* ChartPageView.swift */, 89A605E824328862009C1096 /* Checkmark.swift */, + C10C57E42E6F767500A4825C /* CircleTintedButton.swift */, + 895788AA242E69A1002CB114 /* CircularAccessoryButtonStyle.swift */, + C19E23B72E83566300C20D83 /* CircularProgressWithCheckmark.swift */, + B47BA4272F506D50006BAAB3 /* CompleteOnboardingView.swift */, 89A605EE2432925D009C1096 /* CompletionCheckmark.swift */, + 895788B4242E69C8002CB114 /* Extensions */, + C17D52032E7F0568001D2AD2 /* LabelValueRow.swift */, + C1550B0B2E6F249A009369DC /* LoopCircleView.swift */, + C17D52012E7F03CF001D2AD2 /* LoopHeader.swift */, + C19E23B32E8350B900C20D83 /* PresetActivateButtonConfirm.swift */, + C19E23B52E83512A00C20D83 /* PresetActivateCrownConfirm.swift */, + C1275DDA2E8175AF0013B99D /* PresetConfirmationView.swift */, + C10C57ED2E7081C900A4825C /* PresetDetailView.swift */, + C10C57EB2E7070FB00A4825C /* PresetsListView.swift */, + C1275DDC2E8185960013B99D /* PresetsView.swift */, + C10C57F92E708B1D00A4825C /* PresetWatchCard.swift */, 894F6DD8243C060600CCE676 /* ScalablePositionedText.swift */, 89A605EA243288E4009C1096 /* TopDownTriangle.swift */, - E98A55F624EEE1E10008715D /* OnOffSelectionView.swift */, + C12D723F2E4FBC5D00BD628A /* WatchActionsView.swift */, ); path = Views; sourceTree = ""; @@ -2307,6 +2435,7 @@ 895788B4242E69C8002CB114 /* Extensions */ = { isa = PBXGroup; children = ( + C1DCEDDC2E983A1B001A7BB0 /* AutomatedTreatmentState.swift */, 89E08FC7242E76E9000D719B /* AnyTransition.swift */, 895788A9242E69A1002CB114 /* Color.swift */, 89F9118E24352F1600ECCAF3 /* DigitalCrownRotation.swift */, @@ -2341,10 +2470,12 @@ 897A5A9724C22DCE00C4E71D /* View Models */ = { isa = PBXGroup; children = ( - 1452F4A82A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift */, + C10509762D8B590D00118A37 /* StatusTableViewModel.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 */, @@ -2359,6 +2490,8 @@ 898ECA5D218ABD17001E9D35 /* Models */ = { isa = PBXGroup; children = ( + C1ED6C7F2E7C9C7A002F91C2 /* PendingPresetReminder.swift */, + 89F9119324358E4500ECCAF3 /* CarbAbsorptionTime.swift */, 898ECA5E218ABD17001E9D35 /* GlucoseChartScaler.swift */, 898ECA5F218ABD17001E9D35 /* GlucoseChartData.swift */, 892FB4CC22040104005293EC /* OverridePresetRow.swift */, @@ -2387,6 +2520,10 @@ 968DCD53F724DE56FFE51920 /* Frameworks */ = { isa = PBXGroup; children = ( + C1275E1D2E8227260013B99D /* LoopKit.framework */, + C1275E1A2E82269A0013B99D /* LoopKit.framework */, + C1275DE12E81FD530013B99D /* LoopKit.framework */, + 840A2F0D2C0F978E003D5E90 /* LoopKitUI.framework */, C159C82E286787EF00A86EC0 /* LoopKit.framework */, C159C8212867859800A86EC0 /* MockKitUI.framework */, C159C8192867857000A86EC0 /* LoopKitUI.framework */, @@ -2440,12 +2577,14 @@ 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 = ""; @@ -2469,14 +2608,6 @@ path = ViewModels; sourceTree = ""; }; - B4CAD8772549D2330057946B /* LoopCore */ = { - isa = PBXGroup; - children = ( - B4CAD8782549D2540057946B /* LoopCompletionFreshnessTests.swift */, - ); - path = LoopCore; - sourceTree = ""; - }; C13072B82A76AF0A009A7C58 /* live_capture */ = { isa = PBXGroup; children = ( @@ -2494,6 +2625,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 = ( @@ -2507,52 +2654,25 @@ 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 */ = { + C1ED6C632E7C6DA6002F91C2 /* Models */ = { 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 */, + C1DCEDF32E999D5A001A7BB0 /* LastManualBolus.swift */, + C1ED6C812E7D8E36002F91C2 /* AcknowledgeAlertUserInfo.swift */, + A9347F3024E7521800C99C34 /* CarbBackfillRequestUserInfo.swift */, + 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */, + E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */, + 4372E48F213CFCE70068E043 /* LoopSettingsUserInfo.swift */, + C1ABA1602E281D330049DF41 /* NotificationActionSelection.swift */, + 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */, + C1ED6C7C2E7C8113002F91C2 /* SetPresetUserInfo.swift */, + C10C57E82E705CCB00A4825C /* SettingsRequestUserInfo.swift */, + 89A1B66D24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift */, + 4FF4D0FF1E18374700846527 /* WatchContext.swift */, + C1201E2B23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift */, + 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */, ); - path = high_and_falling; + path = Models; sourceTree = ""; }; E93E86AC24DDE02C00FF40C8 /* Mock Stores */ = { @@ -2568,30 +2688,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 = ( @@ -2644,12 +2740,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 */, @@ -2724,14 +2814,13 @@ C16DA84322E8E5FF008624C2 /* Install Plugins */, C1D19800232CFA2A0096D646 /* Capture Build Details */, C1092BFE29F88F0600AE3D1C /* Apply Info Customizations */, - 4F70C1EC1DE8DCA8006380B7 /* Embed App Extensions */, + 4F70C1EC1DE8DCA8006380B7 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( 4F7528971DFE1ED400C322D6 /* PBXTargetDependency */, 43A943931B926B7B0051FA24 /* PBXTargetDependency */, - 4F70C1E71DE8DCA7006380B7 /* PBXTargetDependency */, 43D9FFD521EAE05D00AF44BF /* PBXTargetDependency */, E9B07F93253BBA6500BAD8F8 /* PBXTargetDependency */, 14B1736828AED9EE006CCD7C /* PBXTargetDependency */, @@ -2751,42 +2840,24 @@ buildConfigurationList = 43A943991B926B7B0051FA24 /* Build configuration list for PBXNativeTarget "WatchApp" */; buildPhases = ( 43A943701B926B7B0051FA24 /* Resources */, - 43A943981B926B7B0051FA24 /* Embed App Extensions */, 43105EF81BADC8F9009CD81E /* Frameworks */, + C1E9CB59294E67060022387B /* Build Derived Assets */, + 43A9437A1B926B7B0051FA24 /* Sources */, + 43C667D71C5577280050C674 /* Embed Frameworks */, ); buildRules = ( ); dependencies = ( - 43A943811B926B7B0051FA24 /* PBXTargetDependency */, + C16E94F92E7DBBA600AA4E6E /* PBXTargetDependency */, ); name = WatchApp; productName = WatchApp; productReference = 43A943721B926B7B0051FA24 /* WatchApp.app */; - productType = "com.apple.product-type.application.watchapp2"; + productType = "com.apple.product-type.application"; }; - 43A9437D1B926B7B0051FA24 /* WatchApp Extension */ = { + 43D9001A21EB209400AF44BF /* LoopCore-watchOS */ = { isa = PBXNativeTarget; - buildConfigurationList = 43A943951B926B7B0051FA24 /* Build configuration list for PBXNativeTarget "WatchApp Extension" */; - buildPhases = ( - C1E9CB59294E67060022387B /* Build Derived Assets */, - 43A9437A1B926B7B0051FA24 /* Sources */, - 43A9437B1B926B7B0051FA24 /* Frameworks */, - 43A9437C1B926B7B0051FA24 /* Resources */, - 43C667D71C5577280050C674 /* Embed Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - C117ED71232EDB3200DA57CD /* PBXTargetDependency */, - ); - name = "WatchApp Extension"; - productName = "WatchApp Extension"; - productReference = 43A9437E1B926B7B0051FA24 /* WatchApp Extension.appex */; - productType = "com.apple.product-type.watchkit2-extension"; - }; - 43D9001A21EB209400AF44BF /* LoopCore-watchOS */ = { - isa = PBXNativeTarget; - buildConfigurationList = 43D9002721EB209400AF44BF /* Build configuration list for PBXNativeTarget "LoopCore-watchOS" */; + buildConfigurationList = 43D9002721EB209400AF44BF /* Build configuration list for PBXNativeTarget "LoopCore-watchOS" */; buildPhases = ( 43D9001D21EB209400AF44BF /* Headers */, 43D9001F21EB209400AF44BF /* Sources */, @@ -2842,27 +2913,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" */; @@ -2908,8 +2958,8 @@ 43776F841B8022E90074EA36 /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 1340; - LastUpgradeCheck = 1010; + LastSwiftUpdateCheck = 1520; + LastUpgradeCheck = 2600; ORGANIZATIONNAME = "LoopKit Authors"; TargetAttributes = { 14B1735B28AED9EC006CCD7C = { @@ -2918,7 +2968,6 @@ 43776F8B1B8022E90074EA36 = { CreatedOnToolsVersion = 7.0; LastSwiftMigration = 1020; - ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.ApplicationGroups.iOS = { enabled = 1; @@ -2940,7 +2989,6 @@ 43A943711B926B7B0051FA24 = { CreatedOnToolsVersion = 7.0; LastSwiftMigration = 0800; - ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.ApplicationGroups.iOS = { enabled = 0; @@ -2950,28 +2998,6 @@ }; }; }; - 43A9437D1B926B7B0051FA24 = { - CreatedOnToolsVersion = 7.0; - LastSwiftMigration = 1020; - ProvisioningStyle = Automatic; - SystemCapabilities = { - com.apple.ApplicationGroups.iOS = { - enabled = 0; - }; - com.apple.HealthKit = { - enabled = 0; - }; - com.apple.HealthKit.watchos = { - enabled = 1; - }; - com.apple.Keychain = { - enabled = 0; - }; - com.apple.Siri = { - enabled = 1; - }; - }; - }; 43D9001A21EB209400AF44BF = { LastSwiftMigration = 1020; ProvisioningStyle = Automatic; @@ -2987,24 +3013,11 @@ 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; ProvisioningStyle = Automatic; }; - E9B07F7B253BBA6500BAD8F8 = { - ProvisioningStyle = Automatic; - }; }; }; buildConfigurationList = 43776F871B8022E90074EA36 /* Build configuration list for PBXProject "Loop" */; @@ -3050,9 +3063,7 @@ projectRoot = ""; targets = ( 43776F8B1B8022E90074EA36 /* Loop */, - 4F70C1DB1DE8DCA7006380B7 /* Loop Status Extension */, 43A943711B926B7B0051FA24 /* WatchApp */, - 43A9437D1B926B7B0051FA24 /* WatchApp Extension */, 14B1735B28AED9EC006CCD7C /* Loop Widget Extension */, E9B07F7B253BBA6500BAD8F8 /* Loop Intent Extension */, 43D9FFCE21EAE05D00AF44BF /* LoopCore */, @@ -3088,6 +3099,9 @@ A966152623EA5A26005D8B29 /* DefaultAssets.xcassets in Resources */, A966152723EA5A26005D8B29 /* DerivedAssets.xcassets in Resources */, B66D1F332E6A5D6600471149 /* Localizable.xcstrings in Resources */, + B43B5C542EAFBF110096A6AE /* RecentGlucoseTableViewCell.xib in Resources */, + B4C6D2422EA7E38C006F5755 /* LocalizablePlural.xcstrings in Resources */, + B43B5C502EAFB17D0096A6AE /* InsulinSuspendedTableViewCell.xib in Resources */, 43776F971B8022E90074EA36 /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3096,7 +3110,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - A966152B23EA5A37005D8B29 /* DerivedAssets.xcassets in Resources */, + C1275E1F2E822CEE0013B99D /* DerivedAssets.xcassets in Resources */, B66D1F232E6A5D6500471149 /* InfoPlist.xcstrings in Resources */, 43A943761B926B7B0051FA24 /* Interface.storyboard in Resources */, A966152A23EA5A37005D8B29 /* DefaultAssets.xcassets in Resources */, @@ -3112,6 +3126,9 @@ 43A943901B926B7B0051FA24 /* Assets.xcassets in Resources */, B66D1F352E6A5D6600471149 /* Localizable.xcstrings in Resources */, B405E35924D2A75B00DD058D /* DerivedAssets.xcassets in Resources */, + C1275E1F2E822CEE0013B99D /* DerivedAssets.xcassets in Resources */, + A966152A23EA5A37005D8B29 /* DefaultAssets.xcassets in Resources */, + 849466D02EF1EAD300A90718 /* LocalizablePlural.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3135,7 +3152,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 */, @@ -3143,42 +3159,15 @@ 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 */, ); @@ -3343,15 +3332,17 @@ 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 */, 3ED319942EB65A3E00820BCF /* GlucoseActivityAttributes.swift in Sources */, @@ -3359,6 +3350,7 @@ 3ED319A12EB65B4100820BCF /* Bootstrap.swift in Sources */, 14B1737E28AEDC6C006CCD7C /* FeatureFlags.swift in Sources */, 84AA81E72A4A4DEF000B658B /* PumpView.swift in Sources */, + 1455ACAD2C6675E1004F44F2 /* Color.swift in Sources */, 14B1737F28AEDC6C006CCD7C /* PluginManager.swift in Sources */, 3ED3198A2EB659E600820BCF /* GlucoseLiveActivityConfiguration.swift in Sources */, 3ED3198B2EB659E600820BCF /* ChartView.swift in Sources */, @@ -3368,10 +3360,12 @@ 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; }; @@ -3384,7 +3378,6 @@ 4F70C2131DE90339006380B7 /* StatusExtensionContext.swift in Sources */, A9A056B324B93C62007CF06D /* CriticalEventLogExportView.swift in Sources */, 43C05CC521EC29E3006FB252 /* TextFieldTableViewCell.swift in Sources */, - 4FF4D1001E18374700846527 /* WatchContext.swift in Sources */, C1D289B522F90A52003FFBD9 /* BasalDeliveryState.swift in Sources */, 4F2C15821E074FC600E160D4 /* NSTimeInterval.swift in Sources */, 4311FB9B1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift in Sources */, @@ -3393,6 +3386,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 */, @@ -3404,66 +3398,85 @@ 43A51E1F1EB6D62A000736CC /* CarbAbsorptionViewController.swift in Sources */, 43776F901B8022E90074EA36 /* AppDelegate.swift in Sources */, 4372E48B213CB5F00068E043 /* Double.swift in Sources */, + 84DF48C12F6A0AED00BEDB40 /* Double+Closest.swift in Sources */, 430B29932041F5B300BA9F93 /* UserDefaults+Loop.swift in Sources */, + B429CAB42E97C97000FA988E /* LoopStatusModalView.swift in Sources */, 43CE7CDE1CA8B63E003CC1B0 /* Data.swift in Sources */, E9BB27AB23B85C3500FB4987 /* SleepStore.swift in Sources */, 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 */, + 8446319F2F5A2AB3003825AE /* PresetsPerformanceHistoryViewModel.swift in Sources */, C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */, + 84DF48B92F6A0ADB00BEDB40 /* MediaPlayerView.swift in Sources */, 89D6953E23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift in Sources */, 89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */, + C18859AC2AF29BE50010F21F /* TemporaryPresetsManager.swift in Sources */, + B43B5C522EAFB1BE0096A6AE /* InsulinSuspendedTableViewCell.swift in Sources */, 1DA649A7244126CD00F61E75 /* UserNotificationAlertScheduler.swift in Sources */, 439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */, 149A28BD2A853E6C00052EDF /* CarbEntryView.swift in Sources */, - 4F11D3C020DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */, + 84F20DFD2D0B9C3A0089DF02 /* EditPresetDurationView.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 */, + 84213C8D2D949E8100642E78 /* InsulinDeliveryLogEventRow.swift in Sources */, 43FCEEA9221A615B0013DD30 /* StatusChartsManager.swift in Sources */, A9F703752489C9A000C98AD8 /* GlucoseStore+SimulatedCoreData.swift in Sources */, E95D380524EADF78005E2F50 /* GlucoseStoreProtocol.swift in Sources */, 43E3449F1B9D68E900C85C07 /* StatusTableViewController.swift in Sources */, + 84213C892D94941000642E78 /* InsulinDeliveryLogEvent.swift in Sources */, B42D124328D371C400E43D22 /* AlertMuter.swift in Sources */, A96DAC242838325900D94E38 /* DiagnosticLog.swift in Sources */, A9CBE45A248ACBE1008E7BA2 /* DosingDecisionStore+SimulatedCoreData.swift in Sources */, A9C62D8A2331703100535612 /* ServicesManager.swift in Sources */, + C11445B22DA98A3400034864 /* CorrectionRangeInformationView.swift in Sources */, 43DBF0531C93EC8200B3C386 /* DeviceDataManager.swift in Sources */, A9347F2F24E7508A00C99C34 /* WatchHistoricalCarbs.swift in Sources */, + C10509792D8B636900118A37 /* Environment+TemporaryPresetsManager.swift in Sources */, 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 */, + C1B80D632AF97D7200AB7705 /* LoopDataManager+CarbAbsorption.swift in Sources */, DDC389FA2A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift in Sources */, - B4E202302661063E009421B5 /* AutomaticDosingStatus.swift in Sources */, C191D2A125B3ACAA00C26C0B /* DosingStrategySelectionView.swift in Sources */, A977A2F424ACFECF0059C207 /* CriticalEventLogExportManager.swift in Sources */, 89CA2B32226C18B8004D9350 /* TestingScenariosTableViewController.swift in Sources */, + 843F32EA2E2815D400B0B271 /* InsulinDeliveryLogViewModel.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 */, + C120CECC2D8CD6990050944B /* Publisher.swift in Sources */, + 84D1F1A92D09800700CB271F /* PresetDetentView.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 */, A9A056B524B94123007CF06D /* CriticalEventLogExportViewModel.swift in Sources */, 434FF1EE1CF27EEF000DB779 /* UITableViewCell.swift in Sources */, + 84DF48C32F6A0AF600BEDB40 /* Image+Crop.swift in Sources */, 439BED2A1E76093C00B0AED5 /* CGMManager.swift in Sources */, C16B983E26B4893300256B05 /* DoseEnactor.swift in Sources */, 3ED319962EB65A5C00820BCF /* LiveActivityManagementViewModel.swift in Sources */, @@ -3472,10 +3485,19 @@ 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 */, - A9347F3124E7521800C99C34 /* CarbBackfillRequestUserInfo.swift in Sources */, + 842E40A72F22F7E2000CCCE0 /* TintedContent.swift in Sources */, + 842E40A92F22F7E2000CCCE0 /* EstimatedReadTime.swift in Sources */, + 842E40AA2F22F7E2000CCCE0 /* PresetsTrainingView.swift in Sources */, + 842E40AB2F22F7E2000CCCE0 /* IntensitySlider.swift in Sources */, + 842E40AD2F22F7E2000CCCE0 /* PresetsTrainingContent.swift in Sources */, + 842E40AE2F22F7E2000CCCE0 /* PlayMediaButton.swift in Sources */, + 842E40AF2F22F7E2000CCCE0 /* PresetsTrainingCard.swift in Sources */, + 842E40B12F22F7E2000CCCE0 /* CommonUseStep.swift in Sources */, + 842E40B22F22F7E2000CCCE0 /* TherapySettingsExampleView.swift in Sources */, A9CBE458248AB564008E7BA2 /* DoseStore+SimulatedCoreData.swift in Sources */, 897A5A9924C22DE800C4E71D /* BolusEntryViewModel.swift in Sources */, 4374B5EF209D84BF00D17AA8 /* OSLog.swift in Sources */, @@ -3483,11 +3505,18 @@ A9C62D882331703100535612 /* Service.swift in Sources */, 89CAB36324C8FE96009EE3CE /* PredictedGlucoseChartView.swift in Sources */, DDC389F82A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift in Sources */, + 847F23432E4543140035C864 /* ActivePresetBanner.swift in Sources */, + 84BC5BDF2E1C4A7300432000 /* InsulinDeliveryEventDetailsView.swift in Sources */, 4F6663941E905FD2009E74FC /* ChartColorPalette+Loop.swift in Sources */, A9F703732489BC8500C98AD8 /* CarbStore+SimulatedCoreData.swift in Sources */, 4328E0351CFC0AE100E199AA /* WatchDataManager.swift in Sources */, 4345E3FC21F04911009E00E5 /* UIColor+HIG.swift in Sources */, 1D4A3E2E2478628500FD601B /* StoredAlert+CoreDataProperties.swift in Sources */, + 84DF48B72F6A0AD700BEDB40 /* CaptionsView.swift in Sources */, + B4C6D2442EAA2AC2006F5755 /* TimeInterval.swift in Sources */, + 84DF48BD2F6A0AE200BEDB40 /* TranscriptView.swift in Sources */, + B43B5C562EAFBF230096A6AE /* RecentGlucoseTableViewCell.swift in Sources */, + C10509752D8B309400118A37 /* Environment+SettingsManager.swift in Sources */, E95D380124EADE7C005E2F50 /* DoseStoreProtocol.swift in Sources */, 43D381621EBD9759007F8C8F /* HeaderValuesTableViewCell.swift in Sources */, C13DA2B024F6C7690098BB29 /* UIViewController.swift in Sources */, @@ -3502,22 +3531,31 @@ A96DAC2C2838F31200D94E38 /* SharedLogging.swift in Sources */, 4302F4E31D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift in Sources */, 1D63DEA526E950D400F46FA5 /* SupportManager.swift in Sources */, + 84D1F1A72D09053A00CB271F /* StatusTableView.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 */, - 84AA81E52A4A3981000B658B /* DeeplinkManager.swift in Sources */, + 14C970842C5C2FB400E8A01B /* HowCarbEffectsWorksView.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 */, + 84B67A8D2F63558A004C783B /* PresetPerformanceHistoryView.swift in Sources */, C148CEE724FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift in Sources */, + C10509772D8B591200118A37 /* StatusTableViewModel.swift in Sources */, + 84DEB10D2C18FABA00170734 /* IOSFocusModesView.swift in Sources */, + 84DF48BF2F6A0AE500BEDB40 /* VideoView.swift in Sources */, + 84213C752D932F0F00642E78 /* InsulinDeliveryLog.swift in Sources */, DDC389FC2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift in Sources */, 1D12D3B92548EFDD00B53E8B /* main.swift in Sources */, - 435400341C9F878D00D5819C /* SetBolusUserInfo.swift in Sources */, A9DCF32A25B0FABF00C89088 /* LoopUIColorPalette+Default.swift in Sources */, 1DDE273E24AEA4B000796622 /* SettingsView.swift in Sources */, + BAF043BF0BC70EC99AE79F71 /* HealthAccessView.swift in Sources */, A9B607B0247F000F00792BE4 /* UserNotifications+Loop.swift in Sources */, 43F89CA322BDFBBD006BB54E /* UIActivityIndicatorView.swift in Sources */, A999D40624663D18004C89D4 /* PumpManagerError.swift in Sources */, @@ -3528,32 +3566,36 @@ 433EA4C41D9F71C800CD78FB /* CommandResponseViewController.swift in Sources */, E9B3552229358C440076AB04 /* MealDetectionManager.swift in Sources */, C16DA84222E8E112008624C2 /* PluginManager.swift in Sources */, + C151634E2FA2C9C800FEECE8 /* RequiredVersionUpdateView.swift in Sources */, 43785E932120A01B0057DED1 /* NewCarbEntryIntent+Loop.swift in Sources */, 439A7944211FE22F0041B75F /* NSUserActivity.swift in Sources */, 4328E0331CFC091100E199AA /* WatchContext+LoopKit.swift in Sources */, 4F526D611DF8D9A900A04910 /* NetBasal.swift in Sources */, C1DE5D23251BFC4D00439E49 /* SimpleBolusView.swift in Sources */, + 84213C8B2D94948900642E78 /* InsulinDeliveryOverview.swift in Sources */, 89ADE13B226BFA0F0067222B /* TestingScenariosManager.swift in Sources */, - 4F7E8ACB20E2ACB500AEA65E /* WatchPredictedGlucose.swift in Sources */, 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 */, + 84FA9D332CF7FD0D004162B4 /* PresetsHistoryView.swift in Sources */, + 841306BB2F7F0D9C00AF0320 /* ReferencesView.swift in Sources */, C1E3862628247C6100F561A4 /* StoredLoopNotRunningNotification.swift in Sources */, A97F250825E056D500F0EE19 /* OnboardingManager.swift in Sources */, 438D42F91D7C88BC003244B0 /* PredictionInputEffect.swift in Sources */, 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 */, + 84DF48B52F6A0AD400BEDB40 /* AudioPlayer.swift in Sources */, 14D906F42A846510006EB79A /* FavoriteFoodsViewModel.swift in Sources */, 3ED319912EB65A2D00820BCF /* GlucoseActivityAttributes.swift in Sources */, 3ED319922EB65A2D00820BCF /* LiveActivityManager.swift in Sources */, @@ -3564,19 +3606,21 @@ 895FE0952201234000FCF18A /* OverrideSelectionViewController.swift in Sources */, C1EF747228D6A44A00C8C083 /* CrashRecoveryManager.swift in Sources */, A9F66FC3247F451500096EA7 /* UIDevice+Loop.swift in Sources */, + 84DF48BB2F6A0ADD00BEDB40 /* PlayerControls.swift in Sources */, + 84E8BBCA2CCA16290078E6CF /* PresetsView.swift in Sources */, 439706E622D2E84900C81566 /* PredictionSettingTableViewCell.swift in Sources */, 430D85891F44037000AF2D4F /* HUDViewTableViewCell.swift in Sources */, 43A51E211EB6DBDD000736CC /* LoopChartsTableViewController.swift in Sources */, + 84A7B5502D2D972C00B6D202 /* Image+Optional.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; }; @@ -3584,21 +3628,24 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C1275DD62E808E2F0013B99D /* LoopWatchApp.swift in Sources */, 894F6DDD243C0A2300CCE676 /* CarbAmountLabel.swift in Sources */, + B455C7352BD14E30002B847E /* Comparable.swift in Sources */, 89A605E524327F45009C1096 /* DoseVolumeInput.swift in Sources */, 4372E488213C862B0068E043 /* SampleValue.swift in Sources */, + C1ED6C802E7C9C86002F91C2 /* PendingPresetReminder.swift in Sources */, + C10C57FA2E708B2D00A4825C /* PresetWatchCard.swift in Sources */, 89A605EB243288E4009C1096 /* TopDownTriangle.swift in Sources */, 4F2C15741E0209F500E160D4 /* NSTimeInterval.swift in Sources */, 89F9119224358E2B00ECCAF3 /* CarbEntryInputMode.swift in Sources */, - 4FF4D1011E18375000846527 /* WatchContext.swift in Sources */, + B47BA4282F506D58006BAAB3 /* CompleteOnboardingView.swift in Sources */, 89FE21AD24AC57E30033F501 /* Collection.swift in Sources */, 898ECA63218ABD21001E9D35 /* ComplicationChartManager.swift in Sources */, - 43A9438A1B926B7B0051FA24 /* NotificationController.swift in Sources */, 439A7945211FE23A0041B75F /* NSUserActivity.swift in Sources */, 43A943881B926B7B0051FA24 /* ExtensionDelegate.swift in Sources */, - 43511CEE220FC61700566C63 /* HUDRowController.swift in Sources */, 1D3F0F7526D59B6C004A5960 /* Debug.swift in Sources */, 892FB4CD22040104005293EC /* OverridePresetRow.swift in Sources */, + C1550B0C2E6F249A009369DC /* LoopCircleView.swift in Sources */, 4F75F00220FCFE8C00B5570E /* GlucoseChartScene.swift in Sources */, 89E26800229267DF00A3F2AF /* Optional.swift in Sources */, 4328E02F1CFBF81800E199AA /* WKInterfaceImage.swift in Sources */, @@ -3608,68 +3655,68 @@ 89E08FC6242E7506000D719B /* CarbAndDateInput.swift in Sources */, 89E08FC8242E76E9000D719B /* AnyTransition.swift in Sources */, 89A605E324327DFE009C1096 /* CarbAmountInput.swift in Sources */, + C19E23B62E83513400C20D83 /* PresetActivateCrownConfirm.swift in Sources */, + C1275DD82E808E520013B99D /* ContentView.swift in Sources */, 898ECA61218ABD17001E9D35 /* GlucoseChartData.swift in Sources */, 4344629820A8B2D700C4BE6F /* OSLog.swift in Sources */, 4328E02A1CFBE2C500E199AA /* UIColor.swift in Sources */, - 4372E484213A63FB0068E043 /* ChartHUDController.swift in Sources */, + C19E23B42E8350D700C20D83 /* PresetActivateButtonConfirm.swift in Sources */, 895788AF242E69A2002CB114 /* BolusInput.swift in Sources */, 894F6DDB243C07CF00CCE676 /* GramLabel.swift in Sources */, - 4345E40621F68E18009E00E5 /* CarbEntryListController.swift in Sources */, 4FDDD23720DC51DF00D04B16 /* LoopDataManager.swift in Sources */, 89E267FD2292456700A3F2AF /* FeatureFlags.swift in Sources */, 898ECA60218ABD17001E9D35 /* GlucoseChartScaler.swift in Sources */, 894F6DD9243C060600CCE676 /* ScalablePositionedText.swift in Sources */, + C19E23B82E83566700C20D83 /* CircularProgressWithCheckmark.swift in Sources */, + C1FAD5192E7E0C3400F7FAD9 /* ChartPageView.swift in Sources */, 89E08FC4242E73F0000D719B /* GramLabelPositionKey.swift in Sources */, - 4F82655020E69F9A0031A8F5 /* HUDInterfaceController.swift in Sources */, + C10C57FC2E70B8B900A4825C /* EnvironmentValues+GlucoseDisplayUnit.swift in Sources */, 4372E492213D956C0068E043 /* GlucoseRangeSchedule.swift in Sources */, + C12D72402E4FBC5F00BD628A /* WatchActionsView.swift in Sources */, 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 */, + C10C57EE2E7081D200A4825C /* PresetDetailView.swift in Sources */, + C10C57E52E6F767A00A4825C /* CircleTintedButton.swift in Sources */, 4F7E8AC520E2AB9600AEA65E /* Date.swift in Sources */, + C10C57FE2E71E87D00A4825C /* ActiveOverrideView.swift in Sources */, 89F9119424358E4500ECCAF3 /* CarbAbsorptionTime.swift in Sources */, 895788B1242E69A2002CB114 /* Color.swift in Sources */, 89E08FC2242E73DC000D719B /* CarbAmountPositionKey.swift in Sources */, 4F11D3C420DD881A006E072C /* WatchHistoricalGlucose.swift in Sources */, - E98A55F724EEE1E10008715D /* OnOffSelectionView.swift in Sources */, 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 */, + C10C57EC2E7070FF00A4825C /* PresetsListView.swift in Sources */, + C1275DDB2E8175B40013B99D /* PresetConfirmationView.swift in Sources */, 43027F0F1DFE0EC900C51989 /* HKUnit.swift in Sources */, 4344629220A7C19800C4BE6F /* ButtonGroup.swift in Sources */, + C1275DDD2E8185990013B99D /* PresetsView.swift in Sources */, + C17D52022E7F03D0001D2AD2 /* LoopHeader.swift in Sources */, 89A605E924328862009C1096 /* Checkmark.swift in Sources */, 891B508524342BE1005DA578 /* CarbAndBolusFlowViewModel.swift in Sources */, 894F6DD7243C047300CCE676 /* View+Position.swift in Sources */, 898ECA69218ABDA9001E9D35 /* CLKTextProvider+Compound.m in Sources */, 4372E48C213CB6750068E043 /* Double.swift in Sources */, + C17D52082E7F0E1B001D2AD2 /* CarbList.swift in Sources */, 89A605ED24328972009C1096 /* BolusArrow.swift in Sources */, - E98A55F924EEFC200008715D /* OnOffSelectionViewModel.swift in Sources */, - 892FB4CF220402C0005293EC /* OverrideSelectionController.swift in Sources */, 89E08FD0242E8B2B000D719B /* BolusConfirmationView.swift in Sources */, 43785E972120E4500057DED1 /* INRelevantShortcutStore+Loop.swift in Sources */, 89A605E72432860C009C1096 /* PeriodicPublisher.swift in Sources */, 895788AE242E69A2002CB114 /* CarbAndBolusFlow.swift in Sources */, 89A605F12432BD18009C1096 /* BolusConfirmationVisual.swift in Sources */, + C1DCEDDD2E983A22001A7BB0 /* AutomatedTreatmentState.swift in Sources */, 898ECA65218ABD9B001E9D35 /* CGRect.swift in Sources */, 43CB2B2B1D924D450079823D /* WCSession.swift in Sources */, - 4372E491213D05F90068E043 /* LoopSettingsUserInfo.swift in Sources */, - 4345E40421F68AD9009E00E5 /* TextRowController.swift in Sources */, 43BFF0B51E45C1E700FF19A9 /* NumberFormatter.swift in Sources */, - C1201E2D23ECDF3D002DA84A /* WatchContextRequestUserInfo.swift in Sources */, 43A9438E1B926B7B0051FA24 /* ComplicationController.swift in Sources */, 43517917230A0E1A0072ECC0 /* WKInterfaceLabel.swift in Sources */, - A9347F3224E7522400C99C34 /* CarbBackfillRequestUserInfo.swift in Sources */, 895788B3242E69A2002CB114 /* ActionButton.swift in Sources */, 894F6DD3243BCBDB00CCE676 /* Environment+SizeClass.swift in Sources */, - E98A55F524EEE15A0008715D /* OnOffSelectionController.swift in Sources */, - 4328E01A1CFBE1DA00E199AA /* ActionHUDController.swift in Sources */, - 4F11D3C320DD84DB006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */, - 435400351C9F878D00D5819C /* SetBolusUserInfo.swift in Sources */, + C17D52042E7F0578001D2AD2 /* LabelValueRow.swift in Sources */, 895788B2242E69A2002CB114 /* CircularAccessoryButtonStyle.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3678,12 +3725,22 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C1ED6C822E7D8E3D002F91C2 /* AcknowledgeAlertUserInfo.swift in Sources */, E9C00EF324C6222400628F35 /* LoopSettings.swift in Sources */, + C1ED6C6D2E7C6EC1002F91C2 /* NotificationActionSelection.swift in Sources */, + C1ED6C772E7C6F58002F91C2 /* WatchPredictedGlucose.swift in Sources */, C1D0B6312986D4D90098D215 /* LocalizedString.swift in Sources */, + C1ED6C662E7C6E2F002F91C2 /* CarbBackfillRequestUserInfo.swift in Sources */, + C1ED6C732E7C6F23002F91C2 /* SupportedBolusVolumesUserInfo.swift in Sources */, 43C05CB821EBEA54006FB252 /* HKUnit.swift in Sources */, - 4345E3F421F036FC009E00E5 /* Result.swift in Sources */, - C19E96E023D275FA003F79B0 /* LoopCompletionFreshness.swift in Sources */, + C1ED6C792E7C6FC7002F91C2 /* Comparable.swift in Sources */, + C1ED6C622E79BBA5002F91C2 /* NotificationManager.swift in Sources */, + C1ED6C7B2E7C6FE6002F91C2 /* WatchContextRequestUserInfo.swift in Sources */, + C1ED6C6F2E7C6EDE002F91C2 /* IntentExtensionInfo.swift in Sources */, + C1DCEDF42E999D5E001A7BB0 /* LastManualBolus.swift in Sources */, + C1ED6C752E7C6F36002F91C2 /* WatchContext.swift in Sources */, 43D9002021EB209400AF44BF /* NSTimeInterval.swift in Sources */, + C1ED6C712E7C6F07002F91C2 /* SetBolusUserInfo.swift in Sources */, C16575762539FEF3004AE16E /* LoopCoreConstants.swift in Sources */, C17DDC9D28AC33A1005FBF4C /* PersistedProperty.swift in Sources */, 43C05CA921EB2B26006FB252 /* PersistenceController.swift in Sources */, @@ -3691,8 +3748,12 @@ 3ED3199D2EB65A9B00820BCF /* LiveActivitySettings.swift in Sources */, 43C05CAB21EB2B4A006FB252 /* NSBundle.swift in Sources */, 43C05CC721EC2ABC006FB252 /* IdentifiableClass.swift in Sources */, + C1ED6C672E7C6E35002F91C2 /* SettingsRequestUserInfo.swift in Sources */, E9B3552B293591E70076AB04 /* MissedMealNotification.swift in Sources */, - 4345E40221F67300009E00E5 /* PotentialCarbEntryUserInfo.swift in Sources */, + C1ED6C6B2E7C6E58002F91C2 /* LoopSettingsUserInfo.swift in Sources */, + C1ED6C7D2E7C811A002F91C2 /* SetPresetUserInfo.swift in Sources */, + 4345E40221F67300009E00E5 /* GetBolusRecommendationUserInfo.swift in Sources */, + C1ED6C692E7C6E41002F91C2 /* GlucoseBackfillRequestUserInfo.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3700,12 +3761,22 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C1ED6C832E7D8E3D002F91C2 /* AcknowledgeAlertUserInfo.swift in Sources */, + C1ED6C652E7C6E2F002F91C2 /* CarbBackfillRequestUserInfo.swift in Sources */, + C1ED6C6C2E7C6EC1002F91C2 /* NotificationActionSelection.swift in Sources */, E9C00EF224C6221B00628F35 /* LoopSettings.swift in Sources */, + C1ED6C762E7C6F58002F91C2 /* WatchPredictedGlucose.swift in Sources */, C1D0B6302986D4D90098D215 /* LocalizedString.swift in Sources */, + C1ED6C722E7C6F23002F91C2 /* SupportedBolusVolumesUserInfo.swift in Sources */, 43C05CB921EBEA54006FB252 /* HKUnit.swift in Sources */, - 4345E3F521F036FC009E00E5 /* Result.swift in Sources */, - C19E96DF23D275F8003F79B0 /* LoopCompletionFreshness.swift in Sources */, + C1ED6C782E7C6FC7002F91C2 /* Comparable.swift in Sources */, + C1ED6C612E79BBA5002F91C2 /* NotificationManager.swift in Sources */, + C1ED6C7A2E7C6FE5002F91C2 /* WatchContextRequestUserInfo.swift in Sources */, + C1ED6C6E2E7C6EDE002F91C2 /* IntentExtensionInfo.swift in Sources */, + C1DCEDF52E999D5E001A7BB0 /* LastManualBolus.swift in Sources */, + C1ED6C742E7C6F36002F91C2 /* WatchContext.swift in Sources */, 43D9FFFB21EAF3D300AF44BF /* NSTimeInterval.swift in Sources */, + C1ED6C702E7C6F07002F91C2 /* SetBolusUserInfo.swift in Sources */, C16575752539FD60004AE16E /* LoopCoreConstants.swift in Sources */, C17DDC9C28AC339E005FBF4C /* PersistedProperty.swift in Sources */, 43C05CA821EB2B26006FB252 /* PersistenceController.swift in Sources */, @@ -3713,8 +3784,12 @@ 3ED3199C2EB65A9B00820BCF /* LiveActivitySettings.swift in Sources */, 43C05CC821EC2ABC006FB252 /* IdentifiableClass.swift in Sources */, 43C05CAD21EB2BBF006FB252 /* NSUserDefaults.swift in Sources */, + C1ED6C642E7C6DB9002F91C2 /* SettingsRequestUserInfo.swift in Sources */, E9B3552A293591E70076AB04 /* MissedMealNotification.swift in Sources */, - 4345E40121F67300009E00E5 /* PotentialCarbEntryUserInfo.swift in Sources */, + C1ED6C6A2E7C6E58002F91C2 /* LoopSettingsUserInfo.swift in Sources */, + C1ED6C7E2E7C811A002F91C2 /* SetPresetUserInfo.swift in Sources */, + 4345E40121F67300009E00E5 /* GetBolusRecommendationUserInfo.swift in Sources */, + C1ED6C682E7C6E41002F91C2 /* GlucoseBackfillRequestUserInfo.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3727,61 +3802,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; }; @@ -3802,10 +3864,11 @@ B43DA44124D9C12100CAFF4E /* DismissibleHostingController.swift in Sources */, 4F7528A51DFE208C00C322D6 /* NSTimeInterval.swift in Sources */, A9C62D8E2331708700535612 /* AuthenticationTableViewCell+NibLoadable.swift in Sources */, + B4C6D2452EAA2C83006F5755 /* TimeInterval.swift in Sources */, B490A04324D055D900F509FA /* DeviceStatusHighlight.swift in Sources */, B4AC0D3F24B9005300CDB0A1 /* UIImage.swift in Sources */, B4E96D5B248A8229002DABAD /* StatusBarHUDView.swift in Sources */, - 4F7528A11DFE200B00C322D6 /* BasalStateView.swift in Sources */, + 4F7528A11DFE200B00C322D6 /* TreatmentArrowStateView.swift in Sources */, 43BFF0C61E465A4400FF19A9 /* UIColor+HIG.swift in Sources */, 4F7528A01DFE1F9D00C322D6 /* LoopStateView.swift in Sources */, B491B0A324D0B66D004CBE8F /* Color.swift in Sources */, @@ -3833,10 +3896,10 @@ buildActionMask = 2147483647; files = ( E942DE96253BE68F00AC532D /* NSBundle.swift in Sources */, - E9B08021253BBDE900BAD8F8 /* IntentExtensionInfo.swift in Sources */, E9B07FEE253BBC7100BAD8F8 /* OverrideIntentHandler.swift in Sources */, E942DE9F253BE6A900AC532D /* NSTimeInterval.swift in Sources */, E9B08016253BBD7300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */, + E9B08021253BBDE900BAD8F8 /* IntentExtensionInfo.swift in Sources */, 1D3F0F7726D59DCE004A5960 /* Debug.swift in Sources */, E942DF34253BF87F00AC532D /* Intents.intentdefinition in Sources */, E9B07F7F253BBA6500BAD8F8 /* IntentHandler.swift in Sources */, @@ -3856,11 +3919,6 @@ target = 14B1735B28AED9EC006CCD7C /* Loop Widget Extension */; targetProxy = 14B1736728AED9EE006CCD7C /* PBXContainerItemProxy */; }; - 43A943811B926B7B0051FA24 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 43A9437D1B926B7B0051FA24 /* WatchApp Extension */; - targetProxy = 43A943801B926B7B0051FA24 /* PBXContainerItemProxy */; - }; 43A943931B926B7B0051FA24 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 43A943711B926B7B0051FA24 /* WatchApp */; @@ -3876,25 +3934,15 @@ 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 */; targetProxy = 4F7528961DFE1ED400C322D6 /* PBXContainerItemProxy */; }; - C117ED71232EDB3200DA57CD /* PBXTargetDependency */ = { + C16E94F92E7DBBA600AA4E6E /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 43D9001A21EB209400AF44BF /* LoopCore-watchOS */; - targetProxy = C117ED70232EDB3200DA57CD /* PBXContainerItemProxy */; - }; - C11B9D592867781E00500CF8 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 4F75288A1DFE1DC600C322D6 /* LoopUI */; - targetProxy = C11B9D582867781E00500CF8 /* PBXContainerItemProxy */; + targetProxy = C16E94F82E7DBBA600AA4E6E /* PBXContainerItemProxy */; }; C1CCF1152858FA900035389C /* PBXTargetDependency */ = { isa = PBXTargetDependency; @@ -4078,12 +4126,100 @@ C1BCB5AF298309C4001C50FF /* it */, C19E387B298638CE00851444 /* tr */, C1F48FF62995821600C8BD69 /* pl */, - C14952142995822A0095AA84 /* ru */, C1C2478B2995823200371B88 /* sk */, ); name = InfoPlist.strings; sourceTree = ""; }; + C1004DF02981F5B700B8CF94 /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + C1004DF12981F5B700B8CF94 /* da */, + C1004DFE2981F67A00B8CF94 /* sv */, + C1004E062981F6A100B8CF94 /* ro */, + C1004E0E2981F6E200B8CF94 /* nl */, + C1004E162981F6F500B8CF94 /* nb */, + C1004E1E2981F72D00B8CF94 /* fr */, + C1004E252981F74300B8CF94 /* fi */, + C1004E312981F77B00B8CF94 /* de */, + C1BCB5B0298309C4001C50FF /* it */, + C19E387C298638CE00851444 /* tr */, + C1EB0D1D299581D900628475 /* es */, + C1F48FF72995821600C8BD69 /* pl */, + C122DEF829BBFAAE00321F8D /* ru */, + C1D70F7A2A914F71009FE129 /* he */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; + C1004DF32981F5B700B8CF94 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + C1004DF42981F5B700B8CF94 /* da */, + C1004DFF2981F67A00B8CF94 /* sv */, + C1004E072981F6A100B8CF94 /* ro */, + C1004E0F2981F6E200B8CF94 /* nl */, + C1004E172981F6F500B8CF94 /* nb */, + C1004E1F2981F72D00B8CF94 /* fr */, + C1004E262981F74300B8CF94 /* fi */, + C1004E322981F77B00B8CF94 /* de */, + C186B73F298309A700F83024 /* es */, + C1BCB5B1298309C4001C50FF /* it */, + C19E387D298638CE00851444 /* tr */, + C1F48FF82995821600C8BD69 /* pl */, + C1C2478C2995823200371B88 /* sk */, + C122DEF929BBFAAE00321F8D /* ru */, + C15A581F29C7866600D3A5A1 /* ar */, + C1FF3D4929C786A900BDC1EC /* he */, + C1B0CFD429C786BF0045B04D /* ja */, + C1E693CA29C786E200410918 /* pt-BR */, + C192C5FE29C78711001EFEA6 /* vi */, + ); + name = Localizable.strings; + sourceTree = ""; + }; + C11613472983096D00777E7C /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + C11613482983096D00777E7C /* nb */, + C1BCB5B5298309C4001C50FF /* it */, + C18886E629830A5E004C982D /* nl */, + C1AD48CE298639890013B994 /* fr */, + C18B725E299581C600F138D3 /* da */, + C1EB0D20299581D900628475 /* es */, + C1F48FFC2995821600C8BD69 /* pl */, + C1B2679A2995824000BCB7C1 /* tr */, + C1AD62FE29BBFAA80002685D /* ro */, + C122DEFC29BBFAAE00321F8D /* ru */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; + C116134A2983096D00777E7C /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + C116134B2983096D00777E7C /* nb */, + C1BCB5B6298309C4001C50FF /* it */, + C11A2BCE29830A3100AC5135 /* fr */, + C18886E729830A5E004C982D /* nl */, + C18B725F299581C600F138D3 /* da */, + C1EB0D21299581D900628475 /* es */, + C1F48FFD2995821600C8BD69 /* pl */, + C1B2679B2995824000BCB7C1 /* tr */, + C1AD62FF29BBFAA80002685D /* ro */, + C122DEFD29BBFAAE00321F8D /* ru */, + C15A582229C7866600D3A5A1 /* ar */, + C1F4FD5929C7869800D7ACBC /* fi */, + C1FF3D4C29C786A900BDC1EC /* he */, + C1B0CFD829C786BF0045B04D /* ja */, + C1E693CE29C786E200410918 /* pt-BR */, + C1FDCBFF29C786F90056E652 /* sk */, + C1E5A6DE29C7870100703C90 /* sv */, + C192C60229C78711001EFEA6 /* vi */, + ); + name = Localizable.strings; + sourceTree = ""; + }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ @@ -4105,8 +4241,8 @@ 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_DEBUG)"; - CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; GCC_DYNAMIC_NO_PIC = NO; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -4122,7 +4258,7 @@ 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_DEBUG)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -4153,8 +4289,8 @@ 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)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -4170,7 +4306,7 @@ 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)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -4275,7 +4411,7 @@ GCC_WARN_UNUSED_LABEL = YES; GCC_WARN_UNUSED_PARAMETER = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZED_STRING_MACRO_NAMES = ( NSLocalizedString, @@ -4296,7 +4432,7 @@ TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; WARNING_CFLAGS = "-Wall"; - WATCHOS_DEPLOYMENT_TARGET = 7.1; + WATCHOS_DEPLOYMENT_TARGET = 10.6; }; name = Debug; }; @@ -4387,7 +4523,7 @@ GCC_WARN_UNUSED_LABEL = YES; GCC_WARN_UNUSED_PARAMETER = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZED_STRING_MACRO_NAMES = ( NSLocalizedString, @@ -4408,18 +4544,17 @@ VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; WARNING_CFLAGS = "-Wall"; - WATCHOS_DEPLOYMENT_TARGET = 7.1; + WATCHOS_DEPLOYMENT_TARGET = 10.6; }; 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)"; - CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -4433,7 +4568,7 @@ "OTHER_SWIFT_FLAGS[sdk=iphonesimulator*]" = "-D IOS_SIMULATOR -D DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_DEBUG)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -4445,11 +4580,10 @@ 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)"; - CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Loop/Info.plist; @@ -4460,7 +4594,7 @@ OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_RELEASE)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -4469,78 +4603,24 @@ }; name = Release; }; - 43A943961B926B7B0051FA24 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_COMPLICATION_NAME = Complication; - CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; - CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; - 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).LoopWatch.watchkitextension"; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_EXTENSION_DEBUG)"; - 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 = Debug; - }; - 43A943971B926B7B0051FA24 /* Release */ = { - 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).LoopWatch.watchkitextension"; - PRODUCT_NAME = "$(TARGET_NAME)"; - 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 = Release; - }; 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)"; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; 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", + "@executable_path/../../Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWatch"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_DEBUG)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = watchos; SKIP_INSTALL = YES; TARGETED_DEVICE_FAMILY = 4; @@ -4550,21 +4630,21 @@ 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)"; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; 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", + "@executable_path/../../Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWatch"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_RELEASE)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = watchos; SKIP_INSTALL = YES; TARGETED_DEVICE_FAMILY = 4; @@ -4576,8 +4656,10 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_IDENTITY = ""; DEFINES_MODULE = YES; DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; FRAMEWORK_SEARCH_PATHS = ""; INFOPLIST_FILE = LoopCore/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -4587,6 +4669,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++17"; PRODUCT_BUNDLE_IDENTIFIER = com.loopkit.LoopCore; PRODUCT_NAME = LoopCore; SDKROOT = watchos; @@ -4602,8 +4685,10 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_IDENTITY = ""; DEFINES_MODULE = YES; DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; FRAMEWORK_SEARCH_PATHS = ""; INFOPLIST_FILE = LoopCore/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -4613,6 +4698,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++17"; PRODUCT_BUNDLE_IDENTIFIER = com.loopkit.LoopCore; PRODUCT_NAME = LoopCore; SDKROOT = watchos; @@ -4628,8 +4714,10 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_IDENTITY = ""; DEFINES_MODULE = YES; DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; INFOPLIST_FILE = LoopCore/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( @@ -4637,6 +4725,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++17"; PRODUCT_BUNDLE_IDENTIFIER = com.loopkit.LoopCore; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; @@ -4655,8 +4744,10 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_IDENTITY = ""; DEFINES_MODULE = YES; DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; INFOPLIST_FILE = LoopCore/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( @@ -4664,6 +4755,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++17"; PRODUCT_BUNDLE_IDENTIFIER = com.loopkit.LoopCore; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; @@ -4716,49 +4808,283 @@ }; name = Release; }; - 4F70C1E91DE8DCA8006380B7 /* Debug */ = { + 4F7528901DFE1DC600C322D6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_IDENTITY = ""; + DEFINES_MODULE = YES; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + INFOPLIST_FILE = LoopUI/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++17"; + 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; + CODE_SIGN_IDENTITY = ""; + DEFINES_MODULE = YES; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + INFOPLIST_FILE = LoopUI/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++17"; + 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_NS_ASSERTIONS = NO; + 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 = 17.0; + 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_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + WARNING_CFLAGS = "-Wall"; + WATCHOS_DEPLOYMENT_TARGET = 10.6; + }; + name = Testflight; + }; + B4E7CF922AD00A39009B4DF2 /* Testflight */ = { isa = XCBuildConfiguration; buildSettings = { - CODE_SIGN_ENTITLEMENTS = "Loop Status Extension/Loop Status Extension.entitlements"; - CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; - CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; + CODE_SIGN_ENTITLEMENTS = "$(LOOP_ENTITLEMENTS)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + 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 = ""; + 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)"; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + FRAMEWORK_SEARCH_PATHS = ""; + 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 = ""; + SDKROOT = watchos; + SKIP_INSTALL = YES; + 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 = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + 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", ); - PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).statuswidget"; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWidgetExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_STATUS_EXTENSION_DEBUG)"; + PROVISIONING_PROFILE_SPECIFIER = ""; 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_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; - CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + CODE_SIGN_ENTITLEMENTS = "Loop Intent Extension/Loop Intent Extension.entitlements"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; 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 = ""; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -4766,23 +5092,27 @@ 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; + CODE_SIGN_IDENTITY = ""; DEFINES_MODULE = YES; DYLIB_INSTALL_NAME_BASE = "@rpath"; - INFOPLIST_FILE = LoopUI/Info.plist; + ENABLE_MODULE_VERIFIER = YES; + 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)"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++17"; + PRODUCT_BUNDLE_IDENTIFIER = com.loopkit.LoopCore; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -4791,14 +5121,44 @@ SWIFT_INSTALL_OBJC_HEADER = NO; TARGETED_DEVICE_FAMILY = "1,2"; }; - name = Debug; + name = Testflight; }; - 4F7528911DFE1DC600C322D6 /* Release */ = { + B4E7CF992AD00A39009B4DF2 /* Testflight */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_IDENTITY = ""; + DEFINES_MODULE = YES; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + 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", + ); + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++17"; + 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; + CODE_SIGN_IDENTITY = ""; DEFINES_MODULE = YES; DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; INFOPLIST_FILE = LoopUI/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( @@ -4806,6 +5166,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++17"; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopUI"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -4816,14 +5177,32 @@ SWIFT_INSTALL_OBJC_HEADER = NO; TARGETED_DEVICE_FAMILY = "1,2"; }; - 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; buildSettings = { CODE_SIGN_ENTITLEMENTS = "Loop Intent Extension/Loop Intent Extension.entitlements"; - CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; - CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -4835,7 +5214,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).Loop-Intent-Extension"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_INTENT_EXTENSION_DEBUG)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -4849,8 +5228,8 @@ isa = XCBuildConfiguration; buildSettings = { 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)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -4862,7 +5241,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).Loop-Intent-Extension"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_INTENT_EXTENSION_RELEASE)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -4879,6 +5258,7 @@ isa = XCConfigurationList; buildConfigurations = ( 14B1736A28AED9EE006CCD7C /* Debug */, + B4E7CF962AD00A39009B4DF2 /* Testflight */, 14B1736B28AED9EE006CCD7C /* Release */, ); defaultConfigurationIsVisible = 0; @@ -4888,6 +5268,7 @@ isa = XCConfigurationList; buildConfigurations = ( 43776FB41B8022E90074EA36 /* Debug */, + B4E7CF912AD00A39009B4DF2 /* Testflight */, 43776FB51B8022E90074EA36 /* Release */, ); defaultConfigurationIsVisible = 0; @@ -4897,24 +5278,17 @@ isa = XCConfigurationList; buildConfigurations = ( 43776FB71B8022E90074EA36 /* Debug */, + B4E7CF922AD00A39009B4DF2 /* Testflight */, 43776FB81B8022E90074EA36 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 43A943951B926B7B0051FA24 /* Build configuration list for PBXNativeTarget "WatchApp Extension" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 43A943961B926B7B0051FA24 /* Debug */, - 43A943971B926B7B0051FA24 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; 43A943991B926B7B0051FA24 /* Build configuration list for PBXNativeTarget "WatchApp" */ = { isa = XCConfigurationList; buildConfigurations = ( 43A9439A1B926B7B0051FA24 /* Debug */, + B4E7CF942AD00A39009B4DF2 /* Testflight */, 43A9439B1B926B7B0051FA24 /* Release */, ); defaultConfigurationIsVisible = 0; @@ -4924,6 +5298,7 @@ isa = XCConfigurationList; buildConfigurations = ( 43D9002821EB209400AF44BF /* Debug */, + B4E7CF992AD00A39009B4DF2 /* Testflight */, 43D9002921EB209400AF44BF /* Release */, ); defaultConfigurationIsVisible = 0; @@ -4933,6 +5308,7 @@ isa = XCConfigurationList; buildConfigurations = ( 43D9FFD921EAE05D00AF44BF /* Debug */, + B4E7CF982AD00A39009B4DF2 /* Testflight */, 43D9FFDA21EAE05D00AF44BF /* Release */, ); defaultConfigurationIsVisible = 0; @@ -4942,24 +5318,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; @@ -4969,6 +5338,7 @@ isa = XCConfigurationList; buildConfigurations = ( E9B07F95253BBA6500BAD8F8 /* Debug */, + B4E7CF972AD00A39009B4DF2 /* Testflight */, E9B07F96253BBA6500BAD8F8 /* Release */, ); defaultConfigurationIsVisible = 0; @@ -4981,8 +5351,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/LoopKit/ZIPFoundation.git"; requirement = { - branch = "stream-entry"; - kind = branch; + kind = revision; + revision = c67b7509ec82ee2b4b0ab3f97742b94ed9692494; }; }; C1CCF10B2858F4F70035389C /* XCRemoteSwiftPackageReference "SwiftCharts" */ = { @@ -5014,11 +5384,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.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Loop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3ae53addaa..9cb3736d66 100644 --- a/Loop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Loop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,8 +24,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/LoopKit/ZIPFoundation.git", "state" : { - "branch" : "stream-entry", - "revision" : "ad465ee2545392153a64c0976d6e59227d0c1c70" + "revision" : "c67b7509ec82ee2b4b0ab3f97742b94ed9692494" } } ], diff --git a/Loop.xcodeproj/xcshareddata/xcschemes/DoseMathTests.xcscheme b/Loop.xcodeproj/xcshareddata/xcschemes/DoseMathTests.xcscheme index a56f874c88..9fba088786 100644 --- a/Loop.xcodeproj/xcshareddata/xcschemes/DoseMathTests.xcscheme +++ b/Loop.xcodeproj/xcshareddata/xcschemes/DoseMathTests.xcscheme @@ -1,6 +1,6 @@ + parallelizeBuildables = "YES" + buildImplicitDependencies = "YES"> + BlueprintIdentifier = "A9E6758022713F4700E25293" + BuildableName = "LoopKit.framework" + BlueprintName = "LoopKit-watchOS" + ReferencedContainer = "container:../Common/LoopKit/LoopKit.xcodeproj"> @@ -42,9 +42,9 @@ buildForAnalyzing = "YES"> @@ -65,16 +65,6 @@ - - - - - + - - + @@ -415,7 +414,7 @@ - + @@ -509,13 +508,13 @@ diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/Contents.json b/Loop/DefaultAssets.xcassets/Delivery Log/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/Loop/DefaultAssets.xcassets/Delivery Log/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DerivedAssetsBase.xcassets/workout-symbol.symbolset/Contents.json b/Loop/DefaultAssets.xcassets/Delivery Log/autobolus-delivery-log.imageset/Contents.json similarity index 61% rename from Loop/DerivedAssetsBase.xcassets/workout-symbol.symbolset/Contents.json rename to Loop/DefaultAssets.xcassets/Delivery Log/autobolus-delivery-log.imageset/Contents.json index fea0fb11b6..175c132ab1 100644 --- a/Loop/DerivedAssetsBase.xcassets/workout-symbol.symbolset/Contents.json +++ b/Loop/DefaultAssets.xcassets/Delivery Log/autobolus-delivery-log.imageset/Contents.json @@ -1,12 +1,12 @@ { - "info" : { - "author" : "xcode", - "version" : 1 - }, - "symbols" : [ + "images" : [ { - "filename" : "heart.pulse.svg", + "filename" : "autobolus.png", "idiom" : "universal" } - ] + ], + "info" : { + "author" : "xcode", + "version" : 1 + } } diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/autobolus-delivery-log.imageset/autobolus.png b/Loop/DefaultAssets.xcassets/Delivery Log/autobolus-delivery-log.imageset/autobolus.png new file mode 100644 index 0000000000..8c0d5195bf Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Delivery Log/autobolus-delivery-log.imageset/autobolus.png differ diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/automation-off-delivery-log.imageset/Contents.json b/Loop/DefaultAssets.xcassets/Delivery Log/automation-off-delivery-log.imageset/Contents.json new file mode 100644 index 0000000000..a825ff92ef --- /dev/null +++ b/Loop/DefaultAssets.xcassets/Delivery Log/automation-off-delivery-log.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "automation-off.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/automation-off-delivery-log.imageset/automation-off.png b/Loop/DefaultAssets.xcassets/Delivery Log/automation-off-delivery-log.imageset/automation-off.png new file mode 100644 index 0000000000..99b081292a Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Delivery Log/automation-off-delivery-log.imageset/automation-off.png differ diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/automation-off-range-delivery-log.imageset/Contents.json b/Loop/DefaultAssets.xcassets/Delivery Log/automation-off-range-delivery-log.imageset/Contents.json new file mode 100644 index 0000000000..ce55c209e4 --- /dev/null +++ b/Loop/DefaultAssets.xcassets/Delivery Log/automation-off-range-delivery-log.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "automation-off-range.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/automation-off-range-delivery-log.imageset/automation-off-range.png b/Loop/DefaultAssets.xcassets/Delivery Log/automation-off-range-delivery-log.imageset/automation-off-range.png new file mode 100644 index 0000000000..d25148ab98 Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Delivery Log/automation-off-range-delivery-log.imageset/automation-off-range.png differ diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/automation-on-delivery-log.imageset/Contents.json b/Loop/DefaultAssets.xcassets/Delivery Log/automation-on-delivery-log.imageset/Contents.json new file mode 100644 index 0000000000..b50709ccc0 --- /dev/null +++ b/Loop/DefaultAssets.xcassets/Delivery Log/automation-on-delivery-log.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "automation-on.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/automation-on-delivery-log.imageset/automation-on.png b/Loop/DefaultAssets.xcassets/Delivery Log/automation-on-delivery-log.imageset/automation-on.png new file mode 100644 index 0000000000..ca91f747da Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Delivery Log/automation-on-delivery-log.imageset/automation-on.png differ diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/automation-unavailable-delivery-log.imageset/Contents.json b/Loop/DefaultAssets.xcassets/Delivery Log/automation-unavailable-delivery-log.imageset/Contents.json new file mode 100644 index 0000000000..8f82bbe27d --- /dev/null +++ b/Loop/DefaultAssets.xcassets/Delivery Log/automation-unavailable-delivery-log.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "automation-unavailable.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/automation-unavailable-delivery-log.imageset/automation-unavailable.png b/Loop/DefaultAssets.xcassets/Delivery Log/automation-unavailable-delivery-log.imageset/automation-unavailable.png new file mode 100644 index 0000000000..0cdad0fb15 Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Delivery Log/automation-unavailable-delivery-log.imageset/automation-unavailable.png differ diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/basal-delivery-log.imageset/Contents.json b/Loop/DefaultAssets.xcassets/Delivery Log/basal-delivery-log.imageset/Contents.json new file mode 100644 index 0000000000..c5c9e4fb30 --- /dev/null +++ b/Loop/DefaultAssets.xcassets/Delivery Log/basal-delivery-log.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "basal.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/basal-delivery-log.imageset/basal.png b/Loop/DefaultAssets.xcassets/Delivery Log/basal-delivery-log.imageset/basal.png new file mode 100644 index 0000000000..0f5629dd42 Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Delivery Log/basal-delivery-log.imageset/basal.png differ diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/bolus-delivery-log.imageset/Contents.json b/Loop/DefaultAssets.xcassets/Delivery Log/bolus-delivery-log.imageset/Contents.json new file mode 100644 index 0000000000..68e615cdbb --- /dev/null +++ b/Loop/DefaultAssets.xcassets/Delivery Log/bolus-delivery-log.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "bolus.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/bolus-delivery-log.imageset/bolus.png b/Loop/DefaultAssets.xcassets/Delivery Log/bolus-delivery-log.imageset/bolus.png new file mode 100644 index 0000000000..6597b7b995 Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Delivery Log/bolus-delivery-log.imageset/bolus.png differ diff --git a/Loop/DefaultAssets.xcassets/Presets Icon.imageset/Contents.json b/Loop/DefaultAssets.xcassets/Presets Icon.imageset/Contents.json new file mode 100644 index 0000000000..3df3c9d7e4 --- /dev/null +++ b/Loop/DefaultAssets.xcassets/Presets Icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Presets Icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Presets Icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Presets Icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/Presets Icon.imageset/Presets Icon.png b/Loop/DefaultAssets.xcassets/Presets Icon.imageset/Presets Icon.png new file mode 100644 index 0000000000..01239355f9 Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Presets Icon.imageset/Presets Icon.png differ diff --git a/Loop/DefaultAssets.xcassets/Presets Icon.imageset/Presets Icon@2x.png b/Loop/DefaultAssets.xcassets/Presets Icon.imageset/Presets Icon@2x.png new file mode 100644 index 0000000000..5ef0902b53 Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Presets Icon.imageset/Presets Icon@2x.png differ diff --git a/Loop/DefaultAssets.xcassets/Presets Icon.imageset/Presets Icon@3x.png b/Loop/DefaultAssets.xcassets/Presets Icon.imageset/Presets Icon@3x.png new file mode 100644 index 0000000000..45d6fff2d2 Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Presets Icon.imageset/Presets Icon@3x.png differ 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/DerivedAssetsBase.xcassets/dose-chart-bolus-icon.imageset/Contents.json b/Loop/DerivedAssetsBase.xcassets/dose-chart-bolus-icon.imageset/Contents.json new file mode 100644 index 0000000000..a2394dbc45 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/dose-chart-bolus-icon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "DIY Light.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "DIY Dark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DerivedAssetsBase.xcassets/dose-chart-bolus-icon.imageset/DIY Dark.svg b/Loop/DerivedAssetsBase.xcassets/dose-chart-bolus-icon.imageset/DIY Dark.svg new file mode 100644 index 0000000000..16747e827a --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/dose-chart-bolus-icon.imageset/DIY Dark.svg @@ -0,0 +1,10 @@ + + + DIY Dark + + + + + + + \ No newline at end of file diff --git a/Loop/DerivedAssetsBase.xcassets/dose-chart-bolus-icon.imageset/DIY Light.svg b/Loop/DerivedAssetsBase.xcassets/dose-chart-bolus-icon.imageset/DIY Light.svg new file mode 100644 index 0000000000..1c9495d630 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/dose-chart-bolus-icon.imageset/DIY Light.svg @@ -0,0 +1,10 @@ + + + DIY Light + + + + + + + \ No newline at end of file diff --git a/Loop/DerivedAssetsBase.xcassets/insulin.colorset/Contents.json b/Loop/DerivedAssetsBase.xcassets/insulin.colorset/Contents.json index b431287b2a..d90b065476 100644 --- a/Loop/DerivedAssetsBase.xcassets/insulin.colorset/Contents.json +++ b/Loop/DerivedAssetsBase.xcassets/insulin.colorset/Contents.json @@ -2,7 +2,7 @@ "colors" : [ { "color" : { - "platform" : "ios", + "platform" : "universal", "reference" : "systemOrangeColor" }, "idiom" : "universal" diff --git a/Loop/DerivedAssetsBase.xcassets/performance-history-empty.imageset/Contents.json b/Loop/DerivedAssetsBase.xcassets/performance-history-empty.imageset/Contents.json new file mode 100644 index 0000000000..346576c5b9 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/performance-history-empty.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "performance-history-empty.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DerivedAssetsBase.xcassets/performance-history-empty.imageset/performance-history-empty.pdf b/Loop/DerivedAssetsBase.xcassets/performance-history-empty.imageset/performance-history-empty.pdf new file mode 100644 index 0000000000..e02a67f6df Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/performance-history-empty.imageset/performance-history-empty.pdf differ diff --git a/Loop/DerivedAssetsBase.xcassets/presets-selected.imageset/Contents.json b/Loop/DerivedAssetsBase.xcassets/presets-selected.imageset/Contents.json new file mode 100644 index 0000000000..454c684ec3 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/presets-selected.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Temp Presets.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DerivedAssetsBase.xcassets/presets-selected.imageset/Temp Presets.pdf b/Loop/DerivedAssetsBase.xcassets/presets-selected.imageset/Temp Presets.pdf new file mode 100644 index 0000000000..e963fb799f Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/presets-selected.imageset/Temp Presets.pdf differ diff --git a/Loop/DerivedAssetsBase.xcassets/presets.colorset/Contents.json b/Loop/DerivedAssetsBase.xcassets/presets.colorset/Contents.json new file mode 100644 index 0000000000..1ef849d61c --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/presets.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xA7", + "green" : "0x7C", + "red" : "0x11" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xB8", + "green" : "0x88", + "red" : "0x13" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DerivedAssetsBase.xcassets/presets.imageset/Contents.json b/Loop/DerivedAssetsBase.xcassets/presets.imageset/Contents.json new file mode 100644 index 0000000000..4e31050302 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/presets.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Temp Presets-2.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DerivedAssetsBase.xcassets/presets.imageset/Temp Presets-2.pdf b/Loop/DerivedAssetsBase.xcassets/presets.imageset/Temp Presets-2.pdf new file mode 100644 index 0000000000..4fd9b592f2 Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/presets.imageset/Temp Presets-2.pdf differ diff --git a/Loop/DerivedAssetsBase.xcassets/settings.imageset/Contents.json b/Loop/DerivedAssetsBase.xcassets/settings.imageset/Contents.json index d5ccdb99c9..50dba31153 100644 --- a/Loop/DerivedAssetsBase.xcassets/settings.imageset/Contents.json +++ b/Loop/DerivedAssetsBase.xcassets/settings.imageset/Contents.json @@ -1,40 +1,15 @@ { "images" : [ { - "idiom" : "universal", - "filename" : "settings.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "settings@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", "filename" : "settings@3x.png", - "scale" : "3x" - }, - { - "idiom" : "universal", - "scale" : "1x", - "height-class" : "compact" - }, - { - "idiom" : "universal", - "filename" : "settings_compact@2x.png", - "height-class" : "compact", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "settings_compact@3x.png", - "height-class" : "compact", - "scale" : "3x" + "idiom" : "universal" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" } -} \ No newline at end of file +} diff --git a/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings.png b/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings.png deleted file mode 100644 index 14fc3623db..0000000000 Binary files a/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings.png and /dev/null differ diff --git a/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings@2x.png b/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings@2x.png deleted file mode 100644 index 2ed1757720..0000000000 Binary files a/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings@2x.png and /dev/null differ diff --git a/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings@3x.png b/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings@3x.png index 6ee011f3c9..53afbae4cc 100644 Binary files a/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings@3x.png and b/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings@3x.png differ diff --git a/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings_compact@2x.png b/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings_compact@2x.png deleted file mode 100644 index ccac27347e..0000000000 Binary files a/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings_compact@2x.png and /dev/null differ diff --git a/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings_compact@3x.png b/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings_compact@3x.png deleted file mode 100644 index 07fe7deb7a..0000000000 Binary files a/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings_compact@3x.png and /dev/null differ diff --git a/Loop/DerivedAssetsBase.xcassets/workout-selected.imageset/Contents.json b/Loop/DerivedAssetsBase.xcassets/workout-selected.imageset/Contents.json deleted file mode 100644 index 07a9fb7036..0000000000 --- a/Loop/DerivedAssetsBase.xcassets/workout-selected.imageset/Contents.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "workout-selected.pdf" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - }, - "properties" : { - "template-rendering-intent" : "template", - "preserves-vector-representation" : true - } -} \ No newline at end of file diff --git a/Loop/DerivedAssetsBase.xcassets/workout-selected.imageset/workout-selected.pdf b/Loop/DerivedAssetsBase.xcassets/workout-selected.imageset/workout-selected.pdf deleted file mode 100644 index a340acb01d..0000000000 Binary files a/Loop/DerivedAssetsBase.xcassets/workout-selected.imageset/workout-selected.pdf and /dev/null differ diff --git a/Loop/DerivedAssetsBase.xcassets/workout-symbol.symbolset/heart.pulse.svg b/Loop/DerivedAssetsBase.xcassets/workout-symbol.symbolset/heart.pulse.svg deleted file mode 100644 index 2439f1cc36..0000000000 --- a/Loop/DerivedAssetsBase.xcassets/workout-symbol.symbolset/heart.pulse.svg +++ /dev/null @@ -1,239 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Loop/Extensions/AlertStore+SimulatedCoreData.swift b/Loop/Extensions/AlertStore+SimulatedCoreData.swift index 2634bc3274..ecab37321f 100644 --- a/Loop/Extensions/AlertStore+SimulatedCoreData.swift +++ b/Loop/Extensions/AlertStore+SimulatedCoreData.swift @@ -17,7 +17,7 @@ extension AlertStore { private var simulatedPerDay: Int { 12 } private var simulatedLimit: Int { 10000 } - func generateSimulatedHistoricalStoredAlerts(completion: @escaping (Error?) -> Void) { + func generateSimulatedHistoricalStoredAlerts() async throws { var startDate = Calendar.current.startOfDay(for: expireDate) let endDate = Calendar.current.startOfDay(for: historicalEndDate) var simulated = [DatedAlert]() @@ -29,8 +29,7 @@ extension AlertStore { if simulated.count >= simulatedLimit { if let error = addAlerts(alerts: simulated) { - completion(error) - return + throw error } simulated = [] } @@ -38,7 +37,9 @@ extension AlertStore { startDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)! } - completion(addAlerts(alerts: simulated)) + if let error = addAlerts(alerts: simulated) { + throw error + } } func purgeHistoricalStoredAlerts(completion: @escaping (Error?) -> Void) { 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/CarbStore+SimulatedCoreData.swift b/Loop/Extensions/CarbStore+SimulatedCoreData.swift index 3ddcc23c83..f29f645b05 100644 --- a/Loop/Extensions/CarbStore+SimulatedCoreData.swift +++ b/Loop/Extensions/CarbStore+SimulatedCoreData.swift @@ -7,7 +7,7 @@ // import Foundation -import HealthKit +import LoopAlgorithm import LoopKit // MARK: - Simulated Core Data @@ -18,7 +18,7 @@ extension CarbStore { private var simulatedPerDay: Int { 10 } private var simulatedLimit: Int { 10000 } - func generateSimulatedHistoricalCarbObjects(completion: @escaping (Error?) -> Void) { + func generateSimulatedHistoricalCarbObjects() async throws { var startDate = Calendar.current.startOfDay(for: earliestCacheDate) let endDate = Calendar.current.startOfDay(for: historicalEndDate) var simulated = [NewCarbEntry]() @@ -31,28 +31,26 @@ extension CarbStore { } if simulated.count >= simulatedLimit { - if let error = addSimulatedHistoricalCarbObjects(entries: simulated) { - completion(error) - return - } + try await addSimulatedHistoricalCarbObjects(entries: simulated) simulated = [] } startDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)! } - completion(addSimulatedHistoricalCarbObjects(entries: simulated)) + try await addSimulatedHistoricalCarbObjects(entries: simulated) } - private func addSimulatedHistoricalCarbObjects(entries: [NewCarbEntry]) -> Error? { - var addError: Error? - let semaphore = DispatchSemaphore(value: 0) - addNewCarbEntries(entries: entries) { error in - addError = error - semaphore.signal() + private func addSimulatedHistoricalCarbObjects(entries: [NewCarbEntry]) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + addNewCarbEntries(entries: entries) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } } - semaphore.wait() - return addError } func purgeHistoricalCarbObjects(completion: @escaping (Error?) -> Void) { @@ -63,7 +61,7 @@ extension CarbStore { fileprivate extension NewCarbEntry { static func simulated(startDate: Date, grams: Double, absorptionTime: TimeInterval) -> NewCarbEntry { return NewCarbEntry(date: startDate, - quantity: HKQuantity(unit: .gram(), doubleValue: grams), + quantity: LoopQuantity(unit: .gram, doubleValue: grams), startDate: startDate, foodType: "Simulated", absorptionTime: absorptionTime) diff --git a/Loop/Extensions/ChartColorPalette+Loop.swift b/Loop/Extensions/ChartColorPalette+Loop.swift index 5da14fde31..b624c907e9 100644 --- a/Loop/Extensions/ChartColorPalette+Loop.swift +++ b/Loop/Extensions/ChartColorPalette+Loop.swift @@ -12,6 +12,6 @@ import LoopKitUI extension ChartColorPalette { static var primary: ChartColorPalette { - return ChartColorPalette(axisLine: .axisLineColor, axisLabel: .axisLabelColor, grid: .gridColor, glucoseTint: .glucoseTintColor, insulinTint: .insulinTintColor, carbTint: .carbTintColor) + return ChartColorPalette(axisLine: .axisLineColor, axisLabel: .axisLabelColor, grid: .gridColor, presetTint: .presets, glucoseTint: .glucoseTintColor, insulinTint: .insulinTintColor, carbTint: .carbTintColor) } } 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..4b610d7cd9 100644 --- a/Loop/Extensions/DeviceDataManager+DeviceStatus.swift +++ b/Loop/Extensions/DeviceDataManager+DeviceStatus.swift @@ -11,6 +11,10 @@ import LoopKitUI import LoopCore extension DeviceDataManager { + var hasBluetoothIssue: Bool { + bluetoothProvider.bluetoothState == .poweredOff || bluetoothProvider.bluetoothState == .unauthorized || bluetoothProvider.bluetoothState == .unsupported + } + var cgmStatusHighlight: DeviceStatusHighlight? { let bluetoothState = bluetoothProvider.bluetoothState if bluetoothState == .unsupported || bluetoothState == .unauthorized { @@ -41,16 +45,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 +108,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..185bea71b9 100644 --- a/Loop/Extensions/DoseStore+SimulatedCoreData.swift +++ b/Loop/Extensions/DoseStore+SimulatedCoreData.swift @@ -7,7 +7,7 @@ // import Foundation -import HealthKit +import LoopAlgorithm import LoopKit // MARK: - Simulated Core Data @@ -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) } } @@ -102,6 +110,7 @@ fileprivate extension PersistedPumpEvent { endDate: date.addingTimeInterval(duration), value: rate, unit: .unitsPerHour, + decisionId: nil, deliveredUnits: rate * duration / .hours(1))) } @@ -110,7 +119,8 @@ fileprivate extension PersistedPumpEvent { startDate: date, endDate: date.addingTimeInterval(.minutes(1)), value: amount, - unit: .units)) + unit: .units, + decisionId: nil)) } static func simulatedPrime(date: Date) -> PersistedPumpEvent { @@ -135,8 +145,9 @@ fileprivate extension PersistedPumpEvent { endDate: date.addingTimeInterval(duration), value: rate, unit: .unitsPerHour, + decisionId: nil, deliveredUnits: rate * duration / .hours(1), - scheduledBasalRate: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: scheduledRate))) + scheduledBasalRate: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: scheduledRate))) } private static func simulated(date: Date, type: PumpEventType, alarmType: PumpAlarmType? = nil) -> PersistedPumpEvent { diff --git a/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift b/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift index 53f81c5209..83072b4701 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 @@ -18,7 +19,7 @@ extension DosingDecisionStore { private var simulatedStartDateInterval: TimeInterval { .minutes(5) } private var simulatedLimit: Int { 10000 } - func generateSimulatedHistoricalDosingDecisionObjects(completion: @escaping (Error?) -> Void) { + func generateSimulatedHistoricalDosingDecisionObjects() async throws { var startDate = Calendar.current.startOfDay(for: expireDate) let endDate = Calendar.current.startOfDay(for: historicalEndDate) var simulated = [StoredDosingDecision]() @@ -27,43 +28,30 @@ extension DosingDecisionStore { simulated.append(StoredDosingDecision.simulated(date: startDate)) if simulated.count >= simulatedLimit { - if let error = addSimulatedHistoricalDosingDecisionObjects(dosingDecisions: simulated) { - completion(error) - return - } + try await addStoredDosingDecisions(dosingDecisions: simulated) simulated = [] } startDate = startDate.addingTimeInterval(simulatedStartDateInterval) } - - completion(addSimulatedHistoricalDosingDecisionObjects(dosingDecisions: simulated)) - } - - private func addSimulatedHistoricalDosingDecisionObjects(dosingDecisions: [StoredDosingDecision]) -> Error? { - var addError: Error? - let semaphore = DispatchSemaphore(value: 0) - addStoredDosingDecisions(dosingDecisions: dosingDecisions) { error in - addError = error - semaphore.signal() - } - semaphore.wait() - return addError + + try await addStoredDosingDecisions(dosingDecisions: simulated) } - func purgeHistoricalDosingDecisionObjects(completion: @escaping (Error?) -> Void) { - purgeDosingDecisions(before: historicalEndDate, completion: completion) + func purgeHistoricalDosingDecisionObjects() async throws { + try await purgeDosingDecisionObjects(before: historicalEndDate) } } fileprivate extension StoredDosingDecision { static func simulated(date: Date) -> StoredDosingDecision { + let id = UUID(uuidString: "ebd31ac5-4345-4a81-a0fe-871aa0b0938d")! let controllerTimeZone = TimeZone(identifier: "America/Los_Angeles")! let scheduleTimeZone = TimeZone(secondsFromGMT: TimeZone(identifier: "America/Phoenix")!.secondsFromGMT())! let reason = "simulatedCoreData" let settings = StoredDosingDecision.Settings(syncIdentifier: UUID(uuidString: "18CF3948-0B3D-4B12-8BFE-14986B0E6784")!) let scheduleOverride = TemporaryScheduleOverride(context: .preMeal, - settings: TemporaryScheduleOverrideSettings(unit: .milligramsPerDeciliter, + settings: TemporaryPresetSettings(unit: .milligramsPerDeciliter, targetRange: DoubleRange(minValue: 80.0, maxValue: 90.0), insulinNeedsScaleFactor: 1.5), @@ -101,10 +89,10 @@ fileprivate extension StoredDosingDecision { var historicalGlucose = [HistoricalGlucoseValue]() for minutes in stride(from: -120.0, to: 0.0, by: 5.0) { historicalGlucose.append(HistoricalGlucoseValue(startDate: date.addingTimeInterval(.minutes(minutes)), - quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 125 + minutes / 5))) + quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 125 + minutes / 5))) } let originalCarbEntry = StoredCarbEntry(startDate: date.addingTimeInterval(-.minutes(15)), - quantity: HKQuantity(unit: .gram(), doubleValue: 15), + quantity: LoopQuantity(unit: .gram, doubleValue: 15), uuid: UUID(uuidString: "C86DEB61-68E9-464E-9DD5-96A9CB445FD3")!, provenanceIdentifier: Bundle.main.bundleIdentifier!, syncIdentifier: "2B03D96C-6F5D-4140-99CD-80C3E64D6010", @@ -115,7 +103,7 @@ fileprivate extension StoredDosingDecision { userCreatedDate: date.addingTimeInterval(-.minutes(15)), userUpdatedDate: date.addingTimeInterval(-.minutes(1))) let carbEntry = StoredCarbEntry(startDate: date.addingTimeInterval(-.minutes(1)), - quantity: HKQuantity(unit: .gram(), doubleValue: 25), + quantity: LoopQuantity(unit: .gram, doubleValue: 25), uuid: UUID(uuidString: "71B699D7-0E8F-4B13-B7A1-E7751EB78E74")!, provenanceIdentifier: Bundle.main.bundleIdentifier!, syncIdentifier: "2B03D96C-6F5D-4140-99CD-80C3E64D6010", @@ -130,10 +118,10 @@ fileprivate extension StoredDosingDecision { syncIdentifier: "2A67A303-1234-4CB8-8263-79498265368E", syncVersion: 1, startDate: date.addingTimeInterval(-.minutes(1)), - quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 123.45), + quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 123.45), condition: nil, trend: .up, - trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 3.4), + trendRate: LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 3.4), isDisplayOnly: false, wasUserEntered: true, device: HKDevice(name: "Device Name", @@ -162,15 +150,14 @@ fileprivate extension StoredDosingDecision { var predictedGlucose = [PredictedGlucoseValue]() for minutes in stride(from: 5.0, to: 360.0, by: 5.0) { predictedGlucose.append(PredictedGlucoseValue(startDate: date.addingTimeInterval(.minutes(minutes)), - quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 125 + minutes / 5))) + quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 125 + minutes / 5))) } let automaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 0.75, - duration: .minutes(30)), + duration: .minutes(30)), direction: .increase, bolusUnits: 1.25) let manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: 0.2, - pendingInsulin: 0.75, - notice: .predictedGlucoseBelowTarget(minGlucose: PredictedGlucoseValue(startDate: date.addingTimeInterval(.minutes(30)), - quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 95.0)))), + notice: .predictedGlucoseBelowTarget(minGlucose: SimpleGlucoseValue(startDate: date.addingTimeInterval(.minutes(30)), + quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 95.0)))), date: date.addingTimeInterval(-.minutes(1))) let manualBolusRequested = 0.5 let warnings: [Issue] = [Issue(id: "one"), @@ -178,7 +165,8 @@ fileprivate extension StoredDosingDecision { let errors: [Issue] = [Issue(id: "alpha"), Issue(id: "bravo", details: ["size": "tiny"])] - return StoredDosingDecision(date: date, + return StoredDosingDecision(id: id, + date: date, controllerTimeZone: controllerTimeZone, reason: reason, settings: settings, diff --git a/Loop/Extensions/Double+Closest.swift b/Loop/Extensions/Double+Closest.swift new file mode 100644 index 0000000000..c7b4601d8c --- /dev/null +++ b/Loop/Extensions/Double+Closest.swift @@ -0,0 +1,25 @@ +// +// Double+Closest.swift +// Loop +// +// Created by Cameron Ingham on 3/20/25. +// + +extension Double { + func findClosest(in numberSet: [Double]) -> Double { + guard !numberSet.isEmpty else { + return self + } + + guard numberSet.count > 1 else { + return numberSet[0] + } + + return numberSet.reduce(numberSet[0]) { closest, current in + let currentDifference = abs(current - self) + let closestDifference = abs(closest - self) + + return currentDifference < closestDifference ? current : closest + } + } +} diff --git a/Loop/Extensions/Environment+SettingsManager.swift b/Loop/Extensions/Environment+SettingsManager.swift new file mode 100644 index 0000000000..717726f025 --- /dev/null +++ b/Loop/Extensions/Environment+SettingsManager.swift @@ -0,0 +1,24 @@ +// +// Environment+SettingsProvider.swift +// Loop +// +// Created by Pete Schwamb on 3/19/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopAlgorithm + + +@MainActor +private struct SettingsManagerKey: @preconcurrency EnvironmentKey { + static let defaultValue: SettingsManager = SettingsManager.placeholder +} + +extension EnvironmentValues { + var settingsManager: SettingsManager { + get { self[SettingsManagerKey.self] } + set { self[SettingsManagerKey.self] = newValue } + } +} diff --git a/Loop/Extensions/Environment+TemporaryPresetsManager.swift b/Loop/Extensions/Environment+TemporaryPresetsManager.swift new file mode 100644 index 0000000000..fabc168fa9 --- /dev/null +++ b/Loop/Extensions/Environment+TemporaryPresetsManager.swift @@ -0,0 +1,23 @@ +// +// Environment+TemporaryPresetManager.swift +// Loop +// +// Created by Pete Schwamb on 3/19/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit + +@MainActor +private struct TemporaryPresetsManagerKey: @preconcurrency EnvironmentKey { + // Default value should never really be used + static let defaultValue: TemporaryPresetsManager = TemporaryPresetsManager.placeholder +} + +extension EnvironmentValues { + var temporaryPresetsManager: TemporaryPresetsManager { + get { self[TemporaryPresetsManagerKey.self] } + set { self[TemporaryPresetsManagerKey.self] = newValue } + } +} diff --git a/Loop/Extensions/GlucoseStore+SimulatedCoreData.swift b/Loop/Extensions/GlucoseStore+SimulatedCoreData.swift index e5cc830a70..99cc96c19e 100644 --- a/Loop/Extensions/GlucoseStore+SimulatedCoreData.swift +++ b/Loop/Extensions/GlucoseStore+SimulatedCoreData.swift @@ -7,7 +7,7 @@ // import Foundation -import HealthKit +import LoopAlgorithm import LoopKit // MARK: - Simulated Core Data @@ -21,7 +21,7 @@ extension GlucoseStore { private var simulatedValueIncrement: Double { 2.0 * .pi / 72.0 } // 6 hour period private var simulatedLimit: Int { 10000 } - func generateSimulatedHistoricalGlucoseObjects(completion: @escaping (Error?) -> Void) { + func generateSimulatedHistoricalGlucoseObjects() async throws { var startDate = Calendar.current.startOfDay(for: earliestCacheDate) let endDate = Calendar.current.startOfDay(for: historicalEndDate) var value = 0.0 @@ -52,15 +52,12 @@ extension GlucoseStore { } }() simulated.append(NewGlucoseSample.simulated(date: startDate, - quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: new), + quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: new), trend: trend, - trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: trendRateValue))) + trendRate: LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: trendRateValue))) if simulated.count >= simulatedLimit { - if let error = addSimulatedHistoricalGlucoseObjects(samples: simulated) { - completion(error) - return - } + try await addNewGlucoseSamples(samples: simulated) simulated = [] } @@ -68,27 +65,16 @@ extension GlucoseStore { startDate = startDate.addingTimeInterval(simulatedStartDateInterval) } - completion(addSimulatedHistoricalGlucoseObjects(samples: simulated)) - } - - private func addSimulatedHistoricalGlucoseObjects(samples: [NewGlucoseSample]) -> Error? { - var addError: Error? - let semaphore = DispatchSemaphore(value: 0) - addNewGlucoseSamples(samples: samples) { error in - addError = error - semaphore.signal() - } - semaphore.wait() - return addError + try await addNewGlucoseSamples(samples: simulated) } - func purgeHistoricalGlucoseObjects(completion: @escaping (Error?) -> Void) { - purgeCachedGlucoseObjects(before: historicalEndDate, completion: completion) + func purgeHistoricalGlucoseObjects() async throws { + try await purgeCachedGlucoseObjects(before: historicalEndDate) } } fileprivate extension NewGlucoseSample { - static func simulated(date: Date, quantity: HKQuantity, trend: GlucoseTrend?, trendRate: HKQuantity?) -> NewGlucoseSample { + static func simulated(date: Date, quantity: LoopQuantity, trend: GlucoseTrend?, trendRate: LoopQuantity?) -> NewGlucoseSample { return NewGlucoseSample(date: date, quantity: quantity, condition: nil, diff --git a/Loop/Extensions/Image+Crop.swift b/Loop/Extensions/Image+Crop.swift new file mode 100644 index 0000000000..ea20c7d607 --- /dev/null +++ b/Loop/Extensions/Image+Crop.swift @@ -0,0 +1,30 @@ +// +// Image+Crop.swift +// Loop +// +// Created by Cameron Ingham on 3/20/25. +// + +import AVKit +import SwiftUI + +extension Image { + func centerCropped() -> some View { + GeometryReader { geo in + self + .resizable() + .scaledToFill() + .frame(width: geo.size.width, height: geo.size.height) + .clipped() + } + } +} + +extension View { + func centerCropped() -> some View { + GeometryReader { geo in + self + .frame(width: geo.size.width, height: geo.size.height) + } + } +} diff --git a/Loop/Extensions/Image+Optional.swift b/Loop/Extensions/Image+Optional.swift new file mode 100644 index 0000000000..775196265e --- /dev/null +++ b/Loop/Extensions/Image+Optional.swift @@ -0,0 +1,20 @@ +// +// Image+Optional.swift +// Loop +// +// Created by Cameron Ingham on 1/7/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +// Since this `Image` initializer provides a view even if the asset is not found in the bundle, it can double the spacing between adjacent elements in a `VStack`, `HStack`, etc. +extension Image { + init?(_ name: String, bundle: Bundle? = nil) { + if let _ = UIImage(named: name, in: bundle, with: nil) { + self = Image(name, bundle: bundle) + } else { + return nil + } + } +} diff --git a/Loop/Extensions/OverrideSelectionViewController.swift b/Loop/Extensions/OverrideSelectionViewController.swift deleted file mode 100644 index bbe072b813..0000000000 --- a/Loop/Extensions/OverrideSelectionViewController.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// OverrideSelectionViewController.swift -// Loop -// -// Created by Michael Pangburn on 1/27/19. -// Copyright © 2019 LoopKit Authors. All rights reserved. -// - -import LoopKitUI -import LoopCore - - -extension OverrideSelectionViewController: IdentifiableClass { } diff --git a/Loop/Extensions/PersistentDeviceLog+SimulatedCoreData.swift b/Loop/Extensions/PersistentDeviceLog+SimulatedCoreData.swift index d39848337d..a6444d3859 100644 --- a/Loop/Extensions/PersistentDeviceLog+SimulatedCoreData.swift +++ b/Loop/Extensions/PersistentDeviceLog+SimulatedCoreData.swift @@ -14,10 +14,10 @@ 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) { + func generateSimulatedHistoricalDeviceLogEntries() async throws { var startDate = Calendar.current.startOfDay(for: earliestLogEntryDate) let endDate = Calendar.current.startOfDay(for: historicalEndDate) var simulated = [StoredDeviceLogEntry]() @@ -28,17 +28,14 @@ extension PersistentDeviceLog { } if simulated.count >= simulatedLimit { - if let error = addStoredDeviceLogEntries(entries: simulated) { - completion(error) - return - } + try await addStoredDeviceLogEntries(entries: simulated) simulated = [] } startDate = Calendar.current.date(byAdding: .hour, value: 1, to: startDate)! } - completion(addStoredDeviceLogEntries(entries: simulated)) + try await addStoredDeviceLogEntries(entries: simulated) } func purgeHistoricalDeviceLogEntries(completion: @escaping (Error?) -> Void) { diff --git a/Loop/Extensions/Publisher.swift b/Loop/Extensions/Publisher.swift new file mode 100644 index 0000000000..c2fa3bbd78 --- /dev/null +++ b/Loop/Extensions/Publisher.swift @@ -0,0 +1,42 @@ +// +// Publisher.swift +// Loop +// +// Created by Pete Schwamb on 3/20/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import Combine +import Foundation +import Observation + +public func withObservationTracking(of value: @escaping @autoclosure () -> T, execute: @escaping (T) -> Void) { + Observation.withObservationTracking { + execute(value()) + } onChange: { + RunLoop.current.perform { + withObservationTracking(of: value(), execute: execute) + } + } +} + + +enum ObservablePublishers { + static func tracking( + _ object: Object, + keyPath: KeyPath + ) -> AnyPublisher { + let subject = PassthroughSubject() + + withObservationTracking(of: object[keyPath: keyPath]) { newValue in + // When change happens, continue the loop + Task { @MainActor in + subject.send(newValue) + } + } + + subject.send(object[keyPath: keyPath]) + + return subject.eraseToAnyPublisher() + } +} diff --git a/Loop/Extensions/SettingsStore+SimulatedCoreData.swift b/Loop/Extensions/SettingsStore+SimulatedCoreData.swift index 80c990bb38..4e430c0834 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 @@ -18,7 +19,7 @@ extension SettingsStore { private var simulatedPerDay: Int { 2 } private var simulatedLimit: Int { 10000 } - func generateSimulatedHistoricalSettingsObjects(completion: @escaping (Error?) -> Void) { + func generateSimulatedHistoricalSettingsObjects() async throws { var startDate = Calendar.current.startOfDay(for: expireDate) let endDate = Calendar.current.startOfDay(for: historicalEndDate) var simulated = [StoredSettings]() @@ -29,28 +30,14 @@ extension SettingsStore { } if simulated.count >= simulatedLimit { - if let error = addSimulatedHistoricalSettingsObjects(settings: simulated) { - completion(error) - return - } + try await addStoredSettings(settings: simulated) simulated = [] } startDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)! } - completion(addSimulatedHistoricalSettingsObjects(settings: simulated)) - } - - private func addSimulatedHistoricalSettingsObjects(settings: [StoredSettings]) -> Error? { - var addError: Error? - let semaphore = DispatchSemaphore(value: 0) - addStoredSettings(settings: settings) { error in - addError = error - semaphore.signal() - } - semaphore.wait() - return addError + try await addStoredSettings(settings: simulated) } func purgeHistoricalSettingsObjects(completion: @escaping (Error?) -> Void) { @@ -75,14 +62,6 @@ fileprivate extension StoredSettings { override: GlucoseRangeSchedule.Override(value: DoubleRange(minValue: 80.0, maxValue: 90.0), start: date.addingTimeInterval(-.minutes(30)), end: date.addingTimeInterval(.minutes(30)))) - let preMealOverride = TemporaryScheduleOverride(context: .preMeal, - settings: TemporaryScheduleOverrideSettings(unit: .milligramsPerDeciliter, - targetRange: DoubleRange(minValue: 80.0, maxValue: 90.0), - insulinNeedsScaleFactor: 0.5), - startDate: date.addingTimeInterval(-.minutes(30)), - duration: .finite(.minutes(60)), - enactTrigger: .local, - syncIdentifier: UUID()) let basalRateSchedule = BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: .hours(0), value: 1.0), RepeatingScheduleValue(startTime: .hours(8), value: 1.125), RepeatingScheduleValue(startTime: .hours(10), value: 1.25), @@ -102,7 +81,7 @@ fileprivate extension StoredSettings { RepeatingScheduleValue(startTime: .hours(18), value: 45.0), RepeatingScheduleValue(startTime: .hours(21), value: 50.0)], timeZone: scheduleTimeZone) - let carbRatioSchedule = CarbRatioSchedule(unit: .gram(), + let carbRatioSchedule = CarbRatioSchedule(unit: .gram, dailyItems: [RepeatingScheduleValue(startTime: .hours(0), value: 10.0), RepeatingScheduleValue(startTime: .hours(8), value: 12.0), RepeatingScheduleValue(startTime: .hours(10), value: 9.0), @@ -153,10 +132,7 @@ fileprivate extension StoredSettings { dosingEnabled: true, 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..0d7103b7f6 --- /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 + ) -> EnactedTempBasal? { + // 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: EnactedTempBasal { + return self.init(unitsPerHour: 0, duration: 0) + } +} + diff --git a/Loop/Extensions/TimeInterval.swift b/Loop/Extensions/TimeInterval.swift new file mode 100644 index 0000000000..51ce4fc433 --- /dev/null +++ b/Loop/Extensions/TimeInterval.swift @@ -0,0 +1,37 @@ +// +// TimeInterval.swift +// Loop +// +// Created by Nathaniel Hamming on 2025-10-23. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// +import Foundation + +extension TimeInterval { + /// Formats a time interval as a truncated "time ago" string (e.g., "1 hr", "2 mins") + var truncatedTimeAgoString: String? { + let calendar = Calendar.current + let now = Date() + let past = now.addingTimeInterval(-self) + + let components = calendar.dateComponents([.day, .hour, .minute], from: past, to: now) + if let days = components.day, days > 0 { + return String.localizedStringWithFormat( + NSLocalizedString("%d day", tableName: "LocalizablePlural", bundle: .main, value: "%d day", comment: "Singular/plural day count"), + days + ) + } else if let hours = components.hour, hours > 0 { + return String.localizedStringWithFormat( + NSLocalizedString("%d hr", tableName: "LocalizablePlural", bundle: .main, value: "%d hr", comment: "Singular/plural hour count"), + hours + ) + } else if let minutes = components.minute { + return String.localizedStringWithFormat( + NSLocalizedString("%d min", tableName: "LocalizablePlural", bundle: .main, value: "%d min", comment: "Singular/plural minute count"), + minutes + ) + } else { + return nil + } + } +} diff --git a/Loop/Extensions/UIAlertController.swift b/Loop/Extensions/UIAlertController.swift index 3b83aa11d8..e483d7d2fd 100644 --- a/Loop/Extensions/UIAlertController.swift +++ b/Loop/Extensions/UIAlertController.swift @@ -11,40 +11,7 @@ import LoopKit import LoopKitUI -extension UIAlertController { - /** - Initializes an ActionSheet-styled controller for selecting a workout duration - - - parameter handler: A closure to execute when the sheet is dismissed after selection. The closure has a single argument: - - duration: The duration for which the workout is to be enabled - */ - internal convenience init(workoutDurationSelectionHandler handler: @escaping (_ duration: TimeInterval) -> Void) { - self.init( - title: NSLocalizedString("Use Workout Preset", comment: "The title of the alert controller used to select a duration for workout targets"), - message: nil, - preferredStyle: .actionSheet - ) - - let formatter = DateComponentsFormatter() - formatter.allowsFractionalUnits = false - formatter.unitsStyle = .full - - for interval in [1, 2].map({ TimeInterval(hours: $0) }) { - let duration = NSLocalizedString("For %1$@", comment: "The format string used to describe a finite workout targets duration") - - addAction(UIAlertAction(title: String(format: duration, formatter.string(from: interval)!), style: .default) { _ in - handler(interval) - }) - } - - let distantFuture = NSLocalizedString("Until I turn off", comment: "The title of a target alert action specifying workout targets duration until it is turned off by the user") - addAction(UIAlertAction(title: distantFuture, style: .default) { _ in - handler(.infinity) - }) - - addCancelAction() - } - +extension UIAlertController { /** Initializes an ActionSheet-styled controller for selecting a pre-meal preset duration 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/UIImage.swift b/Loop/Extensions/UIImage.swift index 908f0c965a..c7a8f737f7 100644 --- a/Loop/Extensions/UIImage.swift +++ b/Loop/Extensions/UIImage.swift @@ -34,10 +34,6 @@ extension UIImage { static func preMealImage(selected: Bool) -> UIImage? { return UIImage(named: selected ? "Pre-Meal Selected" : "Pre-Meal") } - - static func workoutImage(selected: Bool) -> UIImage? { - return UIImage(named: selected ? "workout-selected" : "workout") - } } private class FrameworkBundle { 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/Localizable.xcstrings b/Loop/Localizable.xcstrings index d6044376d1..1680341172 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -3,6 +3,18 @@ "strings" : { "" : { + }, + " " : { + + }, + " " : { + + }, + " -" : { + + }, + " - " : { + }, " (pending: %@)" : { "comment" : "The string format appended to active insulin that describes pending insulin. (1: pending insulin)", @@ -129,6 +141,36 @@ } } } + }, + " **Tip** Use your %@ **Jogging** preset" : { + + }, + " **Tip** Use your %@ **Walking** preset" : { + + }, + " / " : { + + }, + " %@" : { + + }, + " %@" : { + + }, + " %@ · " : { + + }, + " %@ read" : { + + }, + " %lld hours ago" : { + + }, + " grams" : { + + }, + " of " : { + }, " Pre-meal Preset" : { "comment" : "Status row title for premeal override enabled (leading space is to separate from symbol)", @@ -328,6 +370,7 @@ }, " Safety Notifications are OFF" : { "comment" : "Warning text for when Notifications or Critical Alerts Permissions is disabled", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -408,9 +451,13 @@ } } } + }, + " setting. Adjusting it can help reduce the risk of low glucose if you expect unusual fluctuations." : { + }, " Workout Preset" : { "comment" : "Status row title for workout override enabled (leading space is to separate from symbol)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -504,6 +551,12 @@ } } }, + "-.--" : { + + }, + "–" : { + "comment" : "String denoting lack of a recommended bolus amount in the simple bolus calculator" + }, "." : { "comment" : "Full stop character", "localizations" : { @@ -580,6 +633,24 @@ } } } + }, + "**Tip: If glucose rises to >270 mg/dl,** check %@ to see if a bolus is recommended to bring your glucose back into range." : { + + }, + "**Tip:** Try exercising when your active insulin is close to zero at the start of an activity." : { + + }, + "**Try:** If you often experience low glucose, consider exercising earlier in the day before eating." : { + + }, + "**Try:** Reducing your meal bolus if you expect your glucose to drop." : { + + }, + "%@" : { + + }, + "%@ " : { + }, "%@ %@" : { "comment" : "The format for an active custom preset. (1: preset symbol)(2: preset name)", @@ -836,6 +907,15 @@ } } } + }, + "%@ of insulin" : { + + }, + "%@ read" : { + + }, + "%@ Recommended starting values" : { + }, "%@ remaining" : { "comment" : "Estimated remaining duration with more than a minute", @@ -934,6 +1014,7 @@ }, "%@ U Total" : { "comment" : "The subtitle format describing total insulin. (1: localized insulin total)", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -1823,6 +1904,7 @@ }, "%1$@ APP SOUNDS" : { "comment" : "App sounds title text (1: app name)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -1856,6 +1938,15 @@ } } }, + "%1$@ has been active for more than 24 hours. Make sure you still want it enabled, or turn it off in the app." : { + "comment" : "Active preset reminder alert background body. (1: preset name)" + }, + "%1$@ has been active for more than 24 hours. Make sure you still want it enabled, or turn it off." : { + "comment" : "Active preset reminder alert foreground body. (1: preset name)" + }, + "%1$@ has set your correction range to 110 mg/dL or higher." : { + "comment" : "The format string for the high insulin needs preset warning text on the preset detent screen when stopping a preset. (1: app name)" + }, "%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." : { "comment" : "Alert message for closed loop off informational modal. (1: app name)", "localizations" : { @@ -2105,6 +2196,9 @@ } } }, + "%1$@ Still Active" : { + "comment" : "The format title for the preset still active alert. (1: preset name)" + }, "%1$@ Time Settings Need Attention" : { "comment" : "Time change alert title", "localizations" : { @@ -2804,6 +2898,9 @@ } } }, + "%1$@ will set your correction range to 110 mg/dL or higher when this preset is enabled." : { + "comment" : "The format string for the high insulin needs preset warning text on the preset detent screen when starting a preset. (1: app name)" + }, "%1$@ will stop working in %2$@. You will need to rebuild before that." : { "comment" : "Format string for body for notification of upcoming expiration. (1: app name) (2: amount of time until expiration", "localizations" : { @@ -3141,6 +3238,9 @@ } } } + }, + "•" : { + }, "⚠️" : { "localizations" : { @@ -3199,6 +3299,21 @@ } } } + }, + "0" : { + + }, + "3-6" : { + + }, + "6-9" : { + + }, + "9-20" : { + + }, + "10" : { + }, "15 min glucose regression coefficient (b₁), continued with decay over 30 min" : { "comment" : "Description of the prediction input effect for glucose momentum", @@ -3324,6 +3439,9 @@ } } } + }, + "25-33%" : { + }, "30 min comparison of glucose prediction vs actual, continued with decay over 60 min" : { "comment" : "Description of the prediction input effect for retrospective correction", @@ -3443,6 +3561,9 @@ } } } + }, + "100m sprint" : { + }, "A few seconds remaining" : { "comment" : "Estimated remaining duration with a few seconds", @@ -4122,6 +4243,21 @@ } } } + }, + "A percentage **above 100%** tells the system you need **more** insulin" : { + + }, + "A percentage **below 100%** tells the system you need **less** insulin" : { + + }, + "A preset with %@ overall insulin is on. The system is currently delivering less than your preset baseline." : { + + }, + "A preset with %@ overall insulin is on. The system is currently delivering more than your preset baseline." : { + + }, + "A preset with %@ overall insulin is on. This is your new preset baseline and it overrides your Scheduled Basal." : { + }, "A pump must be configured before a bolus can be delivered." : { "comment" : "Alert message for a missing pump error", @@ -5213,6 +5349,9 @@ } } } + }, + "Add Carbs" : { + }, "Add CGM" : { "comment" : "Action sheet title selecting CGM\nThe title of the CGM chooser in settings\nTitle text for button to add CGM device\nTitle text for button to set up a CGM", @@ -5376,6 +5515,7 @@ }, "Add Meal" : { "comment" : "The label of the carb entry button", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -5742,6 +5882,9 @@ } } } + }, + "Adjust Preset Duration" : { + }, "Adjusted for" : { "localizations" : { @@ -5824,6 +5967,12 @@ } } } + }, + "Aerobic" : { + + }, + "Afternoon Exercise" : { + }, "Alert Management" : { "comment" : "Alert Permissions button text\nTitle of alert management screen", @@ -5910,6 +6059,7 @@ }, "Alert Permissions" : { "comment" : "Alert Permissions button text\nNotification & Critical Alert Permissions screen title", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6005,6 +6155,7 @@ }, "Alert Permissions and Mute Alerts" : { "comment" : "Alert Permissions descriptive text", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6290,6 +6441,7 @@ }, "All Alerts Muted" : { "comment" : "Warning text for when alerts are muted", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6349,6 +6501,7 @@ }, "All alerts muted until" : { "comment" : "Label for when mute alert will end", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6399,6 +6552,18 @@ } } } + }, + "All App Sounds Muted" : { + "comment" : "Warning text for when alerts are muted" + }, + "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" + }, + "All app sounds, including sounds for all critical alerts, are currently muted.\n\nTap Unmute to resume app sounds for your alerts." : { + "comment" : "The alert body for unmute alert confirmation" + }, + "All Events" : { + }, "All Favorites" : { "comment" : "section header for list of existing FavoriteFoods", @@ -6452,6 +6617,12 @@ } } } + }, + "All Out" : { + + }, + "All Presets" : { + }, "Amount Consumed" : { "comment" : "Label for carb quantity entry row on carb entry screen", @@ -6640,6 +6811,7 @@ }, "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", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6960,6 +7132,7 @@ }, "An updated bolus recommendation is available." : { "comment" : "Alert message when glucose data returns while on bolus screen", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -8153,6 +8326,12 @@ } } } + }, + "Are you sure you want to turn automation OFF?" : { + "comment" : "Closed loop alert title" + }, + "ask yourself, am I more likely to go high or low during this event?" : { + }, "at %@" : { "comment" : "Format fragment for a specific time", @@ -8278,6 +8457,12 @@ } } } + }, + "at %1$@" : { + "comment" : "when adding a timestamp. (1: the formatted timestamp)\nwhen adding the date and time. (1: the formatted date and time)" + }, + "At 100%%, %@ assumes your insulin needs are the same as usual." : { + }, "Authenticate to Bolus %@ Units" : { "comment" : "The message displayed during a device authentication prompt for bolus specification", @@ -8492,6 +8677,57 @@ } } } + }, + "Automated" : { + + }, + "Automated (↑ Increase)" : { + + }, + "Automated (↓ Decrease)" : { + + }, + "Automated (Preset Basal Rate)" : { + + }, + "Automated (Scheduled)" : { + + }, + "Automated insulin adjustments by %@ reduce a noticeable rise in glucose" : { + + }, + "Automation" : { + + }, + "Automation is off" : { + "comment" : "title for when automation is off" + }, + "Automation is on" : { + "comment" : "title for when automation is on" + }, + "Automation is unavailable" : { + "comment" : "title for when automation is unavailable" + }, + "Automation is unavailable while your CGM sensor is warming up.\n\nIn the meantime, your pump is still able to deliver insulin.\n\nAutomation will resume when CGM readings are received." : { + "comment" : "message when automation is on and CGM is in warmup" + }, + "Automation is unavailable while your insulin is suspended.\n\nResume insulin if you wish for the app to automate insulin delivery." : { + "comment" : "message when automation is on and insulin delivery is suspended" + }, + "Automation OFF" : { + + }, + "Automation ON" : { + + }, + "Automation unavailable" : { + + }, + "Automation was unsuccessful" : { + "comment" : "title for when automation was unsuccessful" + }, + "Basal Rate" : { + }, "Basal Rate Schedule" : { "comment" : "Details for configuration error when basal rate schedule is missing", @@ -8755,6 +8991,9 @@ } } } + }, + "Basal: " : { + }, "Based on your predicted glucose, no bolus is recommended." : { "comment" : "Caption for bolus screen notice when no bolus is recommended for the predicted glucose", @@ -8838,6 +9077,12 @@ } } } + }, + "Because Omar has gone low while working outdoors in the past, he raises his preset correction range to help prevent another low." : { + + }, + "Before" : { + }, "Bluetooth\nOff" : { "comment" : "Message to the user to that the bluetooth is off", @@ -9208,7 +9453,7 @@ } }, "Bolus" : { - "comment" : "Label for bolus entry row on bolus screen\nLabel for bolus entry row on simple bolus screen\nThe label of the bolus entry button\nTitle for bolus entry screen", + "comment" : "Label for bolus entry row on bolus screen\nLabel for bolus entry row on simple bolus screen\nTitle for bolus entry screen", "localizations" : { "ar" : { "stringUnit" : { @@ -9332,6 +9577,9 @@ } } }, + "Bolus Canceled: Delivered %1$@ of %2$@" : { + "comment" : "The title of the cell indicating a bolus has been canceled. (1: delivered volume)(2: total volume)" + }, "Bolus Issue" : { "comment" : "The notification title for a bolus issue", "localizations" : { @@ -9681,6 +9929,9 @@ } } } + }, + "Bolus: " : { + }, "Bolused %1$@ of %2$@" : { "comment" : "The format string for bolus progress. (1: delivered volume)(2: total volume)", @@ -9992,6 +10243,15 @@ } } }, + "Breathing is slightly uncomfortable. Conversation requires maximal effort." : { + + }, + "Breathing more heavily. Can carry on a conversation, but requires more effort." : { + + }, + "Can I use Focus modes with %1$@?" : { + "comment" : "Focus modes section title (1: app name)" + }, "Cancel" : { "comment" : "Button label for cancel\nButton text to cancel\nCancel button for reset loop alert\nCancel export button title\nThe title of the cancel action in an action sheet", "localizations" : { @@ -10514,6 +10774,9 @@ } } } + }, + "Carb Ratio" : { + }, "Carb Ratio Schedule" : { "comment" : "Details for configuration error when carb ratio schedule is missing", @@ -11234,6 +11497,9 @@ } } } + }, + "CAUTION - Investigational device. Limited by Federal (or United States) law to investigational use." : { + }, "Change the pump battery immediately" : { "comment" : "The notification alert describing a low pump battery", @@ -11499,6 +11765,12 @@ } } }, + "Changing it can lower the chance of your glucose levels going too low if you expect unusual changes." : { + + }, + "Check for potential communication issues with your CGM.\n\nIn the meantime, your pump is still able to deliver insulin." : { + "comment" : "message when automation is off and CGM is in signal loss" + }, "Check settings" : { "comment" : "Details for configuration error when one or more loop settings are missing", "extractionState" : "manual", @@ -11940,6 +12212,9 @@ } } } + }, + "Check your glucose levels around 20 to 30 min after eating. If you're still low, consider eating the same amount." : { + }, "Choose a longer absorption time for larger meals, or those containing fats and proteins. This is only guidance to the algorithm and need not be exact." : { "comment" : "Carb entry section footer text explaining absorption time", @@ -12106,9 +12381,12 @@ } } } + }, + "Choose the glucose value (or values) you want %@ to target when changing how much basal insulin you get." : { + }, "Close" : { - "comment" : "Button title to close view\nThe button label of the action used to dismiss the unsafe notification permission alert", + "comment" : "Preset training needed alert cancel button\nThe button label of the action used to dismiss the unsafe notification permission alert", "localizations" : { "da" : { "stringUnit" : { @@ -12195,6 +12473,9 @@ } } } + }, + "Close Training" : { + }, "Closed Loop" : { "comment" : "The title text for the looping enabled switch cell", @@ -12941,6 +13222,15 @@ } } } + }, + "Combination of high and low intensity" : { + + }, + "Competition Stress" : { + + }, + "Complete each section below to learn how presets can be used for a variety of situations." : { + }, "Complete Setup" : { "comment" : "Title text for button to complete setup", @@ -13015,6 +13305,7 @@ }, "Configuration" : { "comment" : "The title of the Configuration section in settings", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -13291,6 +13582,15 @@ } } } + }, + "Congratulations! You've finished the Presets training." : { + + }, + "Consider an exercise you do regularly and think about how hard you push yourself." : { + + }, + "Consider eating around 10 to 20 grams of fast-acting carbs" : { + }, "Continue" : { "comment" : "Button label for continue\nDefault alert dismissal", @@ -13416,6 +13716,9 @@ } } } + }, + "Continue to %@" : { + }, "Continuous Glucose Monitor" : { "comment" : "Descriptive text for Continuous Glucose Monitor", @@ -13535,6 +13838,9 @@ } } } + }, + "Continuous or exercise without breaks" : { + }, "Correction Range" : { "comment" : "The title of the glucose target range schedule screen\n The title text for the glucose target range schedule", @@ -13661,6 +13967,12 @@ } } } + }, + "Correction range is a " : { + + }, + "Correction range is a **safety setting**. Changing it can help lower your risk of going low if you expect unusual changes." : { + }, "Could Not Restart %1$@" : { "comment" : "Format string for title of reset loop alert. (1: App name)", @@ -13708,6 +14020,9 @@ } } } + }, + "Create new presets" : { + }, "Critical Alerts" : { "comment" : "Critical Alerts Status text", @@ -13798,6 +14113,33 @@ } } }, + "Critical Alerts and Time Sensitive Notifications are important types of iOS notifications used for events that require immediate attention. Examples include:" : { + + }, + "Critical Alerts and Time Sensitive Notifications are turned OFF" : { + "comment" : "Both Critical Alerts and Time Sensitive Notifications disabled banner title" + }, + "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" + }, + "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" + }, + "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" + }, + "Critical Alerts are turned OFF" : { + "comment" : "Critical alerts disabled banner title" + }, + "Critical Alerts are turned OFF. Go to the App to fix the issue now." : { + "comment" : "Critical alerts disabled notification body" + }, + "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" + }, + "Critical Alerts will still sound, but all others will be silenced." : { + "comment" : "Additional description text for temporarily silencing non-critical alerts" + }, "Critical Event Log Ready" : { "comment" : "Critical event log ready text", "localizations" : { @@ -14046,6 +14388,15 @@ } } } + }, + "CrossFit" : { + + }, + "Current Basal Rate" : { + + }, + "Current Delivery" : { + }, "Current Glucose" : { "comment" : "Label for glucose entry row on simple bolus screen", @@ -14418,6 +14769,7 @@ }, "Custom Preset" : { "comment" : "The title of the cell indicating a generic custom preset is enabled", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -14630,6 +14982,9 @@ } } }, + "Date Created" : { + "comment" : "Preset sorting option description for sorting by date created" + }, "dB" : { "comment" : "The short unit display string for decibles", "localizations" : { @@ -14748,6 +15103,12 @@ } } } + }, + "decrease" : { + + }, + "Decreased Delivery" : { + }, "Delete" : { "localizations" : { @@ -15289,6 +15650,10 @@ } } }, + "Delete External Insulin" : { + "comment" : "A button to delete a manually-entered insulin dose.", + "isCommentAutoGenerated" : true + }, "Delete Food" : { "localizations" : { "da" : { @@ -15743,6 +16108,10 @@ } } }, + "Delete this manually-entered insulin entry?" : { + "comment" : "A confirmation dialog asking the user to confirm the deletion of a manually-entered insulin dose.", + "isCommentAutoGenerated" : true + }, "Deliver" : { "comment" : "Button text to deliver a bolus", "localizations" : { @@ -15837,6 +16206,9 @@ } } } + }, + "Delivery Details" : { + }, "Delivery Limits" : { "comment" : "Title text for delivery limits", @@ -15975,6 +16347,15 @@ } } } + }, + "Depending on the activity, you may notice a few common patterns when it comes to your insulin needs:" : { + + }, + "Device Issue" : { + "comment" : "title for when automation is off and there is a device issue" + }, + "Devices" : { + }, "Diabetes Treatment" : { "comment" : "Descriptive text for Therapy Settings", @@ -16135,9 +16516,13 @@ } } } + }, + "Difficulty maintaining exercise or holding a conversation." : { + }, "Disables" : { "comment" : "The action hint of the workout mode toggle button when enabled", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -16256,7 +16641,7 @@ } }, "Dismiss" : { - "comment" : "Default alert dismissal\nThe button label of the action used to dismiss an error alert", + "comment" : "Default alert dismissal", "localizations" : { "ar" : { "stringUnit" : { @@ -16437,6 +16822,9 @@ } } }, + "Don't Start" : { + "comment" : "Label for do not start preset action on scheduled preset reminder alert\nThe title of the notification action to not start a preset" + }, "Done" : { "localizations" : { "cs" : { @@ -16625,6 +17013,9 @@ } } } + }, + "Dose Type" : { + }, "Dosing Strategy" : { "comment" : "The title of the Dosing Strategy section in settings", @@ -16714,6 +17105,9 @@ } } } + }, + "Duration" : { + }, "Duration exceeds: %1$.1f hours" : { "comment" : "Override error description: duration exceed max (1: max duration in hours).", @@ -16785,6 +17179,24 @@ } } } + }, + "Duration: %@" : { + + }, + "During this kind of hard exercise, your body may release hormones that raise glucose. This is more common in the morning before eating." : { + + }, + "Easy breath. Can carry on a conversation." : { + + }, + "Edit Food" : { + + }, + "Edit Preset" : { + + }, + "Edit presets" : { + }, "Enable\nBluetooth" : { "comment" : "Message to the user to enable bluetooth", @@ -17006,6 +17418,7 @@ }, "Enables" : { "comment" : "The action hint of the workout mode toggle button when disabled", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -17123,6 +17536,21 @@ } } }, + "End" : { + + }, + "End Preset" : { + + }, + "End Time" : { + + }, + "End Training?" : { + + }, + "Ensure that notifications are allowed and NOT silenced from %1$@." : { + "comment" : "Focus modes step 4 (1: appName)" + }, "Enter a blood glucose from a meter for a recommended bolus amount." : { "comment" : "Caption for bolus screen notice when glucose data is missing or stale", "localizations" : { @@ -17956,6 +18384,9 @@ } } } + }, + "Event" : { + }, "Event History" : { "comment" : "Segmented button title for insulin delivery log event history", @@ -18069,9 +18500,13 @@ } } } + }, + "Eventually" : { + }, "Eventually %@" : { "comment" : "The subtitle format describing eventual glucose. (1: localized glucose value description)", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -18189,6 +18624,15 @@ } } }, + "Example" : { + + }, + "Example: Allow Notifications from %1$@" : { + "comment" : "Focus mode image 1 caption (1: appName)" + }, + "Example: Silence Notifications from other apps" : { + "comment" : "Focus mode image 2 caption" + }, "Exceeds maximum allowed bolus in settings" : { "comment" : "Bolus error description: bolus exceeds maximum bolus in settings.", "localizations" : { @@ -18455,6 +18899,12 @@ } } } + }, + "Exercise is a common reason to use a preset. Different kinds of exercise and their intensity levels can affect your glucose levels in different ways." : { + + }, + "Explosive sprints or bursts" : { + }, "Export Critical Event Logs" : { "comment" : "The title of the export critical event logs in support", @@ -18628,6 +19078,9 @@ } } }, + "External Insulin" : { + "comment" : "Dose type label for a manually-entered bolus" + }, "Failed to Resume Insulin Delivery" : { "comment" : "The alert title for a resume error", "localizations" : { @@ -18722,6 +19175,18 @@ } } } + }, + "Falling / Falling Quickly" : { + + }, + "Falling Slowly" : { + + }, + "FAQ about Alerts" : { + "comment" : "View title for how mute alerts work" + }, + "Favorite Food Insights" : { + }, "Favorite Foods" : { "comment" : "Title for Favorite Foods view", @@ -18954,6 +19419,12 @@ } } } + }, + "Filter" : { + + }, + "Filtered by:" : { + }, "Fingerstick Glucose" : { "comment" : "Label for manual glucose entry row on bolus screen", @@ -19044,8 +19515,21 @@ } } }, + "Fix now by turning Critical Alerts and Time Sensitive Notifications ON." : { + "comment" : "Both Critical Alerts and Time Sensitive Notifications disabled banner body" + }, + "Fix now by turning Critical Alerts ON." : { + "comment" : "Critical alerts disabled banner body" + }, + "Fix now by turning Notifications and Critical Alerts ON." : { + "comment" : "Both Critical Alerts and Notifications disabled banner body" + }, + "Fix now by turning Notifications ON." : { + "comment" : "Notifications disabled banner body" + }, "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.", + "extractionState" : "stale", "localizations" : { "cs" : { "stringUnit" : { @@ -19127,6 +19611,9 @@ } } }, + "Fix now by turning Time Sensitive Notifications ON." : { + "comment" : "Time sensitive notifications disabled banner body" + }, "Food Type" : { "comment" : "Label for food type entry on add favorite food screen", "localizations" : { @@ -19248,6 +19735,7 @@ }, "For %1$@" : { "comment" : "The format string used to describe a finite workout targets duration", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -19364,9 +19852,16 @@ } } } + }, + "For activities that raise your risk of going low, you can set a higher temporary correction range." : { + + }, + "For mixed-intensity activity:" : { + }, "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)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -19417,6 +19912,15 @@ } } } + }, + "For some people, routine chores and everyday activities can affect glucose levels similar to exercise." : { + + }, + "For these activities, consider setting your insulin needs to **less than 100%**." : { + + }, + "For these activities, consider setting your insulin needs to **more than 100%**." : { + }, "Forecasted blood glucose may still be higher than target range." : { "localizations" : { @@ -19579,6 +20083,7 @@ }, "Frequently asked questions about alerts" : { "comment" : "Label for link to see frequently asked questions", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -19629,9 +20134,13 @@ } } } + }, + "Full out effort. No conversation possible." : { + }, "g" : { "comment" : "The short unit display string for grams", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -19757,6 +20266,7 @@ }, "Get help with Alert Permissions" : { "comment" : "Get help with Alert Permissions support button text", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -19832,6 +20342,9 @@ } } }, + "Get help with iOS Permissions" : { + "comment" : "Get help with iOS Permissions support button text" + }, "Glucose" : { "comment" : "The title of the glucose and prediction graph\nTitle for predicted glucose chart on bolus screen", "localizations" : { @@ -20009,6 +20522,9 @@ } } } + }, + "Glucose Change Chart" : { + }, "Glucose data is %1$@ old" : { "comment" : "The error message when glucose data is too old to be used. (1: glucose data age in minutes)", @@ -20250,6 +20766,7 @@ }, "Glucose Data Now Available" : { "comment" : "Alert title when glucose data returns while on bolus screen", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -20662,8 +21179,15 @@ } } } + }, + "Go to Settings > Focus." : { + "comment" : "Focus modes step 1" + }, + "grams" : { + }, "HARDWARE SOUNDS" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -20714,6 +21238,12 @@ } } } + }, + "Her bolus recommendation is higher than usual because her overall insulin is set higher." : { + + }, + "Her preset is set to **110%%**, which is more than she usually needs. This means %@ will make her basal rates, carb ratio, and insulin sensitivity factor (ISF) stronger. " : { + }, "High Glucose" : { "localizations" : { @@ -20766,6 +21296,18 @@ } } } + }, + "High Intensity (Anaerobic)" : { + + }, + "High-intensity exercise means pushing yourself to your **maximum effort**. It is so hard that talking is nearly impossible, and you can’t keep it up for very long." : { + + }, + "Hiking" : { + + }, + "Historical Data" : { + }, "How can I silence non-Critical Alerts?" : { "localizations" : { @@ -20820,6 +21362,7 @@ } }, "How can I silence only Time Sensitive and Non-Critical alerts?" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -20923,6 +21466,12 @@ } } } + }, + "How does her preset impact her bolus recommendation?" : { + + }, + "How to configure each setting" : { + }, "How to update (LoopDocs)" : { "comment" : "The title text for how to update", @@ -21006,6 +21555,15 @@ } } } + }, + "How to use Presets for everyday activities" : { + + }, + "How to use Presets for exercise" : { + + }, + "How to use Presets when you are sick" : { + }, "https://mysite.herokuapp.com" : { "comment" : "The placeholder text for the nightscout site URL credential", @@ -21018,9 +21576,16 @@ } } } + }, + "if eating less than 2 hours before exercise" : { + + }, + "If glucose drops below 126 mg/dL" : { + }, "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)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -21071,6 +21636,33 @@ } } } + }, + "If you expect your glucose to rise during the activity, you may not need a preset." : { + + }, + "If you have active insulin in your body when you start exercising, you generally have an increased risk of low glucose." : { + + }, + "If you haven’t noticed a rise in glucose with high-intensity exercise, it may be due to:" : { + + }, + "If you often experience low glucose, you may need to reduce how much insulin you deliver for meals eaten 1-2 hours before exercising." : { + + }, + "If you usually experience lows while exercising, watch your glucose levels closely during exercise and consider eating around 3 to 20g of fast-acting carbs." : { + + }, + "If your activity has a higher risk of low glucose, start a physical activity preset at least **1 hour before you begin** and keep it on until you finish." : { + + }, + "If your glucose goes down, you may only need a small decrease in insulin — less than you would for low to moderate-intensity activity." : { + + }, + "If your glucose goes up, you may only need a small increase in insulin — less than you would for high-intensity activity." : { + + }, + "If your glucose isn't dropping, eating too many carbs can raise your blood sugar, trigger more insulin, and increase the risk of low blood sugar during or after the activity." : { + }, "Immediate" : { "comment" : "Immediate Delivery status text", @@ -21201,6 +21793,15 @@ } } } + }, + "Includes basal and automated boluses" : { + + }, + "increase" : { + + }, + "Increased Delivery" : { + }, "Indefinitely" : { "comment" : "The title of a target alert action specifying an indefinitely long workout targets duration", @@ -21671,6 +22272,9 @@ } } }, + "Insulin Automation" : { + "comment" : "Closed loop settings button descriptive text" + }, "Insulin Delivery" : { "comment" : "The title of the insulin delivery graph", "localizations" : { @@ -21795,6 +22399,9 @@ } } } + }, + "Insulin Delivery Log" : { + }, "Insulin effects" : { "comment" : "Details for missing data error when insulin effects are missing\nDetails for missing data error when insulin effects including pending insulin are missing", @@ -21914,6 +22521,9 @@ } } } + }, + "Insulin Event" : { + }, "Insulin Model" : { "comment" : "Details for configuration error when insulin model is missing\n The title text for the insulin model setting row", @@ -22141,6 +22751,9 @@ } } } + }, + "Insulin Resumed" : { + }, "Insulin Sensitivities" : { "comment" : "The title of the insulin sensitivities schedule screen\n The title text for the insulin sensitivity schedule", @@ -22267,6 +22880,9 @@ } } } + }, + "Insulin Sensitivity Factor (ISF)" : { + }, "Insulin Sensitivity Schedule" : { "comment" : "Details for configuration error when insulin sensitivity schedule is missing", @@ -22699,6 +23315,9 @@ } } } + }, + "Interval Training " : { + }, "Invalid absorption time: %1$@ hours" : { "comment" : "Carb error description: invalid absorption time. (1: Input duration in hours).", @@ -23180,8 +23799,12 @@ } } } + }, + "iOS" : { + }, "iOS Critical Alerts and Time Sensitive Alerts are types of Apple notifications. They are used for high-priority events. Some examples include:" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -23233,7 +23856,11 @@ } } }, + "iOS Focus Modes" : { + "comment" : "View title for iOS focus modes\niOS focus modes navigation link label" + }, "IOS FOCUS MODES" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -23284,6 +23911,21 @@ } } } + }, + "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)" + }, + "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)" + }, + "iOS Permissions" : { + "comment" : "Notification & Critical Alert Permissions screen title\niOS Permissions button text" + }, + "iOS Permissions and Mute All App Sounds" : { + "comment" : "Alert Permissions descriptive text" + }, + "ISF" : { + }, "Issue Report" : { "comment" : "The title text for the issue report menu item\nThe view controller title for the issue report screen", @@ -23475,6 +24117,12 @@ } } }, + "Jogging" : { + + }, + "Keep All Notifications ON for %1$@" : { + "comment" : "Time sensitive notifications callout title (1: app name)" + }, "Large Meal Entered" : { "comment" : "Title of the warning shown when a large meal was entered", "localizations" : { @@ -23557,6 +24205,24 @@ } } } + }, + "Larger glucose drop" : { + + }, + "Last Auto Bolus" : { + + }, + "Last Bolus: " : { + + }, + "Last loop completed" : { + + }, + "Last Used" : { + "comment" : "Preset sorting option description for sorting by last used" + }, + "Later that day, Paloma eats a meal with about 30g of carbs." : { + }, "Launches CGM app" : { "comment" : "Glucose HUD accessibility hint", @@ -23685,7 +24351,7 @@ } }, "Learn More" : { - "comment" : "OK button title for alert shown when delivery status is uncertain", + "comment" : "Learn more section header\nOK button title for alert shown when delivery status is uncertain", "localizations" : { "da" : { "stringUnit" : { @@ -23773,6 +24439,9 @@ } } }, + "Learn more about Alerts" : { + "comment" : "Link to learn more about alerts" + }, "Less than a minute remaining" : { "comment" : "Estimated remaining duration with less than a minute", "localizations" : { @@ -23861,6 +24530,18 @@ } } } + }, + "Let’s walk through a simple example to show how a preset might be used in this situation." : { + + }, + "Let’s walk through some examples to show how presets might be used in these situations." : { + + }, + "Light Intensity (Aerobic)" : { + + }, + "Light-to-moderate intensity exercise can cause a drop in glucose levels. This is because your body uses glucose (or sugar) for energy during physical activity." : { + }, "Live activity" : { "comment" : "Alert Permissions live activity\nLive activity screen title", @@ -24023,7 +24704,7 @@ } }, "Log Dose" : { - "comment" : "Button text to log a dose\nTitle for dose logging screen", + "comment" : "Accessibility label for the manual dose entry button on the insulin delivery screen\nButton text to log a dose\nTitle for dose logging screen", "localizations" : { "da" : { "stringUnit" : { @@ -24807,6 +25488,9 @@ } } }, + "Loop is already looping." : { + "comment" : "The error message displayed for LoopError.loopInProgress errors." + }, "Loop normally gives 40% of your predicted insulin needs each dosing cycle.\n\nWhen the Glucose Based Partial Application experiment is enabled, Loop will vary the percentage of recommended bolus delivered each cycle with glucose level.\n\nNear correction range, it will use 20% (similar to Temp Basal), and gradually increase to a maximum of 80% at high glucose (200 mg/dL, 11.1 mmol/L).\n\nPlease be aware that during fast rising glucose, such as after an unannounced meal, this feature, combined with velocity and retrospective correction effects, may result in a larger dose than your ISF would call for." : { "comment" : "Description of Glucose Based Partial Application toggle.", "localizations" : { @@ -25186,8 +25870,15 @@ } } }, + "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" + }, + "Manage iOS Permissions" : { + "comment" : "Manage Permissions in Settings button text" + }, "Manage Permissions in Settings" : { "comment" : "Manage Permissions in Settings button text", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -25268,9 +25959,13 @@ } } } + }, + "Managing Activities of Daily Living" : { + }, "Managing Alerts" : { "comment" : "View title for how mute alerts work", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -25321,6 +26016,9 @@ } } } + }, + "Manual" : { + }, "Manual Dose: %1$@ %2$@" : { "comment" : "Description of a bolus dose entry (1: value (? if no value) in bold, 2: unit)", @@ -25683,6 +26381,15 @@ } } } + }, + "Maximum Intensity (Anaerobic)" : { + + }, + "May experience a rise in glucose." : { + + }, + "May experience drops in glucose." : { + }, "Meal Bolus" : { "comment" : "Title for bolus entry screen when also entering carbs", @@ -25778,9 +26485,19 @@ } } } + }, + "Meal Summary" : { + + }, + "Meal Timing" : { + + }, + "Medium Intensity (Aerobic)" : { + }, "mg/dL" : { "comment" : "The short unit display string for milligrams of glucose per decilter", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -26176,9 +26893,13 @@ } } } + }, + "Mixed-intensity exercise may cause only small changes in glucose levels. Your glucose may go up or down." : { + }, "mmol/L" : { "comment" : "The short unit display string for millimoles of glucose per liter", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -26449,6 +27170,9 @@ } } } + }, + "Monitor your glucose and active insulin at the start of a competition day" : { + }, "More Info" : { "comment" : "Text for more info action on notification of upcoming TestFlight expiration\nText for more info action on notification of upcoming profile expiration", @@ -26562,9 +27286,19 @@ } } } + }, + "Morning Exercise" : { + + }, + "Morning exercise before eating (like a fasted jog) usually causes a smaller drop in glucose levels and may even promote a rise, compared to afternoon exercise." : { + + }, + "Mutable" : { + }, "Mute All Alerts" : { "comment" : "Label for button to mute all alerts", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -26648,6 +27382,7 @@ }, "Mute All Alerts Temporarily" : { "comment" : "Title for mute alert duration selection action sheet", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -26699,8 +27434,14 @@ } } }, + "Mute All App Sounds" : { + "comment" : "Label for button to mute all app sounds" + }, + "Muted until" : { + "comment" : "Label for when mute alert will end" + }, "Name" : { - "comment" : "Label for name row on add favorite food screen", + "comment" : "Label for name row on add favorite food screen\nPreset sorting option description for sorting by name", "localizations" : { "ar" : { "stringUnit" : { @@ -26829,6 +27570,9 @@ } } } + }, + "Navigating the Challenges of Mixed Exercise" : { + }, "Needs Attention" : { "comment" : "Sensor state description for the non-valid state", @@ -27049,6 +27793,12 @@ } } } + }, + "Next, we’ll look at settings you can change and how they affect Omar’s insulin." : { + + }, + "Next, we’ll look at settings you can change and how they affect Paloma’s insulin." : { + }, "Nightscout" : { "comment" : "The title of the Nightscout service", @@ -27181,9 +27931,16 @@ } } } + }, + "No" : { + + }, + "No Activity" : { + }, "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", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -27323,6 +28080,12 @@ } } } + }, + "No change in glucose." : { + + }, + "no change needed" : { + }, "No connected devices, or failure during device connection" : { "comment" : "The error message displayed for device connection errors.", @@ -27442,6 +28205,9 @@ } } } + }, + "No Delivery" : { + }, "No Maximum Bolus Configured" : { "comment" : "Alert title for a missing maximum bolus setting error", @@ -27635,6 +28401,7 @@ }, "No Recent Glucose" : { "comment" : "The title of the cell indicating that there is no recent glucose", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -28131,6 +28898,9 @@ } } } + }, + "None in last 24 hours" : { + }, "Notification Delivery" : { "comment" : "Notification Delivery Status text", @@ -28454,6 +29224,7 @@ }, "Notifications give you important %1$@ app information without requiring you to open the app." : { "comment" : "Alert Permissions descriptive text (1: app name)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -28618,6 +29389,9 @@ } } }, + "Observed changes in glucose, subtracting changes modeled from insulin delivery, can be used to estimate carbohydrate absorption." : { + "comment" : "Section explaining carb effects chart" + }, "Off" : { "comment" : "Notification Setting Status is Off", "localizations" : { @@ -28797,7 +29571,7 @@ } }, "OK" : { - "comment" : "Alert acknowledgment OK button\nCritical Alert permissions disabled alert button\nDefault action for alert when alert acknowledgment fails\nNotifications permissions disabled alert button\nText for ok action on notification of upcoming TestFlight expiration\nText for ok action on notification of upcoming profile expiration\nThe title of the notification action to acknowledge a device alert", + "comment" : "Alert acknowledgment OK button\nCritical Alert permissions disabled alert button\nDefault action for alert when alert acknowledgment fails\nLabel for acknowledging the preset has been active for 24 hours\nNotifications permissions disabled alert button\nText for ok action on notification of upcoming TestFlight expiration\nText for ok action on notification of upcoming profile expiration\nThe title of the notification action to acknowledge a device alert", "localizations" : { "ar" : { "stringUnit" : { @@ -28926,6 +29700,25 @@ } } } + }, + "Omar asks himself, **do I expect I will need more or less insulin than usual?**" : { + + }, + "Omar Octopus wants to create a preset for some yard work he’ll be doing around the house." : { + + }, + "Omar sets his correction range a little higher, to %@-%@ %@. This tells %@ to step in sooner." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Omar sets his correction range a little higher, to %1$@-%2$@ %3$@. This tells %4$@ to step in sooner." + } + } + } + }, + "Omar’s Current Therapy Settings" : { + }, "On" : { "comment" : "Notification Setting Status is On", @@ -29057,6 +29850,21 @@ } } } + }, + "On until" : { + + }, + "on until %@" : { + "comment" : "The format for the description of a custom preset end date\nThe format for the description of a finite custom preset end date" + }, + "on until carbs added" : { + "comment" : "The format for the description of a premeal preset end date" + }, + "on until turned off" : { + "comment" : "The format for the description of an indefinite custom preset end date" + }, + "Once saved, Omar’s new preset will display in his Presets lists." : { + }, "Override Presets" : { "comment" : "The title text for the override presets", @@ -29183,6 +29991,27 @@ } } } + }, + "Paloma Porpoise sees her glucose is running higher than usual. She creates a preset to help manage her glucose while she is sick." : { + + }, + "Paloma wants %@ to know she needs more insulin than usual." : { + + }, + "Paloma’s Adjusted Therapy Settings" : { + + }, + "Paloma’s Current Therapy Settings" : { + + }, + "Pay attention to your insulin needs before and after exercising, playing sports, or doing unusually hard physical labor." : { + + }, + "Physical stress, like illness, can cause glucose to rise." : { + + }, + "Planning for physical activity can be tough. If you forget to set a preset ahead of time, consider these strategies:" : { + }, "Possible Missed Meal" : { "comment" : "The notification title for a meal that was possibly not logged in Loop.", @@ -29260,9 +30089,13 @@ } } } + }, + "Power lifting" : { + }, "Pre-Meal Targets" : { "comment" : "The label of the pre-meal mode toggle button", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -30207,6 +31040,36 @@ } } } + }, + "Preset Delivery" : { + + }, + "Preset Disabled" : { + + }, + "Preset Enabled" : { + + }, + "Preset Summary" : { + + }, + "Presets" : { + "comment" : "Presets screen title\nTitle text for button to Preset Settings" + }, + "Presets can be used for a variety of situations. Explore the uses below to learn tips for these common scenarios." : { + + }, + "Presets for Daily Activity" : { + + }, + "Presets for Exercise" : { + + }, + "Presets for Illness" : { + + }, + "Presets Performance History" : { + }, "Profile Expiration" : { "comment" : "Settings App Profile expiration view", @@ -30987,6 +31850,9 @@ } } }, + "Pump Inoperable. Automatic dosing is disabled." : { + "comment" : "The error message displayed for LoopError.pumpInoperable errors." + }, "Pump Manager" : { "comment" : "Details for configuration error when pump manager is missing", "localizations" : { @@ -31562,7 +32428,7 @@ } }, "Pump Suspended. Automatic dosing is disabled." : { - "comment" : "The error message displayed for pumpSuspended errors.", + "comment" : "The error message displayed for LoopError.pumpSuspended errors.", "localizations" : { "da" : { "stringUnit" : { @@ -32027,6 +32893,12 @@ } } } + }, + "Recent Events" : { + + }, + "Recognizing how hard you feel you're working during exercise can help you understand its impact on your glucose levels." : { + }, "Recommendation expired: %1$@ old" : { "comment" : "The error message when a recommendation has expired. (1: age of recommendation in minutes)", @@ -32373,6 +33245,9 @@ } } } + }, + "Recommended bolus adjusted due to preset" : { + }, "Recommended Bolus Exceeds Maximum Bolus" : { "comment" : "Title for bolus screen warning when recommended bolus exceeds max bolus", @@ -32582,6 +33457,12 @@ } } } + }, + "Recommended Insulin Reduction" : { + + }, + "Recommended: " : { + }, "Remote Bolus Entry: %@ U" : { "comment" : "The notification title for a remote bolus. (1: Bolus amount)\nThe notification title for a remote failure. (1: Bolus amount)", @@ -32920,6 +33801,9 @@ } } }, + "Resume insulin if you wish for the app to restart insulin delivery.\n\nIf you wish for the app to automate your insulin, go to Settings and toggle Closed Loop to on." : { + "comment" : "message when automation is off and insulin delivery is suspended" + }, "Retrospective Correction" : { "comment" : "Title of the prediction input effect for retrospective correction", "localizations" : { @@ -33169,6 +34053,15 @@ } } } + }, + "Review Presets Training" : { + + }, + "safety" : { + + }, + "Same Activity, Different Intensity" : { + }, "Save" : { "localizations" : { @@ -33487,6 +34380,15 @@ } } } + }, + "Scheduled basal" : { + + }, + "Scheduled Basal" : { + + }, + "Scheduled reminder" : { + }, "Select Lock Screen Display Options" : { "comment" : "A section header for the lock screen display options.", @@ -33557,6 +34459,9 @@ } } } + }, + "Self-Initiated Events" : { + }, "Sensor Failed" : { "localizations" : { @@ -33728,9 +34633,12 @@ } } } + }, + "Set the glucose value (or values) you want Tidepool Loop to aim for in adjusting your basal insulin." : { + }, "Settings" : { - "comment" : "Label of button that navigation user to iOS Settings\nSettings screen title\nThe label of the settings button", + "comment" : "Label of button that navigation user to iOS Settings\nSettings screen title\nWord referring to the app's settings screen", "localizations" : { "ar" : { "stringUnit" : { @@ -33930,6 +34838,9 @@ } } } + }, + "She can do this by raising her **Overall Insulin** setting. This tells %@ to deliver more than her usual amount, making her insulin settings stronger." : { + }, "Shows last loop error" : { "comment" : "Loop Completion HUD accessibility hint", @@ -34346,6 +35257,12 @@ } } } + }, + "Since he doesn’t plan to push himself too hard, he expects his insulin needs to stay the same, so he leaves the setting at 100%." : { + + }, + "Since Paloma doesn't know when she'll feel better, she sets hers to “Until I Turn Off”." : { + }, "Site URL" : { "comment" : "The title of the nightscout site URL credential", @@ -34478,6 +35395,21 @@ } } } + }, + "Sitting or laying down, no change in breathing." : { + + }, + "Skip and complete training up to" : { + + }, + "Skip to Chapter" : { + + }, + "Smaller glucose drop" : { + + }, + "Soccer" : { + }, "Software Update" : { "comment" : "Software update button link text", @@ -34555,6 +35487,27 @@ } } } + }, + "Sort" : { + + }, + "Sort By" : { + + }, + "Stable Glucose" : { + + }, + "Start Preset" : { + "comment" : "The title of the notification action to start a preset" + }, + "Start Required Training" : { + "comment" : "CPreset training needed alert start training button" + }, + "Start Scheduled Preset?" : { + "comment" : "Scheduled preset reminder title" + }, + "Start Time" : { + }, "Start time is out of range: %@" : { "comment" : "Carb error description: invalid start time is out of range.", @@ -34870,6 +35823,15 @@ } } } + }, + "Starting your exercise with high active insulin" : { + + }, + "Stay hydrated" : { + + }, + "Stress during a game, match or tournament causes your body to release hormones like adrenaline and cortisol, which may raise your glucose and cause %@ to increase insulin delivery." : { + }, "Support" : { "comment" : "Section title for Support\nThe title of the support section in settings", @@ -35139,6 +36101,15 @@ } } }, + "Swimming" : { + + }, + "Tap “Apps”." : { + "comment" : "Focus modes step 3" + }, + "Tap a provided Focus option — like Do Not Disturb, Personal, or Sleep." : { + "comment" : "Focus modes step 2" + }, "Tap here to set up a CGM" : { "comment" : "Descriptive text for button to add CGM device", "localizations" : { @@ -35426,6 +36397,7 @@ }, "Tap to Add" : { "comment" : "The subtitle of the cell displaying an action to add a manually measurement glucose value", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -35518,6 +36490,9 @@ } } } + }, + "Tap to listen" : { + }, "Tap to Resume" : { "comment" : "The subtitle of the cell displaying an action to resume insulin delivery\nThe subtitle of the cell displaying an action to resume onboarding", @@ -35735,6 +36710,7 @@ }, "Tap to Unmute Alerts" : { "comment" : "Label for button to unmute all alerts", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -35786,8 +36762,12 @@ } } }, + "Tap to Unmute All App Sounds" : { + "comment" : "Label for button to unmute all app sounds" + }, "Tap Unmute to resume sound for your alerts and alarms." : { "comment" : "The alert body for unmute alert confirmation", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -35833,6 +36813,24 @@ } } }, + "Tap your CGM or insulin pump status icons right away for more information and steps to resolve the issue." : { + "comment" : "message when automation is off and there is a bluetooth or pump issue\nmessage when automation is on and there is a bluetooth or pump issue" + }, + "Tap your CGM status icon right away for more information and steps to resolve the issue.\n\nIn the meantime, your pump is still able to deliver insulin." : { + "comment" : "message when automation is off and CGM is inoperable\nmessage when automation is on and CGM is inoperable" + }, + "Temp Basal" : { + + }, + "Temp Basal: " : { + + }, + "Temporarily silence all sounds from %1$@, including sounds for all critical alerts such as Urgent Low, Sensor Fail, Pump Expiration and others." : { + + }, + "Temporary Settings Adjustments" : { + "comment" : "Descriptive text for Preset Settings" + }, "TestFlight" : { "comment" : "Settings app TestFlight section", "localizations" : { @@ -36056,6 +37054,12 @@ } } } + }, + "That said, insulin needs vary from person to person. Some people find they don’t need to adjust their insulin at all for high-intensity exercise." : { + + }, + "The \"Overall Insulin\" percentage controls total insulin delivery by adjusting your:" : { + }, "The bolus amount entered is smaller than the minimum deliverable." : { "comment" : "Alert message for a bolus too small validation error", @@ -36311,6 +37315,9 @@ } } } + }, + "The exercise may not be vigorous enough to produce these results" : { + }, "The legacy model used by Loop, allowing customization of action duration." : { "comment" : "Subtitle description of Walsh insulin model setting", @@ -37014,6 +38021,12 @@ } } } + }, + "These patterns are based on published exercise consensus guidelines and are meant to be used as a starting point. What works for one person may not work for you." : { + + }, + "These recommendations should be used as a starting point. Checking your glucose during exercise will help you find the settings that work best for you." : { + }, "This option only applies when Loop's Dosing Strategy is set to Automatic Bolus." : { "comment" : "String shown when glucose based partial application cannot be enabled because dosing strategy is not set to Automatic Bolus", @@ -37073,8 +38086,37 @@ } } } + }, + "This range is usually higher than your correction range when you are not exercising." : { + + }, + "This reflects a %@ %@ from the original %@ due to preset adjustments." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This reflects a %1$@ %2$@ from the original %3$@ due to preset adjustments." + } + } + } + }, + "This required training will show you how to change your settings with confidence and create custom presets that fit your needs.\n\nThis training covers:" : { + + }, + "Tidepool Loop will actively adjust your insulin dosing in response to your glucose as often as every 5 minutes." : { + "comment" : "message when automation is on and the glucose value is fresh" + }, + "Tidepool Loop will continue trying to restore automation, but check for potential communication issues with your CGM or insulin pump." : { + "comment" : "message when automation is on and pump is in signal loss\nmessage when automation is on and the glucose value is not fresh" + }, + "Tidepool Loop will continue trying to restore automation, but check for potential communication issues with your CGM.\n\nIn the meantime, your pump is still able to deliver insulin." : { + "comment" : "message when automation is on and CGM is in signal loss" + }, + "Time of Day" : { + }, "Time Sensitive Alerts" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -37209,6 +38251,45 @@ } } }, + "Time Sensitive Notifications are turned OFF" : { + "comment" : "Time sensitive notifications disabled banner title" + }, + "Time Sensitive notifications are turned OFF. Go to the App to fix the issue now." : { + "comment" : "Time sensitive notifications disabled notification body" + }, + "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" + }, + "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" + }, + "Tip" : { + + }, + "To be safe, %@ will remind her after 24 hours that the preset is still running." : { + + }, + "To create a new preset, you must complete the required training." : { + "comment" : "Preset training needed alert message" + }, + "To help avoid lows, set a range **higher** than your typical correction range." : { + + }, + "To help avoid lows, set a range higher than your typical correction range." : { + + }, + "To help prevent lows, she will increase her correction range." : { + + }, + "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" + }, + "Total Insulin Delivery" : { + + }, + "Training Required for New Presets" : { + "comment" : "Preset training needed alert title" + }, "Transmitter Low Battery" : { "localizations" : { "da" : { @@ -37358,6 +38439,7 @@ }, "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)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -37498,8 +38580,24 @@ } } }, + "Turn On Critical Alerts" : { + "comment" : "Critical alerts disabled alert title\nCritical alerts disabled notification title" + }, + "Turn On Critical Alerts and Time Sensitive Notifications" : { + "comment" : "Both Critical Alerts and Time Sensitive Notifications disabled alert title\nBoth Critical Alerts and Time Sensitive Notifications disabled notification title" + }, + "Turn on the preset as soon as you remember and keep it on until the activity ends" : { + + }, + "Turn On Time Sensitive Notifications" : { + "comment" : "Time sensitive notifications disabled alert title" + }, + "Turn On Time Sensitive Notifications " : { + "comment" : "Time sensitive notifications disabled alert title" + }, "U" : { "comment" : "The short unit display string for international units of insulin", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -38063,7 +39161,7 @@ } }, "Unknown" : { - "comment" : "Event title displayed when StoredPumpEvent.title is not set\nThe default description to use when an entry has no dose description\nlabel for when the alert mute end time is unknown\nresult when time cannot be formatted", + "comment" : "Event title displayed when StoredPumpEvent.title is not set\nThe default description to use when an entry has no dose description\nresult when time cannot be formatted", "localizations" : { "ar" : { "stringUnit" : { @@ -38188,7 +39286,7 @@ } }, "Unknown Error: %1$@" : { - "comment" : "The error message displayed for unknown errors. (1: unknown error)", + "comment" : "The error message displayed for unknown LoopError errors. (1: unknown error)", "localizations" : { "ar" : { "stringUnit" : { @@ -38443,7 +39541,7 @@ } }, "Unmute" : { - "comment" : "The title of the action used to unmute alerts", + "comment" : "The title of the action used to unmute app sounds", "localizations" : { "da" : { "stringUnit" : { @@ -38503,6 +39601,7 @@ }, "Unmute Alerts?" : { "comment" : "The alert title for unmute alert confirmation", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -38559,6 +39658,12 @@ } } } + }, + "Unmute All App Sounds?" : { + "comment" : "The alert title for unmute all app sounds confirmation" + }, + "Unsupported" : { + }, "Unsupported Notification Service: %1$@" : { "comment" : "Error message when a service can't be found to handle a push notification. (1: Service Identifier)", @@ -38621,6 +39726,7 @@ }, "until %@" : { "comment" : "The format for the description of a custom preset end date", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -38894,6 +40000,7 @@ }, "Until I turn off" : { "comment" : "The title of a target alert action specifying workout targets duration until it is turned off by the user", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -39166,6 +40273,7 @@ }, "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)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -39216,6 +40324,12 @@ } } } + }, + "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)" + }, + "Use the slider to rate the effort on a scale of 0–10, with 10 being the hardest you’ve ever worked." : { + }, "Use Workout Glucose Targets" : { "comment" : "The title of the alert controller used to select a duration for workout targets", @@ -39345,6 +40459,7 @@ }, "Use Workout Preset" : { "comment" : "The title of the alert controller used to select a duration for workout targets", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -39431,6 +40546,22 @@ } } } + }, + "Very Easy" : { + + }, + "Viewing entry %lld of %lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Viewing entry %1$lld of %2$lld" + } + } + } + }, + "Walking" : { + }, "Walsh" : { "comment" : "Title of insulin model setting", @@ -39560,6 +40691,7 @@ }, "Warning! Safety notifications are turned OFF" : { "comment" : "Alert Permissions Need Attention alert title", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -39634,8 +40766,12 @@ } } } + }, + "What are examples of Critical Alerts and Time Sensitive Notifications?" : { + }, "What are examples of Critical and Time Sensitive alerts?" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -39907,6 +41043,9 @@ } } } + }, + "When deciding to adjust your overall insulin, **ask yourself, does my body need more or less than usual?**" : { + }, "When enabled, Loop can notify you when it detects a meal that wasn't logged." : { "comment" : "Description of missed meal notifications.", @@ -40072,9 +41211,16 @@ } } } + }, + "When using a preset for activity, keep in mind four key factors that may impact your glucose." : { + + }, + "When using high-insulin presets, **you may not need to start your preset 1 hour before**." : { + }, "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)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -40127,6 +41273,7 @@ } }, "While mute alerts is on, your insulin pump and CGM hardware may still sound." : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -40183,6 +41330,9 @@ } } } + }, + "While sick, Paloma expects to eat less or not absorb everything she eats." : { + }, "While trying to restart %1$@ an error occured.\n\n%2$@" : { "comment" : "Format string for message of reset loop alert. (1: App name) (2: error description)", @@ -40236,9 +41386,16 @@ } } } + }, + "While turned on, Paloma’s preset will display on the home screen and in her Presets list." : { + + }, + "With Preset On" : { + }, "Workout Targets" : { "comment" : "The label of the workout mode toggle button", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -40358,6 +41515,7 @@ }, "Workout Temp Adjust has been turned on for more than 24 hours. Make sure you still want it enabled, or turn it off in the app." : { "comment" : "Workout override still on reminder alert body.", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -40447,6 +41605,7 @@ }, "Workout Temp Adjust Still On" : { "comment" : "Workout override still on reminder alert title", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -40534,6 +41693,9 @@ } } }, + "Would you like to start your %1$@ preset?\n\nThis will end any active preset." : { + "comment" : "Scheduled preset reminder alert body. (1: preset name)" + }, "Yes" : { "comment" : "The title of the action used when confirming entered amount of carbohydrates.", "localizations" : { @@ -40622,9 +41784,31 @@ } } } + }, + "Yes, Start Now" : { + "comment" : "Label for do yes, start preset now action on scheduled preset reminder alert" + }, + "Yes, turn OFF" : { + + }, + "You can choose how long your preset lasts." : { + + }, + "You can now:" : { + + }, + "You can use presets for a variety of situations. Explore the uses below to learn tips for these common scenarios." : { + + }, + "You do not have to set a new correction range for each preset, but before deciding to adjust your correction range, " : { + + }, + "You don’t need to change the correction range for every preset. But before you decide to change it, ask yourself: *Am I more likely to go high or low during this time?*" : { + }, "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)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -40706,6 +41890,21 @@ } } }, + "You may review the training materials again at any time via the Learning Hub, located at the bottom of the Preset screen." : { + + }, + "you need **less** insulin than usual" : { + + }, + "you need **more** insulin than usual" : { + + }, + "You’ll have to restart this section and some features will be disabled until you complete the training." : { + + }, + "You’ll need to ensure these settings for each Focus Mode you have enabled or plan to enable." : { + "comment" : "iOS focus modes callout title" + }, "Your %1$@’s time has been changed. %2$@ needs accurate time records to make predictions about your glucose and adjust your insulin accordingly.\n\nCheck in your %1$@ Settings (General / Date & Time) and verify that 'Set Automatically' is turned ON. Failure to resolve could lead to serious under-delivery or over-delivery of insulin." : { "comment" : "Time change alert body. (1: app name)", "localizations" : { @@ -40783,6 +41982,9 @@ } } }, + "Your CGM sensor is warming up.\n\nIn the meantime, your pump is still able to deliver insulin.\n\nIf you wish for the app to automate your insulin, go to Settings and toggle Closed Loop to on." : { + "comment" : "message when automation is off and CGM is in warmup" + }, "Your glucose is below %1$@. Are you sure you want to bolus?" : { "comment" : "Format string for simple bolus screen warning when glucose is below glucose warning limit.", "localizations" : { @@ -41222,6 +42424,9 @@ } } }, + "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" + }, "Your maximum bolus amount is %1$@." : { "comment" : "Warning for simple bolus when max bolus is exceeded. (1: maximum bolus)", "localizations" : { @@ -41305,6 +42510,12 @@ } } }, + "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" + }, + "Your pump and CGM will continue to operate, but the app will not adjust insulin dosing automatically.\n\nIf you wish for the app to automate your insulin, go to Settings and toggle Closed Loop to on." : { + "comment" : "message when automation is off, glucose value is fresh and devices are good" + }, "Your pump data is stale. %1$@ cannot recommend a bolus amount." : { "comment" : "Caption for bolus screen notice when pump data is missing or stale", "localizations" : { diff --git a/Loop/Managers/AlertPermissionsChecker.swift b/Loop/Managers/AlertPermissionsChecker.swift index bae4512e6a..fe9409b8d9 100644 --- a/Loop/Managers/AlertPermissionsChecker.swift +++ b/Loop/Managers/AlertPermissionsChecker.swift @@ -11,8 +11,9 @@ import Combine import LoopKit import SwiftUI +@MainActor protocol AlertPermissionsCheckerDelegate: AnyObject { - func notificationsPermissions(requiresRiskMitigation: Bool, scheduledDeliveryEnabled: Bool) + func notificationsPermissions(requiresRiskMitigation: Bool, scheduledDeliveryEnabled: Bool, permissions: NotificationCenterSettingsFlags) } public class AlertPermissionsChecker: ObservableObject { @@ -34,7 +35,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 } @@ -73,10 +74,8 @@ public class AlertPermissionsChecker: ObservableObject { if FeatureFlags.criticalAlertsEnabled { newSettings.criticalAlertsDisabled = settings.criticalAlertSetting == .disabled } - if #available(iOS 15.0, *) { - newSettings.scheduledDeliveryEnabled = settings.scheduledDeliverySetting == .enabled - newSettings.timeSensitiveNotificationsDisabled = settings.alertSetting != .disabled && settings.timeSensitiveSetting == .disabled - } + newSettings.scheduledDeliveryEnabled = settings.scheduledDeliverySetting == .enabled + newSettings.timeSensitiveDisabled = settings.alertSetting != .disabled && settings.timeSensitiveSetting == .disabled self.notificationCenterSettings = newSettings completion?() } @@ -84,12 +83,7 @@ public class AlertPermissionsChecker: ObservableObject { } static func gotoSettings() { - // TODO with iOS 16 this API changes to UIApplication.openNotificationSettingsURLString - if #available(iOS 15.4, *) { - UIApplication.shared.open(URL(string: UIApplicationOpenNotificationSettingsURLString)!) - } else { - UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) - } + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) } } @@ -106,54 +100,183 @@ 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 + ) + } + + + /* + All Permutations of NotificationCenterSettingsFlags + + none = 0 + notificationsDisabled = 1 + criticalAlertsDisabled = 2 + notificationsDisabled & criticalAlertsDisabled = 3 + timeSensitiveDisabled = 4 + notificationsDisabled & timeSensitiveDisabled = 5 (Not Possible) + criticalAlertsDisabled & timeSensitiveDisabled == 6 + notificationsDisabled & criticalAlertsDisabled & timeSensitiveDisabled = 7 (Not Possible) + scheduledDeliveryEnabled = 8 + notificationsDisabled & scheduledDeliveryEnabled = 9 (Not Possible) + criticalAlertsDisabled & scheduledDeliveryEnabled = 10 + notificationsDisabled & criticalAlertsDisabled & scheduledDeliveryEnabled = 11 (Not Possible) + timeSensitiveDisabled & scheduledDeliveryEnabled = 12 + notificationsDisabled & timeSensitiveDisabled & scheduledDeliveryEnabled = 13 (Not Possible) + criticalAlertsDisabled & timeSensitiveDisabled & scheduledDeliveryEnabled = 14 + notificationsDisabled & criticalAlertsDisabled & timeSensitiveDisabled & scheduledDeliveryEnabled = 15 (Not Possible) + */ + init?(permissions: NotificationCenterSettingsFlags) { + switch permissions { + case .notificationsDisabled, NotificationCenterSettingsFlags(rawValue: 9): + self = .notificationsDisabled + case .criticalAlertsDisabled, NotificationCenterSettingsFlags(rawValue: 10): + self = .criticalAlertsDisabled + case .timeSensitiveDisabled, NotificationCenterSettingsFlags(rawValue: 5), NotificationCenterSettingsFlags(rawValue: 12), NotificationCenterSettingsFlags(rawValue: 13): + self = .timeSensitiveDisabled + case NotificationCenterSettingsFlags(rawValue: 3), NotificationCenterSettingsFlags(rawValue: 11): + self = .criticalAlertsAndNotificationDisabled + case NotificationCenterSettingsFlags(rawValue: 6), NotificationCenterSettingsFlags(rawValue: 7), NotificationCenterSettingsFlags(rawValue: 14), NotificationCenterSettingsFlags(rawValue: 15): + self = .criticalAlertsAndTimeSensitiveDisabled + default: + return nil + } + } + } - static func constructUnsafeNotificationPermissionsInAppAlert(acknowledgementCompletion: @escaping () -> Void ) -> UIAlertController { - dispatchPrecondition(condition: .onQueue(.main)) - let alertController = UIAlertController(title: Self.unsafeNotificationPermissionsAlertContent.title, - message: Self.unsafeNotificationPermissionsAlertContent.body, + @MainActor + static func constructUnsafeNotificationPermissionsInAppAlert(alert: UnsafeNotificationPermissionAlert, acknowledgementCompletion: @escaping (UnsafeNotificationPermissionAlert) -> Void) -> UIAlertController { + 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 AlertPermissionsChecker.gotoSettings() - acknowledgementCompletion() + acknowledgementCompletion(alert) })) alertController.addAction(UIAlertAction(title: NSLocalizedString("Close", comment: "The button label of the action used to dismiss the unsafe notification permission alert"), style: .cancel, - handler: { _ in acknowledgementCompletion() - })) + handler: { _ in acknowledgementCompletion(alert) }) + ) + return alertController } @@ -178,7 +301,13 @@ extension AlertPermissionsChecker { trigger: .immediate) private func notificationCenterSettingsChanged(_ newValue: NotificationCenterSettingsFlags) { - delegate?.notificationsPermissions(requiresRiskMitigation: newValue.requiresRiskMitigation, scheduledDeliveryEnabled: newValue.scheduledDeliveryEnabled) + Task { + await delegate?.notificationsPermissions( + requiresRiskMitigation: newValue.requiresRiskMitigation, + scheduledDeliveryEnabled: newValue.scheduledDeliveryEnabled, + permissions: newValue + ) + } } } @@ -188,10 +317,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 +340,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..b683e43ddc 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -12,7 +12,9 @@ import Combine protocol AlertManagerResponder: AnyObject { /// Method for our Handlers to call to kick off alert response. Differs from AlertResponder because here we need the whole `Identifier`. - func acknowledgeAlert(identifier: Alert.Identifier) + @MainActor + func acknowledgeAlert(identifier: Alert.Identifier) async throws + func userDidSelectAction(alertIdentifier: Alert.Identifier, actionIdentifier: String) async throws } public enum AlertUserNotificationUserInfoKey: String { @@ -24,7 +26,9 @@ public enum AlertUserNotificationUserInfoKey: String { /// - managing the different responders that might acknowledge the alert /// - serializing alerts to storage /// - etc. +@MainActor public final class AlertManager { + nonisolated private static let soundsDirectoryURL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).last!.appendingPathComponent("Sounds") private let log = DiagnosticLog(category: "AlertManager") @@ -36,6 +40,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 +63,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 +93,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 + await self?.loopDidComplete(loopDataManager.lastLoopCompleted) + } } } .store(in: &cancellables) @@ -130,12 +137,16 @@ public final class AlertManager { let content = Alert.Content(title: title, body: body, acknowledgeActionButtonLabel: NSLocalizedString("Dismiss", comment: "Default alert dismissal")) - issueAlert(Alert(identifier: bluetoothPoweredOffIdentifier, foregroundContent: content, backgroundContent: content, trigger: .immediate)) + Task { + await issueAlert(Alert(identifier: bluetoothPoweredOffIdentifier, foregroundContent: content, backgroundContent: content, trigger: .immediate)) + } } private func onBluetoothPoweredOn() { log.default("Bluetooth powered on") - retractAlert(identifier: bluetoothPoweredOffIdentifier) + Task { + await retractAlert(identifier: bluetoothPoweredOffIdentifier) + } } private func onBluetoothPoweredOff() { @@ -149,31 +160,35 @@ public final class AlertManager { let fgcontent = Alert.Content(title: title, body: fgBody, acknowledgeActionButtonLabel: NSLocalizedString("Dismiss", comment: "Default alert dismissal")) - issueAlert(Alert(identifier: bluetoothPoweredOffIdentifier, - foregroundContent: fgcontent, - backgroundContent: bgcontent, - trigger: .immediate, - interruptionLevel: .critical)) + Task { + await issueAlert(Alert(identifier: bluetoothPoweredOffIdentifier, + foregroundContent: fgcontent, + backgroundContent: bgcontent, + trigger: .immediate, + interruptionLevel: .critical)) + } } // MARK: - Loop Not Running alerts - func loopDidComplete(_ lastLoopDate: Date? = nil) { + func loopDidComplete(_ lastLoopDate: Date? = nil) async { // use now if there is no lastLoopDate - rescheduleLoopNotRunningNotifications(lastLoopDate ?? Date()) + await rescheduleLoopNotRunningNotifications(lastLoopDate ?? Date()) } private func rescheduleLoopNotRunningNotifications() { - guard let lastLoopDate = getLastLoopDate() else { return } - rescheduleLoopNotRunningNotifications(lastLoopDate) + Task { + guard let lastLoopDate = getLastLoopDate() else { return } + await rescheduleLoopNotRunningNotifications(lastLoopDate) + } } - func rescheduleLoopNotRunningNotifications(_ lastLoopDate: Date) { - clearLoopNotRunningNotifications() - scheduleLoopNotRunningNotifications(lastLoopDate) + func rescheduleLoopNotRunningNotifications(_ lastLoopDate: Date) async { + await clearLoopNotRunningNotifications() + await scheduleLoopNotRunningNotifications(lastLoopDate) } - func scheduleLoopNotRunningNotifications(_ lastLoopDate: Date) { + func scheduleLoopNotRunningNotifications(_ lastLoopDate: Date) async { // Give a little extra time for a loop-in-progress to complete let gracePeriod = TimeInterval(minutes: 0.5) @@ -197,14 +212,10 @@ public final class AlertManager { notificationContent.title = NSLocalizedString("Loop Failure", comment: "The notification title for a loop failure") let shouldMuteAlert = alertMuter.shouldMuteAlert(scheduledAt: timeUntilNotification) if isCritical, FeatureFlags.criticalAlertsEnabled { - if #available(iOS 15.0, *) { - notificationContent.interruptionLevel = .critical - } + notificationContent.interruptionLevel = .critical notificationContent.sound = shouldMuteAlert ? .defaultCriticalSound(withAudioVolume: 0.0) : .defaultCritical } else { - if #available(iOS 15.0, *) { - notificationContent.interruptionLevel = .timeSensitive - } + notificationContent.interruptionLevel = .timeSensitive notificationContent.sound = shouldMuteAlert ? nil : .default } notificationContent.categoryIdentifier = LoopNotificationCategory.loopNotRunning.rawValue @@ -230,12 +241,16 @@ public final class AlertManager { isCritical: isCritical) scheduledNotifications.append(scheduledNotification) } - UNUserNotificationCenter.current().add(request) + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + self.log.error("Error scheduling loop not running notification %{public}@", String(describing: error)) + } } UserDefaults.appGroup?.loopNotRunningNotifications = scheduledNotifications } - func inferDeliveredLoopNotRunningNotifications() { + func inferDeliveredLoopNotRunningNotifications() async { // Infer that any past alerts have been delivered at this point let now = getCurrentDate() var stillPendingNotifications = [StoredLoopNotRunningNotification]() @@ -245,7 +260,7 @@ public final class AlertManager { let content = Alert.Content(title: notification.title, body: notification.body, acknowledgeActionButtonLabel: "ios-notification-default") let interruptionLevel: Alert.InterruptionLevel = notification.isCritical ? .critical : .timeSensitive let alert = Alert(identifier: alertIdentifier, foregroundContent: nil, backgroundContent: content, trigger: .immediate, interruptionLevel: interruptionLevel) - recordIssued(alert: alert, at: notification.alertAt) + await recordIssued(alert: alert, at: notification.alertAt) } else { stillPendingNotifications.append(notification) } @@ -253,102 +268,98 @@ public final class AlertManager { UserDefaults.appGroup?.loopNotRunningNotifications = stillPendingNotifications } - func clearLoopNotRunningNotifications() { - inferDeliveredLoopNotRunningNotifications() + func clearLoopNotRunningNotifications() async { + await inferDeliveredLoopNotRunningNotifications() // Clear out any existing not-running notifications - UNUserNotificationCenter.current().getDeliveredNotifications { (notifications) in - let loopNotRunningIdentifiers = notifications.filter({ - $0.request.content.categoryIdentifier == LoopNotificationCategory.loopNotRunning.rawValue - }).map({ - $0.request.identifier - }) + let notifications = await UNUserNotificationCenter.current().deliveredNotifications() + let loopNotRunningIdentifiers = notifications.filter({ + $0.request.content.categoryIdentifier == LoopNotificationCategory.loopNotRunning.rawValue + }).map({ + $0.request.identifier + }) - UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: loopNotRunningIdentifiers) - } + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: loopNotRunningIdentifiers) } private func getLastLoopDate() -> Date? { ExtensionDataManager.lastLoopCompleted } - // MARK: - Workout reminder - private func scheduleWorkoutOverrideReminder() { - issueAlert(workoutOverrideReminderAlert) - } - - private func retractWorkoutOverrideReminder() { - retractAlert(identifier: AlertManager.workoutOverrideReminderAlertIdentifier) - } - - static var workoutOverrideReminderAlertIdentifier: Alert.Identifier { - return Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: "WorkoutOverrideReminder") - } - - private var workoutOverrideReminderAlert: Alert { - let title = NSLocalizedString("Workout Temp Adjust Still On", comment: "Workout override still on reminder alert title") - let body = NSLocalizedString("Workout Temp Adjust has been turned on for more than 24 hours. Make sure you still want it enabled, or turn it off in the app.", comment: "Workout override still on reminder alert body.") - let content = Alert.Content(title: title, - body: body, - acknowledgeActionButtonLabel: NSLocalizedString("Dismiss", comment: "Default alert dismissal")) - return Alert(identifier: AlertManager.workoutOverrideReminderAlertIdentifier, - foregroundContent: content, - backgroundContent: content, - trigger: .delayed(interval: .hours(24))) - } - // MARK: - Rescheduling Muted Alerts func rescheduleMutedAlerts(_ newValue: AlertMuter.Configuration) { UserDefaults.standard.alertMuterConfiguration = newValue rescheduleLoopNotRunningNotifications() - lookupAllPendingDelayedOrRepeatingAlerts() { [weak self] result in - switch result { - case .success(let persistedAlerts): + Task { + do { + let persistedAlerts = try await lookupAllPendingDelayedOrRepeatingAlerts() for persistedAlert in persistedAlerts { - self?.rescheduleAlertWithSchedulers(persistedAlert.alert, issuedDate: persistedAlert.issuedDate) + await self.rescheduleAlertWithSchedulers(persistedAlert.alert, issuedDate: persistedAlert.issuedDate) } - case .failure(let error): - self?.log.error("error looking up all delayed or repeating alerts: %{public}@", String(describing: error)) + } catch { + self.log.error("error looking up all delayed or repeating alerts: %{public}@", String(describing: error)) } } } + } // MARK: AlertManagerResponder implementation extension AlertManager: AlertManagerResponder { - func acknowledgeAlert(identifier: Alert.Identifier) { - if let responder = responders[identifier.managerIdentifier]?.value { - responder.acknowledgeAlert(alertIdentifier: identifier.alertIdentifier) { (error) in - if let error = error { - self.presentAcknowledgementFailedAlert(error: error) + func userDidSelectAction(alertIdentifier: Alert.Identifier, actionIdentifier: String) async throws { + if let responder = responders[alertIdentifier.managerIdentifier]?.value { + do { + let storedAlert = try await alertStore.lookupAllMatching(identifier: alertIdentifier, limit: 1).first + + if let storedAlert, + let alert = try? Alert(from: storedAlert, adjustedForStorageTime: false) + { + try await responder.handleAlertAction(actionIdentifier: actionIdentifier, from: alert) + } else { + log.error("Unable to get preset name from stored alert: %{public}@", String(describing: storedAlert)) } + } catch { + log.error("Unable to fetch alert for preset action: %{public}@, ${public}@", String(describing: alertIdentifier), String(describing: error)) } } - userNotificationAlertScheduler.acknowledgeAlert(identifier: identifier) - alertStore.recordAcknowledgement(of: identifier) + + try await acknowledgeAlert(identifier: alertIdentifier); } - func presentAcknowledgementFailedAlert(error: Error) { - DispatchQueue.main.async { - let message: String - if let localizedError = error as? LocalizedError { - message = [localizedError.localizedDescription, localizedError.recoverySuggestion].compactMap({$0}).joined(separator: "\n\n") - } else { - message = String(format: NSLocalizedString("%1$@ is unable to clear the alert from your device", comment: "Message for alert shown when alert acknowledgement fails for a device, and the device does not provide a LocalizedError. (1: app name)"), Bundle.main.bundleDisplayName) + func acknowledgeAlert(identifier: Alert.Identifier) async throws { + self.log.default("acknowledgeAlert called for identifier %{public}@", String(describing: identifier)) + if let responder = responders[identifier.managerIdentifier]?.value { + do { + try await responder.acknowledgeAlert(alertIdentifier: identifier.alertIdentifier) + } catch { + await self.presentAcknowledgementFailedAlert(error: error) } - self.log.info("Alert acknowledgement failed: %{public}@", message) + } + userNotificationAlertScheduler.alertWasAcknowledged(identifier: identifier) + await modalAlertScheduler.removePresentedAlert(identifier: identifier) + try await alertStore.recordAcknowledgement(of: identifier) + } - let alert = UIAlertController( - title: NSLocalizedString("Unable To Clear Alert", comment: "Title for alert shown when alert acknowledgement fails"), - message: message, - preferredStyle: .alert) - alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "Default action for alert when alert acknowledgment fails"), style: .default)) - - self.alertPresenter.present(alert, animated: true) + + func presentAcknowledgementFailedAlert(error: Error) async { + let message: String + if let localizedError = error as? LocalizedError { + message = [localizedError.localizedDescription, localizedError.recoverySuggestion].compactMap({$0}).joined(separator: "\n\n") + } else { + message = String(format: NSLocalizedString("%1$@ is unable to clear the alert from your device", comment: "Message for alert shown when alert acknowledgement fails for a device, and the device does not provide a LocalizedError. (1: app name)"), Bundle.main.bundleDisplayName) } + self.log.info("Alert acknowledgement failed: %{public}@", message) + + let alert = UIAlertController( + title: NSLocalizedString("Unable To Clear Alert", comment: "Title for alert shown when alert acknowledgement fails"), + message: message, + preferredStyle: .alert) + alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "Default action for alert when alert acknowledgment fails"), style: .default)) + + await self.alertPresenter.present(alert, animated: true) } } @@ -356,30 +367,36 @@ extension AlertManager: AlertManagerResponder { extension AlertManager: AlertIssuer { - public func issueAlert(_ alert: Alert) { + public func issueAlert(_ alert: Alert) async { guard playbackFinished else { deferredAlerts.append(alert) return } analyticsServicesManager.didIssueAlert(identifier: alert.identifier.value, interruptionLevel: alert.interruptionLevel) scheduleAlertWithSchedulers(alert) - alertStore.recordIssued(alert: alert) + await alertStore.recordIssued(alert: alert) } - public func retractAlert(identifier: Alert.Identifier) { - unscheduleAlertWithSchedulers(identifier: identifier) - alertStore.recordRetraction(of: identifier) + public func retractAlert(identifier: Alert.Identifier) async { + log.default("retractAlert: %{public}@", String(describing: identifier)) + guard playbackFinished else { + deferredRetractions.append(identifier) + return + } + await unscheduleAlertWithSchedulers(identifier: identifier) + do { + try await alertStore.recordRetraction(of: identifier) + } catch { + log.error("Unable to recordRetraction of %@: %@", String(describing: identifier), String(describing: error)) + } } 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 { + log.default("Scheduling modal alert during replay: %{public}@", String(describing: alert)) modalAlertScheduler.scheduleAlert(alert) } } @@ -389,13 +406,13 @@ extension AlertManager: AlertIssuer { userNotificationAlertScheduler.scheduleAlert(alert, muted: alertMuter.shouldMuteAlert(alert, issuedDate: issuedDate)) } - private func unscheduleAlertWithSchedulers(identifier: Alert.Identifier) { - modalAlertScheduler.unscheduleAlert(identifier: identifier) + private func unscheduleAlertWithSchedulers(identifier: Alert.Identifier) async { + await modalAlertScheduler.unscheduleAlert(identifier: identifier) userNotificationAlertScheduler.unscheduleAlert(identifier: identifier) } - private func rescheduleAlertWithSchedulers(_ alert: Alert, issuedDate: Date) { - unscheduleAlertWithSchedulers(identifier: alert.identifier) + private func rescheduleAlertWithSchedulers(_ alert: Alert, issuedDate: Date) async { + await unscheduleAlertWithSchedulers(identifier: alert.identifier) scheduleAlertWithSchedulers(alert, issuedDate: issuedDate) } } @@ -404,10 +421,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 } @@ -439,192 +458,148 @@ extension AlertManager { extension AlertManager { - func playbackAlertsFromPersistence() { - playbackAlertsFromAlertStore() - } - - private func playbackAlertsFromAlertStore() { - let updateGroup = DispatchGroup() - updateGroup.enter() - alertStore.lookupAllUnacknowledgedUnretracted { - switch $0 { - case .failure(let error): - self.log.error("Could not fetch unacknowledged alerts: %@", error.localizedDescription) - case .success(let alerts): - alerts.forEach { alert in - do { - if let alert = try Alert(from: alert, adjustedForStorageTime: true) { - self.replayAlert(alert) - } - } catch { - self.log.error("Error decoding alert from persistent storage: %@", error.localizedDescription) + func playbackAlertsFromPersistence() async { + guard !playbackFinished else { return } + await playbackAlertsFromAlertStore() + } + + private func playbackAlertsFromAlertStore() async { + do { + let alerts = try await alertStore.lookupAllUnacknowledgedUnretracted() + alerts.forEach { alert in + do { + if let alert = try Alert(from: alert, adjustedForStorageTime: true) { + self.replayAlert(alert) } + } catch { + self.log.error("Error decoding alert from persistent storage: %@", error.localizedDescription) } } - updateGroup.leave() - } - updateGroup.enter() - alertStore.lookupAllAcknowledgedUnretractedRepeatingAlerts { - switch $0 { - case .failure(let error): - self.log.error("Could not fetch acknowledged unretracted repeating alerts: %@", error.localizedDescription) - case .success(let alerts): - alerts.forEach { alert in - do { - if let alert = try Alert(from: alert, adjustedForStorageTime: true) { - self.replayAlert(alert) - } - } catch { - self.log.error("Error decoding alert from persistent storage: %@", error.localizedDescription) + } catch { + self.log.error("Could not fetch unacknowledged alerts: %@", error.localizedDescription) + } + do { + let alerts = try await alertStore.lookupAllAcknowledgedUnretractedRepeatingAlerts() + alerts.forEach { alert in + do { + if let alert = try Alert(from: alert, adjustedForStorageTime: true) { + self.replayAlert(alert) } + } catch { + self.log.error("Error decoding alert from persistent storage: %@", error.localizedDescription) } } - updateGroup.leave() + } catch { + self.log.error("Could not fetch acknowledged unretracted repeating alerts: %@", error.localizedDescription) } - updateGroup.notify(queue: .main) { - self.playbackFinished = true + self.playbackFinished = true + Task { @MainActor in for alert in self.deferredAlerts { - self.issueAlert(alert) + await self.issueAlert(alert) + } + for identifier in self.deferredRetractions { + await self.retractAlert(identifier: identifier) } } } - } // 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 { + let startDate = Date() - .days(3.5) // Report the last 3 and half days of alerts + let header = "## Alerts\n" + do { + let (_, objects) = try await alertStore.executeQuery(since: startDate, limit: 100, ascending: false) + 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") + return report + } catch { + return header } } } // MARK: PersistedAlertStore extension AlertManager: PersistedAlertStore { - public func doesIssuedAlertExist(identifier: Alert.Identifier, completion: @escaping (Result) -> Void) { - alertStore.lookupAllMatching(identifier: identifier) { result in - switch result { - case .success(let storedAlerts): - completion(.success(!storedAlerts.isEmpty)) - case .failure(let error): - completion(.failure(error)) - } - } + public func doesIssuedAlertExist(identifier: LoopKit.Alert.Identifier) async throws -> Bool { + let storedAlerts = try await alertStore.lookupAllMatching(identifier: identifier) + return !storedAlerts.isEmpty } - - public func lookupAllUnretracted(managerIdentifier: String, completion: @escaping (Result<[PersistedAlert], Error>) -> Void) { - alertStore.lookupAllUnretracted(managerIdentifier: managerIdentifier) { - switch $0 { - case .success(let alerts): - do { - let result = try alerts.compactMap { - if let alert = try Alert(from: $0, adjustedForStorageTime: false) { - return PersistedAlert( - alert: alert, - issuedDate: $0.issuedDate, - retractedDate: $0.retractedDate, - acknowledgedDate: $0.acknowledgedDate - ) - } else { - return nil - } - } - completion(.success(result)) - } catch { - completion(.failure(error)) - } - case .failure(let error): - completion(.failure(error)) + + public func lookupAllUnretracted(managerIdentifier: String) async throws -> [LoopKit.PersistedAlert] { + let alerts = try await alertStore.lookupAllUnretracted(managerIdentifier: managerIdentifier) + return try alerts.compactMap { + if let alert = try Alert(from: $0, adjustedForStorageTime: false) { + return PersistedAlert( + alert: alert, + issuedDate: $0.issuedDate, + retractedDate: $0.retractedDate, + acknowledgedDate: $0.acknowledgedDate + ) + } else { + return nil } } } - - public func lookupAllUnacknowledgedUnretracted(managerIdentifier: String, completion: @escaping (Result<[PersistedAlert], Error>) -> Void) { - alertStore.lookupAllUnacknowledgedUnretracted(managerIdentifier: managerIdentifier) { - switch $0 { - case .success(let alerts): - do { - let result = try alerts.compactMap { - if let alert = try Alert(from: $0, adjustedForStorageTime: false) { - return PersistedAlert( - alert: alert, - issuedDate: $0.issuedDate, - retractedDate: $0.retractedDate, - acknowledgedDate: $0.acknowledgedDate - ) - } else { - return nil - } - } - completion(.success(result)) - } catch { - completion(.failure(error)) - } - case .failure(let error): - completion(.failure(error)) + + public func lookupAllUnacknowledgedUnretracted(managerIdentifier: String) async throws -> [LoopKit.PersistedAlert] { + let alerts = try await alertStore.lookupAllUnacknowledgedUnretracted(managerIdentifier: managerIdentifier) + let result = try alerts.compactMap { + if let alert = try Alert(from: $0, adjustedForStorageTime: false) { + return PersistedAlert( + alert: alert, + issuedDate: $0.issuedDate, + retractedDate: $0.retractedDate, + acknowledgedDate: $0.acknowledgedDate + ) + } else { + return nil } } + return result } - private func lookupAllPendingDelayedOrRepeatingAlerts(completion: @escaping (Result<[PersistedAlert], Error>) -> Void) { + private func lookupAllPendingDelayedOrRepeatingAlerts() async throws -> [PersistedAlert] { // the interval provided is not used in the search. Just the trigger stored type value - alertStore.lookupAllUnacknowledgedUnretracted(filteredByTriggers: [Alert.Trigger.delayed(interval: 0).storedType, Alert.Trigger.repeating(repeatInterval: 0).storedType]) { - switch $0 { - case .success(let alerts): - do { - let result = try alerts.compactMap { - if let alert = try Alert(from: $0, adjustedForStorageTime: false) { - return PersistedAlert( - alert: alert, - issuedDate: $0.issuedDate, - retractedDate: $0.retractedDate, - acknowledgedDate: $0.acknowledgedDate - ) - } else { - return nil - } - } - completion(.success(result)) - } catch { - completion(.failure(error)) - } - case .failure(let error): - completion(.failure(error)) + let alerts = try await alertStore.lookupAllUnacknowledgedUnretracted(filteredByTriggers: [Alert.Trigger.delayed(interval: 0).storedType, Alert.Trigger.repeating(repeatInterval: 0).storedType]) + return try alerts.compactMap { + if let alert = try Alert(from: $0, adjustedForStorageTime: false) { + return PersistedAlert( + alert: alert, + issuedDate: $0.issuedDate, + retractedDate: $0.retractedDate, + acknowledgedDate: $0.acknowledgedDate + ) + } else { + return nil } } } - public func recordRetractedAlert(_ alert: Alert, at date: Date) { - alertStore.recordRetractedAlert(alert, at: date) + public func recordRetractedAlert(_ alert: Alert, at date: Date) async throws { + try await alertStore.recordRetractedAlert(alert, at: date) } - private func recordIssued(alert: Alert, at date: Date = Date(), completion: ((Result) -> Void)? = nil) { - alertStore.recordIssued(alert: alert, at: date, completion: completion) + private func recordIssued(alert: Alert, at date: Date = Date()) async { + await alertStore.recordIssued(alert: alert, at: date) } } @@ -690,55 +665,58 @@ extension AlertManager: BluetoothObserver { } } - -// MARK: - PresetActivationObserver -extension AlertManager: PresetActivationObserver { - func presetActivated(context: TemporaryScheduleOverride.Context, duration: TemporaryScheduleOverride.Duration) { - switch context { - case .legacyWorkout: - if duration == .indefinite { - scheduleWorkoutOverrideReminder() - } - default: - break - } - } - - func presetDeactivated(context: TemporaryScheduleOverride.Context) { - switch context { - case .legacyWorkout: - retractWorkoutOverrideReminder() - default: - break - } - } -} - // 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 { + _ = issueOrRetract( + alert: AlertPermissionsChecker.scheduledDeliveryEnabledAlert, + condition: scheduledDeliveryEnabled, + alreadyIssued: UserDefaults.standard.hasIssuedScheduledDeliveryEnabledAlert, + setAlreadyIssued: { + UserDefaults.standard.hasIssuedScheduledDeliveryEnabledAlert = $0 + }, + issueHandler: { alert in + Task { + await self.issueAlert(alert) + } + }, + retractionHandler: { alert in + Task { + await self.retractAlert(identifier: alert.identifier) + } + } + ) + + return } + + _ = 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) + ) + Task { + await self.recordIssued(alert: alert) + } + }, + retractionHandler: { alert in + // need to dismiss the in-app alert outside of the alert system + Task { + try await self.recordRetractedAlert(alert, at: Date()) + await self.dismissUnsafeNotificationPermissionsInAppAlert() + } + } + ) } private func issueOrRetract(alert: LoopKit.Alert, @@ -763,28 +741,30 @@ extension AlertManager: AlertPermissionsCheckerDelegate { } } - private func presentUnsafeNotificationPermissionsInAppAlert() { - DispatchQueue.main.async { - let alertController = AlertPermissionsChecker.constructUnsafeNotificationPermissionsInAppAlert() { [weak self] in - self?.acknowledgeAlert(identifier: AlertPermissionsChecker.unsafeNotificationPermissionsAlertIdentifier) - } - self.alertPresenter.present(alertController, animated: true) { [weak self] in - // the completion is called after the alert is presented - self?.unsafeNotificationPermissionsAlertController = alertController + private func presentUnsafeNotificationPermissionsInAppAlert(_ alert: AlertPermissionsChecker.UnsafeNotificationPermissionAlert) { + Task { @MainActor in + let alertController = AlertPermissionsChecker.constructUnsafeNotificationPermissionsInAppAlert(alert: alert) { [weak self] alert in + UserDefaults.standard.hasIssuedNotificationPermissionsAlert = false + Task { + try await self?.acknowledgeAlert(identifier: alert.alertIdentifier) + } } + + await self.alertPresenter.present(alertController, animated: true) + // this is called after the alert is presented + unsafeNotificationPermissionsAlertController = alertController } } - private func dismissUnsafeNotificationPermissionsInAppAlert() { + private func dismissUnsafeNotificationPermissionsInAppAlert() async { guard let alertController = unsafeNotificationPermissionsAlertController else { return } - alertPresenter.dismissAlert(alertController, animated: true) { [weak self] in - self?.unsafeNotificationPermissionsAlertController = nil - } + await alertPresenter.dismissAlert(alertController, animated: true) + unsafeNotificationPermissionsAlertController = nil } } extension AlertManager { - func presentLoopResetConfirmationAlert(confirmAction: @escaping (@escaping () -> Void) -> Void, cancelAction: @escaping () -> Void) { + func presentLoopResetConfirmationAlert(confirmAction: @escaping (@escaping () -> Void) -> Void, cancelAction: @escaping () -> Void) async { let alert = UIAlertController(title: "Loop Reset Requested", message: "We've detected a Loop reset may be needed. Tapping confirm will reset Loop and quit the app.", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "Confirm", style: .default, handler: { _ in confirmAction() { @@ -795,16 +775,16 @@ extension AlertManager { cancelAction() })) - alertPresenter.present(alert, animated: true) + await alertPresenter.present(alert, animated: true) } - func presentCouldNotResetLoopAlert(error: Error) { + func presentCouldNotResetLoopAlert(error: Error) async { let titleString = String(format: NSLocalizedString("Could Not Restart %1$@", comment: "Format string for title of reset loop alert. (1: App name)"), Bundle.main.bundleDisplayName) let message = String(format: NSLocalizedString("While trying to restart %1$@ an error occured.\n\n%2$@", comment: "Format string for message of reset loop alert. (1: App name) (2: error description)"), Bundle.main.bundleDisplayName, error.localizedDescription) let alert = UIAlertController(title: titleString, message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "Cancel button for reset loop alert"), style: .cancel)) - alertPresenter.present(alert, animated: true) + await alertPresenter.present(alert, animated: true) } } diff --git a/Loop/Managers/Alerts/AlertStore.swift b/Loop/Managers/Alerts/AlertStore.swift index d8d6db7e5c..1e5cd8fa51 100644 --- a/Loop/Managers/Alerts/AlertStore.swift +++ b/Loop/Managers/Alerts/AlertStore.swift @@ -9,6 +9,7 @@ import CoreData import LoopKit +@MainActor public protocol AlertStoreDelegate: AnyObject { /** Informs the delegate that the alert store has updated alert data. @@ -82,150 +83,131 @@ public class AlertStore { self.expireAfter = expireAfter } - public func recordIssued(alert: Alert, at date: Date = Date(), completion: ((Result) -> Void)? = nil) { - self.managedObjectContext.performAndWait { - _ = StoredAlert(from: alert, context: self.managedObjectContext, issuedDate: date) + public func recordIssued(alert: Alert, at date: Date = Date()) async { + await self.managedObjectContext.perform { do { - try self.managedObjectContext.save() + _ = StoredAlert(from: alert, context: self.managedObjectContext, issuedDate: date) + if self.managedObjectContext.hasChanges { + try self.managedObjectContext.save() + } self.log.default("Recorded alert: %{public}@", alert.identifier.value) - self.purgeExpired() - self.delegate?.alertStoreHasUpdatedAlertData(self) - completion?(.success) } catch { self.log.error("Could not store alert: %{public}@, %{public}@", alert.identifier.value, String(describing: error)) - completion?(.failure(error)) } } + + await self.managedObjectContext.perform { + self.purgeExpired() + } + + await delegate?.alertStoreHasUpdatedAlertData(self) } - public func recordRetractedAlert(_ alert: Alert, at date: Date, completion: ((Result) -> Void)? = nil) { - self.managedObjectContext.performAndWait { + public func recordRetractedAlert(_ alert: Alert, at date: Date) async throws { + try await self.managedObjectContext.perform { let storedAlert = StoredAlert(from: alert, context: self.managedObjectContext, issuedDate: date) storedAlert.retractedDate = date - do { - try self.managedObjectContext.save() - self.log.default("Recorded retracted alert: %{public}@", alert.identifier.value) - self.purgeExpired() - self.delegate?.alertStoreHasUpdatedAlertData(self) - completion?(.success) - } catch { - self.log.error("Could not store retracted alert: %{public}@, %{public}@", alert.identifier.value, String(describing: error)) - completion?(.failure(error)) - } + try self.managedObjectContext.save() + self.log.default("Recorded retracted alert: %{public}@", alert.identifier.value) + self.purgeExpired() } + await delegate?.alertStoreHasUpdatedAlertData(self) } - public func recordAcknowledgement(of identifier: Alert.Identifier, at date: Date = Date(), - completion: ((Result) -> Void)? = nil) { - recordUpdateOfAll(identifier: identifier, + public func recordAcknowledgement(of identifier: Alert.Identifier, at date: Date = Date()) async throws { + try await recordUpdateOfAll(identifier: identifier, addingPredicate: NSPredicate(format: "acknowledgedDate == nil"), with: { $0.acknowledgedDate = date return .save - }, - completion: completion) + }) } - public func recordRetraction(of identifier: Alert.Identifier, at date: Date = Date(), - completion: ((Result) -> Void)? = nil) { - recordUpdateOfLatest(identifier: identifier, - addingPredicate: NSPredicate(format: "retractedDate == nil"), - with: { - // if the alert was retracted before it was ever shown, delete it. - // Note: this only applies to .delayed or .repeating alerts! - if let delay = $0.trigger.interval, $0.issuedDate + delay >= date { - return .delete - } else { - $0.retractedDate = date - return .save - } - }, - completion: completion) - } - - public func lookupAllMatching(identifier: Alert.Identifier, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { - managedObjectContext.perform { - do { - let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() - let predicates = [ - NSPredicate(format: "managerIdentifier = %@", identifier.managerIdentifier), - NSPredicate(format: "alertIdentifier = %@", identifier.alertIdentifier), - ] - fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) - fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: true) ] - let result = try self.managedObjectContext.fetch(fetchRequest) - completion(.success(result)) - } catch { - completion(.failure(error)) + public func recordRetraction(of identifier: Alert.Identifier, at date: Date = Date()) async throws { + try await recordUpdateOfLatest( + identifier: identifier, + addingPredicate: NSPredicate(format: "retractedDate == nil"), + with: { + // if the alert was retracted before it was ever shown, delete it. + // Note: this only applies to .delayed or .repeating alerts! + if let delay = $0.trigger.interval, $0.issuedDate + delay >= date { + return .delete + } else { + $0.retractedDate = date + return .save + } + }) + } + + public func lookupAllMatching(identifier: Alert.Identifier, limit: Int? = nil, mostRecentFirst: Bool = false) async throws -> [StoredAlert] { + try await managedObjectContext.perform { + let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() + let predicates = [ + NSPredicate(format: "managerIdentifier = %@", identifier.managerIdentifier), + NSPredicate(format: "alertIdentifier = %@", identifier.alertIdentifier), + ] + if let limit { + fetchRequest.fetchLimit = limit } + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: !mostRecentFirst) ] + return try self.managedObjectContext.fetch(fetchRequest) } } - public func lookupAllUnretracted(managerIdentifier: String? = nil, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { - managedObjectContext.perform { - do { - let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() - var predicates = [ - NSPredicate(format: "retractedDate == nil"), - ] - if let managerIdentifier = managerIdentifier { - predicates.insert(NSPredicate(format: "managerIdentifier = %@", managerIdentifier), at: 0) - } - fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) - fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: true) ] - let result = try self.managedObjectContext.fetch(fetchRequest) - completion(.success(result)) - } catch { - completion(.failure(error)) + public func lookupAllUnretracted(managerIdentifier: String? = nil) async throws -> [StoredAlert] { + try await managedObjectContext.perform { + let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() + var predicates = [ + NSPredicate(format: "retractedDate == nil"), + ] + if let managerIdentifier = managerIdentifier { + predicates.insert(NSPredicate(format: "managerIdentifier = %@", managerIdentifier), at: 0) } + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: true) ] + return try self.managedObjectContext.fetch(fetchRequest) } } - public func lookupAllUnacknowledgedUnretracted(managerIdentifier: String? = nil, filteredByTriggers triggersStoredType: [AlertTriggerStoredType]? = nil, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { - managedObjectContext.perform { - do { - let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() - var predicates = [ - NSPredicate(format: "acknowledgedDate == nil"), - NSPredicate(format: "retractedDate == nil"), - ] - if let managerIdentifier = managerIdentifier { - predicates.insert(NSPredicate(format: "managerIdentifier = %@", managerIdentifier), at: 0) - } - if let triggersStoredType = triggersStoredType { - var triggerPredicates: [NSPredicate] = [] - for triggerStoredType in triggersStoredType { - triggerPredicates.append(NSPredicate(format: "triggerType == %d", triggerStoredType)) - } - let triggerFilterPredicate = NSCompoundPredicate(orPredicateWithSubpredicates: triggerPredicates) - predicates.append(triggerFilterPredicate) + public func lookupAllUnacknowledgedUnretracted( + managerIdentifier: String? = nil, + filteredByTriggers triggersStoredType: [AlertTriggerStoredType]? = nil + ) async throws -> [StoredAlert] { + try await managedObjectContext.perform { + let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() + var predicates = [ + NSPredicate(format: "acknowledgedDate == nil"), + NSPredicate(format: "retractedDate == nil"), + ] + if let managerIdentifier = managerIdentifier { + predicates.insert(NSPredicate(format: "managerIdentifier = %@", managerIdentifier), at: 0) + } + if let triggersStoredType = triggersStoredType { + var triggerPredicates: [NSPredicate] = [] + for triggerStoredType in triggersStoredType { + triggerPredicates.append(NSPredicate(format: "triggerType == %d", triggerStoredType)) } - fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) - fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: true) ] - let result = try self.managedObjectContext.fetch(fetchRequest) - completion(.success(result)) - } catch { - completion(.failure(error)) + let triggerFilterPredicate = NSCompoundPredicate(orPredicateWithSubpredicates: triggerPredicates) + predicates.append(triggerFilterPredicate) } + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: true) ] + return try self.managedObjectContext.fetch(fetchRequest) } } - public func lookupAllAcknowledgedUnretractedRepeatingAlerts(completion: @escaping (Result<[StoredAlert], Error>) -> Void) { - managedObjectContext.perform { - do { - let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() - let repeatingTrigger = Alert.Trigger.repeating(repeatInterval: 0) - fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - NSPredicate(format: "acknowledgedDate != nil"), - NSPredicate(format: "retractedDate == nil"), - NSPredicate(format: "triggerType == \(repeatingTrigger.storedType)") - ]) - fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: true) ] - let result = try self.managedObjectContext.fetch(fetchRequest) - completion(.success(result)) - } catch { - completion(.failure(error)) - } + public func lookupAllAcknowledgedUnretractedRepeatingAlerts() async throws -> [StoredAlert] { + try await managedObjectContext.perform { + let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() + let repeatingTrigger = Alert.Trigger.repeating(repeatInterval: 0) + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + NSPredicate(format: "acknowledgedDate != nil"), + NSPredicate(format: "retractedDate == nil"), + NSPredicate(format: "triggerType == \(repeatingTrigger.storedType)") + ]) + fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: true) ] + return try self.managedObjectContext.fetch(fetchRequest) } } @@ -237,49 +219,39 @@ extension AlertStore { private func recordUpdateOfAll(identifier: Alert.Identifier, addingPredicate predicate: NSPredicate, - with updateBlock: @escaping ManagedObjectUpdateBlock, - completion: ((Result) -> Void)?) { - managedObjectContext.performAndWait { - self.lookupAll(identifier: identifier, predicate: predicate) { - switch $0 { - case .success(let objects): - if objects.count > 0 { - let result = self.update(objects: objects, with: updateBlock) - completion?(result) - } else { - self.log.error("Alert not found for update: %{public}@", identifier.value) - completion?(.failure(AlertStoreError.notFound)) - } - case .failure(let error): - completion?(.failure(error)) - } + with updateBlock: @escaping ManagedObjectUpdateBlock) async throws + { + try await managedObjectContext.perform { + let objects = try self.lookupAll(identifier: identifier, predicate: predicate) + if objects.count > 0 { + try self.update(objects: objects, with: updateBlock) + } else { + self.log.error("Alert not found for update: %{public}@", identifier.value) + throw AlertStoreError.notFound } } + purgeExpired() + await delegate?.alertStoreHasUpdatedAlertData(self) } private func recordUpdateOfLatest(identifier: Alert.Identifier, addingPredicate predicate: NSPredicate, - with updateBlock: @escaping ManagedObjectUpdateBlock, - completion: ((Result) -> Void)?) { - managedObjectContext.performAndWait { - self.lookupLatest(identifier: identifier, predicate: predicate) { - switch $0 { - case .success(let object): - if let object = object { - let result = self.update(objects: [object], with: updateBlock) - completion?(result) - } else { - self.log.error("Alert not found for update: %{public}@", identifier.value) - completion?(.failure(AlertStoreError.notFound)) - } - case .failure(let error): - completion?(.failure(error)) - } + with updateBlock: @escaping ManagedObjectUpdateBlock) async throws + { + try await managedObjectContext.perform { + let object = try self.lookupLatest(identifier: identifier, predicate: predicate) + if let object = object { + try self.update(objects: [object], with: updateBlock) + } else { + self.log.error("Alert not found for update: %{public}@", identifier.value) + throw AlertStoreError.notFound } } + purgeExpired() + await delegate?.alertStoreHasUpdatedAlertData(self) } - private func update(objects: [StoredAlert], with updateBlock: @escaping ManagedObjectUpdateBlock) -> Result { + private func update(objects: [StoredAlert], with updateBlock: @escaping ManagedObjectUpdateBlock) throws { objects.forEach { alert in let shouldDelete = updateBlock(alert) == .delete if shouldDelete { @@ -287,50 +259,29 @@ extension AlertStore { } self.log.default("%{public}@ alert: %{public}@", shouldDelete ? "Deleted" : "Recorded", alert.identifier.value) } - do { - try self.managedObjectContext.save() - } catch { - return .failure(error) - } - self.purgeExpired() - self.delegate?.alertStoreHasUpdatedAlertData(self) - return .success + try self.managedObjectContext.save() } - private func lookupAll(identifier: Alert.Identifier, predicate: NSPredicate, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { - managedObjectContext.perform { - do { - let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() - fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - identifier.equalsPredicate, - predicate - ]) - fetchRequest.fetchLimit = Self.totalFetchLimit - let result = try self.managedObjectContext.fetch(fetchRequest) - completion(.success(result)) - } catch { - completion(.failure(error)) - } - } + private func lookupAll(identifier: Alert.Identifier, predicate: NSPredicate) throws -> [StoredAlert] { + let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + identifier.equalsPredicate, + predicate + ]) + fetchRequest.fetchLimit = Self.totalFetchLimit + return try managedObjectContext.fetch(fetchRequest) } - private func lookupLatest(identifier: Alert.Identifier, predicate: NSPredicate, completion: @escaping (Result) -> Void) { - managedObjectContext.perform { - do { - let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() - fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - identifier.equalsPredicate, - predicate - ]) - fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: false) ] - fetchRequest.fetchLimit = 1 - let result = try self.managedObjectContext.fetch(fetchRequest) - completion(.success(result.last)) - } catch { - completion(.failure(error)) - } - } + private func lookupLatest(identifier: Alert.Identifier, predicate: NSPredicate) throws -> StoredAlert? { + let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + identifier.equalsPredicate, + predicate + ]) + fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: false) ] + fetchRequest.fetchLimit = 1 + return try self.managedObjectContext.fetch(fetchRequest).last } } @@ -343,7 +294,10 @@ extension AlertStore { // Must be invoked within NSManagedObjectContext perform or performAndWait block private func purgeExpired() { - purge(before: expireDate) + managedObjectContext.perform { [weak self] in + guard let self else { return } + self.purge(before: self.expireDate) + } } func purge(before date: Date, completion: (Error?) -> Void) { @@ -417,30 +371,50 @@ extension AlertStore { } } - public enum AlertQueryResult { - case success(QueryAnchor, [SyncAlertObject]) - case failure(Error) - } + typealias AlertQueryResult = (QueryAnchor, [SyncAlertObject]) - 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 + ) async throws -> AlertQueryResult { let sinceDateFilter = SinceDateFilter(predicateExpressionNotYetExpired: predicateExpressionNotYetExpired, date: date, excludingFutureAlerts: excludingFutureAlerts, now: now) - executeAlertQuery(fromQueryAnchor: queryAnchor, queryFilter: sinceDateFilter, limit: limit, completion: completion) + return try await executeAlertQuery(fromQueryAnchor: queryAnchor, queryFilter: sinceDateFilter, limit: limit, ascending: ascending) + } + + func executeAlertQuery(fromQueryAnchor queryAnchor: QueryAnchor?, queryFilter: QueryFilter? = nil, limit: Int, ascending: Bool = true, completion: @escaping (Result) -> Void) + { + Task { + do { + let result = try await executeAlertQuery(fromQueryAnchor: queryAnchor, queryFilter: queryFilter, limit: limit, ascending: ascending) + completion(.success(result)) + } catch { + completion(.failure(error)) + } + } } - 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 + ) async throws -> AlertQueryResult { var queryAnchor = queryAnchor ?? QueryAnchor() var queryResult = [SyncAlertObject]() var queryError: Error? guard limit > 0 else { - completion(.success(queryAnchor, [])) - return + return (queryAnchor, []) } - self.managedObjectContext.performAndWait { + await self.managedObjectContext.perform { let storedRequest: NSFetchRequest = StoredAlert.fetchRequest() let queryAnchorPredicate = NSPredicate(format: "modificationCounter > %d", queryAnchor.modificationCounter) @@ -449,7 +423,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 { @@ -465,25 +439,20 @@ extension AlertStore { } if let queryError = queryError { - completion(.failure(queryError)) - return + throw queryError } - completion(.success(queryAnchor, queryResult)) + return (queryAnchor, queryResult) } // At the moment, this is only used for unit testing - internal func fetch(identifier: Alert.Identifier? = nil, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { - self.managedObjectContext.perform { + internal func fetch(identifier: Alert.Identifier? = nil) async throws -> [StoredAlert] { + return try await self.managedObjectContext.perform { let storedRequest: NSFetchRequest = StoredAlert.fetchRequest() storedRequest.predicate = identifier?.equalsPredicate storedRequest.sortDescriptors = [NSSortDescriptor(key: "modificationCounter", ascending: true)] - do { - let stored = try self.managedObjectContext.fetch(storedRequest) - completion(.success(stored)) - } catch { - completion(.failure(error)) - } + let stored = try self.managedObjectContext.fetch(storedRequest) + return stored } } } @@ -628,9 +597,12 @@ extension AlertStore { return error } - self.delegate?.alertStoreHasUpdatedAlertData(self) + Task { @MainActor in + self.delegate?.alertStoreHasUpdatedAlertData(self) + } self.log.info("Added %d StoredAlerts", alerts.count) return nil } + } diff --git a/Loop/Managers/Alerts/InAppModalAlertScheduler.swift b/Loop/Managers/Alerts/InAppModalAlertScheduler.swift index b00c809f7a..a762f3c60e 100644 --- a/Loop/Managers/Alerts/InAppModalAlertScheduler.swift +++ b/Loop/Managers/Alerts/InAppModalAlertScheduler.swift @@ -9,6 +9,7 @@ import UIKit import LoopKit +@MainActor public class InAppModalAlertScheduler { private weak var alertPresenter: AlertPresenter? @@ -19,7 +20,9 @@ public class InAppModalAlertScheduler { typealias ActionFactoryFunction = (String?, UIAlertAction.Style, ((UIAlertAction) -> Void)?) -> UIAlertAction private let newActionFunc: ActionFactoryFunction - + + private let log = DiagnosticLog(category: "InAppModalAlertScheduler") + typealias TimerFactoryFunction = (TimeInterval, Bool, (() -> Void)?) -> Timer private let newTimerFunc: TimerFactoryFunction @@ -47,19 +50,21 @@ public class InAppModalAlertScheduler { } } - public func unscheduleAlert(identifier: Alert.Identifier) { - DispatchQueue.main.async { - self.removePendingAlert(identifier: identifier) - self.removePresentedAlert(identifier: identifier) - } + public func unscheduleAlert(identifier: Alert.Identifier) async { + log.default("unscheduleAlert modal alert: %{public}@", String(describing: identifier)) + + removePendingAlert(identifier: identifier) + await removePresentedAlert(identifier: identifier) } - func removePresentedAlert(identifier: Alert.Identifier, completion: (() -> Void)? = nil) { + func removePresentedAlert(identifier: Alert.Identifier) async { guard let alertPresented = alertsPresented[identifier] else { - completion?() + log.default("No presented modal alert with identifier %{public}@", String(describing: identifier)) return } - alertPresenter?.dismissAlert(alertPresented.0, animated: true, completion: completion) + + log.default("Dismissing modal alert with identifier %{public}@", String(describing: identifier)) + await alertPresenter?.dismissAlert(alertPresented.0, animated: true) clearPresentedAlert(identifier: identifier) } @@ -95,32 +100,40 @@ extension InAppModalAlertScheduler { guard let content = alert.foregroundContent else { return } - DispatchQueue.main.async { + Task { @MainActor in + log.default("Presenting modal alert: %{public}@", String(describing: alert.identifier)) if self.isAlertPresented(identifier: alert.identifier) { return } let alertController = self.constructAlert(title: content.title, message: content.body, - action: content.acknowledgeActionButtonLabel, - isCritical: alert.interruptionLevel == .critical) { [weak self] in + actions: content.actions, + isCritical: alert.interruptionLevel == .critical) + { [weak self] (action) in // the completion is called after the alert is acknowledged self?.clearPresentedAlert(identifier: alert.identifier) - self?.alertManagerResponder?.acknowledgeAlert(identifier: alert.identifier) - } - self.alertPresenter?.present(alertController, animated: true) { [weak self] in - // the completion is called after the alert is presented - self?.addPresentedAlert(alert: alert, controller: alertController) + Task { + if action.identifier == "acknowledge" { + try await self?.alertManagerResponder?.acknowledgeAlert(identifier: alert.identifier) + } else { + try await self?.alertManagerResponder?.userDidSelectAction(alertIdentifier: alert.identifier, actionIdentifier: action.identifier) + } + } } + addPresentedAlert(alert: alert, controller: alertController) + await self.alertPresenter?.present(alertController, animated: true) } } private func addPendingAlert(alert: Alert, timer: Timer) { dispatchPrecondition(condition: .onQueue(.main)) + alertsPending[alert.identifier] = (timer, alert) } private func addPresentedAlert(alert: Alert, controller: UIAlertController) { dispatchPrecondition(condition: .onQueue(.main)) + log.default("Adding presented modal alert: %{public}@", String(describing: alert.identifier)) alertsPresented[alert.identifier] = (controller, alert) } @@ -144,11 +157,40 @@ extension InAppModalAlertScheduler { return alertsPresented.index(forKey: identifier) != nil } - private func constructAlert(title: String, message: String, action: String, isCritical: Bool, acknowledgeCompletion: @escaping () -> Void) -> UIAlertController { + private func constructAlert( + title: String, + message: String, + actions: [Alert.UserAlertAction], + isCritical: Bool, + handleAction: @escaping (Alert.UserAlertAction) -> Void + ) -> UIAlertController { dispatchPrecondition(condition: .onQueue(.main)) // For now, this is a simple alert with an "OK" button let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) - alertController.addAction(newActionFunc(action, .default, { _ in acknowledgeCompletion() })) + for action in actions { + alertController.addAction( + newActionFunc( + action.label, + action.style.uiKitStyle, + { _ in + handleAction(action) + }) + ) + } return alertController } } + + +extension Alert.UserAlertAction.Style { + var uiKitStyle: UIAlertAction.Style { + switch self { + case .default: + return .default + case .destructive: + return .destructive + case .cancel: + return .cancel + } + } +} diff --git a/Loop/Managers/Alerts/StoredAlert.swift b/Loop/Managers/Alerts/StoredAlert.swift index 39ecc0a041..fb77d85005 100644 --- a/Loop/Managers/Alerts/StoredAlert.swift +++ b/Loop/Managers/Alerts/StoredAlert.swift @@ -18,13 +18,13 @@ extension StoredAlert { encoder.dateEncodingStrategy = .iso8601 return encoder }() - + static let decoder: JSONDecoder = { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return decoder }() - + convenience init(from alert: Alert, context: NSManagedObjectContext, issuedDate: Date = Date(), syncIdentifier: UUID = UUID()) { do { /// This code, using the `init(entity:insertInto:)` instead of the `init(context:)` avoids warnings during unit testing that look like this: @@ -190,26 +190,11 @@ extension Alert.InterruptionLevel { // Since this is arbitrary anyway, might as well make it match iOS's values switch self { case .active: - if #available(iOS 15.0, *) { - return NSNumber(value: UNNotificationInterruptionLevel.active.rawValue) - } else { - // https://developer.apple.com/documentation/usernotifications/unnotificationinterruptionlevel/active - return 1 - } + return NSNumber(value: UNNotificationInterruptionLevel.active.rawValue) case .timeSensitive: - if #available(iOS 15.0, *) { - return NSNumber(value: UNNotificationInterruptionLevel.timeSensitive.rawValue) - } else { - // https://developer.apple.com/documentation/usernotifications/unnotificationinterruptionlevel/timesensitive - return 2 - } + return NSNumber(value: UNNotificationInterruptionLevel.timeSensitive.rawValue) case .critical: - if #available(iOS 15.0, *) { - return NSNumber(value: UNNotificationInterruptionLevel.critical.rawValue) - } else { - // https://developer.apple.com/documentation/usernotifications/unnotificationinterruptionlevel/critical - return 3 - } + return NSNumber(value: UNNotificationInterruptionLevel.critical.rawValue) } } diff --git a/Loop/Managers/Alerts/UserNotificationAlertScheduler.swift b/Loop/Managers/Alerts/UserNotificationAlertScheduler.swift index a1eb654209..584f92e2cb 100644 --- a/Loop/Managers/Alerts/UserNotificationAlertScheduler.swift +++ b/Loop/Managers/Alerts/UserNotificationAlertScheduler.swift @@ -8,6 +8,7 @@ import LoopKit import UIKit +import AudioToolbox public protocol UserNotificationCenter { func add(_ request: UNNotificationRequest, withCompletionHandler: ((Error?) -> Void)?) @@ -33,7 +34,11 @@ public class UserNotificationAlertScheduler { func scheduleAlert(_ alert: Alert, timestamp: Date, muted: Bool = false) { DispatchQueue.main.async { - let request = UNNotificationRequest(from: alert, timestamp: timestamp, muted: muted) + let content = alert.getUserNotificationContent(timestamp: timestamp, muted: muted) + let request = UNNotificationRequest(identifier: alert.identifier.value, + content: content, + trigger: UNTimeIntervalNotificationTrigger(from: alert.trigger)) + self.userNotificationCenter.add(request) { error in if let error = error { self.log.error("Something went wrong posting the user notification: %@", error.localizedDescription) @@ -49,10 +54,8 @@ public class UserNotificationAlertScheduler { self.userNotificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier.value]) } } -} -extension UserNotificationAlertScheduler: AlertManagerResponder { - func acknowledgeAlert(identifier: Alert.Identifier) { + func alertWasAcknowledged(identifier: Alert.Identifier) { DispatchQueue.main.async { self.log.debug("Removing notification %@ from delivered notifications", identifier.value) self.userNotificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier.value]) @@ -66,16 +69,21 @@ fileprivate extension Alert { userNotificationContent.title = backgroundContent.title userNotificationContent.body = backgroundContent.body userNotificationContent.sound = userNotificationSound(muted: muted) - if #available(iOS 15.0, *) { - userNotificationContent.interruptionLevel = interruptionLevel.userNotificationInterruptLevel - } - // TODO: Once we have a final design and approval for custom UserNotification buttons, we'll need to set categoryIdentifier -// userNotificationContent.categoryIdentifier = LoopNotificationCategory.alert.rawValue + userNotificationContent.interruptionLevel = interruptionLevel.userNotificationInterruptLevel + userNotificationContent.categoryIdentifier = categoryIdentifier ?? "" userNotificationContent.threadIdentifier = identifier.value // Used to match categoryIdentifier, but I /think/ we want multiple threads for multiple alert types, no? userNotificationContent.userInfo = [ LoopNotificationUserInfoKey.managerIDForAlert.rawValue: identifier.managerIdentifier, LoopNotificationUserInfoKey.alertTypeID.rawValue: identifier.alertIdentifier, ] + + if let metadata { + for (key, value) in metadata { + guard let value = value.wrapped as? String else { continue } + userNotificationContent.userInfo[key] = value + } + } + return userNotificationContent } @@ -84,8 +92,12 @@ fileprivate extension Alert { switch sound { case .vibrate: + guard interruptionLevel == .critical else { + AudioServicesPlayAlertSound(kSystemSoundID_Vibrate) + return nil + } // setting the audio volume of critical alert to 0 only vibrates - return interruptionLevel == .critical ? .defaultCriticalSound(withAudioVolume: 0) : nil + return .defaultCriticalSound(withAudioVolume: 0) default: if let actualFileName = AlertManager.soundURL(for: self)?.lastPathComponent { let unname = UNNotificationSoundName(rawValue: actualFileName) @@ -111,15 +123,6 @@ fileprivate extension Alert.InterruptionLevel { } } -fileprivate extension UNNotificationRequest { - convenience init(from alert: Alert, timestamp: Date, muted: Bool) { - let content = alert.getUserNotificationContent(timestamp: timestamp, muted: muted) - self.init(identifier: alert.identifier.value, - content: content, - trigger: UNTimeIntervalNotificationTrigger(from: alert.trigger)) - } -} - fileprivate extension UNTimeIntervalNotificationTrigger { convenience init?(from alertTrigger: Alert.Trigger) { switch alertTrigger { diff --git a/Loop/Managers/AnalyticsServicesManager.swift b/Loop/Managers/AnalyticsServicesManager.swift index 808a34c81a..08604ebf1d 100644 --- a/Loop/Managers/AnalyticsServicesManager.swift +++ b/Loop/Managers/AnalyticsServicesManager.swift @@ -9,7 +9,7 @@ import Foundation import LoopKit import LoopCore -import HealthKit +import LoopAlgorithm final class AnalyticsServicesManager { @@ -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() { @@ -206,7 +202,7 @@ final class AnalyticsServicesManager { logEvent("Alert Issued", withProperties: ["identifier": identifier, "interruptionLevel": interruptionLevel.rawValue]) } - func didEnactOverride(name: String, symbol: String, duration: TemporaryScheduleOverride.Duration, insulinSensitivityMultiplier: Double = 1.0, targetRange: ClosedRange? = nil) + func didEnactOverride(name: String, symbol: String, duration: TemporaryScheduleOverride.Duration, insulinSensitivityMultiplier: Double = 1.0, targetRange: ClosedRange? = nil) { let combinedName = "\(symbol) - \(name)" @@ -217,10 +213,10 @@ final class AnalyticsServicesManager { "nameWithEmoji": combinedName ] - if let targetUpperBound = targetRange?.upperBound.doubleValue(for: HKUnit.milligramsPerDeciliter) { + if let targetUpperBound = targetRange?.upperBound.doubleValue(for: LoopUnit.milligramsPerDeciliter) { properties["targetUpperBound"] = targetUpperBound } - if let targetLowerBound = targetRange?.lowerBound.doubleValue(for: HKUnit.milligramsPerDeciliter) { + if let targetLowerBound = targetRange?.lowerBound.doubleValue(for: LoopUnit.milligramsPerDeciliter) { properties["targetLowerBound"] = targetLowerBound } @@ -238,25 +234,30 @@ final class AnalyticsServicesManager { extension AnalyticsServicesManager: PresetActivationObserver { func presetActivated(context: TemporaryScheduleOverride.Context, duration: TemporaryScheduleOverride.Duration) { switch context { - case .legacyWorkout: - didEnactOverride(name: "workout", symbol: "", duration: duration) case .preMeal: didEnactOverride(name: "preMeal", symbol: "", duration: duration) case .custom: didEnactOverride(name: "custom", symbol: "", duration: duration) case .preset(let preset): - didEnactOverride(name: preset.name, symbol: preset.symbol, duration: duration, insulinSensitivityMultiplier: preset.settings.effectiveInsulinNeedsScaleFactor, targetRange: preset.settings.targetRange) + didEnactOverride( + name: preset.name, + symbol: preset.symbol?.textualRepresentation ?? "", + duration: duration, + insulinSensitivityMultiplier: preset.settings.effectiveInsulinNeedsScaleFactor, + targetRange: preset.settings.targetRange + ) + case .activity(let activity): + didEnactOverride( + name: activity.activityType.name, + symbol: activity.preset.symbol?.textualRepresentation ?? "", + duration: activity.preset.duration, + insulinSensitivityMultiplier: activity.preset.settings.effectiveInsulinNeedsScaleFactor, + targetRange: activity.preset.settings.targetRange + ) } } - func presetDeactivated(context: TemporaryScheduleOverride.Context) { - switch context { - case .legacyWorkout: - break - default: - break - } - } + func presetDeactivated(context: TemporaryScheduleOverride.Context) {} } extension AutomaticDosingStrategy { 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 deleted file mode 100644 index 86b17f625b..0000000000 --- a/Loop/Managers/DeeplinkManager.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// DeeplinkManager.swift -// Loop -// -// Created by Cameron Ingham on 6/26/23. -// Copyright © 2023 LoopKit Authors. All rights reserved. -// - -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? - - init(rootViewController: UIViewController?) { - self.rootViewController = rootViewController - } - - func handle(_ url: URL) -> Bool { - guard let rootViewController = rootViewController as? RootNavigationController, let deeplink = Deeplink(url: url) else { - return false - } - - rootViewController.navigate(to: deeplink) - return true - } - - func handle(_ deeplink: Deeplink) -> Bool { - guard let rootViewController = rootViewController as? RootNavigationController else { - return false - } - - rootViewController.navigate(to: deeplink) - return true - } -} diff --git a/Loop/Managers/DeliveryUncertaintyAlertManager.swift b/Loop/Managers/DeliveryUncertaintyAlertManager.swift index d163d9d227..015f98342b 100644 --- a/Loop/Managers/DeliveryUncertaintyAlertManager.swift +++ b/Loop/Managers/DeliveryUncertaintyAlertManager.swift @@ -10,6 +10,7 @@ import Foundation import UIKit import LoopKitUI +@MainActor class DeliveryUncertaintyAlertManager { private let pumpManager: PumpManagerUI private let alertPresenter: AlertPresenter @@ -20,13 +21,14 @@ class DeliveryUncertaintyAlertManager { self.alertPresenter = alertPresenter } - private func showUncertainDeliveryRecoveryView() { + private func showUncertainDeliveryRecoveryView() async { var controller = pumpManager.deliveryUncertaintyRecoveryViewController(colorPalette: .default, allowDebugFeatures: FeatureFlags.allowDebugFeatures) controller.completionDelegate = self - self.alertPresenter.present(controller, animated: true) + controller.modalPresentationStyle = .fullScreen + await self.alertPresenter.present(controller, animated: true) } - func showAlert(animated: Bool = true) { + func showAlert(animated: Bool = true) async { if self.uncertainDeliveryAlert == nil { let alert = UIAlertController( title: NSLocalizedString("Unable To Reach Pump", comment: "Title for alert shown when delivery status is uncertain"), @@ -35,13 +37,14 @@ class DeliveryUncertaintyAlertManager { let actionTitle = NSLocalizedString("Learn More", comment: "OK button title for alert shown when delivery status is uncertain") let action = UIAlertAction(title: actionTitle, style: .default) { (_) in - self.uncertainDeliveryAlert = nil - self.showUncertainDeliveryRecoveryView() + Task { @MainActor in + self.uncertainDeliveryAlert = nil + await self.showUncertainDeliveryRecoveryView() + } } alert.addAction(action) - self.alertPresenter.dismissTopMost(animated: false) { - self.alertPresenter.present(alert, animated: animated) - } + await self.alertPresenter.dismissTopMost(animated: false) + await self.alertPresenter.present(alert, animated: animated) self.uncertainDeliveryAlert = alert } } @@ -58,8 +61,10 @@ extension DeliveryUncertaintyAlertManager: CompletionDelegate { // If delivery still uncertain after recovery view dismissal, present modal alert again. if let vc = object as? UIViewController { vc.dismiss(animated: true) { - if self.pumpManager.status.deliveryIsUncertain { - self.showAlert(animated: false) + Task { + if self.pumpManager.status.deliveryIsUncertain { + await self.showAlert(animated: false) + } } } } diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index e928c5e2d0..a5a6cda38c 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -6,19 +6,43 @@ // Copyright © 2015 Nathan Racklyeft. All rights reserved. // -import BackgroundTasks import HealthKit -import LoopKit +@preconcurrency import LoopKit import LoopKitUI import LoopCore import LoopTestingKit import UserNotifications import Combine +import LoopAlgorithm +public enum DemoError: LocalizedError { + case CommsError +} + +@MainActor +protocol LoopControl { + var lastLoopCompleted: Date? { get } + var automatedTreatmentState: AutomatedTreatmentState? { 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 +54,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 @@ -47,8 +70,6 @@ final class DeviceDataManager { private var lastCGMLoopTrigger: Date = .distantPast - private let automaticDosingStatus: AutomaticDosingStatus - var closedLoopDisallowedLocalizedDescription: String? { if !cgmHasValidSensorSession { return NSLocalizedString("Closed Loop requires an active CGM Sensor Session", comment: "The description text for the looping enabled switch cell when closed loop is not allowed because the sensor is inactive") @@ -72,6 +93,9 @@ final class DeviceDataManager { if !FeatureFlags.afrezzaInsulinModelEnabled { allowed.remove(.afrezza) } + if !FeatureFlags.apidraInsulinModelEnabled { + allowed.remove(.apidra) + } for insulinType in InsulinType.allCases { if !insulinType.pumpAdministerable { @@ -84,17 +108,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 +135,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 +166,14 @@ 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 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 +218,99 @@ 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.cacheStore = cacheStore - self.settingsManager = settingsManager - - let absorptionTimes = LoopCoreConstants.defaultCarbAbsorptionTimes - let sensitivitySchedule = settingsManager.latestSettings.insulinSensitivitySchedule + self.crashRecoveryManager = crashRecoveryManager + self.activeStatefulPluginsProvider = activeStatefulPluginsProvider + self.uploadEventListener = uploadEventListener + self.activeServicesProvider = activeServicesProvider + self.displayGlucosePreference = displayGlucosePreference + self.displayGlucoseUnitBroadcaster = displayGlucoseUnitBroadcaster - 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 - - 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 - self.trustedTimeChecker = trustedTimeChecker + Task { + await cgmStalenessMonitor.checkCGMStaleness() + } - crashRecoveryManager = CrashRecoveryManager(alertIssuer: alertManager) - alertManager.addAlertResponder(managerIdentifier: crashRecoveryManager.managerIdentifier, alertResponder: crashRecoveryManager) + setupPump() + setupCGM() + } + 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 +327,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 +375,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 +402,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 +411,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 +511,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,8 +542,8 @@ final class DeviceDataManager { func checkDeliveryUncertaintyState() { if let pumpManager = pumpManager, pumpManager.status.deliveryIsUncertain { - DispatchQueue.main.async { - self.deliveryUncertaintyAlertManager?.showAlert() + Task { + await self.deliveryUncertaintyAlertManager?.showAlert() } } } @@ -685,29 +553,74 @@ final class DeviceDataManager { completion(authorizationRequestStatus) } } + + /// The sharing (write) authorization status for a HealthKit type. + /// + /// HealthKit only exposes share/write authorization. Read authorization is + /// intentionally hidden by the system for privacy, so there is no equivalent + /// accessor for read access. + func healthKitSharingStatus(for type: HKObjectType) -> HKAuthorizationStatus { + healthStore.authorizationStatus(for: type) + } // Get HealthKit authorization for all of the stores func authorizeHealthStore(_ completion: @escaping (HKAuthorizationRequestStatus) -> Void) { // Authorize all types at once for simplicity healthStore.requestAuthorization(toShare: shareTypes, read: readTypes) { (success, error) in - if success { - // Call the individual authorization methods to trigger query creation - self.carbStore.hkSampleStore?.authorizationIsDetermined() - self.doseStore.hkSampleStore?.authorizationIsDetermined() - self.glucoseStore.hkSampleStore?.authorizationIsDetermined() + Task { @MainActor in + if success { + // Call the individual authorization methods to trigger query creation + self.carbStore.hkSampleStore?.authorizationIsDetermined() + self.doseStore.hkSampleStore?.authorizationIsDetermined() + self.glucoseStore.hkSampleStore?.authorizationIsDetermined() + } + self.getHealthStoreAuthorization(completion) } + } + } + + private func refreshCGM() async { + guard let cgmManager = cgmManager else { + return + } + + let result = await cgmManager.fetchNewDataIfNeeded() - self.getHealthStoreAuthorization(completion) + 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 +638,7 @@ private extension DeviceDataManager { } if let cgmManagerUI = cgmManager as? CGMManagerUI { - addDisplayGlucoseUnitObserver(cgmManagerUI) + displayGlucoseUnitBroadcaster?.addDisplayGlucoseUnitObserver(cgmManagerUI) } } @@ -733,17 +646,16 @@ private extension DeviceDataManager { dispatchPrecondition(condition: .onQueue(.main)) pumpManager?.pumpManagerDelegate = self - pumpManager?.delegateQueue = queue 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 +681,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 +698,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,53 +730,35 @@ extension DeviceDataManager { // MARK: - Client API extension DeviceDataManager { - func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ error: Error?) -> Void = { _ in }) { + func enactBolus(units: Double, decisionId: UUID?, 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)) { - pumpManager.enactBolus(units: units, activationType: activationType) { (error) in - if let error = error { - self.log.error("%{public}@", String(describing: error)) - switch error { - case .uncertainDelivery: - // Do not generate notification on uncertain delivery error - break - default: - // Do not generate notifications for automatic boluses that fail. - if !activationType.isAutomatic { - NotificationManager.sendBolusFailureNotification(for: error, units: units, at: Date(), activationType: activationType) - } - } - - self.loopManager.bolusRequestFailed(error) { - completion(error) - } - } else { - self.loopManager.bolusConfirmed() { - completion(nil) - } - } - } - // Trigger forecast/recommendation update for remote clients - self.loopManager.updateRemoteRecommendation() + var automaticBolusOngoing = false + if case .inProgress(let dose) = pumpManager.status.bolusState, dose.automatic == true { + automaticBolusOngoing = true } - } - - 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 + + if automaticBolusOngoing && activationType != .automatic { + let _ = try? await pumpManager.cancelBolus() + } + + do { + try await pumpManager.enactBolus(decisionId: decisionId, units: units, activationType: activationType) + } catch PumpManagerError.uncertainDelivery { + // Do not generate notification on uncertain delivery error + } catch { + if !activationType.isAutomatic, let error = error as? PumpManagerError { + do { + try await NotificationManager.sendBolusFailureNotification(for: error, units: units, at: Date(), decisionId: decisionId, activationType: activationType) + } catch { + log.error("Error sending bolus failure notification %{public}@", String(describing: error)) } - continuation.resume() } } } - + var pumpManagerStatus: PumpManagerStatus? { return pumpManager?.status } @@ -880,9 +774,9 @@ extension DeviceDataManager { guard FeatureFlags.cgmManagerCategorizeManualGlucoseRangeEnabled else { // Using Dexcom default glucose thresholds to categorize a glucose range - let urgentLowGlucoseThreshold = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 55) - let lowGlucoseThreshold = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 80) - let highGlucoseThreshold = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 200) + let urgentLowGlucoseThreshold = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 55) + let lowGlucoseThreshold = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 80) + let highGlucoseThreshold = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 200) let glucoseRangeCategory: GlucoseRangeCategory switch glucose.quantity { @@ -926,8 +820,10 @@ extension DeviceDataManager { // MARK: - DeviceManagerDelegate extension DeviceDataManager: DeviceManagerDelegate { - func deviceManager(_ manager: DeviceManager, logEventForDeviceIdentifier deviceIdentifier: String?, type: DeviceLogEntryType, message: String, completion: ((Error?) -> Void)?) { - deviceLog.log(managerIdentifier: manager.pluginIdentifier, deviceIdentifier: deviceIdentifier, type: type, message: message, completion: completion) + nonisolated func deviceManager(_ manager: DeviceManager, logEventForDeviceIdentifier deviceIdentifier: String?, type: DeviceLogEntryType, message: String, completion: ((Error?) -> Void)?) { + Task { @MainActor in + deviceLog.log(managerIdentifier: manager.pluginIdentifier, deviceIdentifier: deviceIdentifier, type: type, message: message, completion: completion) + } } var allowDebugFeatures: Bool { @@ -939,69 +835,72 @@ extension DeviceDataManager: DeviceManagerDelegate { extension DeviceDataManager: AlertIssuer { static let managerIdentifier = "DeviceDataManager" - func issueAlert(_ alert: Alert) { - alertManager?.issueAlert(alert) + func issueAlert(_ alert: Alert) async { + await alertManager?.issueAlert(alert) } - func retractAlert(identifier: Alert.Identifier) { - alertManager?.retractAlert(identifier: identifier) + func retractAlert(identifier: Alert.Identifier) async { + await alertManager?.retractAlert(identifier: identifier) } } // MARK: - PersistedAlertStore extension DeviceDataManager: PersistedAlertStore { - func doesIssuedAlertExist(identifier: Alert.Identifier, completion: @escaping (Swift.Result) -> Void) { + func doesIssuedAlertExist(identifier: LoopKit.Alert.Identifier) async throws -> Bool { precondition(alertManager != nil) - alertManager.doesIssuedAlertExist(identifier: identifier, completion: completion) + return try await alertManager.doesIssuedAlertExist(identifier: identifier) } - func lookupAllUnretracted(managerIdentifier: String, completion: @escaping (Swift.Result<[PersistedAlert], Error>) -> Void) { + + func lookupAllUnretracted(managerIdentifier: String) async throws -> [LoopKit.PersistedAlert] { precondition(alertManager != nil) - alertManager.lookupAllUnretracted(managerIdentifier: managerIdentifier, completion: completion) + return try await alertManager.lookupAllUnretracted(managerIdentifier: managerIdentifier) } - func lookupAllUnacknowledgedUnretracted(managerIdentifier: String, completion: @escaping (Swift.Result<[PersistedAlert], Error>) -> Void) { + func lookupAllUnacknowledgedUnretracted(managerIdentifier: String) async throws -> [LoopKit.PersistedAlert] { precondition(alertManager != nil) - alertManager.lookupAllUnacknowledgedUnretracted(managerIdentifier: managerIdentifier, completion: completion) + return try await alertManager.lookupAllUnacknowledgedUnretracted(managerIdentifier: managerIdentifier) } - func recordRetractedAlert(_ alert: Alert, at date: Date) { + func recordRetractedAlert(_ alert: Alert, at date: Date) async throws { precondition(alertManager != nil) - alertManager.recordRetractedAlert(alert, at: date) + try await alertManager.recordRetractedAlert(alert, at: date) } } // 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 { + Task { @MainActor in do { try await cgmEventStore.add(events: events) } catch { @@ -1011,12 +910,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 +924,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 +946,30 @@ 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 automatedTreatmentState: LoopKit.AutomatedTreatmentState? { + return loopControl.automatedTreatmentState + } + + 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 +981,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 } @@ -1127,35 +1000,26 @@ extension DeviceDataManager: PumpManagerDelegate { return !(cgmManager?.providesBLEHeartbeat == true) } - func pumpManager(_ pumpManager: PumpManager, didUpdate status: PumpManagerStatus, oldStatus: PumpManagerStatus) { - dispatchPrecondition(condition: .onQueue(queue)) - log.default("PumpManager:%{public}@ did update status: %{public}@", String(describing: type(of: pumpManager)), String(describing: status)) + nonisolated func pumpManager(_ pumpManager: PumpManager, didUpdate status: PumpManagerStatus, oldStatus: PumpManagerStatus) { + Task { @MainActor in + log.default("PumpManager:%{public}@ did update status: %{public}@", String(describing: type(of: pumpManager)), String(describing: status)) - doseStore.device = status.device - - if let newBatteryValue = status.pumpBatteryChargeRemaining, - let oldBatteryValue = oldStatus.pumpBatteryChargeRemaining, - newBatteryValue - oldBatteryValue >= LoopConstants.batteryReplacementDetectionThreshold { - analyticsServicesManager.pumpBatteryWasReplaced() - } + doseStore.device = status.device - if status.basalDeliveryState != oldStatus.basalDeliveryState { - loopManager.basalDeliveryState = status.basalDeliveryState - } + if let newBatteryValue = status.pumpBatteryChargeRemaining, + let oldBatteryValue = oldStatus.pumpBatteryChargeRemaining, + newBatteryValue - oldBatteryValue >= LoopConstants.batteryReplacementDetectionThreshold { + analyticsServicesManager.pumpBatteryWasReplaced() + } - updatePumpIsAllowingAutomation(status: status) + updatePumpIsAllowingAutomation(status: status) - // Update the pump-schedule based settings - loopManager.setScheduleTimeZone(status.timeZone) - - if status.insulinType != oldStatus.insulinType { - loopManager.pumpInsulinType = status.insulinType - } - - if status.deliveryIsUncertain != oldStatus.deliveryIsUncertain { - DispatchQueue.main.async { + // Update the pump-schedule based settings + settingsManager.setScheduleTimeZone(status.timeZone) + + if status.deliveryIsUncertain != oldStatus.deliveryIsUncertain { if status.deliveryIsUncertain { - self.deliveryUncertaintyAlertManager?.showAlert() + await self.deliveryUncertaintyAlertManager?.showAlert() } else { self.deliveryUncertaintyAlertManager?.clearAlert() } @@ -1171,80 +1035,82 @@ extension DeviceDataManager: PumpManagerDelegate { } } - func pumpManagerPumpWasReplaced(_ pumpManager: PumpManager) { + nonisolated func pumpManagerPumpWasReplaced(_ pumpManager: PumpManager) { } - func pumpManagerWillDeactivate(_ pumpManager: PumpManager) { - dispatchPrecondition(condition: .onQueue(queue)) - - log.default("Pump manager with identifier '%{public}@' will deactivate", pumpManager.pluginIdentifier) + nonisolated func pumpManagerWillDeactivate(_ pumpManager: PumpManager) { + Task { @MainActor in + log.default("Pump manager with identifier '%{public}@' will deactivate", pumpManager.pluginIdentifier) - DispatchQueue.main.async { self.pumpManager = nil - self.deliveryUncertaintyAlertManager = nil - self.settingsManager.storeSettings() + deliveryUncertaintyAlertManager = nil + settingsManager.storeSettings() } } - func pumpManager(_ pumpManager: PumpManager, didUpdatePumpRecordsBasalProfileStartEvents pumpRecordsBasalProfileStartEvents: Bool) { - dispatchPrecondition(condition: .onQueue(queue)) - log.default("PumpManager:%{public}@ did update pumpRecordsBasalProfileStartEvents to %{public}@", String(describing: type(of: pumpManager)), String(describing: pumpRecordsBasalProfileStartEvents)) - - doseStore.pumpRecordsBasalProfileStartEvents = pumpRecordsBasalProfileStartEvents + nonisolated func pumpManager(_ pumpManager: PumpManager, didUpdatePumpRecordsBasalProfileStartEvents pumpRecordsBasalProfileStartEvents: Bool) { + Task { @MainActor in + 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)) - log.error("PumpManager:%{public}@ did error: %{public}@", String(describing: type(of: pumpManager)), String(describing: error)) + nonisolated func pumpManager(_ pumpManager: PumpManager, didError error: PumpManagerError) { + Task { @MainActor in + dispatchPrecondition(condition: .onQueue(.main)) + log.error("PumpManager:%{public}@ did error: %{public}@", String(describing: type(of: pumpManager)), String(describing: error)) - setLastError(error: error) + setLastError(error: error) + } } - func pumpManager( + nonisolated func pumpManager( _ pumpManager: PumpManager, hasNewPumpEvents events: [NewPumpEvent], lastReconciliation: Date?, replacePendingEvents: Bool, completion: @escaping (_ error: Error?) -> Void) { - dispatchPrecondition(condition: .onQueue(queue)) - 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 { @MainActor in + log.default("PumpManager:%{public}@ hasNewPumpEvents (lastReconciliation = %{public}@)", String(describing: type(of: pumpManager)), String(describing: lastReconciliation)) + 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))) + nonisolated 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)) return doseStore.pumpEventQueryAfterDate } var automaticDosingEnabled: Bool { - automaticDosingStatus.automaticDosingEnabled + settingsManager.dosingEnabled } } @@ -1260,9 +1126,9 @@ extension DeviceDataManager: PumpManagerOnboardingDelegate { precondition(pumpManager.isOnboarded) log.default("Pump manager with identifier '%{public}@' onboarded", pumpManager.pluginIdentifier) - DispatchQueue.main.async { - self.refreshDeviceData() - self.settingsManager.storeSettings() + Task { + await refreshDeviceData() + settingsManager.storeSettings() } } @@ -1274,14 +1140,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 +1155,109 @@ 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) - } - } -} - -// 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 + try await glucoseStore.purgeAllGlucose(for: testingCGMManager.testingDevice) } - 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)) + func deleteTestingCarbData(before: Date = Date()) async throws { + guard let testingCGMManager = cgmManager as? TestingCGMManager, + let testingPumpManager = pumpManager as? TestingPumpManager + else { return } - guard !pumpManager.status.deliveryIsUncertain else { - completion(LoopError.connectionError) + try await carbStore.deleteAllCarbEntries() + } + + func deleteTestingAlertData() async throws { + guard let testingCGMManager = cgmManager as? TestingCGMManager, + let testingPumpManager = pumpManager as? TestingPumpManager + else { 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() + + await withCheckedContinuation { [weak alertStore = alertManager.alertStore] continuation in + alertStore?.purge(before: Date(), completion: { _ in + continuation.resume() + }) } } +} +extension DeviceDataManager: BolusDurationEstimator { + func estimateBolusDuration(bolusUnits: Double) -> TimeInterval? { + pumpManager?.estimatedDuration(toBolus: bolusUnits) + } } extension Notification.Name { @@ -1434,158 +1266,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 +1296,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,25 +1310,24 @@ 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 settings.suspendThreshold = therapySettings.suspendThreshold settings.basalRateSchedule = therapySettings.basalRateSchedule settings.maximumBasalRatePerHour = therapySettings.maximumBasalRatePerHour @@ -1662,97 +1348,87 @@ 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") + } +} + +extension DeviceDataManager: DeliveryDelegate { + var isPumpConfigured: Bool { + return pumpManager != nil + } + + 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 } + + return pumpManager.roundToSupportedBolusVolume(units: units) } - func removeDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) { - displayGlucoseUnitObservers.removeElement(observer) + var pumpInsulinType: InsulinType? { + return pumpManager?.status.insulinType + } + + var isSuspended: Bool { + return pumpManager?.status.basalDeliveryState?.isSuspended ?? false } - func notifyObserversOfDisplayGlucoseUnitChange(to displayGlucoseUnit: HKUnit) { - self.displayGlucoseUnitObservers.forEach { - $0.unitDidChange(to: displayGlucoseUnit) + var isPumpInoperable: Bool { + guard let basalDeliveryState else { + return true } + return basalDeliveryState == .pumpInoperable } -} -extension DeviceDataManager: DeviceSupportDelegate { - var availableSupports: [SupportUI] { [cgmManager, pumpManager].compactMap { $0 as? SupportUI } } + func enact(bolus: Double?, tempBasal: TempBasalRecommendation?, decisionId: UUID?) async throws { + guard let pumpManager = pumpManager else { + throw LoopError.configurationError(.pumpManager) + } - func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) { - self.loopManager.generateDiagnosticReport { (loopReport) in - - let logDurationHours = 84.0 - - 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") - } - - let submodulesInfo = BuildDetails.default.submodules - .sorted(by: { $0.key < $1.key }) - .map { key, value in - "* \(key): \(value.branch), \(value.commitSHA)" - } - .joined(separator: "\n") - - let report = [ - "## Build Details", - "* appNameAndVersion: \(Bundle.main.localizedNameAndVersion)", - "* profileExpiration: \(BuildDetails.default.profileExpirationString)", - "* sourceRoot: \(BuildDetails.default.sourceRoot ?? "N/A")", - "* buildDateString: \(BuildDetails.default.buildDateString ?? "N/A")", - "* xcodeVersion: \(BuildDetails.default.xcodeVersion ?? "N/A")", - "* Workspace branch: \(BuildDetails.default.workspaceGitBranch ?? "N/A")", - "* Workspace SHA: \(BuildDetails.default.workspaceGitRevision ?? "N/A")", - "* Submodule name: branch, SHA", - "\(submodulesInfo)", - "", - "## 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: (bolus, tempBasal))) + + try await doseEnactor.enact(decisionId: decisionId, bolus: bolus, tempBasal: tempBasal, 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..f9df3ebdff 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,16 @@ 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") - - doseDispatchGroup.enter() - pumpManager.enactTempBasal(unitsPerHour: basalAdjustment.unitsPerHour, for: basalAdjustment.duration, completion: { error in - if let error = error { - tempBasalError = error - } - doseDispatchGroup.leave() - }) - } - - doseDispatchGroup.wait() + func enact(decisionId: UUID?, bolus: Double?, tempBasal: TempBasalRecommendation?, with pumpManager: PumpManager) async throws { + if let tempBasal { + self.log.default("Enacting recommended basal change") + try await pumpManager.enactTempBasal(decisionId: decisionId, unitsPerHour: tempBasal.unitsPerHour, for: tempBasal.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 bolus, bolus > 0 { + self.log.default("Enacting recommended bolus dose") + try await pumpManager.enactBolus(decisionId: decisionId, units: bolus, activationType: .automatic) } } } + diff --git a/Loop/Managers/ExtensionDataManager.swift b/Loop/Managers/ExtensionDataManager.swift index 9261dcfc43..32bc0b90c4 100644 --- a/Loop/Managers/ExtensionDataManager.swift +++ b/Loop/Managers/ExtensionDataManager.swift @@ -6,35 +6,63 @@ // Copyright © 2016 Nathan Racklyeft. All rights reserved. // -import HealthKit +import LoopAlgorithm import UIKit import LoopKit +import LoopCore - +@MainActor final class ExtensionDataManager { unowned let deviceManager: DeviceDataManager - private let automaticDosingStatus: AutomaticDosingStatus + unowned let loopDataManager: LoopDataManager + unowned let settingsManager: SettingsManager + unowned let temporaryPresetsManager: TemporaryPresetsManager + private var dataUpdatedObserver: NSObjectProtocol? + private var pumpManagerChangedObserver: NSObjectProtocol? + init(deviceDataManager: DeviceDataManager, - automaticDosingStatus: AutomaticDosingStatus) - { + loopDataManager: LoopDataManager, + settingsManager: SettingsManager, + temporaryPresetsManager: TemporaryPresetsManager + ) { self.deviceManager = deviceDataManager - self.automaticDosingStatus = automaticDosingStatus + self.loopDataManager = loopDataManager + self.settingsManager = settingsManager + self.temporaryPresetsManager = temporaryPresetsManager - NotificationCenter.default.addObserver(self, selector: #selector(notificationReceived(_:)), name: .LoopDataUpdated, object: deviceDataManager.loopManager) - NotificationCenter.default.addObserver(self, selector: #selector(notificationReceived(_:)), name: .PumpManagerChanged, object: nil) - + dataUpdatedObserver = NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: nil, queue: .main) { [weak self] _ in + Task { @MainActor in + self?.update() + } + } + + pumpManagerChangedObserver = NotificationCenter.default.addObserver(forName: .PumpManagerChanged, object: nil, queue: .main) { [weak self] _ in + Task { @MainActor in + self?.update() + } + } + // Wait until LoopDataManager has had a chance to initialize itself - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.update() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.update() + } + } + + deinit { + if let obs = dataUpdatedObserver { + NotificationCenter.default.removeObserver(obs) + } + if let obs = pumpManagerChangedObserver { + NotificationCenter.default.removeObserver(obs) } } - fileprivate static var defaults: UserDefaults? { + nonisolated fileprivate static var defaults: UserDefaults? { return UserDefaults.appGroup } - static var context: StatusExtensionContext? { + nonisolated static var context: StatusExtensionContext? { get { return defaults?.statusExtensionContext } @@ -55,126 +83,118 @@ final class ExtensionDataManager { static var lastLoopCompleted: Date? { context?.lastLoopCompleted } - - @objc private func notificationReceived(_ notification: Notification) { - update() - } 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: LoopUnit) async -> StatusExtensionContext? { let basalDeliveryState = deviceManager.pumpManager?.status.basalDeliveryState - deviceManager.loopManager.getLoopState { (manager, state) in - let dataManager = self.deviceManager - var context = StatusExtensionContext() + 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: LoopUnit.milligramsPerDeciliter, + startDate: Date(), + interval: TimeInterval(minutes: 5)) + #endif + + context.lastLoopCompleted = loopDataManager.lastLoopCompleted + context.mostRecentGlucoseDataDate = loopDataManager.mostRecentGlucoseDataDate + context.mostRecentPumpDataDate = loopDataManager.mostRecentPumpDataDate + + context.isClosedLoop = self.settingsManager.dosingEnabled + + context.preMealPresetAllowed = self.settingsManager.dosingEnabled && self.settingsManager.settings.preMealTargetRange != nil + context.preMealPresetActive = self.temporaryPresetsManager.isPreMealTargetActive() + context.customPresetActive = self.temporaryPresetsManager.isNonPreMealOverrideActive() + + // 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.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 = 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) + } - 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) - } + 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.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 pumpManagerHUDProvider = dataManager.pumpManagerHUDProvider { + context.pumpManagerHUDViewContext = PumpManagerHUDViewContext(pumpManagerHUDViewRawValue: PumpManagerHUDViewRawValueFromHUDProvider(pumpManagerHUDProvider)) + } - context.cgmStatusHighlightContext = DeviceStatusHighlightContext(from: dataManager.cgmStatusHighlight) - context.cgmLifecycleProgressContext = DeviceLifecycleProgressContext(from: dataManager.cgmLifecycleProgress) + context.pumpStatusHighlightContext = DeviceStatusHighlightContext(from: dataManager.pumpStatusHighlight) + context.pumpLifecycleProgressContext = DeviceLifecycleProgressContext(from: dataManager.pumpLifecycleProgress) - context.carbsOnBoard = state.carbsOnBoard?.quantity.doubleValue(for: .gram()) - - completionHandler(context) - } + context.cgmStatusHighlightContext = DeviceStatusHighlightContext(from: dataManager.cgmStatusHighlight) + context.cgmLifecycleProgressContext = DeviceLifecycleProgressContext(from: dataManager.cgmLifecycleProgress) + + context.carbsOnBoard = state.activeCarbs?.value + + return context } } extension ExtensionDataManager: CustomDebugStringConvertible { - var debugDescription: String { + nonisolated var debugDescription: String { return [ "## StatusExtensionDataManager", "appGroupName: \(Bundle.main.appGroupSuiteName)", diff --git a/Loop/Managers/Live Activity/ChartAxisGenerator.swift b/Loop/Managers/Live Activity/ChartAxisGenerator.swift index 0fcc3ca80d..536d10daa7 100644 --- a/Loop/Managers/Live Activity/ChartAxisGenerator.swift +++ b/Loop/Managers/Live Activity/ChartAxisGenerator.swift @@ -8,6 +8,7 @@ import Foundation import HealthKit +import LoopAlgorithm import SwiftCharts import UIKit @@ -22,7 +23,7 @@ struct ChartAxisGenerator { // This logic is copied/ported from generateYAxisValuesUsingLinearSegmentStep static func getYAxis(points: [Double], isMmol: Bool) -> [Double] { - let unit: HKUnit = isMmol ? .millimolesPerLiter : .milligramsPerDeciliter + let unit: LoopUnit = isMmol ? .millimolesPerLiter : .milligramsPerDeciliter let glucoseDisplayRange = [ range.lowerBound.doubleValue(for: unit), @@ -39,7 +40,7 @@ struct ChartAxisGenerator { return [] } - let maxSegmentCount: Double = glucoseValueBelowSoftBoundsMinimum(first, unit) ? 5 : 4 + let maxSegmentCount: Double = glucoseValueBelowSoftBoundsMinimum(first, unit.hkUnit) ? 5 : 4 guard lastPar >=~ first else {fatalError("Invalid range generating axis values")} let multiple: Double = !isMmol ? (yAxisStepSizeMGDLOverride ?? 25) : 1 diff --git a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift index 1b3328d65b..0c4f5104d2 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift @@ -52,10 +52,33 @@ public struct GlucoseActivityAttributes: ActivityAttributes { public struct Preset: Codable, Hashable { public let title: String + /// SF Symbol name to render alongside the title. nil means render the title alone. + /// Emoji symbols are folded into `title` directly (they render as plain text); only + /// `.systemImage` symbols use this field. + public let iconSystemSymbolName: String? public let startDate: Date public let endDate: Date public let minValue: Double public let maxValue: Double + + public init(title: String, iconSystemSymbolName: String? = nil, startDate: Date, endDate: Date, minValue: Double, maxValue: Double) { + self.title = title + self.iconSystemSymbolName = iconSystemSymbolName + self.startDate = startDate + self.endDate = endDate + self.minValue = minValue + self.maxValue = maxValue + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.title = try container.decode(String.self, forKey: .title) + self.iconSystemSymbolName = try container.decodeIfPresent(String.self, forKey: .iconSystemSymbolName) + self.startDate = try container.decode(Date.self, forKey: .startDate) + self.endDate = try container.decode(Date.self, forKey: .endDate) + self.minValue = try container.decode(Double.self, forKey: .minValue) + self.maxValue = try container.decode(Double.self, forKey: .maxValue) + } } public struct GlucoseRangeValue: Identifiable, Codable, Hashable { diff --git a/Loop/Managers/Live Activity/LiveActivityManager.swift b/Loop/Managers/Live Activity/LiveActivityManager.swift index f7a4723f14..179b4a0536 100644 --- a/Loop/Managers/Live Activity/LiveActivityManager.swift +++ b/Loop/Managers/Live Activity/LiveActivityManager.swift @@ -9,6 +9,7 @@ import LoopKitUI import LoopKit import LoopCore +import LoopAlgorithm import Foundation import HealthKit import ActivityKit @@ -25,7 +26,10 @@ class LiveActivityManager : LiveActivityManagerProxy { private let glucoseStore: GlucoseStoreProtocol private let doseStore: DoseStoreProtocol - private var loopSettings: LoopSettings + private var scheduleOverride: TemporaryScheduleOverride? + private var preMealOverride: TemporaryScheduleOverride? + private var glucoseTargetRangeSchedule: GlucoseRangeSchedule? + private var activeInsulin: InsulinValue? private var startDate: Date = Date.now private var settings: LiveActivitySettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() @@ -50,7 +54,7 @@ class LiveActivityManager : LiveActivityManagerProxy { return dateFormatter }() - init?(glucoseStore: GlucoseStoreProtocol, doseStore: DoseStoreProtocol, loopSettings: LoopSettings) { + init?(glucoseStore: GlucoseStoreProtocol, doseStore: DoseStoreProtocol) { guard self.activityInfo.areActivitiesEnabled else { print("ERROR: Live Activities are not enabled...") return nil @@ -58,7 +62,6 @@ class LiveActivityManager : LiveActivityManagerProxy { self.glucoseStore = glucoseStore self.doseStore = doseStore - self.loopSettings = loopSettings // Ensure settings exist if UserDefaults.standard.liveActivity == nil { @@ -80,8 +83,16 @@ class LiveActivityManager : LiveActivityManagerProxy { } } - public func update(loopSettings: LoopSettings) { - self.loopSettings = loopSettings + public func update( + scheduleOverride: TemporaryScheduleOverride?, + preMealOverride: TemporaryScheduleOverride?, + glucoseTargetRangeSchedule: GlucoseRangeSchedule?, + activeInsulin: InsulinValue? + ) { + self.scheduleOverride = scheduleOverride + self.preMealOverride = preMealOverride + self.glucoseTargetRangeSchedule = glucoseTargetRangeSchedule + self.activeInsulin = activeInsulin update() } @@ -92,12 +103,13 @@ class LiveActivityManager : LiveActivityManagerProxy { await endActivity() } - guard let unit = await self.healthStore.cachedPreferredUnits(for: .bloodGlucose) else { + guard let hkUnit = await self.healthStore.cachedPreferredUnits(for: .bloodGlucose) else { print("ERROR: No unit found...") return } + let unit = LoopUnit(from: hkUnit) - let isMmol = unit == HKUnit.millimolesPerLiter + let isMmol = unit == LoopUnit.millimolesPerLiter await self.endUnknownActivities() let statusContext = UserDefaults.appGroup?.statusExtensionContext @@ -142,7 +154,7 @@ class LiveActivityManager : LiveActivityManagerProxy { } var presetContext: Preset? = nil - if let override = self.loopSettings.preMealOverride ?? self.loopSettings.scheduleOverride, let start = glucoseSamples.first?.startDate { + if let override = self.preMealOverride ?? self.scheduleOverride, let start = glucoseSamples.first?.startDate { let presetStart = max(override.startDate, start) let presetEnd = override.duration.isInfinite ? endDateChart : min(override.actualEndDate, endDateChart) // Only create a preset if it overlaps the chart window. If the override ended @@ -150,8 +162,10 @@ class LiveActivityManager : LiveActivityManagerProxy { // presetEnd < presetStart and drawing a RectangleMark with those backwards dates // forces SwiftUI Charts to expand the x-axis far into the past. if presetStart <= presetEnd { + let (title, iconSymbol) = override.liveActivityTitleAndSymbol() presetContext = Preset( - title: override.getTitle(), + title: title, + iconSystemSymbolName: iconSymbol, startDate: presetStart, endDate: presetEnd, minValue: override.settings.targetRange?.lowerBound.doubleValue(for: unit) ?? 0, @@ -161,7 +175,7 @@ class LiveActivityManager : LiveActivityManagerProxy { } var glucoseRanges: [GlucoseRangeValue] = [] - if let glucoseRangeSchedule = self.loopSettings.glucoseTargetRangeSchedule, let start = glucoseSamples.first?.startDate { + if let glucoseRangeSchedule = self.glucoseTargetRangeSchedule, let start = glucoseSamples.first?.startDate { glucoseRanges = getGlucoseRanges( glucoseRangeSchedule: glucoseRangeSchedule, presetContext: presetContext, @@ -174,7 +188,7 @@ class LiveActivityManager : LiveActivityManagerProxy { let yAxisPoints = glucoseSamples.map{ item in item.quantity.doubleValue(for: unit) } + predicatedGlucose let chartYAxis = ChartAxisGenerator.getYAxis( points: yAxisPoints, - isMmol: unit == HKUnit.millimolesPerLiter + isMmol: unit == LoopUnit.millimolesPerLiter ) let state = GlucoseActivityAttributes.ContentState( @@ -309,47 +323,23 @@ class LiveActivityManager : LiveActivityManagerProxy { } private func getInsulinOnBoard() -> String { - let updateGroup = DispatchGroup() - var iob = "??" - - updateGroup.enter() - self.doseStore.insulinOnBoard(at: Date.now) { result in - switch (result) { - case .failure: - break - case .success(let iobValue): - iob = self.iobFormatter.string(from: iobValue.value) ?? "??" - break - } - - updateGroup.leave() - } - - _ = updateGroup.wait(timeout: .distantFuture) - return iob + guard let iob = activeInsulin?.value else { return "??" } + return iobFormatter.string(from: iob) ?? "??" } - private func getGlucoseSample(unit: HKUnit) -> [StoredGlucoseSample] { + private func getGlucoseSample(unit: LoopUnit) -> [StoredGlucoseSample] { let updateGroup = DispatchGroup() var samples: [StoredGlucoseSample] = [] - updateGroup.enter() - // When in spacious mode, we want to show the predictive line // In compact mode, we only want to show the history let timeInterval: TimeInterval = self.settings.addPredictiveLine ? .hours(-2) : .hours(-6) - self.glucoseStore.getGlucoseSamples( - start: adjustedChartStart(Date.now.addingTimeInterval(timeInterval)), - end: Date.now - ) { result in - switch (result) { - case .failure: - break - case .success(let data): - samples = data - break - } - + updateGroup.enter() + Task { + samples = (try? await self.glucoseStore.getGlucoseSamples( + start: adjustedChartStart(Date.now.addingTimeInterval(timeInterval)), + end: Date.now + )) ?? [] updateGroup.leave() } @@ -377,7 +367,7 @@ class LiveActivityManager : LiveActivityManagerProxy { return startOfHour.addingTimeInterval(.minutes(30)) } - private func getGlucoseRanges(glucoseRangeSchedule: GlucoseRangeSchedule, presetContext: Preset?, start: Date, end: Date, unit: HKUnit) -> [GlucoseRangeValue] { + private func getGlucoseRanges(glucoseRangeSchedule: GlucoseRangeSchedule, presetContext: Preset?, start: Date, end: Date, unit: LoopUnit) -> [GlucoseRangeValue] { var glucoseRanges: [GlucoseRangeValue] = [] for item in glucoseRangeSchedule.quantityBetween(start: start, end: end) { let minValue = item.value.lowerBound.doubleValue(for: unit) @@ -571,16 +561,29 @@ class LiveActivityManager : LiveActivityManagerProxy { } extension TemporaryScheduleOverride { - func getTitle() -> String { - switch (self.context) { + /// Returns the Live Activity preset display: a plain-text title (with emoji folded in) + /// and, when the preset uses an SF Symbol, the symbol name to render via Image(systemName:). + func liveActivityTitleAndSymbol() -> (title: String, systemSymbolName: String?) { + switch context { case .preset(let preset): - return "\(preset.symbol) \(preset.name)" + guard let symbol = preset.symbol else { + return (preset.name, nil) + } + switch symbol.symbolType { + case .emoji: + return ("\(symbol.value) \(preset.name)", nil) + case .systemImage: + return (preset.name, symbol.value) + case .image: + // Asset-image symbols can't be loaded from the widget bundle; render name only. + return (preset.name, nil) + } case .custom: - return NSLocalizedString("Custom preset", comment: "The title of the cell indicating a generic custom preset is enabled") + return (NSLocalizedString("Custom preset", comment: "The title of the cell indicating a generic custom preset is enabled"), nil) case .preMeal: - return NSLocalizedString(" Pre-meal Preset", comment: "Status row title for premeal override enabled (leading space is to separate from symbol)") - case .legacyWorkout: - return "" + return (NSLocalizedString("Pre-meal Preset", comment: "Status row title for premeal override enabled"), "fork.knife") + @unknown default: + return ("", nil) } } } diff --git a/Loop/Managers/Live Activity/LiveActivityManagerProxy.swift b/Loop/Managers/Live Activity/LiveActivityManagerProxy.swift index ed88c92794..ffea337d21 100644 --- a/Loop/Managers/Live Activity/LiveActivityManagerProxy.swift +++ b/Loop/Managers/Live Activity/LiveActivityManagerProxy.swift @@ -6,8 +6,16 @@ // Copyright © 2025 LoopKit Authors. All rights reserved. // -import LoopCore +import LoopKit +import LoopAlgorithm protocol LiveActivityManagerProxy { - func update(loopSettings: LoopSettings) + /// Update the live activity with current override and glucose target information. + /// Call this whenever overrides or glucose targets change, or after every loop cycle. + func update( + scheduleOverride: TemporaryScheduleOverride?, + preMealOverride: TemporaryScheduleOverride?, + glucoseTargetRangeSchedule: GlucoseRangeSchedule?, + activeInsulin: InsulinValue? + ) } 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..7b16e274fd 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -8,12 +8,17 @@ import UIKit import Intents +import BackgroundTasks import Combine +import LoopTestingKit import LoopKit import LoopKitUI import MockKit import HealthKit import WidgetKit +import LoopCore +import LoopAlgorithm +import SwiftUI #if targetEnvironment(simulator) enum SimulatorError: Error { @@ -21,13 +26,14 @@ enum SimulatorError: Error { } #endif +@MainActor public protocol AlertPresenter: AnyObject { /// Present the alert view controller, with or without animation. /// - Parameters: /// - viewControllerToPresent: The alert view controller to present. /// - animated: Animate the alert view controller presentation or not. /// - completion: Completion to call once view controller is presented. - func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)?) + func present(_ viewControllerToPresent: UIViewController, animated flag: Bool) async /// Retract any alerts with the given identifier. This includes both pending and delivered alerts. @@ -35,26 +41,21 @@ public protocol AlertPresenter: AnyObject { /// - Parameters: /// - animated: Animate the alert view controller dismissal or not. /// - completion: Completion to call once view controller is dismissed. - func dismissTopMost(animated: Bool, completion: (() -> Void)?) + func dismissTopMost(animated: Bool) async /// Dismiss an alert, even if it is not the top most alert. /// - Parameters: /// - alertToDismiss: The alert to dismiss /// - animated: Animate the alert view controller dismissal or not. /// - completion: Completion to call once view controller is dismissed. - func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool, completion: (() -> Void)?) -} - -public extension AlertPresenter { - func present(_ viewController: UIViewController, animated: Bool) { present(viewController, animated: animated, completion: nil) } - func dismissTopMost(animated: Bool) { dismissTopMost(animated: animated, completion: nil) } - func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool) { dismissAlert(alertToDismiss, animated: animated, completion: nil) } + func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool) async } protocol WindowProvider: AnyObject { var window: UIWindow? { get } } +@MainActor class LoopAppManager: NSObject { private enum State: Int { case initialize @@ -74,6 +75,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! @@ -83,17 +89,30 @@ class LoopAppManager: NSObject { private var analyticsServicesManager = AnalyticsServicesManager() 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! + private var requiredUpdateModalPresented = false + + // 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 = WeakSet() private var state: State = .initialize private let log = DiagnosticLog(category: "LoopAppManager") private let widgetLog = DiagnosticLog(category: "LoopWidgets") - private let automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: false, isAutomaticDosingAllowed: false) - lazy private var cancellables = Set() func initialize(windowProvider: WindowProvider, launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { @@ -102,50 +121,61 @@ class LoopAppManager: NSObject { self.windowProvider = windowProvider self.launchOptions = launchOptions - + if FeatureFlags.siriEnabled && INPreferences.siriAuthorizationStatus() == .notDetermined { 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() + UNUserNotificationCenter.current().delegate = self + + 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() + NotificationManager.setNotificationCategories() + await launchHomeScreen() } - + askUserToConfirmLoopReset() } @@ -161,13 +191,12 @@ class LoopAppManager: NSObject { self.state = state.next } - private func launchManagers() { + private func launchManagers() async { dispatchPrecondition(condition: .onQueue(.main)) precondition(state == .launchManagers) windowProvider?.window?.tintColor = .loopAccent OrientationLock.deviceOrientationController = self - UNUserNotificationCenter.current().delegate = self resetLoopManager = ResetLoopManager(delegate: self) @@ -187,54 +216,253 @@ 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 + } + } + + 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, alertIssuer: alertManager) + temporaryPresetsManager.presetHistory.delegate = self + + temporaryPresetsManager.addTemporaryPresetObserver(analyticsServicesManager) + + await temporaryPresetsManager.scheduleNextPresetReminder() + + 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 + ) - settingsManager = SettingsManager(cacheStore: cacheStore, - expireAfter: localCacheDuration, - alertMuter: alertManager.alertMuter) + 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 hkUnit = await self.healthStore.cachedPreferredUnits(for: .bloodGlucose) { + let unit = LoopUnit(from: hkUnit) + self.displayGlucosePreference.unitDidChange(to: unit) + self.notifyObserversOfDisplayGlucoseUnitChange(to: unit) + } + } + } + + crashRecoveryManager = CrashRecoveryManager(alertIssuer: alertManager) + + loopDataManager = LoopDataManager( + lastLoopCompleted: ExtensionDataManager.context?.lastLoopCompleted, + temporaryPresetsManager: temporaryPresetsManager, + settingsProvider: settingsManager, + doseStore: doseStore, + glucoseStore: glucoseStore, + carbStore: carbStore, + crashRecoveryManager: crashRecoveryManager, + dosingDecisionStore: dosingDecisionStore, + trustedTimeOffset: { self.trustedTimeChecker.detectedSystemTimeOffset }, + analyticsServicesManager: analyticsServicesManager, + carbAbsorptionModel: .piecewiseLinear, + dosingStrategySelectionEnabled: FeatureFlags.dosingStrategySelectionEnabled + ) + + cacheStore.delegate = loopDataManager + + alertManager.addAlertResponder(managerIdentifier: crashRecoveryManager.managerIdentifier, alertResponder: crashRecoveryManager) + alertManager.addAlertResponder(managerIdentifier: temporaryPresetsManager.managerIdentifier, alertResponder: temporaryPresetsManager) + + 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.presetHistory, + 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 + + 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, + settingsManager: settingsManager, + temporaryPresetsManager: temporaryPresetsManager + ) - overrideHistory.delegate = self + watchManager = WatchDataManager( + deviceManager: deviceDataManager, + settingsManager: settingsManager, + loopDataManager: loopDataManager, + carbStore: carbStore, + glucoseStore: glucoseStore, + analyticsServicesManager: analyticsServicesManager, + temporaryPresetsManager: temporaryPresetsManager, + alertManager: alertManager, + 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) - + servicesManager.supportManager = supportManager + + supportManager.onRequiredUpdate = { [weak self] in + self?.handleRequiredVersionUpdate() + } + 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!) - deeplinkManager = DeeplinkManager(rootViewController: rootViewController) - for support in supportManager.availableSupports { if let analyticsService = support as? AnalyticsService { analyticsServicesManager.addService(analyticsService) @@ -252,23 +480,39 @@ 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) + state = state.next - automaticDosingStatus.$isAutomaticDosingAllowed - .combineLatest(deviceDataManager.loopManager.$dosingEnabled) - .map { $0 && $1 } - .assign(to: \.automaticDosingStatus.automaticDosingEnabled, on: self) + await loopDataManager.updateDisplayState() + + NotificationCenter.default.publisher(for: .LoopCycleCompleted) + .sink { [weak self] _ in + Task { + await self?.loopCycleDidComplete() + } + } .store(in: &cancellables) + } - state = state.next + 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,35 +522,122 @@ class LoopAppManager: NSObject { onboardingManager.launch { DispatchQueue.main.async { self.state = self.state.next - self.resumeLaunch() + Task { + await self.resumeLaunch() + await self.alertManager.playbackAlertsFromPersistence() + } } } } + + private lazy var settingsViewModel: SettingsViewModel = { + let deletePumpDataFunc: () -> PumpManagerViewModel.DeleteTestingDataFunc? = { [weak self] in + (self?.deviceDataManager.pumpManager is TestingPumpManager) ? { + Task { [weak self] in try? await self?.deviceDataManager.deleteTestingPumpData() + }} : nil + } + let deleteCGMDataFunc: () -> CGMManagerViewModel.DeleteTestingDataFunc? = { [weak self] in + (self?.deviceDataManager.cgmManager is TestingCGMManager) ? { + Task { [weak self] in try? await self?.deviceDataManager.deleteTestingCGMData() + }} : nil + } + let pumpViewModel = PumpManagerViewModel( + image: { [weak self] in (self?.deviceDataManager.pumpManager as? PumpManagerUI)?.smallImage }, + name: { [weak self] in self?.deviceDataManager.pumpManager?.localizedTitle ?? "" }, + isSetUp: { [weak self] in self?.deviceDataManager.pumpManager?.isOnboarded == true }, + availableDevices: deviceDataManager.availablePumpManagers, + deleteTestingDataFunc: deletePumpDataFunc + ) + + let cgmViewModel = CGMManagerViewModel( + image: {[weak self] in (self?.deviceDataManager.cgmManager as? DeviceManagerUI)?.smallImage }, + name: {[weak self] in self?.deviceDataManager.cgmManager?.localizedTitle ?? "" }, + isSetUp: {[weak self] in self?.deviceDataManager.cgmManager?.isOnboarded == true }, + availableDevices: deviceDataManager.availableCGMManagers, + deleteTestingDataFunc: deleteCGMDataFunc + ) + let servicesViewModel = ServicesViewModel(showServices: FeatureFlags.includeServicesInSettingsEnabled, + availableServices: { [weak self] in self?.servicesManager.availableServices ?? [] }, + activeServices: { [weak self] in self?.servicesManager.activeServices ?? [] }) + let versionUpdateViewModel = VersionUpdateViewModel(supportManager: supportManager, guidanceColors: .default) + + let viewModel = SettingsViewModel(alertPermissionsChecker: alertPermissionsChecker, + alertMuter: alertManager.alertMuter, + versionUpdateViewModel: versionUpdateViewModel, + pumpManagerSettingsViewModel: pumpViewModel, + cgmManagerSettingsViewModel: cgmViewModel, + servicesViewModel: servicesViewModel, + criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: criticalEventLogExportManager), + therapySettings: { [weak self] in self?.settingsManager.therapySettings ?? TherapySettings() }, + initialDosingEnabled: self.settingsManager.settings.dosingEnabled, + automaticDosingStrategy: self.settingsManager.settings.automaticDosingStrategy, + lastLoopCompletion: loopDataManager.$lastLoopCompleted, + mostRecentGlucoseDataDate: loopDataManager.$publishedMostRecentGlucoseDataDate, + mostRecentPumpDataDate: loopDataManager.$publishedMostRecentPumpDataDate, + availableSupports: supportManager.availableSupports, + isOnboardingComplete: onboardingManager.isComplete, + therapySettingsViewModelDelegate: deviceDataManager, + presetHistory: temporaryPresetsManager.presetHistory, + deliveryDelegate: deviceDataManager, + deviceManager: deviceDataManager + ) + + viewModel.favoriteFoodInsightsDelegate = loopDataManager + + return viewModel + }() - private func launchHomeScreen() { + private func launchHomeScreen() async { dispatchPrecondition(condition: .onQueue(.main)) precondition(state == .launchHomeScreen) + + let viewModel = StatusTableViewModel( + alertPermissionsChecker: alertPermissionsChecker, + alertMuter: alertManager.alertMuter, + deviceDataManager: deviceDataManager, + onboardingManager: onboardingManager, + supportManager: supportManager, + testingScenariosManager: testingScenariosManager, + settingsManager: settingsManager, + temporaryPresetsManager: temporaryPresetsManager, + loopDataManager: loopDataManager, + diagnosticReportGenerator: self, + simulatedData: self, + analyticsServicesManager: analyticsServicesManager, + servicesManager: servicesManager, + carbStore: carbStore, + doseStore: doseStore, + criticalEventLogExportManager: criticalEventLogExportManager, + bluetoothStateManager: bluetoothStateManager, + settingsViewModel: settingsViewModel + ) - let storyboard = UIStoryboard(name: "Main", bundle: Bundle(for: Self.self)) - let statusTableViewController = storyboard.instantiateViewController(withIdentifier: "MainStatusViewController") as! StatusTableViewController - statusTableViewController.alertPermissionsChecker = alertPermissionsChecker - statusTableViewController.alertMuter = alertManager.alertMuter - statusTableViewController.automaticDosingStatus = automaticDosingStatus - statusTableViewController.deviceManager = deviceDataManager - statusTableViewController.onboardingManager = onboardingManager - statusTableViewController.supportManager = supportManager - statusTableViewController.testingScenariosManager = testingScenariosManager - bluetoothStateManager.addBluetoothObserver(statusTableViewController) + let statusTableView = StatusTableView(viewModel: viewModel) + self.statusTableViewController = statusTableView.viewController + var rootNavigationController = rootViewController as? RootNavigationController if rootNavigationController == nil { rootNavigationController = RootNavigationController() rootViewController = rootNavigationController } - rootNavigationController?.setViewControllers([statusTableViewController], animated: true) - - deviceDataManager.refreshDeviceData() + rootNavigationController?.setViewControllers([ + UIHostingController( + rootView: statusTableView + .environmentObject(deviceDataManager.displayGlucosePreference) + .environment(\.appName, Bundle.main.bundleDisplayName) + .environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice) + .environment(\.guidanceColors, .default) + .environment(\.loopStatusColorPalette, .loopStatus) + .environment(\.colorPalette, .default) + .environment(\.settingsManager, settingsManager) + .environment(\.temporaryPresetsManager, temporaryPresetsManager) + .edgesIgnoringSafeArea(.top) + ) + ], animated: true) + + await deviceDataManager.refreshDeviceData() handleRemoteNotificationFromLaunchOptions() @@ -314,7 +645,7 @@ class LoopAppManager: NSObject { self.state = state.next - alertManager.playbackAlertsFromPersistence() + await alertManager.playbackAlertsFromPersistence() } // MARK: - Life Cycle @@ -325,15 +656,17 @@ class LoopAppManager: NSObject { } settingsManager?.didBecomeActive() deviceDataManager?.didBecomeActive() - alertManager.inferDeliveredLoopNotRunningNotifications() - + Task { + await alertManager?.inferDeliveredLoopNotRunningNotifications() + } + widgetLog.default("Refreshing widget. Reason: App didBecomeActive") WidgetCenter.shared.reloadAllTimelines() } // 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,14 +682,36 @@ class LoopAppManager: NSObject { guard let notification = notification else { return false } - deviceDataManager?.servicesManager.handleRemoteNotification(notification) + servicesManager.handleRemoteNotification(notification) return true } // MARK: - Deeplinking func handle(_ url: URL) -> Bool { - deeplinkManager.handle(url) + guard let deeplink = Deeplink(url: url) else { + return false + } + + switch deeplink { + case let .carbEntry(carbEntryLink): + if let carbEntryLink { + switch carbEntryLink { + case let .carbEntryDetected(value): + statusTableViewController?.presentCarbEntryScreen(nil, value: value) + } + } else { + statusTableViewController?.presentCarbEntryScreen(nil) + } + case .preMeal: + statusTableViewController?.presentPresets() + case .bolus: + statusTableViewController?.presentBolusScreen() + case .customPresets: + statusTableViewController?.presentPresets() + } + + return true } // MARK: - Continuity @@ -395,20 +750,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() { @@ -423,6 +764,30 @@ class LoopAppManager: NSObject { deviceDataManager.deviceWhitelist = DeviceWhitelist(cgmDevices: Array(whitelistedCGMs), pumpDevices: Array(whitelistedPumps)) } + private func handleRequiredVersionUpdate() { + settingsManager.mutateLoopSettings { settings in + settings.dosingEnabled = false + } + settingsViewModel.closedLoopPreference = false + + guard !requiredUpdateModalPresented else { return } + requiredUpdateModalPresented = true + + let appName = Bundle.main.bundleDisplayName + NotificationManager.sendRequiredUpdateNotification(appName: appName) + + let updateView = RequiredVersionUpdateView(appName: appName) { [weak self] in + self?.supportManager.openAppStore() + } + let hostingController = UIHostingController(rootView: updateView) + hostingController.modalPresentationStyle = .overFullScreen + hostingController.modalTransitionStyle = .crossDissolve + hostingController.isModalInPresentation = true + hostingController.view.backgroundColor = .clear + + rootViewController?.topmostViewController.present(hostingController, animated: true) + } + private func isProtectedDataAvailable() -> Bool { let fileManager = FileManager.default do { @@ -446,72 +811,74 @@ class LoopAppManager: NSObject { get { windowProvider?.window?.rootViewController } set { windowProvider?.window?.rootViewController = newValue } } + + private var statusTableViewController: StatusTableViewController? } // MARK: - AlertPresenter extension LoopAppManager: AlertPresenter { - func present(_ viewControllerToPresent: UIViewController, animated: Bool, completion: (() -> Void)?) { - DispatchQueue.main.async { - self.rootViewController?.topmostViewController.present(viewControllerToPresent, animated: animated, completion: completion) + func present(_ viewControllerToPresent: UIViewController, animated: Bool) async { + await withCheckedContinuation { continuation in + self.rootViewController?.topmostViewController.present(viewControllerToPresent, animated: animated) { + continuation.resume() + } } } - func dismissTopMost(animated: Bool, completion: (() -> Void)?) { - rootViewController?.topmostViewController.dismiss(animated: animated, completion: completion) + func dismissTopMost(animated: Bool) async { + await withCheckedContinuation { continuation in + rootViewController?.topmostViewController.dismiss(animated: animated) { + continuation.resume() + } + } } - func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool, completion: (() -> Void)?) { - if rootViewController?.topmostViewController == alertToDismiss { - dismissTopMost(animated: animated, completion: completion) - } else { - // check if the alert to dismiss is presenting another alert (and so on) - // calling dismiss() on an alert presenting another alert will only dismiss the presented alert - // (and any other alerts presented by the presented alert) - - // get the stack of presented alerts that would be undesirably dismissed - var presentedAlerts: [UIAlertController] = [] - var currentAlert = alertToDismiss - while let presentedAlert = currentAlert.presentedViewController as? UIAlertController { - presentedAlerts.append(presentedAlert) - currentAlert = presentedAlert - } + func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool) async { + await alertToDismiss.dismiss(animated: animated) + } +} - if presentedAlerts.isEmpty { - alertToDismiss.dismiss(animated: animated, completion: completion) - } else { - // Do not animate any of these view transitions, since the alert to dismiss is not at the top of the stack - - // dismiss all the child presented alerts. - // Calling dismiss() on a VC that is presenting an other VC will dismiss the presented VC and all of its child presented VCs - alertToDismiss.dismiss(animated: false) { - // dismiss the desired alert - // Calling dismiss() on a VC that is NOT presenting any other VCs will dismiss said VC - alertToDismiss.dismiss(animated: false) { - // present the child alerts that were undesirably dismissed - var orderedPresentationBlock: (() -> Void)? = nil - for alert in presentedAlerts.reversed() { - if alert == presentedAlerts.last { - orderedPresentationBlock = { - self.present(alert, animated: false, completion: completion) - } - } else { - orderedPresentationBlock = { - self.present(alert, animated: false, completion: orderedPresentationBlock) - } - } - } - orderedPresentationBlock?() - } - } +extension UIViewController { + func dismiss(animated flag: Bool) async { + await withCheckedContinuation { continuation in + self.dismiss(animated: flag) { + continuation.resume() } } } } +@MainActor +protocol DisplayGlucoseUnitBroadcaster: AnyObject { + func addDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) + func removeDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) + func notifyObserversOfDisplayGlucoseUnitChange(to displayGlucoseUnit: LoopUnit) +} + +extension LoopAppManager: DisplayGlucoseUnitBroadcaster { + func addDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) { + displayGlucoseUnitObservers.insert(observer) + Task { @MainActor in + observer.unitDidChange(to: self.displayGlucosePreference.unit) + } + } + + func removeDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) { + displayGlucoseUnitObservers.remove(observer) + displayGlucoseUnitObservers.cleanupDeallocatedElements() + } + + func notifyObserversOfDisplayGlucoseUnitChange(to displayGlucoseUnit: LoopUnit) { + self.displayGlucoseUnitObservers.forEach { + $0.unitDidChange(to: displayGlucoseUnit) + } + } +} + // MARK: - DeviceOrientationController -extension LoopAppManager: DeviceOrientationController { +extension LoopAppManager: @preconcurrency DeviceOrientationController { func setDefaultSupportedInferfaceOrientations() { supportedInterfaceOrientations = Self.defaultSupportedInterfaceOrientations } @@ -520,7 +887,8 @@ extension LoopAppManager: DeviceOrientationController { // MARK: - UNUserNotificationCenterDelegate extension LoopAppManager: UNUserNotificationCenterDelegate { - func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + + nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { switch notification.request.identifier { // TODO: Until these notifications are converted to use the new alert system, they shall still show in the foreground case LoopNotificationCategory.bolusFailure.rawValue, @@ -531,7 +899,8 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { LoopNotificationCategory.remoteBolusFailure.rawValue, LoopNotificationCategory.remoteCarbs.rawValue, LoopNotificationCategory.remoteCarbsFailure.rawValue, - LoopNotificationCategory.missedMeal.rawValue: + LoopNotificationCategory.missedMeal.rawValue, + LoopNotificationCategory.requiredUpdate.rawValue: completionHandler([.badge, .sound, .list, .banner]) default: // For all others, banners are not to be displayed while in the foreground @@ -539,7 +908,9 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { } } - func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { + log.default("didReceive UNNotificationResponse: %{public}@", String(describing: response)) switch response.actionIdentifier { case NotificationManager.Action.retryBolus.rawValue: if let units = response.notification.request.content.userInfo[LoopNotificationUserInfoKey.bolusAmount.rawValue] as? Double, @@ -548,21 +919,18 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { let activationType = BolusActivationType(rawValue: activationTypeRawValue), startDate.timeIntervalSinceNow >= TimeInterval(minutes: -5) { - deviceDataManager?.analyticsServicesManager.didRetryBolus() - - deviceDataManager?.enactBolus(units: units, activationType: activationType) { (_) in - DispatchQueue.main.async { - completionHandler() - } - } - return + analyticsServicesManager.didRetryBolus() + try? await deviceDataManager?.enactBolus(units: units, decisionId: UUID(uuidString: response.notification.request.content.userInfo[LoopNotificationUserInfoKey.decisionId.rawValue] as? String ?? ""), activationType: activationType) } case NotificationManager.Action.acknowledgeAlert.rawValue: let userInfo = response.notification.request.content.userInfo - if let alertIdentifier = userInfo[LoopNotificationUserInfoKey.alertTypeID.rawValue] as? Alert.AlertIdentifier, - let managerIdentifier = userInfo[LoopNotificationUserInfoKey.managerIDForAlert.rawValue] as? String { - alertManager?.acknowledgeAlert(identifier: Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: alertIdentifier)) + if let alertIdentifier = userInfo[LoopNotificationUserInfoKey.alertTypeID.rawValue] as? LoopKit.Alert.AlertIdentifier, + let managerIdentifier = userInfo[LoopNotificationUserInfoKey.managerIDForAlert.rawValue] as? String + { + try? await alertManager?.acknowledgeAlert(identifier: Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: + alertIdentifier)) } + case UNNotificationDefaultActionIdentifier: guard response.notification.request.identifier == LoopNotificationCategory.missedMeal.rawValue else { break @@ -575,7 +943,7 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { let mealTime = userInfo[LoopNotificationUserInfoKey.missedMealTime.rawValue] as? Date, let carbAmount = userInfo[LoopNotificationUserInfoKey.missedMealCarbAmount.rawValue] as? Double { - let missedEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), + let missedEntry = NewCarbEntry(quantity: LoopQuantity(unit: .gram, doubleValue: carbAmount), startDate: mealTime, foodType: nil, @@ -586,10 +954,14 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { rootViewController?.restoreUserActivityState(carbActivity) default: - break + let userInfo = response.notification.request.content.userInfo + if let alertIdentifier = userInfo[LoopNotificationUserInfoKey.alertTypeID.rawValue] as? LoopKit.Alert.AlertIdentifier, + let managerIdentifier = userInfo[LoopNotificationUserInfoKey.managerIDForAlert.rawValue] as? String + { + let identifier = Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: alertIdentifier) + try? await alertManager.userDidSelectAction(alertIdentifier: identifier, actionIdentifier: response.actionIdentifier) + } } - - completionHandler() } } @@ -598,10 +970,10 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { // MARK: - UNUserNotificationCenterDelegate extension LoopAppManager: TemporaryScheduleOverrideHistoryDelegate { - func temporaryScheduleOverrideHistoryDidUpdate(_ history: TemporaryScheduleOverrideHistory) { - UserDefaults.appGroup?.overrideHistory = history - - deviceDataManager.remoteDataServicesManager.triggerUpload(for: .overrides) + nonisolated func temporaryScheduleOverrideHistoryDidUpdate(_ history: TemporaryScheduleOverrideHistory) { + Task { + await remoteDataServicesManager.triggerUpload(for: .overrides) + } } } @@ -611,12 +983,14 @@ extension LoopAppManager: ResetLoopManagerDelegate { } func presentConfirmationAlert(confirmAction: @escaping (PumpManager?, @escaping () -> Void) -> Void, cancelAction: @escaping () -> Void) { - alertManager.presentLoopResetConfirmationAlert( - confirmAction: { [weak self] completion in - confirmAction(self?.deviceDataManager.pumpManager, completion) - }, - cancelAction: cancelAction - ) + Task { + await alertManager.presentLoopResetConfirmationAlert( + confirmAction: { [weak self] completion in + confirmAction(self?.deviceDataManager.pumpManager, completion) + }, + cancelAction: cancelAction + ) + } } func loopWillReset() { @@ -632,14 +1006,167 @@ 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() } } } func presentCouldNotResetLoopAlert(error: Error) { - alertManager.presentCouldNotResetLoopAlert(error: error) + Task { + await alertManager.presentCouldNotResetLoopAlert(error: error) + } + } +} + +// MARK: - ServicesManagerDosingDelegate + +extension LoopAppManager: ServicesManagerDosingDelegate { + func deliverBolus(amountInUnits: Double, decisionId: UUID?) async throws { + try await deviceDataManager.enactBolus(units: amountInUnits, decisionId: decisionId, 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 submodulesInfo = BuildDetails.default.submodules + .sorted(by: { $0.key < $1.key }) + .map { key, value in + "* \(key): \(value.branch), \(value.commitSHA)" + } + .joined(separator: "\n") + + let entries: [String] = [ + "## Build Details", + "* appNameAndVersion: \(Bundle.main.localizedNameAndVersion)", + "* profileExpiration: \(BuildDetails.default.profileExpirationString)", + "* sourceRoot: \(BuildDetails.default.sourceRoot ?? "N/A")", + "* buildDateString: \(BuildDetails.default.buildDateString ?? "N/A")", + "* xcodeVersion: \(BuildDetails.default.xcodeVersion ?? "N/A")", + "* Workspace branch: \(BuildDetails.default.workspaceGitBranch ?? "N/A")", + "* Workspace SHA: \(BuildDetails.default.workspaceGitRevision ?? "N/A")", + "* Submodule name: branch, SHA", + "\(submodulesInfo)", + "", + "## 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 + +@MainActor +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") + } + + guard settingsManager.settingsStore != nil else { + fatalError("\(#function) invoke with no settings store") + } + + Task { @MainActor in + do { + try await settingsManager.settingsStore?.generateSimulatedHistoricalSettingsObjects() + try await self.doseStore.generateSimulatedHistoricalPumpEvents() + try await self.glucoseStore.generateSimulatedHistoricalGlucoseObjects() + try await self.carbStore.generateSimulatedHistoricalCarbObjects() + try await self.dosingDecisionStore.generateSimulatedHistoricalDosingDecisionObjects() + try await self.deviceDataManager.deviceLog.generateSimulatedHistoricalDeviceLogEntries() + try await self.alertManager.alertStore.generateSimulatedHistoricalStoredAlerts() + } catch { + completion(error) + return + } + + } + } + + 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() + try await self.dosingDecisionStore.purgeHistoricalDosingDecisionObjects() + } catch { + 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..dfc1ce65c7 --- /dev/null +++ b/Loop/Managers/LoopDataManager+CarbAbsorption.swift @@ -0,0 +1,103 @@ +// +// LoopDataManager+CarbAbsorption.swift +// Loop +// +// Created by Pete Schwamb on 11/6/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +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.presetHistory.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) + + // 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: start, + to: end.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration), + carbRatios: carbRatioWithOverrides, + insulinSensitivities: sensitivityWithOverrides, + absorptionModel: CarbAbsorptionModel.piecewiseLinear.model + ) + + return CarbAbsorptionReview( + carbEntries: carbEntries, + carbStatuses: carbStatus, + effectsVelocities: insulinCounteractionEffects, + carbEffects: carbEffects + ) + } +} diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index c9aef285e8..b05e0c63f9 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -8,130 +8,209 @@ 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) -} -final class LoopDataManager { - enum LoopUpdateContext: Int { - case insulin - case carbs - case glucose - case preferences - case loopFinished +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) } - let loopLock = UnfairLock() + var asTuple: (algoInput: StoredDataAlgorithmInput?, algoOutput: AlgorithmOutput?) { + return (algoInput: input, algoOutput: output) + } +} - static let LoopUpdateContextKey = "com.loudnate.Loop.LoopDataManager.LoopUpdateContext" +protocol DeliveryDelegate: AnyObject { + var isSuspended: Bool { get } + var pumpInsulinType: InsulinType? { get } + var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? { get } + var isPumpConfigured: Bool { get } + + func enact(bolus: Double?, tempBasal: TempBasalRecommendation?, decisionId: UUID?) async throws + func enactBolus(units: Double, decisionId: UUID?, activationType: BolusActivationType) async throws + func roundBasalRate(unitsPerHour: Double) -> Double + func roundBolusVolume(units: Double) -> Double +} - private let carbStore: CarbStoreProtocol +extension PumpManagerStatus.BasalDeliveryState { + var currentTempBasal: DoseEntry? { + switch self { + case .tempBasal(let dose): + return dose + default: + return nil + } + } - private let mealDetectionManager: MealDetectionManager + func currentBasalRate(currentScheduledBasalRate: Double) -> Double? { + switch self { + case .tempBasal(let dose): + return dose.unitsPerHour + case .suspended: + return 0 + case .pumpInoperable: + return nil + default: + return currentScheduledBasalRate + } + } +} - private let doseStore: DoseStoreProtocol +protocol DosingManagerDelegate { + func didMakeDosingDecision(_ decision: StoredDosingDecision) +} - let dosingDecisionStore: DosingDecisionStoreProtocol +enum LoopUpdateContext: Int { + case insulin + case carbs + case glucose + case preferences + case forecast +} - private let glucoseStore: GlucoseStoreProtocol +@MainActor +final class LoopDataManager: ObservableObject { + nonisolated static let LoopUpdateContextKey = "com.loudnate.Loop.LoopDataManager.LoopUpdateContext" - let latestStoredSettingsProvider: LatestStoredSettingsProvider + // Represents the current state of the loop algorithm for display + var displayState = AlgorithmDisplayState() - weak var delegate: LoopDataManagerDelegate? + // Display state convenience accessors + var predictedGlucose: [PredictedGlucoseValue]? { + displayState.output?.predictedGlucose + } - private let logger = DiagnosticLog(category: "LoopDataManager") - private let widgetLog = DiagnosticLog(category: "LoopWidgets") + var tempBasalRecommendation: TempBasalRecommendation? { + displayState.output?.recommendation?.automatic?.basalAdjustment + } + + var automaticBolusRecommendation: Double? { + displayState.output?.recommendation?.automatic?.bolusUnits + } - private let analyticsServicesManager: AnalyticsServicesManager + var automaticRecommendation: AutomaticDoseRecommendation? { + displayState.output?.recommendation?.automatic + } - private let trustedTimeOffset: () -> TimeInterval + @Published private(set) var lastLoopCompleted: Date? + @Published private(set) var publishedMostRecentGlucoseDataDate: Date? + @Published private(set) var publishedMostRecentPumpDataDate: Date? + @Published private(set) var lastManualBolus: LastManualBolus? - private let now: () -> Date - private let automaticDosingStatus: AutomaticDosingStatus + var deliveryDelegate: DeliveryDelegate? - lazy private var cancellables = Set() + let analyticsServicesManager: AnalyticsServicesManager? + let carbStore: CarbStoreProtocol + let doseStore: DoseStoreProtocol + let temporaryPresetsManager: TemporaryPresetsManager + let settingsProvider: SettingsProvider + let dosingDecisionStore: DosingDecisionStoreProtocol + let glucoseStore: GlucoseStoreProtocol + let crashRecoveryManager: CrashRecoveryManager + + let logger = DiagnosticLog(category: "LoopDataManager") + + private let widgetLog = DiagnosticLog(category: "LoopWidgets") + + private let trustedTimeOffset: () async -> TimeInterval + + private var now: Date { TestingDate.currentTestingDate() } // 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 + } - private var timeBasedDoseApplicationFactor: Double = 1.0 + var latestGlucose: GlucoseSampleValue? { + displayState.input?.glucoseHistory.last + } private var insulinOnBoard: InsulinValue? private var liveActivityManager: LiveActivityManagerProxy? + var lastReservoirValue: ReservoirValue? { + doseStore.lastReservoirValue + } + + + var carbAbsorptionModel: CarbAbsorptionModel - deinit { - for observer in notificationObservers { - NotificationCenter.default.removeObserver(observer) + private var lastManualBolusRecommendation: ManualBolusRecommendation? + + private(set) var dosingStrategySelectionEnabled: Bool + + var usePositiveMomentumAndRCForManualBoluses: Bool + + 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, + crashRecoveryManager: CrashRecoveryManager, 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, + dosingStrategySelectionEnabled: 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.crashRecoveryManager = crashRecoveryManager 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 + self.analyticsServicesManager = analyticsServicesManager + self.carbAbsorptionModel = carbAbsorptionModel + self.usePositiveMomentumAndRCForManualBoluses = usePositiveMomentumAndRCForManualBoluses + self.automationHistory = UserDefaults.standard.automationHistory + self.publishedMostRecentGlucoseDataDate = glucoseStore.latestGlucose?.startDate + self.dosingStrategySelectionEnabled = dosingStrategySelectionEnabled + self.publishedMostRecentPumpDataDate = mostRecentPumpDataDate if #available(iOS 16.2, *) { self.liveActivityManager = LiveActivityManager( glucoseStore: self.glucoseStore, - doseStore: self.doseStore, - loopSettings: self.settings + doseStore: self.doseStore ) } @@ -146,27 +225,16 @@ final class LoopDataManager { } 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) - } - } - self?.liveActivityManager?.update(loopSettings: settings) + // TemporaryPresetsManager handles presetActivated/Deactivated observers automatically + self?.temporaryPresetsManager.scheduleOverride = preset.createOverride(enactTrigger: .remote("Siri")) + Task { @MainActor in + await self?.updateDisplayState() } // Remove the override from UserDefaults so we don't set it multiple times appGroup.intentExtensionOverrideToSet = nil }) + // Required for device settings in stored dosing decisions UIDevice.current.isBatteryMonitoringEnabled = true @@ -177,14 +245,8 @@ final class LoopDataManager { object: self.carbStore, queue: nil ) { (note) -> Void in - self.dataAccessQueue.async { - self.logger.default("Received notification of carb entries changing") - self.liveActivityManager?.update(loopSettings: self.settings) - - self.carbEffect = nil - self.carbsOnBoard = nil - self.recentCarbEntries = nil - self.remoteRecommendationNeedsUpdating = true + Task { @MainActor in + await self.updateDisplayState() self.notify(forChange: .carbs) } }, @@ -193,1986 +255,841 @@ final class LoopDataManager { object: self.glucoseStore, queue: nil ) { (note) in - self.dataAccessQueue.async { - self.logger.default("Received notification of glucose samples changing") - self.liveActivityManager?.update(loopSettings: self.settings) - - self.glucoseMomentumEffect = nil - self.remoteRecommendationNeedsUpdating = true - + Task { @MainActor in + self.restartGlucoseValueStalenessTimer() + await self.updateDisplayState() self.notify(forChange: .glucose) } }, NotificationCenter.default.addObserver( - forName: nil, + forName: DoseStore.valuesDidChange, object: self.doseStore, queue: OperationQueue.main ) { (note) in - self.dataAccessQueue.async { - self.logger.default("Received notification of dosing changing") - self.liveActivityManager?.update(loopSettings: self.settings) - - self.clearCachedInsulinEffects() - self.remoteRecommendationNeedsUpdating = true - + Task { @MainActor in + 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 + await self.updateDisplayState() + self.notify(forChange: .forecast) + } + } } ] // 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 - .removeDuplicates() - .dropFirst() - .receive(on: DispatchQueue.main) - .sink { if !$0 { - self.mutateSettings { settings in - settings.clearOverride(matching: .preMeal) + + withObservationTracking(of: settingsProvider.dosingEnabled) { [weak self] enabled in + if let self, self.automationHistory.last?.enabled != enabled { + self.automationHistory.append(AutomationHistoryEntry(startDate: self.now, enabled: enabled)) + + // Clean up entries older than 36 hours; we should not be interpolating basal data before then. + let now = now + self.automationHistory = self.automationHistory.filter({ entry in + now.timeIntervalSince(entry.startDate) < .hours(36) + }) + + Task { + await self.updateDisplayState() + } + } + + if !enabled { + temporaryPresetsManager.endPreMealOverride() + Task { + try? await self?.cancelActiveTempBasal(for: .automaticDosingDisabled) } - self.cancelActiveTempBasal(for: .automaticDosingDisabled) - } } - .store(in: &cancellables) + } + } } - /// Loop-related settings + // MARK: - Calculation state + // Note: settings are now accessed via settingsProvider.settings (StoredSettings) + // and overrides via temporaryPresetsManager. DIY's lockedSettings/mutateSettings + // were removed as part of the Swift Concurrency migration. + + 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? = nil, + presumePresetEndingNow: 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 + let baseTime = baseTime ?? now - if newValue.preMealOverride != oldValue.preMealOverride { - // The prediction isn't actually invalid, but a target range change requires recomputing recommended doses - predictedGlucose = nil - - self.liveActivityManager?.update(loopSettings: newValue) - } + var dosesStart = baseTime.addingTimeInterval(-dosesInputHistory) - if newValue.scheduleOverride != oldValue.scheduleOverride { - overrideHistory.recordOverride(settings.scheduleOverride) + // Ensure dosing data goes back before ensureDosingCoverageStart, if specified + if let ensureDosingCoverageStart { + dosesStart = min(ensureDosingCoverageStart, dosesStart) + } - if let oldPreset = oldValue.scheduleOverride { - for observer in self.presetActivationObservers { - observer.presetDeactivated(context: oldPreset.context) - } - self.liveActivityManager?.update(loopSettings: newValue) - } - if let newPreset = newValue.scheduleOverride { - for observer in self.presetActivationObservers { - observer.presetActivated(context: newPreset.context, duration: newPreset.duration) - } - - self.liveActivityManager?.update(loopSettings: newValue) - } + let doses = try await doseStore.getNormalizedDoseEntries( + start: dosesStart, + end: baseTime + ) - // Invalidate cached effects affected by the override - invalidateCachedEffects = true - - // Update the affected schedules - mealDetectionManager.carbRatioScheduleApplyingOverrideHistory = carbRatioScheduleApplyingOverrideHistory - mealDetectionManager.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivityScheduleApplyingOverrideHistory - } + // 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 newValue.insulinSensitivitySchedule != oldValue.insulinSensitivitySchedule { - carbStore.insulinSensitivitySchedule = newValue.insulinSensitivitySchedule - doseStore.insulinSensitivitySchedule = newValue.insulinSensitivitySchedule - mealDetectionManager.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivityScheduleApplyingOverrideHistory - invalidateCachedEffects = true - analyticsServicesManager.didChangeInsulinSensitivitySchedule() - } + // Doses with a start time before baseTime might still end after baseTime + let dosesEnd = max(baseTime, doses.map { $0.endDate }.max() ?? baseTime) - if newValue.basalRateSchedule != oldValue.basalRateSchedule { - doseStore.basalProfile = newValue.basalRateSchedule + let basal = try await settingsProvider.getBasalHistory(startDate: dosesStart, endDate: dosesEnd) - if let newValue = newValue.basalRateSchedule, let oldValue = oldValue.basalRateSchedule, newValue.items != oldValue.items { - analyticsServicesManager.didChangeBasalRateSchedule() - } + guard !basal.isEmpty else { + throw LoopError.configurationError(.basalRateSchedule) } - if newValue.carbRatioSchedule != oldValue.carbRatioSchedule { - carbStore.carbRatioSchedule = newValue.carbRatioSchedule - mealDetectionManager.carbRatioScheduleApplyingOverrideHistory = carbRatioScheduleApplyingOverrideHistory - invalidateCachedEffects = true - analyticsServicesManager.didChangeCarbRatioSchedule() - } + let forecastEndTime = baseTime.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration).dateCeiledToTimeInterval(GlucoseMath.defaultDelta) - 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 carbsStart = baseTime.addingTimeInterval(CarbMath.dateAdjustmentPast + .minutes(-1)) // additional minute to handle difference in seconds between carb entry and carb ratio - if newValue.maximumBolus != oldValue.maximumBolus { - mealDetectionManager.maximumBolus = newValue.maximumBolus + // 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 invalidateCachedEffects { - dataAccessQueue.async { - // Invalidate cached effects based on this schedule - self.carbEffect = nil - self.carbsOnBoard = nil - self.clearCachedInsulinEffects() - } + let carbRatio = try await settingsProvider.getCarbRatioHistory( + startDate: carbsStart, + endDate: forecastEndTime + ) + + guard !carbRatio.isEmpty else { + throw LoopError.configurationError(.carbRatioSchedule) } - notify(forChange: .preferences) - analyticsServicesManager.didChangeLoopSettings(from: oldValue, to: newValue) - } + let glucose = try await glucoseStore.getGlucoseSamples(start: carbsStart, end: baseTime) - @Published private(set) var dosingEnabled: Bool + let dosesWithModel = doses.map { $0.simpleDose(with: insulinModel(for: $0.insulinType)) } - let overrideHistory: TemporaryScheduleOverrideHistory + let recommendationInsulinModel = insulinModel(for: deliveryDelegate?.pumpInsulinType ?? .novolog) - // MARK: - Calculation state + let recommendationEffectInterval = DateInterval( + start: baseTime, + duration: recommendationInsulinModel.effectDuration + ) + let neededSensitivityTimeline = LoopAlgorithm.timelineIntervalForSensitivity( + doses: dosesWithModel, + glucoseHistoryStart: glucose.first?.startDate ?? baseTime, + recommendationEffectInterval: recommendationEffectInterval + ) - fileprivate let dataAccessQueue: DispatchQueue = DispatchQueue(label: "com.loudnate.Naterade.LoopDataManager.dataAccessQueue", qos: .utility) + let sensitivity = try await settingsProvider.getInsulinSensitivityHistory( + startDate: neededSensitivityTimeline.start, + endDate: neededSensitivityTimeline.end + ) - 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.presetHistory.getOverrideHistory(startDate: neededSensitivityTimeline.start, endDate: forecastEndTime) - private var retrospectiveGlucoseEffect: [GlucoseEffect] = [] { - didSet { - predictedGlucose = nil + // For recommendation, we should consider preMeal override to be ending at time of dose + if presumePresetEndingNow, + let activeOverride = temporaryPresetsManager.activeOverride, + let index = overrides.lastIndex(of: activeOverride) { + overrides[index].scheduledEndDate = baseTime } - } - - /// When combining retrospective glucose discrepancies, extend the window slightly as a buffer. - private let retrospectiveCorrectionGroupingIntervalMultiplier = 1.01 - private var retrospectiveGlucoseDiscrepancies: [GlucoseEffect]? { - didSet { - retrospectiveGlucoseDiscrepanciesSummed = retrospectiveGlucoseDiscrepancies?.combinedSums(of: LoopMath.retrospectiveCorrectionGroupingInterval * retrospectiveCorrectionGroupingIntervalMultiplier) + guard !sensitivity.isEmpty else { + throw LoopError.configurationError(.insulinSensitivitySchedule) } - } - private var retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange]? - - private var suspendInsulinDeliveryEffect: [GlucoseEffect] = [] + let sensitivityWithOverrides = overrides.applySensitivity(over: sensitivity) - fileprivate var predictedGlucose: [PredictedGlucoseValue]? { - didSet { - recommendedAutomaticDose = nil - predictedGlucoseIncludingPendingInsulin = nil + guard !basal.isEmpty else { + throw LoopError.configurationError(.basalRateSchedule) } - } - - fileprivate var predictedGlucoseIncludingPendingInsulin: [PredictedGlucoseValue]? + let basalWithOverrides = overrides.applyBasal(over: basal) - private var recentCarbEntries: [StoredCarbEntry]? + guard !carbRatio.isEmpty else { + throw LoopError.configurationError(.carbRatioSchedule) + } + let carbRatioWithOverrides = overrides.applyCarbRatio(over: carbRatio) - fileprivate var recommendedAutomaticDose: (recommendation: AutomaticDoseRecommendation, date: Date)? - fileprivate var carbsOnBoard: CarbValue? + var target: [AbsoluteScheduleValue>] - var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? { - get { - return lockedBasalDeliveryState.value - } - set { - self.logger.debug("Updating basalDeliveryState to %{public}@", String(describing: newValue)) - lockedBasalDeliveryState.value = newValue - } - } - private let lockedBasalDeliveryState: Locked - - var pumpInsulinType: InsulinType? { - get { - return lockedPumpInsulinType.value + guard var suspendThreshold = dosingLimits.suspendThreshold else { + throw LoopError.configurationError(.suspendThreshold) } - set { - lockedPumpInsulinType.value = newValue - } - } - private let lockedPumpInsulinType: Locked - fileprivate var lastRequestedBolus: DoseEntry? + // If we have an active override, and it's not a preMeal override that should be disabled, + // or ended for other reasons (like comparing effects without preset), then override the + // target for the entire forecast. + if let activeOverride = temporaryPresetsManager.activeOverride, + !presumePresetEndingNow + { + guard let schedule = settingsProvider.settings.glucoseTargetRangeSchedule else + { + throw LoopError.configurationError(.glucoseTargetRangeSchedule) + } + let scheduledRange = schedule.quantityRange(at: baseTime) + let overriddenTargetRange = activeOverride.effectiveCorrectionRangeDuring(scheduledRange: scheduledRange) + target = [ + AbsoluteScheduleValue( + startDate: baseTime, + endDate: forecastEndTime, + value: overriddenTargetRange + ) + ] - /// 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 - } - } - private let lockedLastLoopCompleted: Locked + if activeOverride.veryHighInsulinNeeds { + suspendThreshold = max(TemporaryScheduleOverride.highInsulinNeedsMitigationCorrectionRangeLimit, suspendThreshold) + } - fileprivate var lastLoopError: LoopError? + } else { + target = try await settingsProvider.getTargetRangeHistory(startDate: baseTime, endDate: forecastEndTime) + } - /// A timeline of average velocity of glucose change counteracting predicted insulin effects - fileprivate var insulinCounteractionEffects: [GlucoseEffectVelocity] = [] { - didSet { - carbEffect = nil - carbsOnBoard = nil + guard !target.isEmpty else { + throw LoopError.configurationError(.glucoseTargetRangeSchedule) } - } - // Confined to dataAccessQueue - private var lastIntegralRetrospectiveCorrectionEnabled: Bool? - private var cachedRetrospectiveCorrection: RetrospectiveCorrection? - var retrospectiveCorrection: RetrospectiveCorrection { - let currentIntegralRetrospectiveCorrectionEnabled = UserDefaults.standard.integralRetrospectiveCorrectionEnabled - - if lastIntegralRetrospectiveCorrectionEnabled != currentIntegralRetrospectiveCorrectionEnabled || cachedRetrospectiveCorrection == nil { - lastIntegralRetrospectiveCorrectionEnabled = currentIntegralRetrospectiveCorrectionEnabled - if currentIntegralRetrospectiveCorrectionEnabled { - cachedRetrospectiveCorrection = IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) - } else { - cachedRetrospectiveCorrection = StandardRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) - } - } - - return cachedRetrospectiveCorrection! - } - - func clearCachedInsulinEffects() { - insulinEffect = nil - insulinEffectIncludingPendingInsulin = nil - predictedGlucose = nil - } + // Create dosing strategy based on user setting + let applicationFactorStrategy: ApplicationFactorStrategy = UserDefaults.standard.glucoseBasedApplicationFactorEnabled + ? GlucoseBasedApplicationFactorStrategy() + : ConstantApplicationFactorStrategy() - // MARK: - Background task management + let correctionRange = target.closestPrior(to: baseTime)?.value - private var backgroundTask: UIBackgroundTaskIdentifier = .invalid + let effectiveBolusApplicationFactor: Double? - private func startBackgroundTask() { - endBackgroundTask() - backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "PersistenceController save") { - self.endBackgroundTask() - } + 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: target, + suspendThreshold: suspendThreshold, + maxBolus: maxBolus, + maxBasalRate: maxBasalRate, + useIntegralRetrospectiveCorrection: UserDefaults.standard.integralRetrospectiveCorrectionEnabled, + includePositiveVelocityAndRC: true, + carbAbsorptionModel: carbAbsorptionModel, + recommendationInsulinModel: recommendationInsulinModel, + recommendationType: .manualBolus, + automaticBolusApplicationFactor: effectiveBolusApplicationFactor) } - private func endBackgroundTask() { - if backgroundTask != .invalid { - UIApplication.shared.endBackgroundTask(backgroundTask) - backgroundTask = .invalid - } + func loopingReEnabled() async { + await updateDisplayState() + self.notify(forChange: .forecast) } - private func loopDidComplete(date: Date, dosingDecision: StoredDosingDecision, duration: TimeInterval) { - logger.default("Loop completed successfully.") - lastLoopCompleted = date - analyticsServicesManager.loopDidSucceed(duration) - dosingDecisionStore.storeDosingDecision(dosingDecision) {} + func updateDisplayState(forceStoreRemoteRecommendation: Bool = false) async { + + var newState = AlgorithmDisplayState() + do { + let lastManualBolusVisibilityWindowStartDate = now.addingTimeInterval(.days(-1)) + + var input = try await fetchData(for: now, ensureDosingCoverageStart: lastManualBolusVisibilityWindowStartDate) + input.recommendationType = .manualBolus + newState.input = input + newState.output = await runAlgorithm(input: input) + + let lastStoredManualBolus = input.doses.last( + where: { + $0.startDate >= lastManualBolusVisibilityWindowStartDate && $0.deliveryType == .bolus && $0.automatic == false + }) + + // Reflect the most recent user-entered bolus still present in the store. This + // both updates to a newer bolus and clears/downgrades the value when the shown + // bolus is no longer there (e.g. the user deleted it) — the previous logic only + // ever moved forward, so a deleted bolus lingered in the "Last Bolus" footer. + // A just-enacted bolus that the store may not have persisted yet is preserved. + let recentlyEnactedCutoff = now.addingTimeInterval(-.minutes(1)) + if let lastStoredManualBolus { + let shownIsNewerThanStored = (self.lastManualBolus?.startDate).map { $0 > lastStoredManualBolus.startDate } ?? false + let shownWasJustEnacted = (self.lastManualBolus?.startDate).map { $0 >= recentlyEnactedCutoff } ?? false + if !(shownIsNewerThanStored && shownWasJustEnacted) { + self.lastManualBolus = LastManualBolus(amount: lastStoredManualBolus.volume, startDate: lastStoredManualBolus.startDate) + } + } else if let lastManualBolus = self.lastManualBolus, lastManualBolus.startDate < recentlyEnactedCutoff { + self.lastManualBolus = nil + } + } 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 + + // DIY: Update Live Activity with current override and target range state + liveActivityManager?.update( + scheduleOverride: temporaryPresetsManager.scheduleOverride, + preMealOverride: temporaryPresetsManager.preMealOverride, + glucoseTargetRangeSchedule: settingsProvider.settings.glucoseTargetRangeSchedule, + activeInsulin: displayState.activeInsulin + ) - NotificationCenter.default.post(name: .LoopCompleted, object: self) + await updateRemoteRecommendation(force: forceStoreRemoteRecommendation) } - 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) {} + private nonisolated func runAlgorithm(input: StoredDataAlgorithmInput) async -> AlgorithmOutput { + LoopAlgorithm.run(input: input) } - // This is primarily for remote clients displaying a bolus recommendation and forecast - // Should be called after any significant change to forecast input data. + /// 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)) - var remoteRecommendationNeedsUpdating: Bool = false + let recommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel, direction: .decrease) - func updateRemoteRecommendation() { - dataAccessQueue.async { - if self.remoteRecommendationNeedsUpdating { - var (dosingDecision, updateError) = self.update(for: .updateRemoteRecommendation) + var dosingDecision = StoredDosingDecision(reason: reason.rawValue) + dosingDecision.settings = StoredDosingDecision.Settings(settingsProvider.settings) + dosingDecision.automaticDoseRecommendation = recommendation - 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)) - } - } - self.remoteRecommendationNeedsUpdating = false + do { + crashRecoveryManager.dosingStarted(dose: recommendation) + try await deliveryDelegate?.enact(bolus: recommendation.bolusUnits, tempBasal: recommendation.basalAdjustment, decisionId: dosingDecision.id) + self.crashRecoveryManager.dosingFinished() + } catch { + dosingDecision.appendError(error as? LoopError ?? .unknownError(error)) + if reason == .maximumBasalRateChanged { + throw CancelTempBasalFailedMaximumBasalRateChangedError(reason: error) + } else { + throw error } } - } -} -// MARK: Background task management -extension LoopDataManager: PersistenceControllerDelegate { - func persistenceControllerWillSave(_ controller: PersistenceController) { - startBackgroundTask() - } + await dosingDecisionStore.storeDosingDecision(dosingDecision) - func persistenceControllerDidSave(_ controller: PersistenceController, error: PersistenceController.PersistenceControllerError?) { - endBackgroundTask() + // DIY: refresh post-dose forecast and persist an "updateRemoteRecommendation" + // decision so Nightscout sees the post-cancel state. + await updateDisplayState(forceStoreRemoteRecommendation: true) } -} -// MARK: - Preferences -extension LoopDataManager { + func loop() async { + let loopBaseTime = now - /// The basal rate schedule, applying recent overrides relative to the current moment in time. - var basalRateScheduleApplyingOverrideHistory: BasalRateSchedule? { - return doseStore.basalProfileApplyingOverrideHistory - } + var dosingDecision = StoredDosingDecision( + date: loopBaseTime, + reason: "loop", + settings: StoredDosingDecision.Settings(settingsProvider.settings) + ) - /// The carb ratio schedule, applying recent overrides relative to the current moment in time. - var carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule? { - return carbStore.carbRatioScheduleApplyingOverrideHistory - } + do { + guard let deliveryDelegate else { + preconditionFailure("Unable to dose without dosing delegate.") + } - /// The insulin sensitivity schedule, applying recent overrides relative to the current moment in time. - var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? { - return carbStore.insulinSensitivityScheduleApplyingOverrideHistory - } + logger.debug("Running Loop at %{public}@", String(describing: loopBaseTime)) + NotificationCenter.default.post(name: .LoopRunning, object: self) - /// 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 - } - } -} + var input = try await fetchData(for: loopBaseTime) + // Trim future basal + input.doses = input.doses.trimmed(to: loopBaseTime) -// 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 } - } + var dosingStrategy: AutomaticDosingStrategy = .automaticBolus - completion?(.success(samples)) - case .failure(let error): - completion?(.failure(error)) - } + if dosingStrategySelectionEnabled { + dosingStrategy = settingsProvider.settings.automaticDosingStrategy } - } - } - - /// 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) - } + input.recommendationType = dosingStrategy.recommendationType - 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 } + guard let latestGlucose = input.glucoseHistory.last else { + throw LoopError.missingDataError(.glucose) + } - dataAccessQueue.async { - self.cancelActiveTempBasal(for: reason, completion: nil) - } - } - - private func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason, completion: ((Error?) -> Void)?) { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) + guard loopBaseTime.timeIntervalSince(latestGlucose.startDate) <= LoopAlgorithm.inputDataRecencyInterval else { + throw LoopError.glucoseTooOld(date: latestGlucose.startDate) + } - let recommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel) - recommendedAutomaticDose = (recommendation: recommendation, date: now()) + guard latestGlucose.startDate.timeIntervalSince(loopBaseTime) <= LoopAlgorithm.inputDataRecencyInterval else { + throw LoopError.invalidFutureGlucose(date: latestGlucose.startDate) + } - var dosingDecision = StoredDosingDecision(reason: reason.rawValue) - dosingDecision.settings = StoredDosingDecision.Settings(latestStoredSettingsProvider.latestSettings) - dosingDecision.controllerStatus = UIDevice.current.controllerStatus - dosingDecision.automaticDoseRecommendation = recommendation - - let error = enactRecommendedAutomaticDose() - - dosingDecision.pumpManagerStatus = delegate?.pumpManagerStatus - dosingDecision.cgmManagerStatus = delegate?.cgmManagerStatus - dosingDecision.lastReservoirValue = StoredDosingDecision.LastReservoirValue(doseStore.lastReservoirValue) - - if let error = error { - dosingDecision.appendError(error) - } - 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) - } - - - /// 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) - } - - self.carbEffect = nil - self.carbsOnBoard = nil - completion(.success(storedCarbEntry)) - case .failure(let error): - completion(.failure(error)) - } + guard loopBaseTime.timeIntervalSince(doseStore.lastAddedPumpData) <= LoopAlgorithm.inputDataRecencyInterval else { + throw LoopError.pumpDataTooOld(date: doseStore.lastAddedPumpData) } - } - - if let replacingEntry = replacingEntry { - carbStore.replaceCarbEntry(replacingEntry, withEntry: carbEntry, completion: addCompletion) - } else { - carbStore.addCarbEntry(carbEntry, completion: addCompletion) - } - } - func deleteCarbEntry(_ oldEntry: StoredCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) { - carbStore.deleteCarbEntry(oldEntry) { result in - completion(result) - } - } + var output = LoopAlgorithm.run(input: input) + switch output.recommendationResult { + case .success(let recommendation): + // Round delivery amounts to pump supported amounts, + // And determine if a change in dosing should be made. - /// 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) - - completion?() - } - } - - /// Notifies the manager that the bolus is confirmed, but not fully delivered. - /// - /// - 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?() - } - } - - /// Notifies the manager that the bolus failed. - /// - /// - 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?() - } - } - - /// Logs a new external bolus insulin dose in the DoseStore and HealthKit - /// - /// - Parameters: - /// - 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) { - 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))) - } + let algoRecommendation = recommendation.automatic! + logger.default("Algorithm recommendation: %{public}@", String(describing: algoRecommendation)) - completion(.success(( - newValue: newValue, - lastValue: previousValue, - areStoredValuesContinuous: areStoredValuesContinuous - ))) + var recommendationToEnact = algoRecommendation + // Round bolus recommendation based on pump bolus precision + if let bolus = algoRecommendation.bolusUnits, bolus > 0 { + recommendationToEnact.bolusUnits = deliveryDelegate.roundBolusVolume(units: bolus) } - } else { - assertionFailure() - } - } - } - - func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) { - let dosingDecision = StoredDosingDecision(date: date, - reason: bolusDosingDecision.reason.rawValue, - settings: StoredDosingDecision.Settings(latestStoredSettingsProvider.latestSettings), - scheduleOverride: bolusDosingDecision.scheduleOverride, - controllerStatus: UIDevice.current.controllerStatus, - pumpManagerStatus: delegate?.pumpManagerStatus, - cgmManagerStatus: delegate?.cgmManagerStatus, - lastReservoirValue: StoredDosingDecision.LastReservoirValue(doseStore.lastReservoirValue), - historicalGlucose: bolusDosingDecision.historicalGlucose, - originalCarbEntry: bolusDosingDecision.originalCarbEntry, - carbEntry: bolusDosingDecision.carbEntry, - manualGlucoseSample: bolusDosingDecision.manualGlucoseSample, - carbsOnBoard: bolusDosingDecision.carbsOnBoard, - insulinOnBoard: bolusDosingDecision.insulinOnBoard, - glucoseTargetRangeSchedule: bolusDosingDecision.glucoseTargetRangeSchedule, - 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) + var basal = algoRecommendation.basalAdjustment + + basal.unitsPerHour = deliveryDelegate.roundBasalRate(unitsPerHour: basal.unitsPerHour) - if error == nil, self.automaticDosingStatus.automaticDosingEnabled == true { - error = self.enactRecommendedAutomaticDose() - } else { - self.logger.default("Not adjusting dosing during open loop.") - } + let scheduledBasalRate = input.basal.closestPrior(to: loopBaseTime)!.value + let activeOverride = temporaryPresetsManager.presetHistory.activeOverride(at: loopBaseTime) - self.finishLoop(startDate: startDate, dosingDecision: dosingDecision, error: error) - } - } + let basalAdjustment = basal.adjustForCurrentDelivery( + at: loopBaseTime, + neutralBasalRate: scheduledBasalRate, + currentTempBasal: deliveryDelegate.basalDeliveryState?.currentTempBasal, + continuationInterval: .minutes(11), + neutralBasalRateMatchesPump: activeOverride == nil + ) + + if let basalAdjustment { + recommendationToEnact.basalAdjustment = basalAdjustment + } + + output.recommendationResult = .success(.init(automatic: recommendationToEnact)) - private func finishLoop(startDate: Date, dosingDecision: StoredDosingDecision, error: LoopError? = nil) { - let date = now() - let duration = date.timeIntervalSince(startDate) + if recommendationToEnact != algoRecommendation { + logger.default("Recommendation changed to: %{public}@", String(describing: recommendationToEnact)) + } - if let error = error { - loopDidError(date: date, error: error, dosingDecision: dosingDecision, duration: duration) - } else { - loopDidComplete(date: date, dosingDecision: dosingDecision, duration: duration) - } + dosingDecision.updateFrom(input: input, output: output) - 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)) + if self.settingsProvider.dosingEnabled { + if deliveryDelegate.basalDeliveryState == .pumpInoperable { + throw LoopError.pumpInoperable } - 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 + if deliveryDelegate.isSuspended { + throw LoopError.pumpSuspended } - 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) - } - ) - } - } - } + logger.default("Enacting: %{public}@", String(describing: recommendationToEnact)) - // 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() - } + try await deliveryDelegate.enact(bolus: recommendationToEnact.bolusUnits, tempBasal: basalAdjustment, decisionId: dosingDecision.id) - updateRemoteRecommendation() - } + logger.default("loop() completed successfully.") + lastLoopCompleted = now + let duration = lastLoopCompleted!.timeIntervalSince(loopBaseTime) + + dosingDecision.enactedTempBasal = basalAdjustment + dosingDecision.enactedBolusAmount = recommendationToEnact.bolusUnits - fileprivate enum UpdateReason: String { - case loop - case getLoopState - case updateRemoteRecommendation - } + analyticsServicesManager?.loopDidSucceed(duration) + } else { + self.logger.default("Not adjusting dosing during open loop.") + } - fileprivate func update(for reason: UpdateReason) -> (StoredDosingDecision, LoopError?) { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) + await dosingDecisionStore.storeDosingDecision(dosingDecision) + NotificationCenter.default.post(name: .LoopCycleCompleted, object: self) - 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 + throw error } - updateGroup.leave() - } - _ = updateGroup.wait(timeout: .distantFuture) - - guard let lastGlucoseDate = latestGlucoseDate else { - dosingDecision.appendWarnings(warnings.value) - dosingDecision.appendError(.missingDataError(.glucose)) - return (dosingDecision, .missingDataError(.glucose)) + } 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) } - 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() - } - } + // DIY: refresh post-dose forecast and persist an "updateRemoteRecommendation" + // decision (Nightscout's Loop pill + forecast source — paired with the just-stored + // "loop" decision by NightscoutService). Runs for both success and error paths. + await updateDisplayState(forceStoreRemoteRecommendation: true) - 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 - } + logger.default("Loop ended") + } - updateGroup.leave() - } - } + func recommendManualBolus( + manualGlucoseSample: NewGlucoseSample? = nil, + potentialCarbEntry: NewCarbEntry? = nil, + originalCarbEntry: StoredCarbEntry? = nil, + truncatingActiveOverride: Bool = false + ) async throws -> ManualBolusRecommendation? { - 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 - } + var endingPremealOverride = false - updateGroup.leave() - } + if potentialCarbEntry != nil, + let activeOverride = temporaryPresetsManager.activeOverride, + activeOverride.context == .preMeal + { + endingPremealOverride = true } - _ = 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) + var input = try await self.fetchData(for: now, presumePresetEndingNow: truncatingActiveOverride || endingPremealOverride) + .addingGlucoseSample(sample: manualGlucoseSample?.asStoredGlucoseSample) + .removingCarbEntry(carbEntry: originalCarbEntry) + .addingCarbEntry(carbEntry: potentialCarbEntry?.asStoredCarbEntry) - updateGroup.leave() - } + input.includePositiveVelocityAndRC = usePositiveMomentumAndRCForManualBoluses + input.recommendationType = .manualBolus - _ = 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 - } + let output = LoopAlgorithm.run(input: input) - 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 + 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 } - updateGroup.leave() + return manualBolusRecommendation + case .failure(let error): + throw error } + } - _ = 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)) + public func totalDeliveredToday() async -> InsulinValue? + { + guard let data = displayState.input else { + return nil } - 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) - } + let now = data.predictionStart + let midnight = Calendar.current.startOfDay(for: now) - return updatePredictedGlucoseAndRecommendedDose(with: dosingDecision) - } + let annotatedDoses = data.doses.annotated(with: data.basal, fillBasalGaps: true) + let trimmed = annotatedDoses.map { $0.trimmed(from: midnight, to: now)} - private func notify(forChange context: LoopUpdateContext) { - NotificationCenter.default.post(name: .LoopDataUpdated, - object: self, - userInfo: [ - type(of: self).LoopUpdateContextKey: context.rawValue - ] + return InsulinValue( + startDate: midnight, + value: trimmed.reduce(0.0) { $0 + $1.volume } ) } - /// 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 + var iobValues: [InsulinValue] { + dosesRelativeToBasal.insulinOnBoardTimeline() } - /// - 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 + var dosesRelativeToBasal: [BasalRelativeDose] { + displayState.output?.dosesRelativeToBasal ?? [] + } - guard now().timeIntervalSince(lastGlucoseDate) <= LoopCoreConstants.inputDataRecencyInterval else { - throw LoopError.glucoseTooOld(date: glucose.startDate) + func updateRemoteRecommendation(force: Bool = false) async { + if lastManualBolusRecommendation == nil { + lastManualBolusRecommendation = displayState.output?.recommendation?.manual } - guard lastGlucoseDate.timeIntervalSince(now()) <= LoopCoreConstants.futureGlucoseDataInterval else { - throw LoopError.invalidFutureGlucose(date: lastGlucoseDate) - } + let recommendationChanged = lastManualBolusRecommendation != displayState.output?.recommendation?.manual - guard now().timeIntervalSince(pumpStatusDate) <= LoopCoreConstants.inputDataRecencyInterval else { - throw LoopError.pumpDataTooOld(date: pumpStatusDate) + // DIY: post-dose "updateRemoteRecommendation" decisions are also Nightscout's + // Loop pill + forecast source (NightscoutService pairs them with the cached "loop" + // decision). Force-store after every Loop cycle and temp basal cancel so NS stays + // current even when the manual bolus recommendation hasn't changed. + guard force || recommendationChanged else { + return } - 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 - ) + lastManualBolusRecommendation = displayState.output?.recommendation?.manual - effects.append(potentialCarbEffect) - - retrospectiveGlucoseEffect = computeRetrospectiveGlucoseEffect(startingAt: glucose, carbEffects: potentialCarbEffect) + if let output = displayState.output { + var dosingDecision = StoredDosingDecision(date: now, 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) } - } 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) + 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])) } - - 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) - } + dosingDecision.controllerStatus = UIDevice.current.controllerStatus + self.logger.debug("Manual bolus rec = %{public}@", String(describing: dosingDecision.manualBolusRecommendation)) + await self.dosingDecisionStore.storeDosingDecision(dosingDecision) } - - // 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 } + + // MARK: - Glucose Staleness - 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() - } + private var glucoseValueStalenessTimer: Timer? - updateGroup.wait() - } + private func restartGlucoseValueStalenessTimer() { + stopGlucoseValueStalenessTimer() + startGlucoseValueStalenessTimerIfNeeded() + } - 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 + 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) } - - 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 - ) + RunLoop.main.add(glucoseValueStalenessTimer!, forMode: .default) } - 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) + private var glucoseValueStaleDate: Date? { + guard let latestGlucoseDataDate = glucoseStore.latestGlucose?.startDate else { return nil } + return latestGlucoseDataDate.addingTimeInterval(LoopAlgorithm.inputDataRecencyInterval) } +} - /// - 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 +// MARK: - Background task management +extension LoopDataManager: PersistenceControllerDelegate { + nonisolated func persistenceControllerWillSave(_ controller: PersistenceController) { + Task { + await startBackgroundTask() } - - 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) + nonisolated func persistenceControllerDidSave(_ controller: PersistenceController, error: PersistenceController.PersistenceControllerError?) { + Task { + await endBackgroundTask() } + } +} - guard glucoseMomentumEffect != nil else { - throw LoopError.missingDataError(.momentumEffect) - } - guard carbEffect != nil else { - throw LoopError.missingDataError(.carbEffect) - } +// 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 addGlucose(_ samples: [NewGlucoseSample]) async throws -> [StoredGlucoseSample] { + return try await glucoseStore.addGlucoseSamples(samples) + } - guard insulinEffect != nil else { - throw LoopError.missingDataError(.insulinEffect) + /// 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) 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.endPreMealOverride() + return storedCarbEntry + } - return try recommendManualBolus(forPrediction: predictedGlucose, consideringPotentialCarbEntry: potentialCarbEntry) + @discardableResult + func deleteCarbEntry(_ oldEntry: StoredCarbEntry) async throws -> Bool { + try await carbStore.deleteCarbEntry(oldEntry) } - - /// - 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 - } + /// Logs a new external bolus insulin dose in the DoseStore and HealthKit + /// + /// - Parameters: + /// - 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) async { + let syncIdentifier = Data(UUID().uuidString.utf8).hexadecimalString + let dose = DoseEntry(type: .bolus, startDate: startDate, value: units, unit: .units, decisionId: nil, syncIdentifier: syncIdentifier, insulinType: insulinType, manuallyEntered: true) - let volumeRounder = { (_ units: Double) in - return self.delegate?.roundBolusVolume(units: units) ?? units + do { + try await doseStore.addDoses([dose], from: nil) + self.notify(forChange: .insulin) + } catch { + logger.error("Error storing manual dose: %{public}@", error.localizedDescription) } - - 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 - ) + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) async { + let dosingDecision = StoredDosingDecision(id: bolusDosingDecision.id, + date: date, + reason: bolusDosingDecision.reason.rawValue, + settings: StoredDosingDecision.Settings(settingsProvider.settings), + scheduleOverride: bolusDosingDecision.scheduleOverride, + controllerStatus: UIDevice.current.controllerStatus, + lastReservoirValue: StoredDosingDecision.LastReservoirValue(doseStore.lastReservoirValue), + historicalGlucose: bolusDosingDecision.historicalGlucose, + originalCarbEntry: bolusDosingDecision.originalCarbEntry, + carbEntry: bolusDosingDecision.carbEntry, + manualGlucoseSample: bolusDosingDecision.manualGlucoseSample, + carbsOnBoard: bolusDosingDecision.carbsOnBoard, + insulinOnBoard: bolusDosingDecision.insulinOnBoard, + glucoseTargetRangeSchedule: bolusDosingDecision.glucoseTargetRangeSchedule, + predictedGlucose: bolusDosingDecision.predictedGlucose, + manualBolusRecommendation: bolusDosingDecision.manualBolusRecommendation, + manualBolusRequested: bolusDosingDecision.manualBolusRequested) + Task { await dosingDecisionStore.storeDosingDecision(dosingDecision) } } - 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 + private func notify(forChange context: LoopUpdateContext) { + NotificationCenter.default.post(name: .LoopDataUpdated, + object: self, + userInfo: [ + type(of: self).LoopUpdateContextKey: context.rawValue + ] ) } - /// 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)) + /// 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) - // 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 + var suspendDoses: [BasalRelativeDose] = [] + + let basal = try await settingsProvider.getBasalHistory(startDate: startSuspend, endDate: endSuspend) + let sensitivity = try await settingsProvider.getInsulinSensitivityHistory(startDate: startSuspend, endDate: endSuspend) - 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() { + for (index, basalItem) in basal.enumerated() { var startSuspendDoseDate: Date var endSuspendDoseDate: Date + guard basalItem.endDate > startSuspend && basalItem.startDate < endSuspend else { + continue + } + if index == 0 { startSuspendDoseDate = startSuspend } else { startSuspendDoseDate = basalItem.startDate } - if index == basalItems.count - 1 { + if index == basal.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 - } - - var error: LoopError? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return updateError ?? loopDataManager.lastLoopError - } - - var insulinCounteractionEffects: [GlucoseEffectVelocity] { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.insulinCounteractionEffects - } - - var predictedGlucose: [PredictedGlucoseValue]? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.predictedGlucose - } - - var predictedGlucoseIncludingPendingInsulin: [PredictedGlucoseValue]? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.predictedGlucoseIncludingPendingInsulin - } - - var recommendedAutomaticDose: (recommendation: AutomaticDoseRecommendation, date: Date)? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - guard loopDataManager.lastRequestedBolus == nil else { - return nil + endSuspendDoseDate = basal[index + 1].startDate } - 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 - } - 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) - } - - 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) - } - - 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) - } + let suspendDose = BasalRelativeDose( + type: .basal(scheduledRate: basalItem.value), + startDate: startSuspendDoseDate, + endDate: endSuspendDoseDate, + volume: 0 + ) - 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) + suspendDoses.append(suspendDose) } - } - /// 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? { - + // Calculate predicted glucose effect of suspending insulin delivery + return suspendDoses.glucoseEffects( + insulinSensitivityHistory: sensitivity + ).filterDateRange(startSuspend, endSuspend) + } + + func computeSimpleBolusRecommendation(at date: Date, mealCarbs: LoopQuantity?, manualGlucose: LoopQuantity?) -> 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() + + // Determine activeInsulin + let activeInsulin: LoopQuantity + if let iob = displayState.activeInsulin?.value { + activeInsulin = LoopQuantity.init(unit: .internationalUnit, doubleValue: iob) + } else if let input = displayState.input { + let basal = input.basal + let dosesRelativeToBasal: [BasalRelativeDose] = input.doses.annotated(with: basal) + let iob = dosesRelativeToBasal.insulinOnBoard(at: date) + activeInsulin = LoopQuantity.init(unit: .internationalUnit, doubleValue: iob) + } else { + return nil } - 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 suspendThreshold = settingsProvider.settings.suspendThreshold?.quantity, + let carbRatioSchedule = temporaryPresetsManager.carbRatioScheduleApplyingOverrideHistory, + let correctionRangeSchedule = temporaryPresetsManager.effectiveCorrectionRangeSchedule(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) @@ -2185,178 +1102,122 @@ extension LoopDataManager { } } } - + let bolusAmount = SimpleBolusCalculator.recommendedInsulin( mealCarbs: mealCarbs, manualGlucose: manualGlucose, - activeInsulin: HKQuantity.init(unit: .internationalUnit(), doubleValue: iob), + activeInsulin: activeInsulin, carbRatioSchedule: carbRatioSchedule, correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule, at: date) - - dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: bolusAmount.doubleValue(for: .internationalUnit()), pendingInsulin: 0, notice: notice), - date: Date()) - + + dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate( + recommendation: ManualBolusRecommendation(amount: bolusAmount.doubleValue(for: .internationalUnit), notice: notice), + date: now + ) + 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 asStoredGlucoseSample: 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 { @@ -2395,111 +1256,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) } @@ -2508,19 +1270,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) @@ -2537,7 +1296,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) } @@ -2546,22 +1305,22 @@ extension LoopDataManager: ServicesManagerDelegate { throw CarbActionError.invalidCarbs } - guard amountInGrams <= LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram()) else { + guard amountInGrams <= LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram) else { throw CarbActionError.exceedsMaxCarbs } if let startDate = startDate { - let maxStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) - let minStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryPastTime) + let maxStartDate = now.addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) + let minStartDate = now.addingTimeInterval(LoopConstants.maxCarbEntryPastTime) guard startDate <= maxStartDate && startDate >= minStartDate else { throw CarbActionError.invalidStartDate(startDate) } } - 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 quantity = LoopQuantity(unit: .gram, doubleValue: amountInGrams) + let candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate ?? now, foodType: foodType, absorptionTime: absorptionTime) + + let _ = try await carbStore.addCarbEntry(candidateCarbEntry) } enum CarbActionError: LocalizedError { @@ -2598,19 +1357,393 @@ 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: LoopQuantity? { + settingsProvider.settings.suspendThreshold?.quantity + } + + func enactBolus(units: Double, decisionId: UUID?, activationType: BolusActivationType) async throws { + let startDate = now + try await deliveryDelegate?.enactBolus(units: units, decisionId: decisionId, activationType: activationType) + lastManualBolus = LastManualBolus(amount: units, startDate: startDate) + } + +} + +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.effectiveCorrectionRangeSchedule(presumingMealEntry: presumingMealEntry) + } + + func generatePrediction( + originalCarbEntry: StoredCarbEntry?, + potentialCarbEntry: NewCarbEntry?, + potentialDose: SimpleInsulinDose?, + manualGlucose: NewGlucoseSample? + ) async throws -> (historicGlucose: [StoredGlucoseSample], predictedGlucose: [PredictedGlucoseValue]) { + + var endingPremealOverride = false + + if potentialCarbEntry != nil, + let activeOverride = temporaryPresetsManager.activeOverride, + activeOverride.context == .preMeal + { + endingPremealOverride = true + } + + var input = try await fetchData(for: now, presumePresetEndingNow: endingPremealOverride, ensureDosingCoverageStart: nil) + + // Add potential bolus, carbs, manual glucose + input = input + .addingDose(dose: potentialDose) + .addingGlucoseSample(sample: manualGlucose?.asStoredGlucoseSample) + .removingCarbEntry(carbEntry: originalCarbEntry) + .addingCarbEntry(carbEntry: potentialCarbEntry?.asStoredCarbEntry) + + let prediction = try input.predictGlucose() + + return (historicGlucose: input.glucoseHistory, predictedGlucose: prediction) + } +} + + +extension LoopDataManager: CarbEntryViewModelDelegate { + func isScheduleOverrideActive(at date: Date) -> Bool { + temporaryPresetsManager.isScheduleOverrideActive(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 + ) + + 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.presetHistory.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) + + // Overlay basal history on basal doses, splitting doses to get amount delivered relative to basal + let annotatedDoses = doses.map({ $0.simpleDose(with: insulinModel(for: $0.insulinType)) }).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: start, + to: end.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration), + carbRatios: carbRatioWithOverrides, + insulinSensitivities: sensitivityWithOverrides, + absorptionModel: CarbAbsorptionModel.piecewiseLinear.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 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))", + + "* presetHistory: \(temporaryPresetsManager.presetHistory.recentEvents.map(String.init(describing:)))", + + "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 { + + func scheduledBasalRate(at date: Date? = nil) -> Double? { + settings.basalRateSchedule?.value(at: date ?? now) + } + + func currentBasalRate(at date: Date? = nil) -> Double? { + guard let scheduledBasalRate = scheduledBasalRate(at: date ?? now) else { + return nil + } + + return deliveryDelegate?.basalDeliveryState?.currentBasalRate(currentScheduledBasalRate: scheduledBasalRate) } + var automatedTreatmentState: AutomatedTreatmentState? { + guard let input = displayState.input else { + return nil + } + + let now = now + + // need to compare amounts that the pump can actually deliver, instead of calculated amounts + guard let neutralBasal = input.basal.closestPrior(to: now)?.value, + let deliverableNeutralBasal = deliveryDelegate?.roundBolusVolume(units: neutralBasal), + let currentlyDeliveredBasalRate = currentBasalRate(at: now) + else { + return nil + } + + if currentlyDeliveredBasalRate > deliverableNeutralBasal { + return .increasedInsulin + } else if currentlyDeliveredBasalRate < deliverableNeutralBasal { + if currentlyDeliveredBasalRate == 0 { + return .minimumDelivery + } else { + return .decreasedInsulin + } + } else { + let recentAutomaticBoluses = input.doses.filter({ dose in + dose.deliveryType == .bolus && + dose.automatic && + dose.startDate.addingTimeInterval(.minutes(5)) > now + }) + if !recentAutomaticBoluses.isEmpty { + return .increasedInsulin + } + return scheduledBasalRate(at: now) != deliverableNeutralBasal ? .neutralOverride : .neutralNoOverride + } + } +} + +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..979009f92b 100644 --- a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift +++ b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift @@ -7,24 +7,31 @@ // import Foundation -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? + private let unit = LoopUnit.milligramsPerDeciliter /// The last missed meal notification that was sent /// Internal for unit testing @@ -40,46 +47,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 +200,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 +227,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 +260,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 +278,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 +324,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 +350,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 +379,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: LoopQuantity( + unit: .milligramsPerDeciliter, + doubleValue: velocityPerSecond * duration + ) + ) + } +} + diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index 996d147047..42eff19f42 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -11,14 +11,7 @@ import UserNotifications import LoopKit import LoopCore -enum NotificationManager { - - enum Action: String { - case retryBolus - case acknowledgeAlert - } -} - +@MainActor extension NotificationManager { private static var notificationCategories: Set { var categories = [UNNotificationCategory]() @@ -39,7 +32,7 @@ extension NotificationManager { let acknowledgeAlertAction = UNNotificationAction( identifier: Action.acknowledgeAlert.rawValue, title: NSLocalizedString("OK", comment: "The title of the notification action to acknowledge a device alert"), - options: .foreground + options: [] ) categories.append(UNNotificationCategory( @@ -49,6 +42,26 @@ extension NotificationManager { options: .customDismissAction )) + let yesStartPresetAction = UNNotificationAction( + identifier: Action.startPreset.rawValue, + title: NSLocalizedString("Start Preset", comment: "The title of the notification action to start a preset"), + options: .foreground + ) + + let doNotStartPresetAction = UNNotificationAction( + identifier: Action.acknowledgeAlert.rawValue, + title: NSLocalizedString("Don't Start", comment: "The title of the notification action to not start a preset"), + options: [] + ) + + categories.append(UNNotificationCategory( + identifier: LoopNotificationCategory.presetReminder.rawValue, + actions: [yesStartPresetAction, doNotStartPresetAction], + intentIdentifiers: [], + options: .customDismissAction + )) + + return Set(categories) } @@ -73,13 +86,19 @@ extension NotificationManager { } } } + } + + static func setNotificationCategories() { + let center = UNUserNotificationCenter.current() center.setNotificationCategories(notificationCategories) } - + + // MARK: - Notifications - - static func sendBolusFailureNotification(for error: PumpManagerError, units: Double, at startDate: Date, activationType: BolusActivationType) { + + @MainActor + static func sendBolusFailureNotification(for error: PumpManagerError, units: Double, at startDate: Date, decisionId: UUID?, activationType: BolusActivationType) async throws { let notification = UNMutableNotificationContent() notification.title = NSLocalizedString("Bolus Issue", comment: "The notification title for a bolus issue") @@ -104,6 +123,10 @@ extension NotificationManager { LoopNotificationUserInfoKey.bolusStartDate.rawValue: startDate, LoopNotificationUserInfoKey.bolusActivationType.rawValue: activationType.rawValue ] + + if let decisionId { + notification.userInfo[LoopNotificationUserInfoKey.decisionId.rawValue] = decisionId.uuidString + } let request = UNNotificationRequest( // Only support 1 bolus notification at once @@ -112,13 +135,12 @@ extension NotificationManager { trigger: nil ) - UNUserNotificationCenter.current().add(request) + try await UNUserNotificationCenter.current().add(request) } - @MainActor static func sendRemoteBolusNotification(amount: Double) { let notification = UNMutableNotificationContent() - let quantityFormatter = QuantityFormatter(for: .internationalUnit()) + let quantityFormatter = QuantityFormatter(for: .internationalUnit) guard let amountDescription = quantityFormatter.numberFormatter.string(from: amount) else { return } @@ -138,10 +160,9 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } - @MainActor static func sendRemoteBolusFailureNotification(for error: Error, amountInUnits: Double) { let notification = UNMutableNotificationContent() - let quantityFormatter = QuantityFormatter(for: .internationalUnit()) + let quantityFormatter = QuantityFormatter(for: .internationalUnit) guard let amountDescription = quantityFormatter.numberFormatter.string(from: amountInUnits) else { return } @@ -159,7 +180,6 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } - @MainActor static func sendRemoteCarbEntryNotification(amountInGrams: Double) { let notification = UNMutableNotificationContent() @@ -180,7 +200,6 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } - @MainActor static func sendRemoteCarbEntryFailureNotification(for error: Error, amountInGrams: Double) { let notification = UNMutableNotificationContent() @@ -263,6 +282,22 @@ extension NotificationManager { } } + static func sendRequiredUpdateNotification(appName: String) { + let notification = UNMutableNotificationContent() + notification.title = String(format: NSLocalizedString("Required %1$@ App Update", comment: "The notification title for a required app update (1: app name)"), appName) + notification.body = String(format: NSLocalizedString("To continue to use %1$@, go to the App Store to install the latest version.", comment: "The notification body for a required app update (1: app name)"), appName) + notification.interruptionLevel = .critical + notification.sound = .defaultCritical + + let request = UNNotificationRequest( + identifier: LoopNotificationCategory.requiredUpdate.rawValue, + content: notification, + trigger: nil + ) + + UNUserNotificationCenter.current().add(request) + } + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) } diff --git a/Loop/Managers/OnboardingManager.swift b/Loop/Managers/OnboardingManager.swift index b9f6c8c232..b384fdbb62 100644 --- a/Loop/Managers/OnboardingManager.swift +++ b/Loop/Managers/OnboardingManager.swift @@ -10,7 +10,9 @@ import os.log import HealthKit import LoopKit import LoopKitUI +import LoopCore +@MainActor class OnboardingManager { private let pluginManager: PluginManager private let bluetoothProvider: BluetoothProvider @@ -18,6 +20,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 +46,7 @@ class OnboardingManager { init(pluginManager: PluginManager, bluetoothProvider: BluetoothProvider, deviceDataManager: DeviceDataManager, + settingsManager: SettingsManager, statefulPluginManager: StatefulPluginManager, servicesManager: ServicesManager, loopDataManager: LoopDataManager, @@ -53,6 +57,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 +67,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 +148,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, dosingStrategySelectionEnabled: loopDataManager.dosingStrategySelectionEnabled) onboardingViewController.cgmManagerOnboardingDelegate = deviceDataManager onboardingViewController.pumpManagerOnboardingDelegate = deviceDataManager onboardingViewController.serviceOnboardingDelegate = servicesManager @@ -255,12 +260,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 } } @@ -369,7 +374,7 @@ extension OnboardingManager: CGMManagerProvider { // MARK: - PumpManagerProvider -extension OnboardingManager: PumpManagerProvider { +extension OnboardingManager: @preconcurrency PumpManagerProvider { var activePumpManager: PumpManager? { deviceDataManager.pumpManager } var availablePumpManagers: [PumpManagerDescriptor] { deviceDataManager.availablePumpManagers } @@ -395,6 +400,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) } @@ -416,40 +426,36 @@ extension OnboardingManager: StatefulPluggableProvider { // MARK: - ServiceProvider -extension OnboardingManager: ServiceProvider { +extension OnboardingManager: @preconcurrency 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) - } +// MARK: - TherapySettingsProvider - return .success(.userInteractionRequired(serviceUI.settingsViewController(colorPalette: .default))) +extension OnboardingManager: @preconcurrency OnboardingTherapySettingsProvider { + var onboardingTherapySettings: TherapySettings { + return settingsManager.therapySettings } } -// MARK: - TherapySettingsProvider +// MARK: - PluginHost -extension OnboardingManager: TherapySettingsProvider { - var onboardingTherapySettings: TherapySettings { - return loopDataManager.therapySettings +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..11712704e7 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: + Task { + do { + try await remoteDataService.uploadDoseData(created: created, deleted: deleted) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .dose, queryAnchor) continueUpload = queryAnchor != previousQueryAnchor - self.uploadSucceeded(key) + await self.uploadSucceeded(key) + } catch { + self.log.error("Error synchronizing dose data: %{public}@", String(describing: error)) + await 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 + await self.uploadSucceeded(key) + } catch { + self.log.error("Error synchronizing pump event data: %{public}@", String(describing: error)) + await 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) + await self.uploadSucceeded(key) + } catch { + self.log.error("Error synchronizing settings data: %{public}@", String(describing: error)) + await 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) + await self.uploadSucceeded(key) + } catch { + self.log.error("Error synchronizing temporary override data: %{public}@", String(describing: error)) + await 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) + await self.uploadSucceeded(key) + } catch { + self.log.error("Error synchronizing pump event data: %{public}@", String(describing: error)) + await 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..afc0154a63 100644 --- a/Loop/Managers/ResetLoopManager.swift +++ b/Loop/Managers/ResetLoopManager.swift @@ -8,6 +8,7 @@ import LoopKit +@MainActor protocol ResetLoopManagerDelegate: AnyObject { func loopWillReset() func loopDidReset() @@ -42,33 +43,35 @@ class ResetLoopManager { if UserDefaults.appGroup?.userRequestedLoopReset == true && !resetAlertPresented { resetAlertPresented = true - - delegate?.presentConfirmationAlert( - confirmAction: { [weak self] pumpManager, completion in - self?.resetAlertPresented = false - - guard let pumpManager else { - self?.resetLoop { - completion() - } - return - } - - pumpManager.prepareForDeactivation() { [weak self] error in - guard let error = error else { - self?.resetLoop() { + + Task { @MainActor in + delegate?.presentConfirmationAlert( + confirmAction: { [weak self] pumpManager, completion in + self?.resetAlertPresented = false + + guard let pumpManager else { + self?.resetLoop { completion() } return } - - self?.delegate?.presentCouldNotResetLoopAlert(error: error) + + pumpManager.prepareForDeactivation() { [weak self] error in + guard let error = error else { + self?.resetLoop() { + completion() + } + return + } + + self?.delegate?.presentCouldNotResetLoopAlert(error: error) + } + }, cancelAction: { [weak self] in + self?.resetAlertPresented = false + UserDefaults.appGroup?.userRequestedLoopReset = false } - }, cancelAction: { [weak self] in - self?.resetAlertPresented = false - UserDefaults.appGroup?.userRequestedLoopReset = false - } - ) + ) + } } checkIfLoopIsAlreadyReset() @@ -89,25 +92,29 @@ class ResetLoopManager { } private func resetLoop(completion: @escaping () -> Void) { - delegate?.loopWillReset() - - delegate?.resetTestingData { [weak self] in - self?.resetLoopDocuments() - self?.resetLoopUserDefaults() - self?.delegate?.loopDidReset() - completion() + Task { @MainActor in + delegate?.loopWillReset() + + delegate?.resetTestingData { [weak self] in + self?.resetLoopDocuments() + self?.resetLoopUserDefaults() + self?.delegate?.loopDidReset() + completion() + } } } 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..c7252128fa 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 @@ -28,6 +29,7 @@ class ServicesManager { weak var servicesManagerDelegate: ServicesManagerDelegate? weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? + weak var supportManager: SupportManager? private var services = [Service]() @@ -86,7 +88,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 +123,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 @@ -137,6 +143,9 @@ class ServicesManager { if let remoteDataService = service as? RemoteDataService { remoteDataServicesManager.addService(remoteDataService) } + if let provider = service as? SupportProviding { + supportManager?.addSupport(provider.createSupport()) + } saveState() } @@ -144,6 +153,9 @@ class ServicesManager { public func removeActiveService(_ service: Service) { servicesLock.withLock { + if let provider = service as? SupportProviding { + supportManager?.removeSupport(provider.createSupport()) + } if let remoteDataService = service as? RemoteDataService { remoteDataServicesManager.removeService(remoteDataService) } @@ -213,10 +225,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,12 +239,12 @@ class ServicesManager { private func endBackgroundTask(_ backgroundTask: UIBackgroundTaskIdentifier?) async { guard let backgroundTask else {return} - await UIApplication.shared.endBackgroundTask(backgroundTask) + UIApplication.shared.endBackgroundTask(backgroundTask) } } public protocol ServicesManagerDosingDelegate: AnyObject { - func deliverBolus(amountInUnits: Double) async throws + func deliverBolus(amountInUnits: Double, decisionId: UUID?) async throws } public protocol ServicesManagerDelegate: AnyObject { @@ -254,25 +266,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 +301,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,22 +321,22 @@ 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 } } - func deliverRemoteBolus(amountInUnits: Double) async throws { + func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?) async throws { do { guard amountInUnits > 0 else { @@ -344,12 +351,12 @@ extension ServicesManager: ServiceDelegate { throw BolusActionError.exceedsMaxBolus } - try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits) - await NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) - await remoteDataServicesManager.triggerUpload(for: .dose) + try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits, decisionId: decisionId) + 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 } } @@ -374,15 +381,17 @@ extension ServicesManager: ServiceDelegate { } extension ServicesManager: AlertIssuer { - func issueAlert(_ alert: Alert) { - alertManager.issueAlert(alert) + func issueAlert(_ alert: Alert) async { + await alertManager.issueAlert(alert) } - func retractAlert(identifier: Alert.Identifier) { - alertManager.retractAlert(identifier: identifier) + func retractAlert(identifier: Alert.Identifier) async { + await alertManager.retractAlert(identifier: identifier) } } +extension ServicesManager: ActiveServicesProvider { } + // MARK: - ServiceOnboardingDelegate extension ServicesManager: ServiceOnboardingDelegate { @@ -398,7 +407,9 @@ extension ServicesManager: ServiceOnboardingDelegate { } extension ServicesManager { - var availableSupports: [SupportUI] { activeServices.compactMap { $0 as? SupportUI } } + var availableSupports: [SupportUI] { + activeServices.compactMap { ($0 as? SupportUI) ?? ($0 as? SupportProviding)?.createSupport() } + } } // Service extension for rawValue diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift index e3fdb60bf7..a97566bc38 100644 --- a/Loop/Managers/SettingsManager.swift +++ b/Loop/Managers/SettingsManager.swift @@ -10,31 +10,36 @@ import Foundation import LoopKit import UserNotifications import UIKit -import HealthKit import Combine import LoopCore import LoopKitUI import os.log +import LoopAlgorithm +@MainActor protocol DeviceStatusProvider { var pumpManagerStatus: PumpManagerStatus? { get } var cgmManagerStatus: CGMManagerStatus? { get } } +@MainActor +@Observable class SettingsManager { - let settingsStore: SettingsStore + let settingsStore: SettingsStore? var remoteDataServicesManager: RemoteDataServicesManager? + var analyticsServicesManager: AnalyticsServicesManager? + var deviceStatusProvider: DeviceStatusProvider? var alertMuter: AlertMuter var displayGlucosePreference: DisplayGlucosePreference? - public var latestSettings: StoredSettings + private var storedSettings: StoredSettings private var remoteNotificationRegistrationResult: Swift.Result? @@ -42,19 +47,34 @@ class SettingsManager { private let log = OSLog(category: "SettingsManager") - init(cacheStore: PersistenceController, expireAfter: TimeInterval, alertMuter: AlertMuter) + var dosingEnabled: Bool { + get { storedSettings.dosingEnabled } + set { storedSettings.dosingEnabled = newValue } + } + + init(cacheStore: PersistenceController?, expireAfter: TimeInterval, alertMuter: AlertMuter, analyticsServicesManager: AnalyticsServicesManager? = nil) { - settingsStore = SettingsStore(store: cacheStore, expireAfter: expireAfter) + self.analyticsServicesManager = analyticsServicesManager + self.alertMuter = alertMuter - if let storedSettings = settingsStore.latestSettings { - latestSettings = storedSettings + if let cacheStore { + settingsStore = SettingsStore(store: cacheStore, expireAfter: expireAfter) + } else { + settingsStore = nil + } + + if let latest = settingsStore?.latestSettings { + storedSettings = latest } else { - log.default("SettingsStore has no latestSettings: initializing empty StoredSettings.") - latestSettings = StoredSettings() + log.default("SettingsStore has no settings: initializing empty StoredSettings.") + storedSettings = StoredSettings() } - settingsStore.delegate = self + dosingEnabled = settings.dosingEnabled + + settingsStore?.delegate = self + // Migrate old settings from UserDefaults if var legacyLoopSettings = UserDefaults.appGroup?.legacyLoopSettings { @@ -69,20 +89,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,37 +104,31 @@ 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, + overridePresets: settings.overridePresets, + maximumBasalRatePerHour: settings.maximumBasalRatePerHour, + maximumBolus: settings.maximumBolus, + suspendThreshold: settings.suspendThreshold, + automaticDosingStrategy: settings.automaticDosingStrategy, + defaultRapidActingModel: settings.defaultRapidActingModel?.presetForRapidActingInsulin) } } private func mergeSettings(newLoopSettings: LoopSettings? = nil, notificationSettings: NotificationSettings? = nil, deviceToken: String? = nil) -> StoredSettings { let newLoopSettings = newLoopSettings ?? loopSettings - let newNotificationSettings = notificationSettings ?? settingsStore.latestSettings?.notificationSettings + let newNotificationSettings = notificationSettings ?? settingsStore?.latestSettings?.notificationSettings return StoredSettings(date: Date(), dosingEnabled: newLoopSettings.dosingEnabled, glucoseTargetRangeSchedule: newLoopSettings.glucoseTargetRangeSchedule, 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 +156,102 @@ 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 + storedSettings = 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) { + let shouldUpdate = settings.basalRateSchedule?.timeZone != timeZone || + settings.carbRatioSchedule?.timeZone != timeZone || + settings.insulinSensitivitySchedule?.timeZone != timeZone || + settings.glucoseTargetRangeSchedule?.timeZone != timeZone + guard shouldUpdate else { return } + + 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) { + 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) } @@ -204,14 +269,172 @@ class SettingsManager { } func purgeHistoricalSettingsObjects(completion: @escaping (Error?) -> Void) { - settingsStore.purgeHistoricalSettingsObjects(completion: completion) + 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 + ), + 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.suspendThreshold = newValue.suspendThreshold + settings.maximumBolus = newValue.maximumBolus + settings.maximumBasalRatePerHour = newValue.maximumBasalRatePerHour + settings.overridePresets = newValue.overridePresets ?? [] + } + } + } + + public func correctionRangeGuardrailForPreset(_ preset: SelectablePreset) -> Guardrail { + switch preset { + case .preMeal: + return preMealGuardrail + default: + return Guardrail.temporaryPresetCorrectionRange + } + } + + public var preMealGuardrail: Guardrail { + if let scheduleRange = settings.glucoseTargetRangeSchedule?.scheduleRange() { + return Guardrail.correctionRangeOverride( + for: .preMeal, + correctionRangeScheduleRange: scheduleRange, + suspendThreshold: settings.suspendThreshold + ) + } else { + return Guardrail.correctionRange + } + } + + func savePreset(_ preset: SelectablePreset) { + switch(preset) { + case .preMeal(let range): + mutateLoopSettings { settings in + settings.preMealTargetRange = range + } + case .custom(let preset): + if let index = settings.overridePresets.firstIndex(where: { $0.id == preset.id }) { + mutateLoopSettings { settings in + settings.overridePresets[index] = preset + } + } + case .activity(let activity): + if let index = settings.overridePresets.firstIndex(where: { $0.id == activity.preset.id }) { + mutateLoopSettings { settings in + settings.overridePresets[index] = activity.preset + } + } else { + mutateLoopSettings { settings in + settings.overridePresets.append(activity.preset) + } + } + } + } + + func createPreset(_ preset: TemporaryPreset) { + mutateLoopSettings { settings in + settings.overridePresets.append(preset) + } + } + + func deletePreset(_ preset: SelectablePreset) { + guard preset.canBeDeleted else { return } + + switch(preset) { + case .preMeal, .activity: + break // cannot delete these + case .custom(let preset): + mutateLoopSettings { settings in + settings.overridePresets = settings.overridePresets.filter { $0.id != preset.id } + } + } + } +} + +@MainActor +protocol SettingsProvider: Observable { + var settings: StoredSettings { get } + var dosingEnabled: Bool { 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 { + var settings: StoredSettings { storedSettings } + + func executeSettingsQuery(fromQueryAnchor queryAnchor: SettingsStore.QueryAnchor?, limit: Int, completion: @escaping (SettingsStore.SettingsQueryResult) -> Void) { + settingsStore!.executeSettingsQuery(fromQueryAnchor: queryAnchor, limit: limit, completion: completion) + } +} + +extension SettingsManager { + static var placeholder: SettingsManager { + .init( + cacheStore: .controllerInLocalDirectory(), + expireAfter: .hours(1), + alertMuter: .init() + ) } } // MARK: - SettingsStoreDelegate extension SettingsManager: SettingsStoreDelegate { - func settingsStoreHasUpdatedSettingsData(_ settingsStore: SettingsStore) { - remoteDataServicesManager?.triggerUpload(for: .settings) + nonisolated func settingsStoreHasUpdatedSettingsData(_ settingsStore: SettingsStore) { + Task { + await remoteDataServicesManager?.triggerUpload(for: .settings) + } } } @@ -221,13 +444,8 @@ private extension NotificationSettings { let timeSensitiveSetting: NotificationSettings.NotificationSetting let scheduledDeliverySetting: NotificationSettings.NotificationSetting - if #available(iOS 15.0, *) { - timeSensitiveSetting = NotificationSettings.NotificationSetting(notificationSettings.timeSensitiveSetting) - scheduledDeliverySetting = NotificationSettings.NotificationSetting(notificationSettings.scheduledDeliverySetting) - } else { - timeSensitiveSetting = .unknown - scheduledDeliverySetting = .unknown - } + timeSensitiveSetting = NotificationSettings.NotificationSetting(notificationSettings.timeSensitiveSetting) + scheduledDeliverySetting = NotificationSettings.NotificationSetting(notificationSettings.scheduledDeliverySetting) self.init(authorizationStatus: NotificationSettings.AuthorizationStatus(notificationSettings.authorizationStatus), soundSetting: NotificationSettings.NotificationSetting(notificationSettings.soundSetting), 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..b72a4fd031 100644 --- a/Loop/Managers/StatusChartsManager.swift +++ b/Loop/Managers/StatusChartsManager.swift @@ -10,40 +10,41 @@ import LoopKit import LoopUI import LoopKitUI import SwiftCharts +import LoopAlgorithm class StatusChartsManager: ChartsManager { enum ChartIndex: Int, CaseIterable { case glucose - case iob case dose + case iob case cob } let glucose: PredictedGlucoseChart - let iob: IOBChart let dose: DoseChart + let iob: IOBChart let cob: COBChart init(colors: ChartColorPalette, settings: ChartSettings, traitCollection: UITraitCollection) { let glucose = PredictedGlucoseChart(predictedGlucoseBounds: FeatureFlags.predictedGlucoseChartClampEnabled ? .default : nil, yAxisStepSizeMGDLOverride: FeatureFlags.predictedGlucoseChartClampEnabled ? 40 : nil) - let iob = IOBChart() let dose = DoseChart() + let iob = IOBChart() let cob = COBChart() self.glucose = glucose - self.iob = iob self.dose = dose + self.iob = iob self.cob = cob super.init(colors: colors, settings: settings, charts: ChartIndex.allCases.map({ (index) -> ChartProviding in switch index { case .glucose: return glucose - case .iob: - return iob case .dose: return dose + case .iob: + return iob case .cob: return cob } @@ -108,8 +109,8 @@ extension StatusChartsManager { invalidateChart(atIndex: ChartIndex.iob.rawValue) } - func iobChart(withFrame frame: CGRect) -> Chart? { - return chart(atIndex: ChartIndex.iob.rawValue, frame: frame) + func iobChart(withFrame frame: CGRect, highlightLabelOffsetY: CGFloat) -> Chart? { + return chart(atIndex: ChartIndex.iob.rawValue, frame: frame, highlightLabelOffsetY: highlightLabelOffsetY) } } diff --git a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift index a7ffef2e5e..b6c7d16c5e 100644 --- a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift @@ -7,50 +7,23 @@ // 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..401dfb7e31 100644 --- a/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift @@ -6,10 +6,28 @@ // Copyright © 2020 LoopKit Authors. All rights reserved. // +import LoopAlgorithm import LoopKit -protocol DosingDecisionStoreProtocol: AnyObject { - func storeDosingDecision(_ dosingDecision: StoredDosingDecision, completion: @escaping () -> Void) +struct LightDosingDecision: DosingDecision { + let automaticDoseRecommendation: AutomaticDoseRecommendation? + let carbEntry: StoredCarbEntry? + let id: UUID + let manualBolusRecommendation: ManualBolusRecommendationWithDate? + let manualBolusRequested: Double? + let scheduleOverride: TemporaryScheduleOverride? + let syncIdentifier: UUID +} + +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) + + func findDosingDecisionsById(_ id: UUID) async throws -> D? + func findDosingDecisionsByIds(_ ids: [UUID]) async throws -> [D] } extension DosingDecisionStore: DosingDecisionStoreProtocol { } diff --git a/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift b/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift index adde73c4c7..915f0016f7 100644 --- a/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift @@ -7,33 +7,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 18f3df912d..52273365a6 100644 --- a/Loop/Managers/SupportManager.swift +++ b/Loop/Managers/SupportManager.swift @@ -12,14 +12,16 @@ import LoopKit import LoopKitUI import SwiftUI +@MainActor public protocol DeviceSupportDelegate { var availableSupports: [SupportUI] { get } 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") @@ -35,27 +37,22 @@ public final class SupportManager { } } + var onRequiredUpdate: (() -> Void)? + 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 +83,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 +95,7 @@ public final class SupportManager { } .store(in: &cancellables) - NotificationCenter.default.publisher(for: .LoopCompleted) + NotificationCenter.default.publisher(for: .LoopCycleCompleted) .sink { [weak self] _ in self?.performCheck() } @@ -140,6 +136,9 @@ extension SupportManager { Task { @MainActor in let versionUpdate = await checkVersion() self.notify(versionUpdate) + if versionUpdate == .required { + self.onRequiredUpdate?() + } } } @@ -242,16 +241,16 @@ 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) { - alertIssuer.issueAlert(alert) + public func issueAlert(_ alert: LoopKit.Alert) async { + await alertIssuer.issueAlert(alert) } - public func retractAlert(identifier: LoopKit.Alert.Identifier) { - alertIssuer.retractAlert(identifier: identifier) + public func retractAlert(identifier: LoopKit.Alert.Identifier) async { + await alertIssuer.retractAlert(identifier: identifier) } } @@ -283,7 +282,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,14 +330,14 @@ fileprivate extension UserDefaults { extension SupportUI { var rawValue: RawStateValue { return [ - "supportIdentifier": Self.pluginIdentifier, + "supportIdentifier": pluginIdentifier, "state": rawState ] } - } extension Bundle { + @MainActor fileprivate func loadAndInstantiateSupport() throws -> SupportUI? { try loadAndReturnError() diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift new file mode 100644 index 0000000000..3a062a3f7f --- /dev/null +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -0,0 +1,513 @@ +// +// 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 LoopAlgorithm + +protocol PresetActivationObserver: AnyObject { + func presetActivated(context: TemporaryScheduleOverride.Context, duration: TemporaryScheduleOverride.Duration) + func presetDeactivated(context: TemporaryScheduleOverride.Context) +} + +@MainActor +@Observable +class TemporaryPresetsManager { + + @ObservationIgnored private let log = OSLog(category: "TemporaryPresetsManager") + + let managerIdentifier = "TemporaryPresetsManager" + + @ObservationIgnored private var settingsProvider: SettingsProvider + + var presetHistory: TemporaryScheduleOverrideHistory + + @ObservationIgnored private var alertIssuer: AlertIssuer? + + @ObservationIgnored private var presetActivationObservers: [PresetActivationObserver] = [] + + @ObservationIgnored private var overrideIntentObserver: NSKeyValueObservation? = nil + + private var now: Date { TestingDate.currentTestingDate() } + + init(settingsProvider: SettingsProvider, alertIssuer: AlertIssuer? = nil, presetHistory: TemporaryScheduleOverrideHistory? = nil) { + self.settingsProvider = settingsProvider + self.alertIssuer = alertIssuer + + self.presetHistory = presetHistory ?? TemporaryScheduleOverrideHistoryContainer.shared.fetch() + TemporaryScheduleOverrideHistory.relevantTimeWindow = Bundle.main.localCacheDuration + + _scheduleOverride = self.presetHistory.activeOverride(at: now) + + overrideIntentObserver = UserDefaults.appGroup?.observe( + \.intentExtensionOverrideToSet, + options: [.new], + changeHandler: + { [weak self] (defaults, change) in + Task { @MainActor 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) + } + + var preMealOverride: TemporaryScheduleOverride? { + scheduleOverride?.context == .preMeal ? scheduleOverride : nil + } + + var scheduleOverride: TemporaryScheduleOverride? { + didSet { + guard oldValue != scheduleOverride else { + return + } + + presetHistory.recordOverride(scheduleOverride) + + if let oldPreset = oldValue { + for observer in self.presetActivationObservers { + observer.presetDeactivated(context: oldPreset.context) + } + + if oldPreset.duration == .indefinite { + Task { @MainActor in + await clearIndefinitePresetReminder(oldPreset) + } + } + } + if let newPreset = scheduleOverride { + for observer in self.presetActivationObservers { + observer.presetActivated(context: newPreset.context, duration: newPreset.duration) + } + + scheduleClearOverride(override: newPreset) + + if newPreset.duration == .indefinite { + Task { @MainActor in + await scheduleIndefinitePresetReminder(newPreset) + } + } + } + + notify(forChange: .preferences) + } + } + + public var activeOverride: TemporaryScheduleOverride? { + if scheduleOverride?.isActive(at: now) == true { + return scheduleOverride + } else { + return nil + } + } + + public var activePreset: SelectablePreset? { + return activeOverride?.createPreset() + } + + var selectablePresets: [SelectablePreset] { + var presets: [SelectablePreset] = [] + + let settings = settingsProvider.settings + + if let activeOverride, activeOverride.context == .custom { + presets.append(activePreset!) + } + + if let preMealTargetRange = settings.preMealTargetRange { + presets.append(.preMeal(range: preMealTargetRange)) + } + + presets.append(contentsOf: settings.overridePresets.map { override in + if override.id.hasPrefix("activity-"), let activityPreset = ActivityPreset(preset: override) { + return .activity(activityPreset) + } else { + return .custom(override) + } + }) + + ActivityPreset.ActivityType.allCases.forEach { activityType in + if !settings.overridePresets.contains(where: { $0.id == activityType.id }) { + presets.append(.activity(ActivityPreset(activityType: activityType, preset: activityType.completeDefaultPreset))) + } + } + + return presets + } + + var clearOverrideTimer: Timer? + public func scheduleClearOverride(override: TemporaryScheduleOverride) { + clearOverrideTimer?.invalidate() + if override.duration.isInfinite { return } + if override.scheduledEndDate < now { return } + + log.default("Scheduling override end timer %{public}@", String(describing: override)) + + + clearOverrideTimer = Timer.scheduledTimer(withTimeInterval: override.scheduledEndDate.timeIntervalSince(now), repeats: false, block: { [weak self] _ in + Task { + self?.log.default("override end timer fired for %{public}@", String(describing: override)) + await self?.endOverride(override) + } + }) + } + + func endOverride(_ override: TemporaryScheduleOverride) { + if override == scheduleOverride { + clearOverride() + } + } + + func scheduleIndefinitePresetReminder(_ override: TemporaryScheduleOverride) async { + let preset = override.createPreset() + let indefinitePresetIdentifier = Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: preset.id) + + let title = String(format: NSLocalizedString("%1$@ Still Active", comment: "The format title for the preset still active alert. (1: preset name)"), preset.name) + + let foregroundBody = String( + format: NSLocalizedString("%1$@ has been active for more than 24 hours. Make sure you still want it enabled, or turn it off.", comment: "Active preset reminder alert foreground body. (1: preset name)"), + preset.name + ) + + let backgroundBody = String( + format: NSLocalizedString("%1$@ has been active for more than 24 hours. Make sure you still want it enabled, or turn it off in the app.", comment: "Active preset reminder alert background body. (1: preset name)"), + preset.name + ) + + let actions = [ + Alert.UserAlertAction( + label: NSLocalizedString("OK", comment: "Label for acknowledging the preset has been active for 24 hours"), + identifier: "ok", + style: .default + ), + ] + + let foregroundContent = Alert.Content(title: title, + body: foregroundBody, + actions: actions) + + let backgroundContent = Alert.Content(title: title, + body: backgroundBody, + actions: actions) + + let metadata: Alert.Metadata = [LoopNotificationUserInfoKey.presetId.rawValue: Alert.MetadataValue(preset.id)] + + let alert = Alert( + identifier: indefinitePresetIdentifier, + foregroundContent: foregroundContent, + backgroundContent: backgroundContent, + trigger: .repeating(repeatInterval: .hours(24)), + interruptionLevel: .timeSensitive, + metadata: metadata, + categoryIdentifier: LoopNotificationCategory.presetReminder.rawValue + ) + + await alertIssuer?.issueAlert(alert) + } + + func clearIndefinitePresetReminder(_ override: TemporaryScheduleOverride) async { + let preset = override.createPreset() + let indefinitePresetIdentifier = Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: preset.id) + await alertIssuer?.retractAlert(identifier: indefinitePresetIdentifier) + } + + public func effectiveCorrectionRangeSchedule(presumingMealEntry: Bool = false) -> GlucoseRangeSchedule? { + + guard let glucoseTargetRangeSchedule = settingsProvider.settings.glucoseTargetRangeSchedule else { + return nil + } + + var scheduleOverride = scheduleOverride + + if presumingMealEntry && scheduleOverride?.context == .preMeal { + scheduleOverride = nil + } + + if let effectiveOverride = scheduleOverride { + return glucoseTargetRangeSchedule.applyingOverride(effectiveOverride) + } else { + return glucoseTargetRangeSchedule + } + } + + public func effectiveCorrectionRange() -> ClosedRange? { + guard let schedule = settingsProvider.settings.glucoseTargetRangeSchedule else { return nil } + + let scheduledRange = schedule.quantityRange(at: now) + + if let override = activeOverride { + return override.effectiveCorrectionRangeDuring(scheduledRange: scheduledRange) + } + + return scheduledRange + } + + public func isScheduleOverrideActive(at date: Date? = nil) -> Bool { + return scheduleOverride?.isActive(at: date ?? now) == true + } + + public func isNonPreMealOverrideActive(at date: Date? = nil) -> Bool { + return isScheduleOverrideActive(at: date ?? now) == true && scheduleOverride?.context != .preMeal + } + + public func isPreMealTargetActive(at date: Date? = nil) -> Bool { + return isScheduleOverrideActive(at: date ?? now) == true && scheduleOverride?.context == .preMeal + } + + public func futureOverrideEnabled(relativeTo date: Date? = nil) -> Bool { + guard let scheduleOverride = scheduleOverride else { return false } + return scheduleOverride.startDate > date ?? now + } + + public func enablePreMealOverride(at date: Date? = nil, for duration: TimeInterval) { + scheduleOverride = makePreMealOverride(beginningAt: date ?? now, for: duration) + } + + private func makePreMealOverride(beginningAt date: Date? = nil, for duration: TimeInterval) -> TemporaryScheduleOverride? { + guard let preMealTargetRange = settingsProvider.settings.preMealTargetRange else { + return nil + } + return TemporaryScheduleOverride( + context: .preMeal, + settings: TemporaryPresetSettings(targetRange: preMealTargetRange), + startDate: date ?? now, + duration: .finite(duration), + enactTrigger: .local, + syncIdentifier: UUID() + ) + } + + func startPreset(withIdentifier identifier: String) { + guard let preset = selectablePresets.first(where: { $0.id == identifier }) else { + log.error("Unable to find preset with identifier ${public}@", identifier) + return + } + startPreset(preset) + } + + + func startPreset(_ preset: SelectablePreset) { + scheduleOverride = preset.createOverride() + } + + public func endPreMealOverride() { + if let activeOverride = scheduleOverride, activeOverride.isActive(), activeOverride.context == .preMeal { + scheduleOverride?.scheduledEndDate = .now + clearOverride() + } + } + + public func clearOverride() { + self.scheduleOverride = nil + } + + public var basalRateScheduleApplyingOverrideHistory: BasalRateSchedule? { + if let basalSchedule = settingsProvider.settings.basalRateSchedule { + return presetHistory.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 presetHistory.resolvingRecentInsulinSensitivitySchedule(insulinSensitivitySchedule) + } else { + return nil + } + } + + public var carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule? { + if let carbRatioSchedule = carbRatioSchedule { + return presetHistory.resolvingRecentCarbRatioSchedule(carbRatioSchedule) + } else { + return nil + } + } + + private func notify(forChange context: LoopUpdateContext) { + NotificationCenter.default.post(name: .LoopDataUpdated, + object: self, + userInfo: [ + LoopDataManager.LoopUpdateContextKey: context.rawValue + ] + ) + } + + func updateActivePresetDuration(newEndDate: Date) { + if var scheduleOverride { + if newEndDate > now { + scheduleOverride.scheduledEndDate = newEndDate + } else { + scheduleOverride.scheduledEndDate = newEndDate.addingTimeInterval(.days(1)) + } + + self.scheduleOverride = scheduleOverride + self.scheduleClearOverride(override: scheduleOverride) + } + } + + var lastUsed: [String: Date]? + + func lastUsed(id: String) -> Date? { + if lastUsed == nil { + let enacts = presetHistory.getOverrideHistory(startDate: .distantPast, endDate: now) + lastUsed = [:] + for enact in enacts { + var id: String + switch enact.context { + case .preMeal: id = "preMeal" + case .activity(let activity): id = activity.id + case .preset(let preset): id = preset.id + case .custom: continue + } + lastUsed![id] = max(lastUsed![id] ?? .distantPast, enact.startDate) + } + } + return lastUsed![id] + } + + func unschedulePresetReminderIfNeeded(_ preset: SelectablePreset) async { + guard preset.isScheduled else { return } + await alertIssuer?.retractAlert(identifier: Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: preset.id)) + } + + func scheduleNextPresetReminder() async { + + let settings = settingsProvider.settings + + let now = now + + let preset = settings.overridePresets.reduce(into: nil as TemporaryPreset?) { result, preset in + if let nextScheduledTime = preset.nextScheduledStartAfter(now) { + if result == nil || nextScheduledTime < (result!.nextScheduledStartAfter(now)!) { + result = preset + } + } + } + + if let preset { + + let nextScheduledPresetReminderIdentifier = Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: preset.id) + await alertIssuer?.retractAlert(identifier: nextScheduledPresetReminderIdentifier) + + let nextScheduledTime = preset.nextScheduledStartAfter(now)! + + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + + let title = NSLocalizedString("Start Scheduled Preset?", comment: "Scheduled preset reminder title") + let body = String( + format: NSLocalizedString("Would you like to start your %1$@ preset?\n\nThis will end any active preset.", comment: "Scheduled preset reminder alert body. (1: preset name)"), + preset.name + ) + + let actions = [ + Alert.UserAlertAction( + label: NSLocalizedString("Don't Start", comment: "Label for do not start preset action on scheduled preset reminder alert"), + identifier: "acknowledge", + style: .default + ), + Alert.UserAlertAction( + label: NSLocalizedString("Yes, Start Now", comment: "Label for do yes, start preset now action on scheduled preset reminder alert"), + identifier: "startPreset", + style: .cancel + ) + ] + + let content = Alert.Content(title: title, + body: body, + actions: actions) + + let metadata: Alert.Metadata = [LoopNotificationUserInfoKey.presetId.rawValue: Alert.MetadataValue(preset.id)] + + let alert = Alert( + identifier: nextScheduledPresetReminderIdentifier, + foregroundContent: content, + backgroundContent: content, + trigger: .delayed(interval: nextScheduledTime.timeIntervalSince(now)), + interruptionLevel: .timeSensitive, + metadata: metadata, + categoryIdentifier: LoopNotificationCategory.presetReminder.rawValue + ) + + await alertIssuer?.issueAlert(alert) + } + } + +} + +extension TemporaryPresetsManager { + static var placeholder: TemporaryPresetsManager { + .init(settingsProvider: SettingsManager.placeholder) + } +} + +extension TemporaryPresetsManager : AlertResponder { + func acknowledgeAlert(alertIdentifier: Alert.AlertIdentifier) async throws { + + } + + func handleAlertAction(actionIdentifier: String, from alert: Alert) async throws { + if actionIdentifier == UNNotificationDismissActionIdentifier { return } + + if actionIdentifier == NotificationManager.Action.startPreset.rawValue, + let metdata = alert.metadata, + let presetIdentifier = metdata["presetId"]?.wrapped as? String + { + startPreset(withIdentifier: presetIdentifier) + await alertIssuer?.retractAlert(identifier: Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: presetIdentifier)) + } else { + log.error("Could not identify preset to activate for alert action: actionIdentifier=%{public}@, alert=%{public}@", actionIdentifier, String(describing: alert)) + } + } +} + +@MainActor +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..9be28dbb7a 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)) - } - } - } +extension CarbStore { /// 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..e5055817b8 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() } @@ -87,10 +111,14 @@ class TrustedTimeChecker { let alertTitle = String(format: NSLocalizedString("%1$@ Time Settings Need Attention", comment: "Time change alert title"), UIDevice.current.model) let alertBody = String(format: NSLocalizedString("Your %1$@’s time has been changed. %2$@ needs accurate time records to make predictions about your glucose and adjust your insulin accordingly.\n\nCheck in your %1$@ Settings (General / Date & Time) and verify that 'Set Automatically' is turned ON. Failure to resolve could lead to serious under-delivery or over-delivery of insulin.", comment: "Time change alert body. (1: app name)"), UIDevice.current.model, Bundle.main.bundleDisplayName) let content = Alert.Content(title: alertTitle, body: alertBody, acknowledgeActionButtonLabel: NSLocalizedString("OK", comment: "Alert acknowledgment OK button")) - alertManager?.issueAlert(Alert(identifier: alertIdentifier, foregroundContent: content, backgroundContent: content, trigger: .immediate)) + Task { + await alertManager?.issueAlert(Alert(identifier: alertIdentifier, foregroundContent: content, backgroundContent: content, trigger: .immediate)) + } } private func retractTimeChangedAlert() { - alertManager?.retractAlert(identifier: alertIdentifier) + Task { + await alertManager?.retractAlert(identifier: alertIdentifier) + } } } diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index bac60b71dc..c64c21253a 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -9,22 +9,54 @@ import HealthKit import UIKit import WatchConnectivity +import LoopAlgorithm import LoopKit import LoopCore + +enum WatchDataManagerError: Error { + case decodingError + case expiredBolusRecommendation +} + +@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 + private unowned let alertManager: AlertManager + + init( + deviceManager: DeviceDataManager, + settingsManager: SettingsManager, + loopDataManager: LoopDataManager, + carbStore: CarbStore, + glucoseStore: GlucoseStore, + analyticsServicesManager: AnalyticsServicesManager?, + temporaryPresetsManager: TemporaryPresetsManager, + alertManager: AlertManager, + healthStore: HKHealthStore + ) { self.deviceManager = deviceManager + self.settingsManager = settingsManager + self.loopDataManager = loopDataManager + self.carbStore = carbStore + self.glucoseStore = glucoseStore + self.analyticsServicesManager = analyticsServicesManager + self.temporaryPresetsManager = temporaryPresetsManager + self.alertManager = alertManager 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 +73,7 @@ final class WatchDataManager: NSObject { } }() - private var lastSentSettings: LoopSettings? + private var lastSentUserInfo: LoopSettingsUserInfo? private var lastSentBolusVolumes: [Double]? private var contextDosingDecisions: [Date: BolusDosingDecision] { @@ -100,8 +132,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 } @@ -117,10 +149,12 @@ final class WatchDataManager: NSObject { private var lastComplicationContext: WatchContext? private let minTrendDrift: Double = 20 - private lazy var minTrendUnit = HKUnit.milligramsPerDeciliter + private lazy var minTrendUnit = LoopUnit.milligramsPerDeciliter private func sendSettingsIfNeeded() { - let settings = deviceManager.loopManager.settings + let userInfo = LoopSettingsUserInfo( + loopSettings: settingsManager.loopSettings, + scheduleOverride: temporaryPresetsManager.scheduleOverride) guard let session = watchSession, session.isPaired, session.isWatchAppInstalled else { return @@ -131,12 +165,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 +179,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,10 +200,16 @@ final class WatchDataManager: NSObject { } guard volumes != lastSentBolusVolumes else { - log.default("Skipping bolus volumes transfer due to no changes") return } + for xfer in session.outstandingUserInfoTransfers { + if (xfer.userInfo["name"] as? String) == SupportedBolusVolumesUserInfo.name { + // We have an outstanding SupportedBolusVolumesUserInfo xfer in progress + return + } + } + lastSentBolusVolumes = volumes log.default("Transferring supported bolus volumes") @@ -187,7 +226,8 @@ final class WatchDataManager: NSObject { return } - createWatchContext { (context) in + Task { + let context = await createWatchContext() self.sendWatchContext(context) } } @@ -231,140 +271,149 @@ 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: .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 + context.isOnboardingCompleted = deviceManager.cgmManager?.isOnboarded == true && deviceManager.pumpManager?.isOnboarded == true + context.deviceIssue = deviceManager.cgmManager == nil || deviceManager.cgmManager?.isInoperable == true || deviceManager.cgmManager?.inSignalLoss == true || deviceManager.pumpManager == nil || deviceManager.pumpManager?.isInoperable == true || deviceManager.pumpManager?.inSignalLoss == true || deviceManager.hasBluetoothIssue - 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()) + log.debug("watch bolus recommended: %{public}@ (with carb entry: %{public}@", String(describing: recommendedBolus.amount), String(describing: potentialCarbEntry)) + } - _ = 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) } } - - 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.glucose = sample?.quantity + context.glucoseDate = sample?.startDate + context.glucoseIsDisplayOnly = sample?.isDisplayOnly + context.glucoseWasUserEntered = sample?.wasUserEntered + context.glucoseSyncIdentifier = sample?.syncIdentifier + } + + context.iob = loopDataManager.activeInsulin?.value + + if deviceManager.isPumpInoperable { + context.insulinDeliveryState = .noDelivery + } else if deviceManager.isSuspended { + context.insulinDeliveryState = .suspended + } else if let automatedTreatmentState = loopDataManager.automatedTreatmentState { + switch automatedTreatmentState { + case .neutralNoOverride: + context.insulinDeliveryState = .neutralNoOverride + case .neutralOverride: + context.insulinDeliveryState = .neutralOverride + case .increasedInsulin: + context.insulinDeliveryState = .increasedInsulin + case .decreasedInsulin: + context.insulinDeliveryState = .decreasedInsulin + case .minimumDelivery: + context.insulinDeliveryState = .minimumDelivery } + } - dosingDecision.predictedGlucose = state.predictedGlucoseIncludingPendingInsulin ?? state.predictedGlucose + context.lastManualBolus = loopDataManager.lastManualBolus - var preMealOverride = settings.preMealOverride - if preMealOverride?.hasFinished() == true { - preMealOverride = nil - } + dosingDecision.historicalGlucose = historicalGlucose + dosingDecision.insulinOnBoard = loopDataManager.activeInsulin + + 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 + } - completion(context) + dosingDecision.scheduleOverride = scheduleOverride + + if scheduleOverride != nil || preMealOverride != nil { + dosingDecision.glucoseTargetRangeSchedule = self.temporaryPresetsManager.effectiveCorrectionRangeSchedule(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 + throw WatchDataManagerError.decodingError } // Prevent any delayed messages from enacting. guard bolus.startDate.timeIntervalSinceNow > -30 else { log.error("Could not enact expired bolus from watch: %{public}@", String(describing: message)) - return + throw WatchDataManagerError.expiredBolusRecommendation } var dosingDecision: BolusDosingDecision @@ -374,179 +423,191 @@ 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) -extension WatchDataManager: WCSessionDelegate { - func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + try await deviceManager.enactBolus(units: bolus.value, decisionId: dosingDecision.id, activationType: bolus.activationType) + self.analyticsServicesManager?.didBolus(source: "Watch", units: bolus.value) + } + + func handleWatchMessage(_ message: [String: Any]) async throws -> [String: Any] { switch message["name"] as? String { - case PotentialCarbEntryUserInfo.name?: - if let potentialCarbEntry = PotentialCarbEntryUserInfo(rawValue: message)?.carbEntry { - self.createWatchContext(recommendingBolusFor: potentialCarbEntry) { (context) in - replyHandler(context.rawValue) - } + case SettingsRequestUserInfo.name?: + let userInfo = LoopSettingsUserInfo( + loopSettings: settingsManager.loopSettings, + scheduleOverride: temporaryPresetsManager.scheduleOverride) + return userInfo.rawValue + case GetBolusRecommendationUserInfo.name?: + if let request = GetBolusRecommendationUserInfo(rawValue: message) { + let context = await createWatchContext(recommendingBolusFor: request.carbEntry) + return context.rawValue } else { log.error("Could not recommend bolus from from unknown message: %{public}@", String(describing: message)) - replyHandler([:]) } case SetBolusUserInfo.name?: // Add carbs if applicable; start the bolus and reply when it's successfully requested - addCarbEntryAndBolusFromWatchMessage(message) - - // Reply immediately - replyHandler([:]) - case LoopSettingsUserInfo.name?: - if let watchSettings = LoopSettingsUserInfo(rawValue: message)?.settings { - // So far we only support watch changes of temporary schedule overrides - var loopSettings = deviceManager.loopManager.settings - loopSettings.preMealOverride = watchSettings.preMealOverride - loopSettings.scheduleOverride = watchSettings.scheduleOverride + try await addCarbEntryAndBolusFromWatchMessage(message) + let updatedContext = await createWatchContext() + lastComplicationContext = updatedContext // Watch will use this to update context + return updatedContext.rawValue + + case SetPresetUserInfo.name?: + log.default("Set Preset from watch: %{public}@", String(describing: message)) + if let userInfo = SetPresetUserInfo(rawValue: message) { + if let presetIdentifier = userInfo.presetIdentifier { + temporaryPresetsManager.startPreset(withIdentifier: presetIdentifier) + } else { + temporaryPresetsManager.clearOverride() + } // Prevent re-sending these updated settings back to the watch - lastSentSettings = loopSettings - deviceManager.loopManager.mutateSettings { settings in - settings = loopSettings + lastSentUserInfo?.scheduleOverride = temporaryPresetsManager.scheduleOverride + + if let alertIdentifier = userInfo.alertIdentifier { + let id = Alert.Identifier(managerIdentifier: temporaryPresetsManager.managerIdentifier, alertIdentifier: alertIdentifier) + try? await alertManager.acknowledgeAlert(identifier: id) } + return [:] } - // Since target range affects recommended bolus, send back a new one - createWatchContext { (context) in - replyHandler(context.rawValue) + let context = await createWatchContext() + return context.rawValue + case AcknowledgeAlertUserInfo.name?: + log.default("Acknowledge alert from watch: %{public}@", String(describing: message)) + if let userInfo = AcknowledgeAlertUserInfo(rawValue: message) { + let id = Alert.Identifier(managerIdentifier: userInfo.managerIdentifier, alertIdentifier: userInfo.alertIdentifier) + try? await alertManager.acknowledgeAlert(identifier: id) } + return [:] case CarbBackfillRequestUserInfo.name?: if let userInfo = CarbBackfillRequestUserInfo(rawValue: message) { - deviceManager.carbStore.getSyncCarbObjects(start: userInfo.startDate) { (result) in - switch result { - case .failure(let error): - self.log.error("%{public}@", String(describing: error)) - replyHandler([:]) - case .success(let objects): - replyHandler(WatchHistoricalCarbs(objects: objects).rawValue) - } + do { + let objects = try await carbStore.getSyncCarbObjects(start: userInfo.startDate) + return WatchHistoricalCarbs(objects: objects).rawValue + } catch { + self.log.error("%{public}@", String(describing: error)) + return [:] } } else { - replyHandler([:]) + return [:] } 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): - self.log.error("Failure getting sync glucose objects: %{public}@", String(describing: error)) - replyHandler([:]) - case .success(let samples): - replyHandler(WatchHistoricalGlucose(samples: samples).rawValue) - } + do { + let samples = try await glucoseStore.getSyncGlucoseSamples(start: userInfo.startDate.addingTimeInterval(1)) + return WatchHistoricalGlucose(samples: samples).rawValue + } catch { + self.log.error("Failure getting sync glucose objects: %{public}@", String(describing: error)) + return [:] } } else { - replyHandler([:]) + return [:] } case WatchContextRequestUserInfo.name?: - self.createWatchContext { (context) in - // Send back the updated prediction and recommended bolus - replyHandler(context.rawValue) + return await createWatchContext().rawValue + case NotificationActionSelection.name?: + if let selection = NotificationActionSelection(rawValue: message) { + let identifier = Alert.Identifier( + managerIdentifier: selection.managerIdentifier, + alertIdentifier: selection.alertIdentifier + ) + try? await alertManager.userDidSelectAction(alertIdentifier: identifier, actionIdentifier: selection.actionIdentifier) } default: - replyHandler([:]) + return [:] + } + + return [:] + } +} + + +extension WatchDataManager: WCSessionDelegate { + nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + Task { @MainActor in + self.log.default("Received message: %{public}@", message) + do { + replyHandler(try await handleWatchMessage(message)) + } catch { + replyHandler(["error":String(describing: error)]) + } } } - func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { + nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { assertionFailure("We currently don't expect any userInfo messages transferred from the watch side") } - func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { - switch activationState { - case .activated: - if let error = error { - log.error("%{public}@", String(describing: error)) - } else { - sendSettingsIfNeeded() - sendWatchContextIfNeeded() - sendSupportedBolusVolumesIfNeeded() + nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + Task { @MainActor in + switch activationState { + case .activated: + if let error = error { + log.error("%{public}@", String(describing: error)) + } else { + sendSettingsIfNeeded() + sendWatchContextIfNeeded() + sendSupportedBolusVolumesIfNeeded() + } + case .inactive, .notActivated: + break + @unknown default: + break } - case .inactive, .notActivated: - break - @unknown default: - break } } - func session(_ session: WCSession, didFinish userInfoTransfer: WCSessionUserInfoTransfer, error: Error?) { - if let error = error { - log.error("%{public}@", String(describing: error)) - - // 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 - sendSettingsIfNeeded() - lastSentBolusVolumes = nil - sendSupportedBolusVolumesIfNeeded() - case LoopSettingsUserInfo.name: - lastSentSettings = nil - sendSettingsIfNeeded() - case SupportedBolusVolumesUserInfo.name: - lastSentBolusVolumes = nil - sendSupportedBolusVolumesIfNeeded() - default: - break + nonisolated func session(_ session: WCSession, didFinish userInfoTransfer: WCSessionUserInfoTransfer, error: Error?) { + Task { @MainActor in + if let error = error { + log.error("%{public}@", String(describing: error)) + + // This might be useless, as userInfoTransfer.userInfo seems to be nil when error is non-nil. + switch userInfoTransfer.userInfo["name"] as? String { + case nil: + lastSentUserInfo = nil + sendSettingsIfNeeded() + lastSentBolusVolumes = nil + sendSupportedBolusVolumesIfNeeded() + case LoopSettingsUserInfo.name: + lastSentUserInfo = nil + sendSettingsIfNeeded() + case SupportedBolusVolumesUserInfo.name: + lastSentBolusVolumes = nil + sendSupportedBolusVolumesIfNeeded() + default: + break + } } } } - func sessionDidBecomeInactive(_ session: WCSession) { + nonisolated func sessionDidBecomeInactive(_ session: WCSession) { // Nothing to do here } - func sessionDidDeactivate(_ session: WCSession) { - lastSentSettings = nil - watchSession = WCSession.default - watchSession?.delegate = self - watchSession?.activate() + nonisolated func sessionDidDeactivate(_ session: WCSession) { + Task { @MainActor in + lastSentUserInfo = nil + watchSession = WCSession.default + watchSession?.delegate = self + watchSession?.activate() + } } - func sessionReachabilityDidChange(_ session: WCSession) { - sendSettingsIfNeeded() - sendSupportedBolusVolumesIfNeeded() + nonisolated func sessionReachabilityDidChange(_ session: WCSession) { + Task { @MainActor in + sendSettingsIfNeeded() + sendSupportedBolusVolumesIfNeeded() + } } } @@ -555,7 +616,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..57038f5cb0 100644 --- a/Loop/Models/ApplicationFactorStrategy.swift +++ b/Loop/Models/ApplicationFactorStrategy.swift @@ -7,14 +7,13 @@ // import Foundation -import HealthKit +import LoopAlgorithm import LoopKit import LoopCore protocol ApplicationFactorStrategy { func calculateDosingFactor( - for glucose: HKQuantity, - correctionRangeSchedule: GlucoseRangeSchedule, - settings: LoopSettings + for glucose: LoopQuantity, + correctionRange: ClosedRange ) -> Double } diff --git a/Loop/Models/AutomaticDosingStatus.swift b/Loop/Models/AutomaticDosingStatus.swift deleted file mode 100644 index ae3930c122..0000000000 --- a/Loop/Models/AutomaticDosingStatus.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// AutomaticDosingStatus.swift -// Loop -// -// Created by Nathaniel Hamming on 2021-05-28. -// Copyright © 2021 LoopKit Authors. All rights reserved. -// - -import Foundation - -class AutomaticDosingStatus { - @Published var automaticDosingEnabled: Bool - @Published var isAutomaticDosingAllowed: Bool - - 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..4a989a67e8 --- /dev/null +++ b/Loop/Models/AutomationHistoryEntry.swift @@ -0,0 +1,68 @@ +// +// AutomationHistoryEntry.swift +// Loop +// +// Created by Pete Schwamb on 9/19/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopAlgorithm + +public struct AutomationHistoryEntry: Codable, Hashable { + var startDate: Date + var enabled: Bool +} + +extension Array where Element == AutomationHistoryEntry { + func toTimeline(from start: Date, to end: Date = .now) -> [AbsoluteScheduleValue] { + guard !isEmpty else { + return [] + } + + var out = [AbsoluteScheduleValue]() + + var iter = makeIterator() + + var prev = iter.next()! + + if prev.startDate > start { + let gapEnd = Swift.min(prev.startDate, end) + out.append(AbsoluteScheduleValue(startDate: start, endDate: gapEnd, value: !prev.enabled)) + } + + while let cur = iter.next() { + guard cur.enabled != prev.enabled else { + continue + } + if cur.startDate > start { + let segmentStart = Swift.max(prev.startDate, start) + let segmentEnd = Swift.min(cur.startDate, end) + out.append(AbsoluteScheduleValue(startDate: segmentStart, endDate: segmentEnd, value: prev.enabled)) + } + prev = cur + } + + if prev.startDate < end { + let segmentStart = Swift.max(prev.startDate, start) + out.append(AbsoluteScheduleValue(startDate: segmentStart, endDate: end, value: prev.enabled)) + } + + return out + } + + func automationEnabled(at date: Date) -> Bool? { + let clampedValues = toTimeline(from: date) + guard let first = clampedValues.first else { + return nil + } + + if date < first.startDate { + return !first.value + } else if let enabled = clampedValues.last(where: { $0.startDate <= date })?.value { + return enabled + } else { + return nil + } + } +} diff --git a/Loop/Models/BolusDosingDecision.swift b/Loop/Models/BolusDosingDecision.swift index 9d63905858..d9b8a47073 100644 --- a/Loop/Models/BolusDosingDecision.swift +++ b/Loop/Models/BolusDosingDecision.swift @@ -7,6 +7,7 @@ // import LoopKit +import LoopAlgorithm struct BolusDosingDecision { enum Reason: String { @@ -15,6 +16,7 @@ struct BolusDosingDecision { case watchBolus } + var id: UUID var reason: Reason var scheduleOverride: TemporaryScheduleOverride? var historicalGlucose: [HistoricalGlucoseValue]? @@ -29,6 +31,7 @@ struct BolusDosingDecision { var manualBolusRequested: Double? init(for reason: Reason) { + self.id = UUID() self.reason = reason } } diff --git a/Loop/Models/ConstantApplicationFactorStrategy.swift b/Loop/Models/ConstantApplicationFactorStrategy.swift index e13c40c42e..41b4b478e2 100644 --- a/Loop/Models/ConstantApplicationFactorStrategy.swift +++ b/Loop/Models/ConstantApplicationFactorStrategy.swift @@ -7,17 +7,16 @@ // import Foundation -import HealthKit import LoopKit import LoopCore +import LoopAlgorithm struct ConstantApplicationFactorStrategy: ApplicationFactorStrategy { func calculateDosingFactor( - for glucose: HKQuantity, - correctionRangeSchedule: GlucoseRangeSchedule, - settings: LoopSettings + for glucose: LoopQuantity, + 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..5df015dac9 100644 --- a/Loop/Models/CrashRecoveryManager.swift +++ b/Loop/Models/CrashRecoveryManager.swift @@ -8,6 +8,7 @@ import Foundation import LoopKit +import LoopAlgorithm class CrashRecoveryManager { @@ -62,12 +63,14 @@ class CrashRecoveryManager { trigger: .immediate, interruptionLevel: .critical) - self.alertIssuer.issueAlert(alert) + Task { + await self.alertIssuer.issueAlert(alert) + } } } extension CrashRecoveryManager: AlertResponder { - func acknowledgeAlert(alertIdentifier: LoopKit.Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { + func acknowledgeAlert(alertIdentifier: LoopKit.Alert.AlertIdentifier) async throws { UserDefaults.appGroup?.inFlightAutomaticDose = nil doseRecoveredFromCrash = nil } diff --git a/Loop/Models/Deeplink.swift b/Loop/Models/Deeplink.swift new file mode 100644 index 0000000000..aaede07661 --- /dev/null +++ b/Loop/Models/Deeplink.swift @@ -0,0 +1,61 @@ +// +// Deeplink.swift +// Loop +// +// Created by Noah Brauner on 8/9/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopAlgorithm + +enum Deeplink: Hashable { + enum Host: String, CaseIterable { + case carbEntry = "carb-entry" + case bolus = "manual-bolus" + case preMeal = "pre-meal-preset" + case customPresets = "custom-presets" + } + + enum CarbEntryLink: Hashable { + case carbEntryDetected(value: LoopQuantity) + } + + case carbEntry(CarbEntryLink?) + + case bolus + case preMeal + case customPresets + + var host: Host { + switch self { + case .carbEntry: .carbEntry + case .bolus: .bolus + case .preMeal: .preMeal + case .customPresets: .customPresets + } + } + + init?(url: URL?) { + guard let url, let host = url.host, let deeplinkHost = Deeplink.Host.allCases.first(where: { $0.rawValue == host }) else { + return nil + } + + let components = URLComponents(url: url, resolvingAgainstBaseURL: true) + + switch deeplinkHost { + case .carbEntry: + if let value = components?.queryItems?.first(where: { $0.name == "value" })?.value, let doubleValue = Double(value) { + self = .carbEntry(.carbEntryDetected(value: LoopQuantity(unit: .gram, doubleValue: doubleValue))) + } else { + self = .carbEntry(nil) + } + case .bolus: + self = .bolus + case .preMeal: + self = .preMeal + case .customPresets: + self = .customPresets + } + } +} diff --git a/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift b/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift index 41caa3d773..e339d4a881 100644 --- a/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift +++ b/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift @@ -7,7 +7,7 @@ // import Foundation -import HealthKit +import LoopAlgorithm import LoopKit import LoopCore @@ -20,13 +20,11 @@ struct GlucoseBasedApplicationFactorStrategy: ApplicationFactorStrategy { static let maxGlucoseSlidingScale = 200.0 // mg/dL func calculateDosingFactor( - for glucose: HKQuantity, - correctionRangeSchedule: GlucoseRangeSchedule, - settings: LoopSettings + for glucose: LoopQuantity, + 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/GlucoseDisplay.swift b/Loop/Models/GlucoseDisplay.swift index 119e06d096..fe81a05b34 100644 --- a/Loop/Models/GlucoseDisplay.swift +++ b/Loop/Models/GlucoseDisplay.swift @@ -7,19 +7,19 @@ // import Foundation -import HealthKit +import LoopAlgorithm import LoopKit struct GlucoseDisplay: GlucoseDisplayable { let isStateValid: Bool let trendType: GlucoseTrend? - let trendRate: HKQuantity? + let trendRate: LoopQuantity? let isLocal: Bool var glucoseRangeCategory: GlucoseRangeCategory? init(isStateValid: Bool, trendType: GlucoseTrend?, - trendRate: HKQuantity?, + trendRate: LoopQuantity?, isLocal: Bool, glucoseRangeCategory: GlucoseRangeCategory?) { @@ -45,7 +45,7 @@ struct GlucoseDisplay: GlucoseDisplayable { struct ManualGlucoseDisplay: GlucoseDisplayable { let isStateValid: Bool let trendType: GlucoseTrend? - let trendRate: HKQuantity? + let trendRate: LoopQuantity? let isLocal: Bool let glucoseRangeCategory: GlucoseRangeCategory? diff --git a/Loop/Models/GlucoseEffectVelocity.swift b/Loop/Models/GlucoseEffectVelocity.swift index 9557f2fd50..4a9ab40796 100644 --- a/Loop/Models/GlucoseEffectVelocity.swift +++ b/Loop/Models/GlucoseEffectVelocity.swift @@ -5,14 +5,14 @@ // Copyright © 2017 LoopKit Authors. All rights reserved. // -import HealthKit import LoopKit +import LoopAlgorithm extension GlucoseEffectVelocity: RawRepresentable { public typealias RawValue = [String: Any] - static let unit = HKUnit.milligramsPerDeciliter.unitDivided(by: .minute()) + static let unit = LoopUnit.milligramsPerDeciliterPerMinute public init?(rawValue: RawValue) { guard let startDate = rawValue["startDate"] as? Date, @@ -24,7 +24,7 @@ extension GlucoseEffectVelocity: RawRepresentable { self.init( startDate: startDate, endDate: rawValue["endDate"] as? Date ?? startDate, - quantity: HKQuantity(unit: type(of: self).unit, doubleValue: doubleValue) + quantity: LoopQuantity(unit: type(of: self).unit, doubleValue: doubleValue) ) } diff --git a/Loop/Models/InsulinDeliveryLogEvent.swift b/Loop/Models/InsulinDeliveryLogEvent.swift new file mode 100644 index 0000000000..9305601a9a --- /dev/null +++ b/Loop/Models/InsulinDeliveryLogEvent.swift @@ -0,0 +1,135 @@ +// +// InsulinDeliveryLogEvent.swift +// Loop +// +// Created by Cameron Ingham on 3/26/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import LoopAlgorithm + +struct InsulinDeliveryLogEvent: Hashable, Identifiable { + enum EventType: Hashable { + enum PumpEventType: Hashable { + enum BasalEventType: Hashable { + enum AutomatedBasalStatus: Hashable { + case scheduled + case moreThanScheduled + case lessThanScheduled + } + + case automationOn(basalStatus: AutomatedBasalStatus) + case automationOff + case automatedPresetBasal + case manualTempBasal(endDate: Date) + } + + case basal(BasalEventType, rate: LoopQuantity) + + enum BolusEventType: Hashable { + case automated + case meal(recommendedAmount: LoopQuantity, carbAmount: LoopQuantity, emoji: String) + case correction(recommendedAmount: LoopQuantity?) + case external + } + + case bolus(BolusEventType, programmedAmount: LoopQuantity?, deliveryAmount: LoopQuantity) + + enum InsulinEventType: Hashable { + case suspended + case resumed + } + + case insulin(InsulinEventType) + } + + case pumpEvent(PumpEventType, DoseEntry?) + + enum AutomationEventType: Hashable { + case on + case off(endDate: Date?) + case unavailable + } + + case automation(AutomationEventType) + + enum PresetEventType: Hashable { + case enabled + case disabled + } + + case preset(PresetEventType, icon: PresetSymbol?, name: String) + } + + let id: String + let type: EventType + let date: Date +} + +extension InsulinDeliveryLogEvent { + var endDate: Date? { + if case let .automation(.off(endDate)) = type { + return endDate + } else if case let .pumpEvent(.basal(.manualTempBasal(endDate), _), _) = type { + return endDate + } else { + return nil + } + } +} + +extension Array { + + struct LogSegment { + let start: Date + let end: Date + var events: [InsulinDeliveryLogEvent] + } + + func sortedByDate() -> [InsulinDeliveryLogEvent] { + sorted { + var isComparingSuspend = false + if case .pumpEvent(.insulin(.suspended), _) = $0.type { + isComparingSuspend = true + } + + if $0.date == $1.date, case .pumpEvent(.insulin(.resumed), _) = $1.type, !isComparingSuspend { + return true + } else { + return $0.date > $1.date + } + } + } + + func segmentItemsByHour() -> [LogSegment] { + let calendar = Calendar.current + + var itemsByHourRange = [LogSegment]() + + for item in sortedByDate() { + let components = calendar.dateComponents([.day, .hour], from: item.endDate ?? item.date) + + guard let hourStart = calendar.date(from: components), let hourEnd = calendar.date(byAdding: .hour, value: 1, to: hourStart) else { + continue + } + + let hourRange = hourStart.. String { switch self { case .pumpManager: @@ -34,6 +35,8 @@ enum ConfigurationErrorDetail: String, Codable { return NSLocalizedString("Maximum Basal Rate Per Hour", comment: "Details for configuration error when maximum basal rate per hour is missing") case .maximumBolus: return NSLocalizedString("Maximum Bolus", comment: "Details for configuration error when maximum bolus is missing") + case .suspendThreshold: + return NSLocalizedString("Suspend Threshold", comment: "Details for configuration error when suspend threshold is missing") } } } @@ -45,7 +48,7 @@ enum MissingDataErrorDetail: String, Codable { case insulinEffect case activeInsulin case insulinEffectIncludingPendingInsulin - + var localizedDetail: String { switch self { case .glucose: @@ -99,12 +102,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 +139,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 +213,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..4594bae7f8 100644 --- a/Loop/Models/ManualBolusRecommendation.swift +++ b/Loop/Models/ManualBolusRecommendation.swift @@ -8,11 +8,11 @@ import Foundation import LoopKit -import HealthKit +import LoopAlgorithm extension BolusRecommendationNotice { - public func description(using unit: HKUnit) -> String { + public func description(using unit: LoopUnit) -> String { switch self { case .glucoseBelowSuspendThreshold(minGlucose: let minGlucose): let glucoseFormatter = NumberFormatter.glucoseFormatter(for: unit) @@ -37,39 +37,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..342fe23db5 100644 --- a/Loop/Models/PredictionInputEffect.swift +++ b/Loop/Models/PredictionInputEffect.swift @@ -7,8 +7,8 @@ // import Foundation -import HealthKit - +import LoopKit +import LoopAlgorithm struct PredictionInputEffect: OptionSet { let rawValue: Int @@ -38,7 +38,7 @@ struct PredictionInputEffect: OptionSet { } } - func localizedDescription(forGlucoseUnit unit: HKUnit) -> String? { + func localizedDescription(forGlucoseUnit unit: LoopUnit) -> String? { switch self { case [.carbs]: return String(format: NSLocalizedString("Carbs Absorbed (g) ÷ Carb Ratio (g/U) × Insulin Sensitivity (%1$@/U)", comment: "Description of the prediction input effect for carbohydrates. (1: The glucose unit string)"), unit.localizedShortUnitString) @@ -55,3 +55,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/SimpleBolusCalculator.swift b/Loop/Models/SimpleBolusCalculator.swift index a26af98466..515aef3f5f 100644 --- a/Loop/Models/SimpleBolusCalculator.swift +++ b/Loop/Models/SimpleBolusCalculator.swift @@ -8,17 +8,17 @@ import Foundation import LoopCore -import HealthKit +import LoopAlgorithm import LoopKit struct SimpleBolusCalculator { - public static func recommendedInsulin(mealCarbs: HKQuantity?, manualGlucose: HKQuantity?, activeInsulin: HKQuantity, carbRatioSchedule: CarbRatioSchedule, correctionRangeSchedule: GlucoseRangeSchedule, sensitivitySchedule: InsulinSensitivitySchedule, at date: Date = Date()) -> HKQuantity { + public static func recommendedInsulin(mealCarbs: LoopQuantity?, manualGlucose: LoopQuantity?, activeInsulin: LoopQuantity, carbRatioSchedule: CarbRatioSchedule, correctionRangeSchedule: GlucoseRangeSchedule, sensitivitySchedule: InsulinSensitivitySchedule, at date: Date = Date()) -> LoopQuantity { var recommendedBolus: Double = 0 if let mealCarbs = mealCarbs { let carbRatio = carbRatioSchedule.quantity(at: date) - recommendedBolus += mealCarbs.doubleValue(for: .gram()) / carbRatio.doubleValue(for: .gram()) + recommendedBolus += mealCarbs.doubleValue(for: .gram) / carbRatio.doubleValue(for: .gram) } if let manualGlucose = manualGlucose { @@ -28,7 +28,7 @@ struct SimpleBolusCalculator { let correctionTarget = correctionRange.averageValue(for: .milligramsPerDeciliter) let correctionBolus = (manualGlucose.doubleValue(for: .milligramsPerDeciliter) - correctionTarget) / sensitivity if correctionBolus >= 0 { - let activeInsulin = max(0, activeInsulin.doubleValue(for: .internationalUnit())) + let activeInsulin = max(0, activeInsulin.doubleValue(for: .internationalUnit)) let correctionBolusMinusActiveInsulin = correctionBolus - activeInsulin recommendedBolus += max(0, correctionBolusMinusActiveInsulin) } else { @@ -46,6 +46,6 @@ struct SimpleBolusCalculator { // No negative recommendation recommendedBolus = max(0, recommendedBolus) - return HKQuantity(unit: .internationalUnit(), doubleValue: recommendedBolus) + return LoopQuantity(unit: .internationalUnit, doubleValue: recommendedBolus) } } diff --git a/Loop/Models/SimpleInsulinDose.swift b/Loop/Models/SimpleInsulinDose.swift new file mode 100644 index 0000000000..f0cd1048dc --- /dev/null +++ b/Loop/Models/SimpleInsulinDose.swift @@ -0,0 +1,89 @@ +// +// 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 automatic: Bool + 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, + automatic: automatic ?? false, + 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, + automatic: automatic, + 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..ba3c169d88 --- /dev/null +++ b/Loop/Models/StoredDataAlgorithmInput.swift @@ -0,0 +1,59 @@ +// +// StoredDataAlgorithmInput.swift +// Loop +// +// Created by Pete Schwamb on 2/23/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +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: LoopQuantity? + + 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 + + var maxActiveInsulinMultiplier: Double? = nil + + var gradualTransitionsThreshold: Double? = nil +} diff --git a/Loop/Models/WatchContext+LoopKit.swift b/Loop/Models/WatchContext+LoopKit.swift index a9adf41da4..997124203d 100644 --- a/Loop/Models/WatchContext+LoopKit.swift +++ b/Loop/Models/WatchContext+LoopKit.swift @@ -7,14 +7,16 @@ // import Foundation -import HealthKit import LoopKit +import LoopAlgorithm +import LoopCore extension WatchContext { - convenience init(glucose: GlucoseSampleValue?, glucoseUnit: HKUnit?) { + convenience init(glucose: GlucoseSampleValue?, glucoseUnit: LoopUnit?) { 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..7064bda19f 100644 --- a/Loop/Plugins/PluginManager.swift +++ b/Loop/Plugins/PluginManager.swift @@ -11,6 +11,7 @@ import Foundation import LoopKit import LoopKitUI +@MainActor class PluginManager { let pluginBundles: [Bundle] @@ -27,6 +28,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 +43,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 +253,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 419c707cbd..5ed4ee1146 100644 --- a/Loop/View Controllers/CarbAbsorptionViewController.swift +++ b/Loop/View Controllers/CarbAbsorptionViewController.swift @@ -6,13 +6,13 @@ // import SwiftUI -import HealthKit import Intents import LoopCore import LoopKit import LoopKitUI import LoopUI import os.log +import LoopAlgorithm private extension RefreshContext { @@ -28,7 +28,11 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif var isOnboardingComplete: Bool = true - var automaticDosingStatus: AutomaticDosingStatus! + var automaticDosingEnabled: Bool! + + var loopDataManager: LoopDataManager! + var carbStore: CarbStore! + var analyticsServicesManager: AnalyticsServicesManager! override func viewDidLoad() { super.viewDidLoad() @@ -40,10 +44,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 +57,9 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif } self?.refreshContext.update(with: .status) - self?.reloadData(animated: true) + Task { @MainActor in + await self?.reloadData(animated: true) + } } }, ] @@ -64,7 +70,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif navigationItem.rightBarButtonItem?.isEnabled = isOnboardingComplete - allowEditing = automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled + allowEditing = automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled if allowEditing { navigationItem.rightBarButtonItems?.append(editButtonItem) @@ -72,7 +78,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 +122,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 +147,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: allInsulinCounteractionEffects) { (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 +234,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() @@ -326,12 +291,13 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif return cell case .entries: - let unit = HKUnit.gram() + let unit = LoopUnit.gram let cell = tableView.dequeueReusableCell(withIdentifier: CarbEntryTableViewCell.className, for: indexPath) as! CarbEntryTableViewCell + cell.accessibilityIdentifier = "cell_CarbEntry" // 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( @@ -357,10 +323,10 @@ 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 observedProgress = Float(absorption.observedProgress.doubleValue(for: .percent)) + 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 @@ -400,14 +366,14 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif } private func updateCell(_ cell: HeaderValuesTableViewCell) { - let unit = HKUnit.gram() + let unit = LoopUnit.gram if let carbsOnBoard = carbsOnBoard, carbsOnBoard.quantity.doubleValue(for: unit) > 0 { cell.COBDateLabel.text = String( 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: LoopQuantity(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: LoopQuantity(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() { @@ -513,15 +494,16 @@ 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)) + if FeatureFlags.simpleBolusCalculatorEnabled && !automaticDosingEnabled { + 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/GlucoseThresholdTableViewController.swift b/Loop/View Controllers/GlucoseThresholdTableViewController.swift index 1657be2779..cb6bbb7011 100644 --- a/Loop/View Controllers/GlucoseThresholdTableViewController.swift +++ b/Loop/View Controllers/GlucoseThresholdTableViewController.swift @@ -7,16 +7,16 @@ // import Foundation -import HealthKit +import LoopAlgorithm import LoopKit import LoopKitUI import UIKit final class GlucoseThresholdTableViewController: TextFieldTableViewController { - public let glucoseUnit: HKUnit + public let glucoseUnit: LoopUnit - init(threshold: Double?, glucoseUnit: HKUnit) { + init(threshold: Double?, glucoseUnit: LoopUnit) { self.glucoseUnit = glucoseUnit super.init(style: .grouped) diff --git a/Loop/View Controllers/InsulinDeliveryTableViewController.swift b/Loop/View Controllers/InsulinDeliveryTableViewController.swift index 2414b8e0d0..4cd4cfe55f 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() - } - 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() - } - } + 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)) } - + 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() } @@ -310,38 +293,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 } } } @@ -356,7 +339,9 @@ public final class InsulinDeliveryTableViewController: UITableViewController { } @IBAction func selectedSegmentChanged(_ sender: Any) { - reloadData() + Task { @MainActor in + await reloadData() + } } @IBAction func confirmDeletion(_ sender: Any) { @@ -376,37 +361,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 @@ -416,7 +399,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 + } } } @@ -424,13 +410,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) @@ -446,18 +456,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] @@ -494,22 +504,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() + } } } } @@ -523,7 +536,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() + } } } } @@ -532,20 +547,24 @@ 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 { - description.append(dose.formatted) + if let dose = pumpEvent.dose { + description.append(String(describing: dose)) + } + + if let raw = pumpEvent.raw { + description.append(raw.hexadecimalString) } return description.joined(separator: "\n\n") @@ -659,3 +678,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/LoopChartsTableViewController.swift b/Loop/View Controllers/LoopChartsTableViewController.swift index 8b1e56447b..699459f0b7 100644 --- a/Loop/View Controllers/LoopChartsTableViewController.swift +++ b/Loop/View Controllers/LoopChartsTableViewController.swift @@ -8,7 +8,6 @@ import UIKit import LoopUI import LoopKitUI -import HealthKit import os.log diff --git a/Loop/View Controllers/PredictionTableViewController.swift b/Loop/View Controllers/PredictionTableViewController.swift index a460e52aaf..df7f672d07 100644 --- a/Loop/View Controllers/PredictionTableViewController.swift +++ b/Loop/View Controllers/PredictionTableViewController.swift @@ -6,13 +6,13 @@ // Copyright © 2016 Nathan Racklyeft. All rights reserved. // -import HealthKit import LoopCore import LoopKit import LoopKitUI import LoopUI import UIKit import os.log +import LoopAlgorithm private extension RefreshContext { @@ -23,6 +23,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 +37,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 +49,9 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable break } - self?.reloadData(animated: true) + Task { + await self?.reloadData(animated: true) + } } }, ] @@ -70,7 +75,7 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable private var retrospectiveGlucoseDiscrepancies: [GlucoseChange]? - private var totalRetrospectiveCorrection: HKQuantity? + private var totalRetrospectiveCorrection: LoopQuantity? private var refreshContext = RefreshContext.all @@ -98,7 +103,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 +113,79 @@ 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() - } - } + var totalRetrospectiveCorrection: LoopQuantity? // 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 - reloadGroup.leave() + self.glucoseChart.setPredictedGlucoseValues(algoOutput?.predictedGlucose ?? []) + + 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 { + let valueAttributedString = NSMutableAttributedString(string: String(describing: lastPoint.copy), attributes: [.font: UIFont.systemFont(ofSize: 22, weight: .semibold), .foregroundColor: ChartColorPalette.primary.glucoseTint]) + let spacer = NSAttributedString(string: "\u{00a0}") + let unitAttributedString = NSAttributedString(string: String(describing: lastPoint).replacingOccurrences(of: String(describing: lastPoint.copy), with: "").trimmingCharacters(in: .whitespacesAndNewlines), attributes: [.font: UIFont.systemFont(ofSize: 15, weight: .regular), .foregroundColor: ChartColorPalette.primary.glucoseTint]) + + valueAttributedString.append(spacer) + valueAttributedString.append(unitAttributedString) + + self.eventualGlucoseDescription = valueAttributedString + } 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.glucoseChart.scheduleOverride = loopDataManager.scheduleOverride + self.glucoseChart.preMealOverride = loopDataManager.preMealOverride - 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 @@ -195,9 +195,11 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable case inputs } - private var eventualGlucoseDescription: String? + private var eventualGlucoseDescription: NSAttributedString? - 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 @@ -243,7 +245,13 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable } if let eventualGlucose = eventualGlucoseDescription { - cell.setTitleLabelText(label: String(format: NSLocalizedString("Eventually %@", comment: "The subtitle format describing eventual glucose. (1: localized glucose value description)"), eventualGlucose)) + let title = NSMutableAttributedString(string: NSLocalizedString("Eventually", comment: ""), attributes: [.font: UIFont.systemFont(ofSize: 15, weight: .regular)]) + let spacer = NSAttributedString(string: "\u{00a0}") + + title.append(spacer) + title.append(eventualGlucose) + + cell.setTitleLabelText(label: title) } else { cell.setTitleLabelText(label: SettingsTableViewCell.NoValueString) } @@ -263,10 +271,10 @@ 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)) + let predicted = LoopQuantity(unit: glucoseChart.glucoseUnit, doubleValue: currentGlucose.quantity.doubleValue(for: glucoseChart.glucoseUnit) - lastDiscrepancy.quantity.doubleValue(for: glucoseChart.glucoseUnit)) var values = [predicted, currentGlucose.quantity].map { formatter.string(from: $0) ?? "?" } formatter.numberFormatter.positivePrefix = formatter.numberFormatter.plusSign values.append(formatter.string(from: lastDiscrepancy.quantity) ?? "?") @@ -282,7 +290,7 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable var totalEffectDisplay = "?" if let totalEffect = self.totalRetrospectiveCorrection { let integralEffectValue = totalEffect.doubleValue(for: glucoseChart.glucoseUnit) - lastDiscrepancy.quantity.doubleValue(for: glucoseChart.glucoseUnit) - let integralEffect = HKQuantity(unit: glucoseChart.glucoseUnit, doubleValue: integralEffectValue) + let integralEffect = LoopQuantity(unit: glucoseChart.glucoseUnit, doubleValue: integralEffectValue) integralEffectDisplay = formatter.string(from: integralEffect) ?? "?" totalEffectDisplay = formatter.string(from: totalEffect) ?? "?" } @@ -326,6 +334,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/RootNavigationController.swift b/Loop/View Controllers/RootNavigationController.swift index 87b04d5e93..7664b009d8 100644 --- a/Loop/View Controllers/RootNavigationController.swift +++ b/Loop/View Controllers/RootNavigationController.swift @@ -6,30 +6,9 @@ // import UIKit -import LoopKit -import LoopKitUI /// The root view controller in Loop class RootNavigationController: UINavigationController { - - /// Its root view controller is always StatusTableViewController after loading - var statusTableViewController: StatusTableViewController! { - return viewControllers.first as? StatusTableViewController - } - - func navigate(to deeplink: Deeplink) { - switch deeplink { - case .carbEntry: - statusTableViewController.presentCarbEntryScreen(nil) - case .preMeal: - statusTableViewController.togglePreMealMode() - case .bolus: - statusTableViewController.presentBolusScreen() - case .customPresets: - statusTableViewController.presentCustomPresets() - } - } - override func restoreUserActivityState(_ activity: NSUserActivity) { switch activity.activityType { case NSUserActivity.viewLoopStatusActivityType: @@ -41,8 +20,7 @@ class RootNavigationController: UINavigationController { popToRootViewController(animated: false) } default: - statusTableViewController.restoreUserActivityState(activity) + viewControllers.first?.restoreUserActivityState(activity) } } - } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 94df013244..db0511d6b2 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -7,7 +7,6 @@ // import UIKit -import HealthKit import SwiftUI import Intents import LoopCore @@ -19,41 +18,83 @@ 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 carbFormatter: QuantityFormatter = QuantityFormatter(for: .gram) + + lazy var insulinFormatter: QuantityFormatter = { + let formatter = QuantityFormatter(for: .internationalUnit) + formatter.numberFormatter.maximumFractionDigits = 3 + return formatter + }() var onboardingManager: OnboardingManager! var testingScenariosManager: TestingScenariosManager! - var automaticDosingStatus: AutomaticDosingStatus! - 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! + + var statusTableViewModel: StatusTableViewModel! + lazy private var cancellables = Set() + + var statusBarBackgroundView: UIView? override func viewDidLoad() { super.viewDidLoad() - - setupToolbarItems() - + + statusTableViewModel.settingsViewModel.delegate = self + statusTableViewModel.settingsViewModel.servicesViewModel.delegate = self + statusTableViewModel.settingsViewModel.pumpManagerSettingsViewModel.didTap = { [weak self] in + self?.onPumpTapped() + } + statusTableViewModel.settingsViewModel.pumpManagerSettingsViewModel.didTapAdd = { [weak self] in + self?.addPumpManager(withIdentifier: $0.identifier) + } + statusTableViewModel.settingsViewModel.cgmManagerSettingsViewModel.didTap = { [weak self] in + self?.onCGMTapped() + } + statusTableViewModel.settingsViewModel.cgmManagerSettingsViewModel.didTapAdd = { [weak self] in + self?.addCGMManager(withIdentifier: $0.identifier) + } + tableView.register(BolusProgressTableViewCell.nib(), forCellReuseIdentifier: BolusProgressTableViewCell.className) - tableView.register(AlertPermissionsDisabledWarningCell.self, forCellReuseIdentifier: AlertPermissionsDisabledWarningCell.className) - tableView.register(MuteAlertsWarningCell.self, forCellReuseIdentifier: MuteAlertsWarningCell.className) + tableView.register(InsulinSuspendedTableViewCell.nib(), forCellReuseIdentifier: InsulinSuspendedTableViewCell.className) + tableView.register(RecentGlucoseTableViewCell.nib(), forCellReuseIdentifier: RecentGlucoseTableViewCell.className) if FeatureFlags.predictedGlucoseChartClampEnabled { statusCharts.glucose.glucoseDisplayRange = LoopConstants.glucoseChartDefaultDisplayBoundClamped @@ -67,10 +108,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 +121,81 @@ final class StatusTableViewController: LoopChartsTableViewController { self?.refreshContext.update(with: .carbs) case .glucose?: self?.refreshContext.formUnion([.glucose, .carbs]) - case .loopFinished?: - self?.refreshContext.update(with: .insulin) + case .forecast?: + self?.refreshContext.update(with: .glucose) } 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() + await self?.reloadData() } }, - 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() + await self?.reloadData() } }, - 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) - .sink { self.automaticDosingStatusChanged($0) } - .store(in: &cancellables) + withObservationTracking(of: self.settingsManager.dosingEnabled) { [weak self] enabled in + self?.automaticDosingStatusChanged(enabled) + } 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) @@ -144,8 +210,7 @@ final class StatusTableViewController: LoopChartsTableViewController { addScenarioStepGestureRecognizers() - tableView.backgroundColor = .secondarySystemBackground - + setupPresetsStatusBar() } override func didReceiveMemoryWarning() { @@ -157,26 +222,26 @@ final class StatusTableViewController: LoopChartsTableViewController { } private var appearedOnce = false - + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(true, animated: animated) navigationController?.setToolbarHidden(false, animated: animated) - updateToolbarItems() - alertPermissionsChecker.checkNow() updateBolusProgress() 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() + guard let self else { return } + Task { @MainActor in + self.statusTableViewModel.settingsViewModel.isOnboardingComplete = self.onboardingManager.isComplete + self.refreshContext.update(with: .status) + await self.reloadData(animated: true) + } } .store(in: &cancellables) } @@ -187,15 +252,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,39 +298,54 @@ final class StatusTableViewController: LoopChartsTableViewController { var onscreen: Bool = false { didSet { updateHUDActive() + loopManager.startGlucoseValueStalenessTimerIfNeeded() } } - + private var bolusState: PumpManagerStatus.BolusState = .noBolus { didSet { if oldValue != bolusState { switch bolusState { - case .inProgress(let dose): - guard case .inProgress = oldValue else { - // Bolus starting + case .inProgress(let doseNew): + switch oldValue { + case .inProgress(let doseOld): + guard doseNew.syncIdentifier != doseOld.syncIdentifier, + doseNew.automatic != true + else { break } + // Different manual bolus is being delivered bolusProgressReporter = deviceManager.pumpManager?.createBolusProgressReporter(reportingOn: DispatchQueue.main) - // If there is an existing bolus progressCell, update its dose values now in case the app is currently in the - // background as otherwise these values won't get initialized and can contain stale data from some earlier bolus. - if let progressCell = tableView.cellForRow(at: IndexPath(row: StatusRow.status.rawValue, section: Section.status.rawValue)) as? BolusProgressTableViewCell { - progressCell.totalUnits = dose.programmedUnits - progressCell.deliveredUnits = 0 - } + case .canceling: break + default: + // Bolus starting + guard doseNew.automatic != true else { break } + bolusProgressReporter = deviceManager.pumpManager?.createBolusProgressReporter(reportingOn: DispatchQueue.main) } default: break } - refreshContext.update(with: .status) - reloadData(animated: true) } } } + + private func setupPresetsStatusBar() { + let backgroundContainerView = UIView() + backgroundContainerView.backgroundColor = .systemBackground + let statusBarBackgroundView = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 0)) + self.statusBarBackgroundView = statusBarBackgroundView + backgroundContainerView.addSubview(statusBarBackgroundView) + tableView.backgroundView = backgroundContainerView + + updateStatusBar() + } private var bolusProgressReporter: DoseProgressReporter? 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,55 +353,24 @@ final class StatusTableViewController: LoopChartsTableViewController { deviceManager.pumpManagerHUDProvider?.visible = active && onscreen } - private func setupToolbarItems() { - let space = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: self, action: nil) - let carbs = UIBarButtonItem(image: UIImage(named: "carbs"), style: .plain, target: self, action: #selector(userTappedAddCarbs)) - 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)) - - let preMeal = createPreMealButtonItem(selected: false, isEnabled: true) - let workout = createWorkoutButtonItem(selected: false, isEnabled: true) - toolbarItems = [ - carbs, - space, - preMeal, - space, - bolus, - space, - workout, - space, - settings - ] - } - - private func updateToolbarItems() { - let isPumpOnboarded = onboardingManager.isComplete || deviceManager.pumpManager?.isOnboarded == true - - toolbarItems![0].accessibilityLabel = NSLocalizedString("Add Meal", comment: "The label of the carb entry button") - toolbarItems![0].isEnabled = isPumpOnboarded - toolbarItems![0].tintColor = UIColor.carbTintColor - toolbarItems![4].accessibilityLabel = NSLocalizedString("Bolus", comment: "The label of the bolus entry button") - toolbarItems![4].isEnabled = isPumpOnboarded - toolbarItems![4].tintColor = UIColor.insulinTintColor - toolbarItems![8].accessibilityLabel = NSLocalizedString("Settings", comment: "The label of the settings button") - toolbarItems![8].tintColor = UIColor.secondaryLabel - - toolbarItems![2] = createPreMealButtonItem(selected: preMealMode == true && preMealModeAllowed, isEnabled: preMealModeAllowed) - toolbarItems![6] = createWorkoutButtonItem(selected: workoutMode == true && workoutModeAllowed, isEnabled: workoutModeAllowed) - } - 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) + } } } } // Toggles the display mode based on the screen aspect ratio. Should not be updated outside of reloadData(). - private var landscapeMode = false + private var landscapeMode = false { + didSet { + setupPresetsStatusBar() + } + } private var lastLoopError: Error? @@ -329,6 +378,10 @@ final class StatusTableViewController: LoopChartsTableViewController { private var refreshContext = RefreshContext.all + private var shouldShowPresets: Bool { + presetsRowMode.hasRow + } + private var shouldShowHUD: Bool { return !landscapeMode } @@ -359,13 +412,18 @@ final class StatusTableViewController: LoopChartsTableViewController { override func createChartsManager() -> ChartsManager { return statusCharts } + + private var deviceIssue: Bool { + // includes when devices are in signal loss, even though that is recoverable + deviceManager.cgmManager == nil || deviceManager.cgmManager?.isInoperable == true || deviceManager.cgmManager?.inSignalLoss == true || deviceManager.pumpManager == nil || deviceManager.pumpManager?.isInoperable == true || deviceManager.pumpManager?.inSignalLoss == true || deviceManager.hasBluetoothIssue + } private func updateChartDateRange() { // How far back should we show data? Use the screen size as a guide. 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)) @@ -377,16 +435,19 @@ final class StatusTableViewController: LoopChartsTableViewController { charts.maxEndDate = chartStartDate.addingTimeInterval(.hours(totalHours)) charts.updateEndDate(charts.maxEndDate) } - - override func reloadData(animated: Bool = false) { + + override func reloadData(animated: Bool = false) async { dispatchPrecondition(condition: .onQueue(.main)) guard view.window != nil else { return } - + // This should be kept up to date immediately - hudView?.loopCompletionHUD.lastLoopCompleted = deviceManager.loopManager.lastLoopCompleted + hudView?.loopCompletionHUD.lastLoopCompleted = loopManager.lastLoopCompleted + hudView?.loopCompletionHUD.deviceIssue = deviceIssue + hudView?.loopCompletionHUD.mostRecentGlucoseDataDate = loopManager.mostRecentGlucoseDataDate + hudView?.loopCompletionHUD.mostRecentPumpDataDate = loopManager.mostRecentPumpDataDate guard !reloading && !deviceManager.authorizationRequired else { return @@ -413,251 +474,190 @@ 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 totalDelivery: Double? var cobValues: [CarbValue]? - var carbsOnBoard: HKQuantity? + var carbsOnBoard: LoopQuantity? let startDate = charts.startDate let basalDeliveryState = self.basalDeliveryState - let automaticDosingEnabled = automaticDosingStatus.automaticDosingEnabled + let automaticDosingEnabled = settingsManager.dosingEnabled - // TODO: Don't always assume currentContext.contains(.status) - reloadGroup.enter() - deviceManager.loopManager.getLoopState { (manager, state) -> Void in - predictedGlucoseValues = state.predictedGlucoseIncludingPendingInsulin ?? [] + let state = await loopManager.algorithmDisplayState + predictedGlucoseValues = state.output?.predictedGlucose ?? [] - // 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 - - // 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 + self.lastLoopError = lastLoopError - if let netBasal = netBasal { - self.hudView?.pumpStatusHUD.basalRateHUD.setNetBasalRate(netBasal.rate, percent: netBasal.percent, at: netBasal.start) - } - } - - 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 automatedTreatmentState = loopManager.automatedTreatmentState { + self.hudView?.pumpStatusHUD.basalRateHUD.setAutomatedTreatmentState(automatedTreatmentState) + } - 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() - } - - 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() - } + doseEntries = try? await loopManager.doseStore.getNormalizedDoseEntries(start: startDate, end: nil) + iobValues = loopManager.iobValues.filterDateRange(startDate, nil) + totalDelivery = await loopManager.totalDeliveredToday()?.value } - updatePresetModeAvailability(automaticDosingEnabled: automaticDosingEnabled) + /// Update the chart data - if deviceManager.loopManager.settings.preMealTargetRange == nil { - preMealMode = nil + // Glucose + if let glucoseSamples = glucoseSamples { + self.statusCharts.setGlucoseValues(glucoseSamples) + } + if (automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled), let predictedGlucoseValues = predictedGlucoseValues { + self.statusCharts.setPredictedGlucoseValues(predictedGlucoseValues) } else { - preMealMode = deviceManager.loopManager.settings.preMealTargetEnabled() + self.statusCharts.setPredictedGlucoseValues([]) } - - if !FeatureFlags.sensitivityOverridesEnabled, deviceManager.loopManager.settings.legacyWorkoutTargetRange == nil { - workoutMode = nil + if !FeatureFlags.predictedGlucoseChartClampEnabled, + let lastPoint = self.statusCharts.glucose.predictedGlucosePoints.last?.y + { + let valueAttributedString = NSMutableAttributedString(string: String(describing: lastPoint.copy), attributes: [.font: UIFont.systemFont(ofSize: 22, weight: .semibold), .foregroundColor: ChartColorPalette.primary.glucoseTint]) + let spacer = NSAttributedString(string: "\u{00a0}") + let unitAttributedString = NSAttributedString(string: String(describing: lastPoint).replacingOccurrences(of: String(describing: lastPoint.copy), with: "").trimmingCharacters(in: .whitespacesAndNewlines), attributes: [.font: UIFont.systemFont(ofSize: 15, weight: .regular), .foregroundColor: ChartColorPalette.primary.glucoseTint]) + + valueAttributedString.append(spacer) + valueAttributedString.append(unitAttributedString) + + self.eventualGlucoseDescription = valueAttributedString } else { - workoutMode = deviceManager.loopManager.settings.nonPreMealOverrideEnabled() + // 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 } - reloadGroup.notify(queue: .main) { - /// 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 - } - - 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, let valueString = insulinFormatter.string(from: activeInsulin.quantity, includeUnit: false) { + let valueAttributedString = NSMutableAttributedString(string: valueString, attributes: [.font: UIFont.systemFont(ofSize: 22, weight: .semibold), .foregroundColor: ChartColorPalette.primary.insulinTint]) + let spacer = NSAttributedString(string: "\u{00a0}") + let unitAttributedString = NSMutableAttributedString(string: insulinFormatter.localizedUnitStringWithPlurality(forQuantity: activeInsulin.quantity, avoidLineBreaking: true), attributes: [.font: UIFont.systemFont(ofSize: 15, weight: .regular), .foregroundColor: ChartColorPalette.primary.insulinTint]) + + valueAttributedString.append(spacer) + valueAttributedString.append(unitAttributedString) + + self.currentIOBDescription = valueAttributedString + } 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()) { + let valueAttributedString = NSMutableAttributedString(string: String(describing: charts.cob.cobPoints[index].y.copy), attributes: [.font: UIFont.systemFont(ofSize: 22, weight: .semibold), .foregroundColor: ChartColorPalette.primary.carbTint]) + let spacer = NSAttributedString(string: "\u{00a0}") + let unitAttributedString = NSAttributedString(string: String(describing: charts.cob.cobPoints[index].y).replacingOccurrences(of: String(describing: charts.cob.cobPoints[index].y.copy), with: "").trimmingCharacters(in: .whitespacesAndNewlines), attributes: [.font: UIFont.systemFont(ofSize: 15, weight: .regular), .foregroundColor: ChartColorPalette.primary.carbTint]) + + valueAttributedString.append(spacer) + valueAttributedString.append(unitAttributedString) + + self.currentCOBDescription = valueAttributedString + } else if let carbsOnBoard = carbsOnBoard, let valueString = carbFormatter.string(from: carbsOnBoard, includeUnit: false) { + let valueAttributedString = NSMutableAttributedString(string: valueString, attributes: [.font: UIFont.systemFont(ofSize: 22, weight: .semibold), .foregroundColor: ChartColorPalette.primary.carbTint]) + let spacer = NSAttributedString(string: "\u{00a0}") + let unitAttributedString = NSAttributedString(string: carbFormatter.localizedUnitStringWithPlurality(forQuantity: carbsOnBoard, avoidLineBreaking: true), attributes: [.font: UIFont.systemFont(ofSize: 15, weight: .regular), .foregroundColor: ChartColorPalette.primary.carbTint]) + + valueAttributedString.append(spacer) + valueAttributedString.append(unitAttributedString) + + self.currentCOBDescription = valueAttributedString + } 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 + 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 - // Show/hide the table view rows - let statusRowMode = self.determineStatusRowMode() + // Pump Status + hudView.pumpStatusHUD.presentStatusHighlight(self.deviceManager.pumpStatusHighlight) + hudView.pumpStatusHUD.presentStatusBadge(self.deviceManager.pumpStatusBadge) + hudView.pumpStatusHUD.lifecycleProgress = self.deviceManager.pumpLifecycleProgress + } - self.updateBannerAndHUDandStatusRows(statusRowMode: statusRowMode, newSize: currentContext.newSize, animated: animated) + // Show/hide the table view rows + let statusRowMode = self.determineStatusRowMode() - self.redrawCharts() + updateBannerAndHUDandStatusRows(statusRowMode: statusRowMode, newSize: currentContext.newSize, animated: animated) - self.tableView.endUpdates() + redrawCharts() - 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() } } private enum Section: Int, CaseIterable { + case presets case alertWarning case hud case status @@ -669,17 +669,16 @@ final class StatusTableViewController: LoopChartsTableViewController { private enum ChartRow: Int, CaseIterable { case glucose case iob - case dose case cob } // MARK: Glucose - private var eventualGlucoseDescription: String? + private var eventualGlucoseDescription: NSAttributedString? // MARK: IOB - private var currentIOBDescription: String? + private var currentIOBDescription: NSAttributedString? // MARK: Dose @@ -687,20 +686,38 @@ final class StatusTableViewController: LoopChartsTableViewController { // MARK: COB - private var currentCOBDescription: String? + private var currentCOBDescription: NSAttributedString? // MARK: - Loop Status Section Data + + private enum PresetsRow: Int, CaseIterable { + case presets = 0 + } + private enum PresetsRowMode { + case hidden + case scheduleOverrideEnabled(TemporaryScheduleOverride) + + var hasRow: Bool { + switch self { + case .hidden: + return false + default: + return true + } + } + } + private enum StatusRow: Int, CaseIterable { case status = 0 } private enum StatusRowMode { case hidden - case scheduleOverrideEnabled(TemporaryScheduleOverride) case enactingBolus case bolusing(dose: DoseEntry) case cancelingBolus + case canceledBolus(dose: DoseEntry) case pumpSuspended(resuming: Bool) case onboardingSuspended case recommendManualGlucoseEntry @@ -715,33 +732,37 @@ final class StatusTableViewController: LoopChartsTableViewController { } } + private var presetsRowMode = PresetsRowMode.hidden private var statusRowMode = StatusRowMode.hidden + private var canceledDose: DoseEntry? = nil + + private func determinePresetsRowMode() -> PresetsRowMode { + if let preset = temporaryPresetsManager.scheduleOverride ?? temporaryPresetsManager.preMealOverride, !preset.hasFinished() { + return .scheduleOverrideEnabled(preset) + } else { + return .hidden + } + } + private func determineStatusRowMode() -> StatusRowMode { let statusRowMode: StatusRowMode - if case .initiating = bolusState { - statusRowMode = .enactingBolus - } else if case .canceling = bolusState { + 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, - !scheduleOverride.hasFinished() - { - statusRowMode = .scheduleOverrideEnabled(scheduleOverride) - } else if let premealOverride = deviceManager.loopManager.settings.preMealOverride, - !premealOverride.hasFinished() - { - statusRowMode = .scheduleOverrideEnabled(premealOverride) } else { statusRowMode = .hidden } @@ -752,44 +773,66 @@ final class StatusTableViewController: LoopChartsTableViewController { private var shouldShowBannerWarning: Bool { alertPermissionsChecker.showWarning || alertMuter.configuration.shouldMute } + + override func viewDidLayoutSubviews() { + updateStatusBar() + } private func updateBannerRow(animated: Bool) { let warningWasVisible = tableView.numberOfRows(inSection: Section.alertWarning.rawValue) != 0 if !shouldShowBannerWarning && warningWasVisible { - tableView.deleteRows(at: [IndexPath(row: 0, section: Section.alertWarning.rawValue)], with: animated ? .top : .none) + tableView.deleteRows(at: [IndexPath(row: 0, section: Section.alertWarning.rawValue)], with: animated ? .fade : .none) } else if shouldShowBannerWarning && !warningWasVisible { tableView.insertRows(at: [IndexPath(row: 0, section: Section.alertWarning.rawValue)], with: animated ? .top : .none) } else { tableView.reloadRows(at: [IndexPath(row: 0, section: Section.alertWarning.rawValue)], with: .none) } } + + private func updateStatusBar() { + statusBarBackgroundView?.backgroundColor = landscapeMode ? .systemBackground : (shouldShowPresets ? .presets : .secondarySystemBackground) + statusBarBackgroundView?.frame.size.height = abs(tableView.contentOffset.y) + (shouldShowPresets ? tableView(tableView, cellForRowAt: IndexPath(row: 0, section: 0)).contentView.frame.height + 8 : 0) + } private func updateBannerAndHUDandStatusRows(statusRowMode: StatusRowMode, newSize: CGSize?, animated: Bool) { + let presetsWasVisible = self.shouldShowPresets let hudWasVisible = self.shouldShowHUD let statusWasVisible = self.shouldShowStatus let oldStatusRowMode = self.statusRowMode + self.presetsRowMode = determinePresetsRowMode() self.statusRowMode = statusRowMode if let newSize = newSize { landscapeMode = newSize.width > newSize.height } + let presetsIsVisible = self.shouldShowPresets let hudIsVisible = self.shouldShowHUD let statusIsVisible = self.shouldShowStatus - + hudView?.cgmStatusHUD?.isVisible = hudIsVisible + hudView?.cgmStatusHUD.isGlucoseValueStale = deviceManager.isGlucoseValueStale tableView.beginUpdates() updateBannerRow(animated: animated) - + + switch (presetsWasVisible, presetsIsVisible) { + case (false, true): + tableView.insertRows(at: [IndexPath(row: 0, section: Section.presets.rawValue)], with: animated ? .top : .none) + case (true, false): + tableView.deleteRows(at: [IndexPath(row: 0, section: Section.presets.rawValue)], with: animated ? .fade : .none) + default: + tableView.reloadRows(at: [IndexPath(row: 0, section: Section.presets.rawValue)], with: animated ? .automatic : .none) + } + switch (hudWasVisible, hudIsVisible) { case (false, true): tableView.insertRows(at: [IndexPath(row: 0, section: Section.hud.rawValue)], with: animated ? .top : .none) case (true, false): - tableView.deleteRows(at: [IndexPath(row: 0, section: Section.hud.rawValue)], with: animated ? .top : .none) + tableView.deleteRows(at: [IndexPath(row: 0, section: Section.hud.rawValue)], with: animated ? .fade : .none) default: break } @@ -799,16 +842,30 @@ 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 (.cancelingBolus, .bolusing): + // this occurs when a cancel command fails + tableView.reloadRows(at: [statusIndexPath], with: animated ? .fade : .none) + 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 (.canceledBolus(_), .cancelingBolus): + break + case (.canceledBolus(_), .bolusing(_)): + break default: tableView.reloadRows(at: [statusIndexPath], with: animated ? .fade : .none) } @@ -831,48 +888,14 @@ final class StatusTableViewController: LoopChartsTableViewController { if let indexPath = tableView.indexPath(for: cell) { self.tableView(tableView, updateSubtitleFor: cell, at: indexPath) + if Section(rawValue: indexPath.section)! == .charts && ChartRow(rawValue: indexPath.row)! == .iob { + cell.setFooterView(content: iobFooterViewContent) + } } } tableView.endUpdates() } - // MARK: - Toolbar data - - private var preMealMode: Bool? = nil { - didSet { - guard oldValue != preMealMode else { - return - } - updatePresetModeAvailability(automaticDosingEnabled: automaticDosingStatus.automaticDosingEnabled) - } - } - private lazy var preMealModeAllowed: Bool = { - onboardingManager.isComplete && - (automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) - && deviceManager.loopManager.settings.preMealTargetRange != nil - }() - - private func updatePresetModeAvailability(automaticDosingEnabled: Bool) { - preMealModeAllowed = onboardingManager.isComplete && - (automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) - && deviceManager.loopManager.settings.preMealTargetRange != nil - workoutModeAllowed = onboardingManager.isComplete && workoutMode != nil - updateToolbarItems() - } - - private var workoutMode: Bool? = nil { - didSet { - guard oldValue != workoutMode else { - return - } - workoutModeAllowed = workoutMode != nil && onboardingManager.isComplete - updateToolbarItems() - } - } - private lazy var workoutModeAllowed: Bool = { - workoutMode != nil && onboardingManager.isComplete - }() - // MARK: - Table view data source override func numberOfSections(in tableView: UITableView) -> Int { @@ -881,6 +904,8 @@ final class StatusTableViewController: LoopChartsTableViewController { override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { switch Section(rawValue: section)! { + case .presets: + return shouldShowPresets ? PresetsRow.allCases.count : 0 case .alertWarning: return shouldShowBannerWarning ? 1 : 0 case .hud: @@ -891,103 +916,90 @@ final class StatusTableViewController: LoopChartsTableViewController { return shouldShowStatus ? StatusRow.allCases.count : 0 } } - - private class AlertPermissionsDisabledWarningCell: UITableViewCell { - override func updateConfiguration(using state: UICellConfigurationState) { - super.updateConfiguration(using: state) - - let adjustViewForNarrowDisplay = bounds.width < 350 - - 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 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.secondaryTextProperties.color = .white - contentConfig.secondaryTextProperties.font = .systemFont(ofSize: adjustViewForNarrowDisplay ? 13 : 15) - contentConfiguration = contentConfig - - var backgroundConfig = backgroundConfiguration?.updated(for: state) - backgroundConfig?.backgroundColor = .critical - backgroundConfiguration = backgroundConfig - backgroundConfiguration?.backgroundInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 5, trailing: 10) - backgroundConfiguration?.cornerRadius = 10 - - let disclosureIndicator = UIImage(systemName: "chevron.right")?.withTintColor(.white) - let imageView = UIImageView(image: disclosureIndicator) - imageView.tintColor = .white - accessoryView = imageView - - contentView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 6, leading: 0, bottom: 13, trailing: 0) - } - } - - private class MuteAlertsWarningCell: UITableViewCell { - var formattedAlertMuteEndTime: String = NSLocalizedString("Unknown", comment: "label for when the alert mute end time is unknown") - - fileprivate class GradientView: UIView { - override static var layerClass: AnyClass { CAGradientLayer.self } - } - - override func updateConfiguration(using state: UICellConfigurationState) { - super.updateConfiguration(using: state) - - 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 image = UIImage(systemName: "speaker.slash.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 25, weight: .thin, scale: .large)) - contentConfig.image = image - contentConfig.imageProperties.tintColor = .white - contentConfig.attributedText = title - contentConfig.textProperties.color = .white - contentConfig.textProperties.font = .systemFont(ofSize: adjustViewForNarrowDisplay ? 16 : 18, weight: .semibold) - contentConfig.textProperties.adjustsFontSizeToFitWidth = true - contentConfig.secondaryText = String(format: NSLocalizedString("Until %1$@", comment: "indication of when alerts will be unmuted (1: time when alerts unmute)"), formattedAlertMuteEndTime) - contentConfig.secondaryTextProperties.color = .white - contentConfig.secondaryTextProperties.font = .systemFont(ofSize: adjustViewForNarrowDisplay ? 13 : 15) - contentConfiguration = contentConfig - - let backgroundGradient = GradientView() - (backgroundGradient.layer as? CAGradientLayer)?.colors = [UIColor.warning.cgColor, UIColor.warning.withAlphaComponent(0.9).cgColor] - - var backgroundConfig = backgroundConfiguration?.updated(for: state) - backgroundConfig?.customView = backgroundGradient - backgroundConfiguration = backgroundConfig - backgroundConfiguration?.backgroundInsets = NSDirectionalEdgeInsets(top: 0, leading: 5, bottom: 5, trailing: 5) - backgroundConfiguration?.cornerRadius = 10 - - let unmuteIndicator = UIImage(systemName: "stop.circle")?.withTintColor(.white) - let imageView = UIImageView(image: unmuteIndicator) - imageView.tintColor = .white - imageView.frame.size = CGSize(width: 30, height: 30) - accessoryView = imageView - - contentView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 6, leading: 0, bottom: 13, trailing: 0) - } + + private class GradientView: UIView { + override static var layerClass: AnyClass { CAGradientLayer.self } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { switch Section(rawValue: indexPath.section)! { - case .alertWarning: - if alertPermissionsChecker.showWarning { - let cell = tableView.dequeueReusableCell(withIdentifier: AlertPermissionsDisabledWarningCell.className, for: indexPath) as! AlertPermissionsDisabledWarningCell - return cell - } else { - let cell = tableView.dequeueReusableCell(withIdentifier: MuteAlertsWarningCell.className, for: indexPath) as! MuteAlertsWarningCell - cell.formattedAlertMuteEndTime = alertMuter.formattedEndTime + case .presets: + let cell = UITableViewCell() + + switch presetsRowMode { + case .hidden: + break + case .scheduleOverrideEnabled(let override): + cell.contentConfiguration = UIHostingConfiguration { + ActivePresetBanner(override: override) + } + .margins(.all, 0) + + cell.backgroundColor = .presets cell.selectionStyle = .none - return cell } + + return cell + case .alertWarning: + let cell = UITableViewCell() + let alert = AlertPermissionsChecker.UnsafeNotificationPermissionAlert(permissions: alertPermissionsChecker.notificationCenterSettings) + + cell.contentConfiguration = UIHostingConfiguration { + if alertPermissionsChecker.showWarning { + if let alert { + HStack { + VStack(alignment: .leading) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(" ") + Text(alert.bannerTitle) + .font(.headline.bold()) + + Text(alert.bannerBody) + .font(.subheadline) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Spacer() + + Text(Image(systemName: "chevron.right")) + .font(.headline) + } + .foregroundStyle(Color.white) + .padding(8) + .background(Color.critical.cornerRadius(10)) + .padding([.top, .horizontal], 8) + } + } else { + HStack { + Text(Image(systemName: "speaker.slash.fill")).font(.title) + Text(" ") + + VStack(alignment: .leading) { + Text(NSLocalizedString("All App Sounds Muted", comment: "Warning text for when alerts are muted")) + .font(.headline.bold()) + + Text(String(format: NSLocalizedString("Until %1$@", comment: "indication of when alerts will be unmuted (1: time when alerts unmute)"), alertMuter.formattedEndTime)) + .font(.subheadline) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Spacer() + + Text(Image(systemName: "stop.circle")) + .font(.title) + } + .foregroundStyle(Color.white) + .padding(8) + .background(Color.critical.cornerRadius(10)) + .padding([.top, .horizontal], 8) + } + } + .margins(.all, 0) + + cell.backgroundColor = .secondarySystemBackground + + return cell 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: @@ -999,22 +1011,25 @@ final class StatusTableViewController: LoopChartsTableViewController { return self?.statusCharts.glucoseChart(withFrame: frame)?.view }) cell.setTitleLabelText(label: NSLocalizedString("Glucose", comment: "The title of the glucose and prediction graph")) - cell.doesNavigate = automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled + cell.setTitleTextColor(color: ChartColorPalette.primary.glucoseTint) + cell.doesNavigate = settingsManager.dosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled case .iob: - cell.setChartGenerator(generator: { [weak self] (frame) in - return self?.statusCharts.iobChart(withFrame: frame)?.view + cell.setSupplementalChartGenerator(generator: { [weak self] (frame) in + return self?.statusCharts.doseChart(withFrame: frame)?.view }) - cell.setTitleLabelText(label: NSLocalizedString("Active Insulin", comment: "The title of the Insulin On-Board graph")) - case .dose: + cell.setChartGenerator(generator: { [weak self] (frame) in - return self?.statusCharts.doseChart(withFrame: frame)?.view + return self?.statusCharts.iobChart(withFrame: frame, highlightLabelOffsetY: cell.supplementalChartContentView?.bounds.height ?? 0)?.view }) - cell.setTitleLabelText(label: NSLocalizedString("Insulin Delivery", comment: "The title of the insulin delivery graph")) + cell.setTitleLabelText(label: NSLocalizedString("Active Insulin", comment: "The title of the Insulin On-Board graph")) + cell.setTitleTextColor(color: ChartColorPalette.primary.insulinTint) + cell.setFooterView(content: iobFooterViewContent) case .cob: cell.setChartGenerator(generator: { [weak self] (frame) in return self?.statusCharts.cobChart(withFrame: frame)?.view }) cell.setTitleLabelText(label: NSLocalizedString("Active Carbohydrates", comment: "The title of the Carbs On-Board graph")) + cell.setTitleTextColor(color: ChartColorPalette.primary.carbTint) } self.tableView(tableView, updateSubtitleFor: cell, at: indexPath) @@ -1026,13 +1041,16 @@ 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 cell.backgroundColor = .secondarySystemBackground cell.titleLabel.text = nil + cell.titleLabel.textColor = .label + cell.titleLabel.font = .systemFont(ofSize: 15, weight: .bold) cell.subtitleLabel.text = nil + cell.subtitleLabel.textColor = .secondaryLabel + cell.subtitleLabel.font = .systemFont(ofSize: 15, weight: .bold) cell.accessoryView = nil return cell } @@ -1042,80 +1060,41 @@ final class StatusTableViewController: LoopChartsTableViewController { switch statusRowMode { case .hidden: let cell = getTitleSubtitleCell() - return cell - case .scheduleOverrideEnabled(let override): - let cell = getTitleSubtitleCell() - switch override.context { - case .preMeal: - let symbolAttachment = NSTextAttachment() - symbolAttachment.image = UIImage(named: "Pre-Meal-symbol")?.withTintColor(.carbTintColor) - - let attributedString = NSMutableAttributedString(attachment: symbolAttachment) - attributedString.append(NSAttributedString(string: NSLocalizedString(" Pre-meal Preset", comment: "Status row title for premeal override enabled (leading space is to separate from symbol)"))) - cell.titleLabel.attributedText = attributedString - case .legacyWorkout: - let symbolAttachment = NSTextAttachment() - symbolAttachment.image = UIImage(named: "workout-symbol")?.withTintColor(.glucoseTintColor) - - let attributedString = NSMutableAttributedString(attachment: symbolAttachment) - attributedString.append(NSAttributedString(string: NSLocalizedString(" Workout Preset", comment: "Status row title for workout override enabled (leading space is to separate from symbol)"))) - cell.titleLabel.attributedText = attributedString - case .preset(let preset): - cell.titleLabel.text = String(format: NSLocalizedString("%@ %@", comment: "The format for an active custom preset. (1: preset symbol)(2: preset name)"), preset.symbol, preset.name) - case .custom: - cell.titleLabel.text = NSLocalizedString("Custom Preset", comment: "The title of the cell indicating a generic custom preset is enabled") - } - - if override.isActive() { - switch override.duration { - case .finite: - let endTimeText = DateFormatter.localizedString(from: override.activeInterval.end, dateStyle: .none, timeStyle: .short) - cell.subtitleLabel.text = String(format: NSLocalizedString("until %@", comment: "The format for the description of a custom preset end date"), endTimeText) - case .indefinite: - cell.subtitleLabel.text = nil - } - } else { - let startTimeText = DateFormatter.localizedString(from: override.startDate, dateStyle: .none, timeStyle: .short) - cell.subtitleLabel.text = String(format: NSLocalizedString("starting at %@", comment: "The format for the description of a custom preset start date"), startTimeText) - } - 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") - + let cell = tableView.dequeueReusableCell(withIdentifier: InsulinSuspendedTableViewCell.className, for: indexPath) as! InsulinSuspendedTableViewCell + cell.selectionStyle = .default if resuming { - let indicatorView = UIActivityIndicatorView(style: .default) - indicatorView.startAnimating() - cell.accessoryView = indicatorView + cell.activityIndicator.startAnimating() + cell.activityIndicator.isHidden = false } else { - cell.subtitleLabel.text = NSLocalizedString("Tap to Resume", comment: "The subtitle of the cell displaying an action to resume insulin delivery") + cell.tapToResumeLabel.text = NSLocalizedString("Tap to Resume", comment: "The subtitle of the cell displaying an action to resume insulin delivery") + cell.tapToResumeLabel.accessibilityIdentifier = "text_InsulinTapToResume" + cell.activityIndicator.stopAnimating() + cell.activityIndicator.isHidden = true } - cell.selectionStyle = .default return cell case .onboardingSuspended: let cell = tableView.dequeueReusableCell(withIdentifier: IconTitleSubtitleTableViewCell.className, for: indexPath) as! IconTitleSubtitleTableViewCell @@ -1126,22 +1105,61 @@ final class StatusTableViewController: LoopChartsTableViewController { cell.iconImageView.contentMode = .scaleAspectFit cell.iconImageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 28) cell.titleLabel.text = NSLocalizedString("Setup Incomplete", comment: "The title of the cell indicating that onboarding is suspended") + cell.titleLabel.textColor = .label + cell.titleLabel.font = .systemFont(ofSize: 15, weight: .bold) cell.subtitleLabel.text = NSLocalizedString("Tap to Resume", comment: "The subtitle of the cell displaying an action to resume onboarding") + cell.subtitleLabel.textColor = .secondaryLabel + cell.subtitleLabel.font = .systemFont(ofSize: 15, weight: .bold) cell.accessoryView = nil return cell case .recommendManualGlucoseEntry: - let cell = getTitleSubtitleCell() - cell.titleLabel.text = NSLocalizedString("No Recent Glucose", comment: "The title of the cell indicating that there is no recent glucose") - cell.subtitleLabel.text = NSLocalizedString("Tap to Add", comment: "The subtitle of the cell displaying an action to add a manually measurement glucose value") + let cell = tableView.dequeueReusableCell(withIdentifier: RecentGlucoseTableViewCell.className, for: indexPath) as! RecentGlucoseTableViewCell cell.selectionStyle = .default - let imageView = UIImageView(image: UIImage(named: "drop.circle")) - imageView.tintColor = .glucoseTintColor - cell.accessoryView = imageView return cell } } } } + + private var iobFooterText: Text? { + if let lastManualDose = loopManager.lastManualBolus, + let formattedBolusValue = insulinFormatter.string(from: LoopQuantity(unit: .internationalUnit, doubleValue: lastManualDose.amount)) { + + let hoursDifference = Date().timeIntervalSince(lastManualDose.startDate) / 3600 + + // Build a single Text view + let footerText: Text + let lastBolusLabel = Text("Last Bolus: ") + let lastBolusValue = Text("\(formattedBolusValue) ").fontWeight(.semibold) + let icon = Text(Image(systemName: "hourglass.bottomhalf.filled")).foregroundStyle(.secondary) + let exactTime = Text("at \(lastManualDose.startDate.formatted(date: .omitted, time: .shortened))").foregroundStyle(.secondary) + let roundedTime = Text(" \(Int(hoursDifference.rounded())) hours ago").foregroundStyle(.secondary) + + switch hoursDifference { + case ..<6: + footerText = lastBolusLabel + lastBolusValue + exactTime + case 6..<12: + footerText = lastBolusLabel + lastBolusValue.foregroundStyle(.secondary) + icon + roundedTime + default: + footerText = lastBolusLabel + icon + roundedTime + } + + return footerText + } else { + return nil + } + } + + @ViewBuilder + private func iobFooterViewContent() -> some View { + if let iobFooterText = iobFooterText { + iobFooterText + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 36) + .padding(.vertical) + .accessibilityIdentifier("text_ActiveInsulinFooter") + } + } private func tableView(_ tableView: UITableView, updateSubtitleFor cell: ChartTableViewCell, at indexPath: IndexPath) { switch Section(rawValue: indexPath.section)! { @@ -1149,35 +1167,35 @@ final class StatusTableViewController: LoopChartsTableViewController { switch ChartRow(rawValue: indexPath.row)! { case .glucose: if let eventualGlucose = eventualGlucoseDescription { - cell.setSubtitleLabel(label: String(format: NSLocalizedString("Eventually %@", comment: "The subtitle format describing eventual glucose. (1: localized glucose value description)"), eventualGlucose)) + let subtitle = NSMutableAttributedString(string: NSLocalizedString("Eventually", comment: ""), attributes: [.font: UIFont.systemFont(ofSize: 15, weight: .regular)]) + let spacer = NSAttributedString(string: "\u{00a0}") + + subtitle.append(spacer) + subtitle.append(eventualGlucose) + + cell.setSubtitleLabel(label: subtitle) + cell.setTitleLabelAccessibilityIdentifier("Glucose") } else { cell.setSubtitleLabel(label: nil) + cell.setTitleLabelAccessibilityIdentifier("Glucose") } - cell.doesNavigate = automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled + cell.doesNavigate = settingsManager.dosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled case .iob: if let currentIOB = currentIOBDescription { cell.setSubtitleLabel(label: currentIOB) - } else { - cell.setSubtitleLabel(label: nil) - } - case .dose: - let integerFormatter = NumberFormatter() - integerFormatter.maximumFractionDigits = 0 - - if let total = totalDelivery, - let totalString = integerFormatter.string(from: total) { - cell.setSubtitleLabel(label: String(format: NSLocalizedString("%@ U Total", comment: "The subtitle format describing total insulin. (1: localized insulin total)"), totalString)) + cell.setTitleLabelAccessibilityIdentifier("ActiveInsulin_\(currentIOB.string)") } else { cell.setSubtitleLabel(label: nil) } case .cob: if let currentCOB = currentCOBDescription { cell.setSubtitleLabel(label: currentCOB) + cell.setTitleLabelAccessibilityIdentifier("ActiveCarbs_\(currentCOB.string)") } else { cell.setSubtitleLabel(label: nil) } } - case .hud, .status, .alertWarning: + case .presets, .hud, .status, .alertWarning: break } } @@ -1194,17 +1212,23 @@ final class StatusTableViewController: LoopChartsTableViewController { switch ChartRow(rawValue: indexPath.row)! { case .glucose: - return max(106, 0.37 * availableSize) - case .iob, .dose, .cob: - return max(106, 0.21 * availableSize) + return max(106, 0.30 * availableSize) + case .iob: + return max(106, 0.45 * availableSize) + case .cob: + return max(106, 0.25 * availableSize) } - case .hud, .status, .alertWarning: + case .alertWarning: + return UITableView.automaticDimension + case .presets, .hud, .status: return UITableView.automaticDimension } } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { switch Section(rawValue: indexPath.section)! { + case .presets: + statusTableViewModel.pendingPreset = temporaryPresetsManager.activePreset case .alertWarning: if alertPermissionsChecker.showWarning { tableView.deselectRow(at: indexPath, animated: true) @@ -1224,7 +1248,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) @@ -1235,34 +1259,38 @@ 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() } } } - case .scheduleOverrideEnabled(let override): - switch override.context { - case .preMeal, .legacyWorkout: - break - default: - let vc = AddEditOverrideTableViewController(glucoseUnit: statusCharts.glucose.glucoseUnit) - vc.inputMode = .editOverride(override) - 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(let canceledDose): + let doseToReport = canceledDose ?? dose + self.canceledDose = doseToReport + self.updateBannerAndHUDandStatusRows(statusRowMode: .canceledBolus(dose: doseToReport), 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 .noBolus = self.bolusState { + self.updateBannerAndHUDandStatusRows(statusRowMode: .hidden, newSize: nil, animated: true) + } else { + self.updateBannerAndHUDandStatusRows(statusRowMode: .bolusing(dose: dose), newSize: nil, animated: true) + } } } } @@ -1278,22 +1306,90 @@ final class StatusTableViewController: LoopChartsTableViewController { case .charts: switch ChartRow(rawValue: indexPath.row)! { case .glucose: - if automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled { + if settingsManager.dosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled { performSegue(withIdentifier: PredictionTableViewController.className, sender: indexPath) } - case .iob, .dose: - performSegue(withIdentifier: InsulinDeliveryTableViewController.className, sender: indexPath) + case .iob: + let showLegacy = false + + if !showLegacy, let pumpManager = deviceManager.pumpManager { + let hostingController = UIHostingController( + rootView: InsulinDeliveryLog( + viewModel: InsulinDeliveryLogViewModel( + loopDataManager: loopManager, + pumpManager: pumpManager + ), + onTapGesture: { [weak navigationController] doseEntry in + Task { + var dosingDecision: StoredDosingDecision? + if let decisionId = doseEntry.decisionId { + dosingDecision = try await self.loopManager.dosingDecisionStore.findDosingDecisionsById(decisionId) + } + + let viewController = CommandResponseViewController(command: { (completionHandler) -> String in + var description = [String]() + + let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + + formatter.dateStyle = .none + formatter.timeStyle = .short + + return formatter + }() + + description.append(timeFormatter.string(from: doseEntry.startDate)) + + description.append(String(describing: doseEntry)) + + if let dosingDecision { + description.append(String(describing: dosingDecision)) + } + + return description.joined(separator: "\n\n") + }) + + navigationController?.pushViewController(viewController, animated: true) + } + }, + onEnterManualDose: { [weak self] in + self?.presentManualDoseEntry() + } + ) + .navigationTitle(Text("Insulin")) + .environment(\.colorPalette, .default) + .environment(\.loopStatusColorPalette, .loopStatus) + ) + + hostingController.hidesBottomBarWhenPushed = true + + navigationController?.pushViewController( + hostingController, + animated: true + ) + } else { + performSegue(withIdentifier: InsulinDeliveryTableViewController.className, sender: indexPath) + } case .cob: performSegue(withIdentifier: CarbAbsorptionViewController.className, sender: indexPath) } } } + private func presentManualDoseEntry() { + let viewModel = ManualEntryDoseViewModel(delegate: loopManager) + let manualEntryDoseView = ManualEntryDoseView(viewModel: viewModel) + let hostingController = DismissibleHostingController(rootView: manualEntryDoseView, isModalInPresentation: false) + let navigationWrapper = UINavigationController(rootViewController: hostingController) + hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: navigationWrapper, action: #selector(dismissWithAnimation)) + present(navigationWrapper, animated: true) + } + 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("All app sounds, including sounds for all critical alerts, are currently muted.\n\nTap Unmute to resume 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() } @@ -1337,24 +1433,22 @@ final class StatusTableViewController: LoopChartsTableViewController { switch targetViewController { case let vc as CarbAbsorptionViewController: vc.isOnboardingComplete = onboardingManager.isComplete - vc.automaticDosingStatus = automaticDosingStatus + vc.automaticDosingEnabled = settingsManager.dosingEnabled 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 - } - vc.presets = deviceManager.loopManager.settings.overridePresets - vc.glucoseUnit = statusCharts.glucose.glucoseUnit - vc.overrideHistory = deviceManager.loopManager.overrideHistory.getEvents() - vc.delegate = self case let vc as PredictionTableViewController: vc.deviceManager = deviceManager + vc.settingsManager = settingsManager + vc.loopDataManager = loopManager default: break } @@ -1368,20 +1462,26 @@ final class StatusTableViewController: LoopChartsTableViewController { presentCarbEntryScreen(nil) } - func presentCarbEntryScreen(_ activity: NSUserActivity?) { + func presentCarbEntryScreen(_ activity: NSUserActivity?, value: LoopQuantity? = nil) { let navigationWrapper: UINavigationController - if FeatureFlags.simpleBolusCalculatorEnabled && !automaticDosingStatus.automaticDosingEnabled { - let viewModel = SimpleBolusViewModel(delegate: deviceManager, displayMealEntry: true) + if FeatureFlags.simpleBolusCalculatorEnabled && !settingsManager.dosingEnabled { + let viewModel = SimpleBolusViewModel(delegate: loopManager, displayMealEntry: true, displayGlucosePreference: deviceManager.displayGlucosePreference) if let activity = activity { viewModel.restoreUserActivityState(activity) } + if let carbString = value?.doubleValue(for: .gram) { + viewModel.enteredCarbString = carbString.formatted() + } let bolusEntryView = SimpleBolusView(viewModel: viewModel).environmentObject(deviceManager.displayGlucosePreference) let hostingController = DismissibleHostingController(rootView: bolusEntryView, isModalInPresentation: false) 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 = CarbEntryViewModel(delegate: loopManager) + viewModel.carbsQuantity = value?.doubleValue(for: .gram) + viewModel.deliveryDelegate = deviceManager + viewModel.analyticsServicesManager = loopManager.analyticsServicesManager if let activity { viewModel.restoreUserActivityState(activity) } @@ -1390,7 +1490,7 @@ final class StatusTableViewController: LoopChartsTableViewController { let hostingController = DismissibleHostingController(rootView: carbEntryView, isModalInPresentation: false) present(hostingController, animated: true) } - deviceManager.analyticsServicesManager.didDisplayCarbEntryScreen() + analyticsServicesManager?.didDisplayCarbEntryScreen() } @IBAction func presentBolusScreen() { @@ -1399,27 +1499,24 @@ final class StatusTableViewController: LoopChartsTableViewController { @ViewBuilder func bolusEntryView(enableManualGlucoseEntry: Bool = false) -> some View { - if FeatureFlags.simpleBolusCalculatorEnabled && !automaticDosingStatus.automaticDosingEnabled { + if FeatureFlags.simpleBolusCalculatorEnabled && !settingsManager.dosingEnabled { 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 }() @@ -1431,229 +1528,69 @@ 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() - } - - private func createPreMealButtonItem(selected: Bool, isEnabled: Bool) -> UIBarButtonItem { - let item = UIBarButtonItem(image: UIImage.preMealImage(selected: selected), style: .plain, target: self, action: #selector(premealButtonTapped(_:))) - item.accessibilityLabel = NSLocalizedString("Pre-Meal Targets", comment: "The label of the pre-meal mode toggle button") - - if selected { - item.accessibilityTraits.insert(.selected) - item.accessibilityHint = NSLocalizedString("Disables", comment: "The action hint of the workout mode toggle button when enabled") - } else { - item.accessibilityHint = NSLocalizedString("Enables", comment: "The action hint of the workout mode toggle button when disabled") - } - - item.tintColor = UIColor.carbTintColor - item.isEnabled = isEnabled - - return item - } - - private func createWorkoutButtonItem(selected: Bool, isEnabled: Bool) -> UIBarButtonItem { - let item = UIBarButtonItem(image: UIImage.workoutImage(selected: selected), style: .plain, target: self, action: #selector(toggleWorkoutMode(_:))) - item.accessibilityLabel = NSLocalizedString("Workout Targets", comment: "The label of the workout mode toggle button") - - if selected { - item.accessibilityTraits.insert(.selected) - item.accessibilityHint = NSLocalizedString("Disables", comment: "The action hint of the workout mode toggle button when enabled") - } else { - item.accessibilityHint = NSLocalizedString("Enables", comment: "The action hint of the workout mode toggle button when disabled") - } - - item.tintColor = UIColor.glucoseTintColor - item.isEnabled = isEnabled - - return item - } - - @IBAction func premealButtonTapped(_ sender: UIBarButtonItem) { - togglePreMealMode(confirm: false) + analyticsServicesManager?.didDisplayBolusScreen() } - func togglePreMealMode(confirm: Bool = true) { - 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) - } - } - } else { - presentPreMealModeAlertController() - } - } + private(set) var isShowingPresets: Bool = false - func presentPreMealModeAlertController() { - let vc = UIAlertController(premealDurationSelectionHandler: { duration in - let startDate = Date() - - guard self.workoutMode != true else { - // allow cell animation when switching between presets - self.deviceManager.loopManager.mutateSettings { settings in - settings.clearOverride() - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - self.deviceManager.loopManager.mutateSettings { settings in - settings.enablePreMealOverride(at: startDate, for: duration) - } - } - return - } - - self.deviceManager.loopManager.mutateSettings { settings in - settings.enablePreMealOverride(at: startDate, for: duration) - } - }) - - present(vc, animated: true, completion: nil) - } - - func presentCustomPresets(confirm: Bool = true) { - 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() - } - } - } else { - if FeatureFlags.sensitivityOverridesEnabled { - performSegue(withIdentifier: OverrideSelectionViewController.className, sender: toolbarItems![6]) - } else { - presentWorkoutModeAlertController() - } - } - } - - func presentWorkoutModeAlertController() { - let vc = UIAlertController(workoutDurationSelectionHandler: { duration in - let startDate = Date() - - guard self.preMealMode != true else { - // allow cell animation when switching between presets - self.deviceManager.loopManager.mutateSettings { settings in - settings.clearOverride(matching: .preMeal) - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - self.deviceManager.loopManager.mutateSettings { settings in - settings.enableLegacyWorkoutOverride(at: startDate, for: duration) - } - } - return - } - - self.deviceManager.loopManager.mutateSettings { settings in - settings.enableLegacyWorkoutOverride(at: startDate, for: duration) - } - }) - - present(vc, animated: true, completion: nil) - } - - @IBAction func toggleWorkoutMode(_ sender: UIBarButtonItem) { - presentCustomPresets(confirm: false) + func presentPresets() { + let hostingController = DismissibleHostingController( + rootView: PresetsView( + roundBasalRate: deviceManager.roundBasalRate, + carbStore: deviceManager.carbStore, + doseStore: deviceManager.doseStore, + glucoseStore: deviceManager.glucoseStore, + trainingContent: supportManager.availableSupports.flatMap({ $0.trainingMedia(for: .presets) }), + automationHistory: { [weak self] in self?.loopManager.automationHistory ?? [] } + ) + .onAppear { self.isShowingPresets = true } + .onDisappear { self.isShowingPresets = false } + .environmentObject(deviceManager.displayGlucosePreference) + .environment(\.appName, Bundle.main.bundleDisplayName) + .environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice) + .environment(\.colorPalette, .default) + .environment(\.loopStatusColorPalette, .loopStatus) + .environment(\.temporaryPresetsManager, temporaryPresetsManager) + .environment(\.settingsManager, settingsManager), + isModalInPresentation: false) + present(hostingController, animated: true) } @IBAction func onSettingsTapped(_ sender: UIBarButtonItem) { presentSettings() } - private func presentSettings() { - let deletePumpDataFunc: () -> PumpManagerViewModel.DeleteTestingDataFunc? = { [weak self] in - (self?.deviceManager.pumpManager is TestingPumpManager) ? { - [weak self] in self?.deviceManager.deleteTestingPumpData() - } : nil - } - let deleteCGMDataFunc: () -> CGMManagerViewModel.DeleteTestingDataFunc? = { [weak self] in - (self?.deviceManager.cgmManager is TestingCGMManager) ? { - [weak self] in self?.deviceManager.deleteTestingCGMData() - } : nil - } - let pumpViewModel = PumpManagerViewModel( - image: { [weak self] in self?.deviceManager.pumpManager?.smallImage }, - name: { [weak self] in self?.deviceManager.pumpManager?.localizedTitle ?? "" }, - isSetUp: { [weak self] in self?.deviceManager.pumpManager?.isOnboarded == true }, - availableDevices: deviceManager.availablePumpManagers, - deleteTestingDataFunc: deletePumpDataFunc, - onTapped: { [weak self] in - self?.onPumpTapped() - }, - didTapAddDevice: { [weak self] in - self?.addPumpManager(withIdentifier: $0.identifier) - }) - - let cgmViewModel = CGMManagerViewModel( - image: {[weak self] in (self?.deviceManager.cgmManager as? DeviceManagerUI)?.smallImage }, - name: {[weak self] in self?.deviceManager.cgmManager?.localizedTitle ?? "" }, - isSetUp: {[weak self] in self?.deviceManager.cgmManager?.isOnboarded == true }, - availableDevices: deviceManager.availableCGMManagers, - deleteTestingDataFunc: deleteCGMDataFunc, - onTapped: { [weak self] in - self?.onCGMTapped() - }, - didTapAddDevice: { [weak self] in - 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 ?? [] }, - delegate: self) - let versionUpdateViewModel = VersionUpdateViewModel(supportManager: supportManager, guidanceColors: .default) - let viewModel = SettingsViewModel(alertPermissionsChecker: alertPermissionsChecker, - alertMuter: alertMuter, - versionUpdateViewModel: versionUpdateViewModel, - pumpManagerSettingsViewModel: pumpViewModel, - cgmManagerSettingsViewModel: cgmViewModel, - servicesViewModel: servicesViewModel, - criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: deviceManager.criticalEventLogExportManager), - therapySettings: { [weak self] in self?.deviceManager.loopManager.therapySettings ?? TherapySettings() }, - sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, - initialDosingEnabled: deviceManager.loopManager.settings.dosingEnabled, - isClosedLoopAllowed: automaticDosingStatus.$isAutomaticDosingAllowed, - automaticDosingStrategy: deviceManager.loopManager.settings.automaticDosingStrategy, - availableSupports: supportManager.availableSupports, - isOnboardingComplete: onboardingManager.isComplete, - therapySettingsViewModelDelegate: deviceManager, - delegate: self) + func presentSettings() { let hostingController = DismissibleHostingController( - rootView: SettingsView(viewModel: viewModel, localizedAppNameAndVersion: supportManager.localizedAppNameAndVersion) + rootView: SettingsView(viewModel: statusTableViewModel.settingsViewModel, localizedAppNameAndVersion: supportManager.localizedAppNameAndVersion) .environmentObject(deviceManager.displayGlucosePreference) - .environment(\.appName, Bundle.main.bundleDisplayName), + .environment(\.appName, Bundle.main.bundleDisplayName) + .environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice) + .environment(\.loopStatusColorPalette, .loopStatus) + .environment(\.colorPalette, .default) + .environment(\.settingsManager, settingsManager) + .environment(\.temporaryPresetsManager, temporaryPresetsManager) + .environment(\.dosingStrategySelectionEnabled, FeatureFlags.dosingStrategySelectionEnabled), + 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) @@ -1672,9 +1609,16 @@ final class StatusTableViewController: LoopChartsTableViewController { } private func automaticDosingStatusChanged(_ automaticDosingEnabled: Bool) { - updatePresetModeAvailability(automaticDosingEnabled: automaticDosingEnabled) + log.debug("automaticDosingStatusChanged -> %{public}@", String(describing: 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 @@ -1700,8 +1644,13 @@ 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.loopIconClosed = settingsManager.dosingEnabled + hudView.loopCompletionHUD.lastLoopCompleted = loopManager.lastLoopCompleted + hudView.loopCompletionHUD.mostRecentGlucoseDataDate = loopManager.mostRecentGlucoseDataDate + hudView.loopCompletionHUD.mostRecentPumpDataDate = loopManager.mostRecentPumpDataDate + hudView.loopCompletionHUD.onAgoUpdate = { [weak self] ago in + self?.loopCompletionModalViewModel.ago = ago + } hudView.cgmStatusHUD.stateColors = .cgmStatus hudView.cgmStatusHUD.tintColor = .label @@ -1709,8 +1658,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() + } } } @@ -1741,20 +1692,30 @@ final class StatusTableViewController: LoopChartsTableViewController { hudView.addPumpManagerProvidedHUDView(view) } } - + + private lazy var loopCompletionModalViewModel = LoopStatusModalViewModel( + deviceManager: deviceManager, + loopManager: loopManager, + settingsManager: settingsManager + ) + @objc private func showLoopCompletionMessage(_: Any) { - guard let loopCompletionMessage = hudView?.loopCompletionHUD.loopCompletionMessage else { return } - presentLoopCompletionMessage(title: loopCompletionMessage.title, message: loopCompletionMessage.message) - } + let modalVC = UIHostingController( + rootView: LoopStatusModalView(viewModel: loopCompletionModalViewModel, + onDismiss: { [weak self] in + self?.dismiss(animated: false) + }, + onNavigateToSettings: { [weak self] in + self?.presentSettings() + }) + .environment(\.loopStatusColorPalette, .loopStatus) + ) + modalVC.modalPresentationStyle = .overCurrentContext + modalVC.view.backgroundColor = UIColor.black.withAlphaComponent(0.4) + modalVC.view.frame = view.bounds + modalVC.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - private func presentLoopCompletionMessage(title: String, message: String) { - let action = UIAlertAction(title: NSLocalizedString("Dismiss", comment: "The button label of the action used to dismiss an error alert"), - style: .default) - let alertController = UIAlertController(title: title, - message: message, - preferredStyle: .alert) - alertController.addAction(action) - present(alertController, animated: true) + present(modalVC, animated: false) } @objc private func showLastError(_: Any) { @@ -1772,7 +1733,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) @@ -1843,8 +1806,7 @@ final class StatusTableViewController: LoopChartsTableViewController { present(alert, animated: true, completion: nil) } } - - + // MARK: - Debug Scenarios and Simulated Core Data var lastOrientation: UIDeviceOrientation? @@ -1866,9 +1828,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 } @@ -1895,17 +1859,16 @@ 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 settings.suspendThreshold = therapySettings.suspendThreshold settings.maximumBolus = therapySettings.maximumBolus settings.maximumBasalRatePerHour = therapySettings.maximumBasalRatePerHour @@ -1922,6 +1885,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) @@ -1976,7 +1945,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() @@ -1984,7 +1953,7 @@ final class StatusTableViewController: LoopChartsTableViewController { return } - self.deviceManager.generateSimulatedHistoricalCoreData() { error in + self.simulatedData.generateSimulatedHistoricalCoreData() { error in DispatchQueue.main.async { dismissActivityIndicator() if let error = error { @@ -2003,7 +1972,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 { @@ -2069,21 +2038,27 @@ 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))) - - basalDeliveryState = status.basalDeliveryState - bolusState = status.bolusState - - refreshContext.update(with: .status) - reloadData(animated: true) + + if basalDeliveryState == status.basalDeliveryState, + bolusState == status.bolusState + { + // if the basal and bolus states have not changed, still update UI + Task { @MainActor in + refreshContext.update(with: .status) + await self.reloadData(animated: true) + } + } else { + basalDeliveryState = status.basalDeliveryState + bolusState = status.bolusState + } } } extension StatusTableViewController: CGMManagerStatusObserver { func cgmManager(_ manager: CGMManager, didUpdate status: CGMManagerStatus) { refreshContext.update(with: .status) - reloadData(animated: true) + Task { await reloadData(animated: true) } } } @@ -2097,63 +2072,15 @@ extension StatusTableViewController: DoseProgressObserver { self.bolusProgressReporter = nil DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: { self.bolusState = .noBolus - self.reloadData(animated: true) + Task { + self.refreshContext.update(with: .insulin) + await self.reloadData(animated: true) + } }) } } } -extension StatusTableViewController: OverrideSelectionViewControllerDelegate { - func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didUpdatePresets presets: [TemporaryScheduleOverridePreset]) { - deviceManager.loopManager.mutateSettings { settings in - settings.overridePresets = presets - } - } - - func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didConfirmOverride override: TemporaryScheduleOverride) { - deviceManager.loopManager.mutateSettings { settings in - settings.scheduleOverride = override - } - } - - func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didConfirmPreset preset: TemporaryScheduleOverridePreset) { - let intent = EnableOverridePresetIntent() - intent.overrideName = preset.name - - let interaction = INInteraction(intent: intent, response: nil) - interaction.identifier = preset.id.uuidString - interaction.groupIdentifier = preset.name - interaction.donate { (error) in - if let error = error { - os_log(.error, "Failed to donate intent: %{public}@", String(describing: error)) - } - } - deviceManager.loopManager.mutateSettings { settings in - settings.scheduleOverride = preset.createOverride(enactTrigger: .local) - } - } - - func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didCancelOverride override: TemporaryScheduleOverride) { - deviceManager.loopManager.mutateSettings { settings in - settings.scheduleOverride = nil - } - } -} - -extension StatusTableViewController: AddEditOverrideTableViewControllerDelegate { - func addEditOverrideTableViewController(_ vc: AddEditOverrideTableViewController, didSaveOverride override: TemporaryScheduleOverride) { - deviceManager.loopManager.mutateSettings { settings in - settings.scheduleOverride = override - } - } - - func addEditOverrideTableViewController(_ vc: AddEditOverrideTableViewController, didCancelOverride override: TemporaryScheduleOverride) { - deviceManager.loopManager.mutateSettings { settings in - settings.scheduleOverride = nil - } - } -} - extension StatusTableViewController { fileprivate func addCGMManager(withIdentifier identifier: String) { switch deviceManager.setupCGMManager(withIdentifier: identifier) { @@ -2174,9 +2101,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 @@ -2204,24 +2131,39 @@ extension StatusTableViewController { extension StatusTableViewController: BluetoothObserver { func bluetoothDidUpdateState(_ state: BluetoothState) { refreshContext.update(with: .status) - reloadData(animated: true) + Task { await reloadData(animated: true) } } } // MARK: - SettingsViewModel delegation extension StatusTableViewController: SettingsViewModelDelegate { + var automaticDosingEnabled: Bool { + get { + settingsManager.dosingEnabled + } + set { + if settingsManager.dosingEnabled != newValue { + settingsManager.dosingEnabled = newValue + } + } + } + var closedLoopDescriptiveText: String? { return deviceManager.closedLoopDisallowedLocalizedDescription } + + var automationHistory: [AutomationHistoryEntry] { + loopManager.automationHistory + } 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 } } @@ -2230,7 +2172,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) } @@ -2241,13 +2183,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: @@ -2257,16 +2199,22 @@ 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) } } + +extension StatusTableViewController { + override func scrollViewDidScroll(_ scrollView: UIScrollView) { + updateStatusBar() + } +} diff --git a/Loop/View Controllers/TextFieldTableViewController.swift b/Loop/View Controllers/TextFieldTableViewController.swift index 07c686e0f8..5dc954e726 100644 --- a/Loop/View Controllers/TextFieldTableViewController.swift +++ b/Loop/View Controllers/TextFieldTableViewController.swift @@ -7,7 +7,6 @@ // import LoopKitUI -import HealthKit /// Convenience static constructors used to contain common configuration diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index a86f20e0cc..d876b335ca 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -7,7 +7,6 @@ // import Combine -import HealthKit import LocalAuthentication import Intents import os.log @@ -17,43 +16,43 @@ import LoopKitUI import LoopUI import SwiftUI import SwiftCharts +import LoopAlgorithm +@MainActor protocol BolusEntryViewModelDelegate: AnyObject { - - func withLoopState(do block: @escaping (LoopState) -> Void) - func saveGlucose(sample: NewGlucoseSample) async -> StoredGlucoseSample? - - func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry? , - completion: @escaping (_ result: Result) -> Void) + var settings: StoredSettings { get } + var scheduleOverride: TemporaryScheduleOverride? { get } + var preMealOverride: TemporaryScheduleOverride? { get } + var mostRecentGlucoseDataDate: Date? { get } + var mostRecentPumpDataDate: Date? { get } - 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 fetchData(for baseTime: Date?, presumePresetEndingNow: Bool, ensureDosingCoverageStart: Date?) async throws -> StoredDataAlgorithmInput + func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool) -> GlucoseRangeSchedule? - 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 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, decisionId: UUID?, activationType: BolusActivationType) async throws - var mostRecentGlucoseDataDate: Date? { get } - - var mostRecentPumpDataDate: Date? { get } - - var isPumpConfigured: Bool { get } - - var pumpInsulinType: InsulinType? { get } - - var settings: LoopSettings { get } + func insulinModel(for type: InsulinType?) -> InsulinModel - var displayGlucosePreference: DisplayGlucosePreference { get } + func recommendManualBolus( + manualGlucoseSample: NewGlucoseSample?, + potentialCarbEntry: NewCarbEntry?, + originalCarbEntry: StoredCarbEntry?, + truncatingActiveOverride: Bool + ) async throws -> ManualBolusRecommendation? - func roundBolusVolume(units: Double) -> Double + func generatePrediction( + originalCarbEntry: StoredCarbEntry?, + potentialCarbEntry: NewCarbEntry?, + potentialDose: SimpleInsulinDose?, + manualGlucose: NewGlucoseSample? + ) async throws -> (historicGlucose: [StoredGlucoseSample], predictedGlucose: [PredictedGlucoseValue]) - func updateRemoteRecommendation() + var activeInsulin: InsulinValue? { get } + var activeCarbs: CarbValue? { get } } @MainActor @@ -67,13 +66,12 @@ final class BolusEntryViewModel: ObservableObject { case carbEntryPersistenceFailure case manualGlucoseEntryOutOfAcceptableRange case manualGlucoseEntryPersistenceFailure - case glucoseNoLongerStale case forecastInfo } enum Notice: Equatable { case predictedGlucoseInRange - case predictedGlucoseBelowSuspendThreshold(suspendThreshold: HKQuantity) + case predictedGlucoseBelowSuspendThreshold(suspendThreshold: LoopQuantity) case glucoseBelowTarget case staleGlucoseData case futureGlucoseData @@ -100,26 +98,26 @@ final class BolusEntryViewModel: ObservableObject { @Published var predictedGlucoseValues: [GlucoseValue] = [] @Published var chartDateInterval: DateInterval - @Published var activeCarbs: HKQuantity? - @Published var activeInsulin: HKQuantity? + @Published var activeCarbs: LoopQuantity? + @Published var activeInsulin: LoopQuantity? @Published var targetGlucoseSchedule: GlucoseRangeSchedule? @Published var preMealOverride: TemporaryScheduleOverride? private var savedPreMealOverride: TemporaryScheduleOverride? @Published var scheduleOverride: TemporaryScheduleOverride? - var maximumBolus: HKQuantity? + var maximumBolus: LoopQuantity? let originalCarbEntry: StoredCarbEntry? let potentialCarbEntry: NewCarbEntry? let selectedCarbAbsorptionTimeEmoji: String? - @Published var recommendedBolus: HKQuantity? + @Published var recommendedBolus: LoopQuantity? var recommendedBolusAmount: Double? { - recommendedBolus?.doubleValue(for: .internationalUnit()) + recommendedBolus?.doubleValue(for: .internationalUnit) } - @Published var enteredBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0) + @Published var enteredBolus = LoopQuantity(unit: .internationalUnit, doubleValue: 0) var enteredBolusAmount: Double { - enteredBolus.doubleValue(for: .internationalUnit()) + enteredBolus.doubleValue(for: .internationalUnit) } private var userChangedBolusAmount = false @Published var isInitiatingSaveOrBolus = false @@ -145,12 +143,13 @@ final class BolusEntryViewModel: ObservableObject { }() @Published var isManualGlucoseEntryEnabled = false - @Published var manualGlucoseQuantity: HKQuantity? + @Published var manualGlucoseQuantity: LoopQuantity? var manualGlucoseSample: NewGlucoseSample? // 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 +214,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 +232,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) @@ -242,19 +241,18 @@ final class BolusEntryViewModel: ObservableObject { private func observeEnteredManualGlucoseChanges() { $manualGlucoseQuantity + .dropFirst() .sink { [weak self] manualGlucoseQuantity in guard let self = self else { return } // 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.enteredBolus = LoopQuantity(unit: .internationalUnit, doubleValue: 0) - 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 +299,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 +315,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 +347,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 +363,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 } @@ -393,7 +374,7 @@ final class BolusEntryViewModel: ObservableObject { self.dosingDecision.manualGlucoseSample = nil } - let activationType = BolusActivationType.activationTypeFor(recommendedAmount: recommendedBolus?.doubleValue(for: .internationalUnit()), bolusAmount: amountToDeliver) + let activationType = BolusActivationType.activationTypeFor(recommendedAmount: recommendedBolus?.doubleValue(for: .internationalUnit), bolusAmount: amountToDeliver) if let carbEntry = potentialCarbEntry { if originalCarbEntry == nil { @@ -407,7 +388,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 +398,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, decisionId: dosingDecision.id, activationType: activationType) + } catch { + log.error("Failed to enact 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 @@ -440,7 +422,7 @@ final class BolusEntryViewModel: ObservableObject { } private lazy var bolusAmountFormatter: NumberFormatter = { - let formatter = QuantityFormatter(for: .internationalUnit()) + let formatter = QuantityFormatter(for: .internationalUnit) formatter.numberFormatter.roundingMode = .down return formatter.numberFormatter }() @@ -460,7 +442,7 @@ final class BolusEntryViewModel: ObservableObject { } var maximumBolusAmountString: String? { - guard let maxBolusAmount = maximumBolus?.doubleValue(for: .internationalUnit()) else { + guard let maxBolusAmount = maximumBolus?.doubleValue(for: .internationalUnit) else { return nil } return formatBolusAmount(maxBolusAmount) @@ -469,7 +451,7 @@ final class BolusEntryViewModel: ObservableObject { var carbEntryAmountAndEmojiString: String? { guard let potentialCarbEntry = potentialCarbEntry, - let carbAmountString = QuantityFormatter(for: .gram()).string(from: potentialCarbEntry.quantity) + let carbAmountString = QuantityFormatter(for: .gram).string(from: potentialCarbEntry.quantity) else { return nil } @@ -497,174 +479,124 @@ 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 { - chartGlucoseValues.append(manualGlucoseSample.quantitySample) + chartGlucoseValues.append(LoopQuantitySample(with: manualGlucoseSample.quantitySample)) } self.glucoseValues = chartGlucoseValues } /// - 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 - ) - } + let startDate = now() + + let insulinModel = delegate.insulinModel(for: deliveryDelegate?.pumpInsulinType) + + let enteredBolusDose = SimpleInsulinDose( + deliveryType: .bolus, + automatic: false, + startDate: startDate, + endDate: startDate, + volume: enteredBolus.doubleValue(for: .internationalUnit), + insulinModel: insulinModel + ) + + let (glucoseHistory, prediction) = try await delegate.generatePrediction( + originalCarbEntry: originalCarbEntry, + potentialCarbEntry: potentialCarbEntry, + potentialDose: enteredBolusDose, + manualGlucose: manualGlucoseSample + ) + + storedGlucoseValues = glucoseHistory + predictedGlucoseValues = prediction + dosingDecision.predictedGlucose = prediction } catch { predictedGlucoseValues = [] + dosingDecision.predictedGlucose = [] } - DispatchQueue.main.async { - self.predictedGlucoseValues = predictedGlucoseValues - self.dosingDecision.predictedGlucose = predictedGlucoseValues - completion() - } } - - private func getInsulinOnBoard() async -> InsulinValue? { - guard let delegate = delegate else { - return nil + + struct PresetEffectedRecommendation { + let originalAmount: Double + let recommendedAmount: Double + + let formatter = QuantityFormatter(for: .internationalUnit) + + var originalAmountString: String? { + formatter.string(from: LoopQuantity(unit: .internationalUnit, doubleValue: originalAmount)) } - - 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) - } - } + + var recommendedAmountString: String? { + formatter.string(from: LoopQuantity(unit: .internationalUnit, doubleValue: recommendedAmount)) } - } - - private func updatePredictionAndRecommendation() async { - guard let delegate = delegate else { - return + + var differenceString: String? { + formatter.string(from: LoopQuantity(unit: .internationalUnit, doubleValue: abs(recommendedAmount - originalAmount))) } - 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() - } + + var showPredictionDifference: Bool { + originalAmount != recommendedAmount } - } - - 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 - } - } + + var direction: String { + recommendedAmount > originalAmount ? NSLocalizedString("increase", comment: "") : NSLocalizedString("decrease", comment: "") } } - private func updateRecommendedBolusAndNotice(from state: LoopState, isUpdatingFromUserInput: Bool) { - dispatchPrecondition(condition: .notOnQueue(.main)) + @Published var presetEffectedRecommendation: PresetEffectedRecommendation? + + 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 recommendedBolus: LoopQuantity? let notice: Notice? do { - recommendation = try computeBolusRecommendation(from: state) + recommendation = try await computeBolusRecommendation() - if let recommendation = recommendation { - recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: delegate.roundBolusVolume(units: recommendation.amount)) - //recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: recommendation.amount) - + if let recommendation, deliveryDelegate != nil { + if let originalAmount = try await computeBolusRecommendation(truncatingActiveOverride: true)?.amount { + presetEffectedRecommendation = PresetEffectedRecommendation(originalAmount: originalAmount, recommendedAmount: recommendation.amount) + } + recommendedBolus = LoopQuantity(unit: .internationalUnit, doubleValue: recommendation.amount) switch recommendation.notice { case .glucoseBelowSuspendThreshold: if let suspendThreshold = delegate.settings.suspendThreshold { @@ -680,14 +612,14 @@ final class BolusEntryViewModel: ObservableObject { notice = nil } } else { - recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0) + recommendedBolus = LoopQuantity(unit: .internationalUnit, doubleValue: 0) notice = nil } } catch { 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 +630,42 @@ 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(truncatingActiveOverride: Bool = false) async throws -> ManualBolusRecommendation? { + guard let delegate else { + return nil } + + return try await delegate.recommendManualBolus( + manualGlucoseSample: manualGlucoseSample, + potentialCarbEntry: potentialCarbEntry, + originalCarbEntry: originalCarbEntry, + truncatingActiveOverride: truncatingActiveOverride + ) } 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 @@ -755,27 +676,27 @@ final class BolusEntryViewModel: ObservableObject { } maximumBolus = delegate.settings.maximumBolus.map { maxBolusAmount in - HKQuantity(unit: .internationalUnit(), doubleValue: maxBolusAmount) + LoopQuantity(unit: .internationalUnit, doubleValue: maxBolusAmount) } 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()) @@ -805,7 +726,7 @@ final class BolusEntryViewModel: ObservableObject { } func updateEnteredBolus(_ enteredBolusAmount: Double?) { - enteredBolus = HKQuantity(unit: .internationalUnit(), doubleValue: enteredBolusAmount ?? 0) + enteredBolus = LoopQuantity(unit: .internationalUnit, doubleValue: enteredBolusAmount ?? 0) } } @@ -818,12 +739,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 { @@ -841,7 +762,7 @@ extension BolusEntryViewModel { } private var hasBolusEntryReadyToDeliver: Bool { - enteredBolus.doubleValue(for: .internationalUnit()) != 0 + enteredBolus.doubleValue(for: .internationalUnit) != 0 } private var hasDataToSave: Bool { @@ -871,3 +792,4 @@ extension BolusEntryViewModel { } } } + diff --git a/Loop/View Models/CarbEntryViewModel.swift b/Loop/View Models/CarbEntryViewModel.swift index 37dedee326..1cbf48282c 100644 --- a/Loop/View Models/CarbEntryViewModel.swift +++ b/Loop/View Models/CarbEntryViewModel.swift @@ -8,14 +8,19 @@ 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 } +@MainActor +protocol CarbEntryViewModelDelegate: AnyObject, BolusEntryViewModelDelegate, FavoriteFoodInsightsViewModelDelegate { + var defaultAbsorptionTimes: DefaultAbsorptionTimes { get } + func isScheduleOverrideActive(at date: Date) -> Bool + func getGlucoseSamples(start: Date?, end: Date?) async throws -> [StoredGlucoseSample] } +@MainActor final class CarbEntryViewModel: ObservableObject { enum Alert: Identifiable { var id: Self { @@ -35,13 +40,13 @@ final class CarbEntryViewModel: ObservableObject { switch self { case .entryIsMissedMeal: return 1 - case .overrideInProgress: - return 2 + case .glucoseRisingRapidly: + return 3 } } case entryIsMissedMeal - case overrideInProgress + case glucoseRisingRapidly } @Published var alert: CarbEntryViewModel.Alert? @@ -52,17 +57,17 @@ final class CarbEntryViewModel: ObservableObject { let shouldBeginEditingQuantity: Bool @Published var carbsQuantity: Double? = nil - var preferredCarbUnit = HKUnit.gram() + var preferredCarbUnit = LoopUnit.gram var maxCarbEntryQuantity = LoopConstants.maxCarbEntryQuantity var warningCarbEntryQuantity = LoopConstants.warningCarbEntryQuantity @Published var time = Date() private var date = Date() var minimumDate: Date { - get { date.addingTimeInterval(LoopConstants.maxCarbEntryPastTime) } + get { date.addingTimeInterval(CarbMath.dateAdjustmentPast) } } var maximumDate: Date { - get { date.addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) } + get { date.addingTimeInterval(CarbMath.dateAdjustmentFuture) } } @Published var foodType = "" @@ -72,7 +77,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 +85,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,24 +137,32 @@ 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 } return NewCarbEntry( date: date, - quantity: HKQuantity(unit: preferredCarbUnit, doubleValue: quantity), + quantity: LoopQuantity(unit: preferredCarbUnit, doubleValue: quantity), startDate: time, foodType: usesCustomFoodType ? foodType : selectedDefaultAbsorptionTimeEmoji, - absorptionTime: absorptionTime + absorptionTime: absorptionTime, + favoriteFoodID: favoriteFoodID ) } else { @@ -166,7 +198,7 @@ final class CarbEntryViewModel: ObservableObject { } guard let carbsQuantity, carbsQuantity > 0 else { return } - let quantity = HKQuantity(unit: preferredCarbUnit, doubleValue: carbsQuantity) + let quantity = LoopQuantity(unit: preferredCarbUnit, doubleValue: carbsQuantity) if quantity.compare(maxCarbEntryQuantity) == .orderedDescending { self.alert = .maxQuantityExceded return @@ -189,14 +221,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 +269,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 +286,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 +329,99 @@ 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) } + @Published var currentOverride: TemporaryScheduleOverride? + 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 { - self.warnings.remove(.overrideInProgress) + + if delegate.isScheduleOverrideActive(at: Date()), + let override = delegate.scheduleOverride ?? delegate.preMealOverride + { + currentOverride = override + } else { + currentOverride = nil } } + 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 filteredGlucoseSamples.count >= 2 else { + warnings.remove(.glucoseRisingRapidly) + return + } + + // Condition 1: Rate of change between the most recent two glucose readings within 14 minutes + let twoReadingWindow = now.addingTimeInterval(.minutes(-14)) + let twoReadingSamples = filteredGlucoseSamples.filterDateRange(twoReadingWindow, now) + + if twoReadingSamples.count >= 2 { + let recentTwo = Array(twoReadingSamples.suffix(2)) + let firstOfTwo = recentTwo[0] + let lastOfTwo = recentTwo[1] + + let duration = lastOfTwo.startDate.timeIntervalSince(firstOfTwo.startDate) + let delta = lastOfTwo.quantity.doubleValue(for: .milligramsPerDeciliter) - firstOfTwo.quantity.doubleValue(for: .milligramsPerDeciliter) + let velocity = delta / duration.minutes // Unit = mg/dL/min + + if velocity >= LoopConstants.missedMealWarningGlucoseRiseThreshold { + warnings.insert(.glucoseRisingRapidly) + return + } + } + + // Condition 2: Rate of change over the most recent three glucose readings within 19 minutes + let threeReadingWindow = now.addingTimeInterval(-LoopConstants.missedMealWarningGlucoseRecencyWindow) + let threeReadingSamples = filteredGlucoseSamples.filterDateRange(threeReadingWindow, now) + + if threeReadingSamples.count >= 3 { + let recentThree = Array(threeReadingSamples.suffix(3)) + let firstOfThree = recentThree[0] + let lastOfThree = recentThree[2] + + let duration = lastOfThree.startDate.timeIntervalSince(firstOfThree.startDate) + let delta = lastOfThree.quantity.doubleValue(for: .milligramsPerDeciliter) - firstOfThree.quantity.doubleValue(for: .milligramsPerDeciliter) + let velocity = delta / duration.minutes // Unit = mg/dL/min + + if velocity >= LoopConstants.missedMealWarningGlucoseRiseThreshold { + warnings.insert(.glucoseRisingRapidly) + return + } + } + + // Neither condition met + 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 82% rename from Loop/View Models/AddEditFavoriteFoodViewModel.swift rename to Loop/View Models/FavoriteFoodAddEditViewModel.swift index 5bd6eb8775..b3f70fe3cf 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. @@ -8,9 +8,9 @@ import SwiftUI import LoopKit -import HealthKit +import LoopAlgorithm -final class AddEditFavoriteFoodViewModel: ObservableObject { +final class FavoriteFoodAddEditViewModel: ObservableObject { enum Alert: Identifiable { var id: Self { return self @@ -23,7 +23,7 @@ final class AddEditFavoriteFoodViewModel: ObservableObject { @Published var name = "" @Published var carbsQuantity: Double? = nil - var preferredCarbUnit = HKUnit.gram() + var preferredCarbUnit = LoopUnit.gram var maxCarbEntryQuantity = LoopConstants.maxCarbEntryQuantity var warningCarbEntryQuantity = LoopConstants.warningCarbEntryQuantity @@ -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? @@ -70,7 +76,7 @@ final class AddEditFavoriteFoodViewModel: ObservableObject { return NewFavoriteFood( name: name, - carbsQuantity: HKQuantity(unit: preferredCarbUnit, doubleValue: quantity), + carbsQuantity: LoopQuantity(unit: preferredCarbUnit, doubleValue: quantity), foodType: foodType, absorptionTime: absorptionTime ) @@ -84,7 +90,7 @@ final class AddEditFavoriteFoodViewModel: ObservableObject { guard let updatedFavoriteFood, absorptionTime <= maxAbsorptionTime else { return } guard let carbsQuantity, carbsQuantity > 0 else { return } - let quantity = HKQuantity(unit: preferredCarbUnit, doubleValue: carbsQuantity) + let quantity = LoopQuantity(unit: preferredCarbUnit, doubleValue: carbsQuantity) if quantity.compare(maxCarbEntryQuantity) == .orderedDescending { self.alert = .maxQuantityExceded return diff --git a/Loop/View Models/FavoriteFoodInsightsViewModel.swift b/Loop/View Models/FavoriteFoodInsightsViewModel.swift new file mode 100644 index 0000000000..502b3e0915 --- /dev/null +++ b/Loop/View Models/FavoriteFoodInsightsViewModel.swift @@ -0,0 +1,165 @@ +// +// 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 + +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..4b425ccee7 100644 --- a/Loop/View Models/FavoriteFoodsViewModel.swift +++ b/Loop/View Models/FavoriteFoodsViewModel.swift @@ -7,9 +7,10 @@ // import SwiftUI -import HealthKit +import LoopAlgorithm import LoopKit import Combine +import os.log final class FavoriteFoodsViewModel: ObservableObject { @Published var favoriteFoods = UserDefaults.standard.favoriteFoods @@ -19,7 +20,7 @@ final class FavoriteFoodsViewModel: ObservableObject { @Published var isEditViewActive = false @Published var isAddViewActive = false - var preferredCarbUnit = HKUnit.gram() + var preferredCarbUnit = LoopUnit.gram lazy var carbFormatter = QuantityFormatter(for: preferredCarbUnit) lazy var absorptionTimeFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() @@ -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..626120bff0 100644 --- a/Loop/View Models/ManualEntryDoseViewModel.swift +++ b/Loop/View Models/ManualEntryDoseViewModel.swift @@ -7,7 +7,6 @@ // import Combine -import HealthKit import LocalAuthentication import Intents import os.log @@ -16,55 +15,41 @@ 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 } - +@MainActor +protocol ManualDoseViewModelDelegate: AnyObject { + var algorithmDisplayState: AlgorithmDisplayState { get async } var pumpInsulinType: InsulinType? { get } + var settings: StoredSettings { get } + var scheduleOverride: TemporaryScheduleOverride? { get } - var settings: LoopSettings { get } + func addManuallyEnteredDose(startDate: Date, units: Double, insulinType: InsulinType?) async + func insulinModel(for type: InsulinType?) -> InsulinModel } +@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 glucoseUnit: LoopUnit = .milligramsPerDeciliter @Published var chartDateInterval: DateInterval - @Published var activeCarbs: HKQuantity? - @Published var activeInsulin: HKQuantity? + @Published var activeCarbs: LoopQuantity? + @Published var activeInsulin: LoopQuantity? @Published var targetGlucoseSchedule: GlucoseRangeSchedule? @Published var preMealOverride: TemporaryScheduleOverride? private var savedPreMealOverride: TemporaryScheduleOverride? @Published var scheduleOverride: TemporaryScheduleOverride? - @Published var enteredBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0) + @Published var enteredBolus = LoopQuantity(unit: .internationalUnit, doubleValue: 0) private var isInitiatingSaveOrBolus = false private let log = OSLog(category: "ManualEntryDoseViewModel") @@ -83,27 +68,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 +136,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 +146,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,44 +156,44 @@ 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) { - let doseVolume = enteredBolus.doubleValue(for: .internationalUnit()) + 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()) + private lazy var bolusVolumeFormatter = QuantityFormatter(for: .internationalUnit) private lazy var absorptionTimeFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() @@ -211,124 +205,69 @@ final class ManualEntryDoseViewModel: ObservableObject { }() var enteredBolusAmountString: String { - let bolusVolume = enteredBolus.doubleValue(for: .internationalUnit()) + let bolusVolume = enteredBolus.doubleValue(for: .internationalUnit) return bolusVolumeFormatter.numberFormatter.string(from: bolusVolume) ?? String(bolusVolume) } // 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() - } - - 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() - } + Task { + await updateFromLoopState() } } - private func updateGlucoseChartValues() { - dispatchPrecondition(condition: .onQueue(.main)) + private func updateFromLoopState() async { + guard let delegate = delegate else { + return + } - self.glucoseValues = storedGlucoseValues - } + let state = await delegate.algorithmDisplayState - /// - 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 predictedGlucoseValues: [PredictedGlucoseValue] - do { - predictedGlucoseValues = try state.predictGlucose( - using: .all, - potentialBolus: enteredBolusDose, - potentialCarbEntry: nil, - replacingCarbEntry: nil, - includingPendingInsulin: true, - considerPositiveVelocityAndRC: true - ) - } catch { - predictedGlucoseValues = [] - } + let enteredBolusDose = SimpleInsulinDose( + deliveryType: .bolus, + automatic: false, + startDate: selectedDoseDate, + endDate: selectedDoseDate, + volume: enteredBolus.doubleValue(for: .internationalUnit), + insulinModel: insulinModel + ) - DispatchQueue.main.async { - self.predictedGlucoseValues = predictedGlucoseValues - completion() - } - } + self.activeInsulin = state.activeInsulin?.quantity + self.activeCarbs = state.activeCarbs?.quantity - private func updateActiveInsulin() { - delegate?.insulinOnBoard(at: Date()) { [weak self] result in - guard let self = self else { return } - DispatchQueue.main.async { - switch result { - case .success(let iob): - self.activeInsulin = HKQuantity(unit: .internationalUnit(), doubleValue: iob.value) - case .failure: - self.activeInsulin = nil - } - } - } - } + if let input = state.input { + 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() + do { + predictedGlucoseValues = try input + .addingDose(dose: enteredBolusDose) + .predictGlucose() + } catch { + predictedGlucoseValues = [] } + } else { + 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 +286,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..6878a5fa5f 100644 --- a/Loop/View Models/ServicesViewModel.swift +++ b/Loop/View Models/ServicesViewModel.swift @@ -33,12 +33,10 @@ public class ServicesViewModel: ObservableObject { init(showServices: Bool, availableServices: @escaping () -> [ServiceDescriptor], - activeServices: @escaping () -> [Service], - delegate: ServicesViewModelDelegate? = nil) { + activeServices: @escaping () -> [Service]) { self.showServices = showServices self.activeServices = activeServices self.availableServices = availableServices - self.delegate = delegate } func didTapService(_ index: Int) { @@ -54,7 +52,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 +63,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..9b918876ab 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -7,11 +7,11 @@ // import Combine +import LoopAlgorithm import LoopCore import LoopKit import LoopKitUI import SwiftUI -import HealthKit public class DeviceViewModel: ObservableObject { public typealias DeleteTestingDataFunc = () -> Void @@ -20,8 +20,8 @@ public class DeviceViewModel: ObservableObject { let image: () -> UIImage? let name: () -> String let deleteTestingDataFunc: () -> DeleteTestingDataFunc? - let didTap: () -> Void - let didTapAdd: (_ device: T) -> Void + var didTap: () -> Void + var didTapAdd: (_ device: T) -> Void var isTestingDevice: Bool { return deleteTestingDataFunc() != nil } @@ -54,9 +54,12 @@ public protocol SettingsViewModelDelegate: AnyObject { func dosingStrategyChanged(_: AutomaticDosingStrategy) func didTapIssueReport() var closedLoopDescriptiveText: String? { get } + var automaticDosingEnabled: Bool { get set } + var automationHistory: [AutomationHistoryEntry] { get } } -public class SettingsViewModel: ObservableObject { +@Observable +class SettingsViewModel { let alertPermissionsChecker: AlertPermissionsChecker @@ -64,7 +67,9 @@ public class SettingsViewModel: ObservableObject { let versionUpdateViewModel: VersionUpdateViewModel - private weak var delegate: SettingsViewModelDelegate? + weak var delegate: SettingsViewModelDelegate? + + weak var deliveryDelegate: DeliveryDelegate? func didTapIssueReport() { delegate?.didTapIssueReport() @@ -76,18 +81,28 @@ public class SettingsViewModel: ObservableObject { let servicesViewModel: ServicesViewModel let criticalEventLogExportViewModel: CriticalEventLogExportViewModel let therapySettings: () -> TherapySettings - let sensitivityOverridesEnabled: Bool - let isOnboardingComplete: Bool + var isOnboardingComplete: Bool let therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? + let presetHistory: TemporaryScheduleOverrideHistory - @Published var isClosedLoopAllowed: Bool - + private(set) var automaticDosingEnabled: Bool { + get { + delegate?.automaticDosingEnabled ?? closedLoopPreference + } + set { + delegate?.automaticDosingEnabled = newValue + } + } + + private(set) var lastLoopCompletion: Date? + private(set) var mostRecentGlucoseDataDate: Date? + private(set) var mostRecentPumpDataDate: Date? + var closedLoopDescriptiveText: String? { - return delegate?.closedLoopDescriptiveText + delegate?.closedLoopDescriptiveText } - - @Published var automaticDosingStrategy: AutomaticDosingStrategy { + var automaticDosingStrategy: AutomaticDosingStrategy { didSet { delegate?.dosingStrategyChanged(automaticDosingStrategy) } @@ -98,13 +113,36 @@ public class SettingsViewModel: ObservableObject { delegate?.dosingEnabledChanged(closedLoopPreference) } } + + private(set) var deviceManager: DeviceDataManager? + + @MainActor + var deviceIssue: Bool { + deviceManager?.cgmManager == nil || deviceManager?.cgmManager?.isInoperable == true || deviceManager?.cgmManager?.inSignalLoss == true || deviceManager?.pumpManager == nil || deviceManager?.pumpManager?.isInoperable == true || deviceManager?.pumpManager?.inSignalLoss == true || deviceManager?.hasBluetoothIssue != false + } + var preMealGuardrail: Guardrail? + + @ObservationIgnored weak var favoriteFoodInsightsDelegate: FavoriteFoodInsightsViewModelDelegate? + + @MainActor var showDeleteTestData: Bool { availableSupports.contains(where: { $0.showsDeleteTestDataUI }) } - lazy private var cancellables = Set() + var loopStatusCircleFreshness: LoopCompletionFreshness { + if automaticDosingEnabled { + let lastLoopCompletion = lastLoopCompletion ?? Date().addingTimeInterval(.minutes(16)) + let age = abs(min(0, lastLoopCompletion.timeIntervalSinceNow)) + return LoopCompletionFreshness(age: age) + } else { + return .fresh + } + } + + @ObservationIgnored lazy private var cancellables = Set() + @MainActor public init(alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, versionUpdateViewModel: VersionUpdateViewModel, @@ -113,14 +151,17 @@ public class SettingsViewModel: ObservableObject { servicesViewModel: ServicesViewModel, criticalEventLogExportViewModel: CriticalEventLogExportViewModel, therapySettings: @escaping () -> TherapySettings, - sensitivityOverridesEnabled: Bool, initialDosingEnabled: Bool, - isClosedLoopAllowed: Published.Publisher, automaticDosingStrategy: AutomaticDosingStrategy, + lastLoopCompletion: Published.Publisher, + mostRecentGlucoseDataDate: Published.Publisher, + mostRecentPumpDataDate: Published.Publisher, availableSupports: [SupportUI], isOnboardingComplete: Bool, therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate?, - delegate: SettingsViewModelDelegate? + presetHistory: TemporaryScheduleOverrideHistory, + deliveryDelegate: DeliveryDelegate?, + deviceManager: DeviceDataManager?, ) { self.alertPermissionsChecker = alertPermissionsChecker self.alertMuter = alertMuter @@ -130,61 +171,99 @@ public class SettingsViewModel: ObservableObject { self.servicesViewModel = servicesViewModel self.criticalEventLogExportViewModel = criticalEventLogExportViewModel self.therapySettings = therapySettings - self.sensitivityOverridesEnabled = sensitivityOverridesEnabled self.closedLoopPreference = initialDosingEnabled - self.isClosedLoopAllowed = false self.automaticDosingStrategy = automaticDosingStrategy + self.lastLoopCompletion = nil + self.mostRecentGlucoseDataDate = nil + self.mostRecentPumpDataDate = nil self.availableSupports = availableSupports self.isOnboardingComplete = isOnboardingComplete self.therapySettingsViewModelDelegate = therapySettingsViewModelDelegate - self.delegate = delegate + self.presetHistory = presetHistory + self.deliveryDelegate = deliveryDelegate + self.deviceManager = deviceManager // This strangeness ensures the composed ViewModels' (ObservableObjects') changes get reported to this ViewModel (ObservableObject) - alertPermissionsChecker.objectWillChange.sink { [weak self] in - self?.objectWillChange.send() - } - .store(in: &cancellables) - alertMuter.objectWillChange.sink { [weak self] in - self?.objectWillChange.send() - } - .store(in: &cancellables) - pumpManagerSettingsViewModel.objectWillChange.sink { [weak self] in - self?.objectWillChange.send() - } - .store(in: &cancellables) - cgmManagerSettingsViewModel.objectWillChange.sink { [weak self] in - self?.objectWillChange.send() - } - .store(in: &cancellables) - - isClosedLoopAllowed - .assign(to: \.isClosedLoopAllowed, on: self) + 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) } + + @MainActor func deleteAllTestingData() { + Task { + try? await deviceManager?.deleteTestingPumpData() + + try? await deviceManager?.deleteTestingCGMData() + + try? await deviceManager?.deleteTestingCarbData() + + try? await deviceManager?.deleteTestingAlertData() + } + } } // For previews only +@MainActor extension SettingsViewModel { - fileprivate class FakeClosedLoopAllowedPublisher { - @Published var mockIsClosedLoopAllowed: Bool = false + fileprivate class FakeLastLoopCompletionPublisher { + @Published var mockLastLoopCompletion: Date? = nil + } + + fileprivate class FakeSettingsProvider: SettingsProvider { + let settings = StoredSettings() + var dosingEnabled: Bool { settings.dosingEnabled } + + 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 { + DosingLimits() + } + + func executeSettingsQuery(fromQueryAnchor queryAnchor: SettingsStore.QueryAnchor?, limit: Int, completion: @escaping (SettingsStore.SettingsQueryResult) -> Void) {} + + } 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, criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: MockCriticalEventLogExporterFactory()), therapySettings: { TherapySettings() }, - sensitivityOverridesEnabled: false, initialDosingEnabled: true, - isClosedLoopAllowed: FakeClosedLoopAllowedPublisher().$mockIsClosedLoopAllowed, automaticDosingStrategy: .automaticBolus, + lastLoopCompletion: FakeLastLoopCompletionPublisher().$mockLastLoopCompletion, + mostRecentGlucoseDataDate: FakeLastLoopCompletionPublisher().$mockLastLoopCompletion, + mostRecentPumpDataDate: FakeLastLoopCompletionPublisher().$mockLastLoopCompletion, availableSupports: [], isOnboardingComplete: false, therapySettingsViewModelDelegate: nil, - delegate: nil) + presetHistory: TemporaryScheduleOverrideHistory(), + deliveryDelegate: nil, + deviceManager: nil + ) } } diff --git a/Loop/View Models/SimpleBolusViewModel.swift b/Loop/View Models/SimpleBolusViewModel.swift index 016c1518fb..7d48563871 100644 --- a/Loop/View Models/SimpleBolusViewModel.swift +++ b/Loop/View Models/SimpleBolusViewModel.swift @@ -7,7 +7,6 @@ // import Foundation -import HealthKit import LoopKit import LoopKitUI import os.log @@ -15,33 +14,38 @@ import SwiftUI import LoopCore import Intents import LocalAuthentication +import LoopAlgorithm +@MainActor 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, decisionId: UUID?, 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? + func computeSimpleBolusRecommendation(at date: Date, mealCarbs: LoopQuantity?, manualGlucose: LoopQuantity?) -> BolusDosingDecision? - var displayGlucosePreference: DisplayGlucosePreference { get } - - var maximumBolus: Double { get } + var maximumBolus: Double? { get } - var suspendThreshold: HKQuantity { get } + var suspendThreshold: LoopQuantity? { 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 @@ -71,7 +75,7 @@ class SimpleBolusViewModel: ObservableObject { @Published var enteredCarbString: String = "" { didSet { if let enteredCarbs = Self.carbAmountFormatter.number(from: enteredCarbString)?.doubleValue, enteredCarbs > 0 { - carbQuantity = HKQuantity(unit: .gram(), doubleValue: enteredCarbs) + carbQuantity = LoopQuantity(unit: .gram, doubleValue: enteredCarbs) } else { carbQuantity = nil } @@ -83,7 +87,7 @@ class SimpleBolusViewModel: ObservableObject { // needed to detect change in display glucose unit when returning to the app - private var cachedDisplayGlucoseUnit: HKUnit + private var cachedDisplayGlucoseUnit: LoopUnit var manualGlucoseString: String { get { @@ -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,14 +121,14 @@ 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 } } let isAddingCarbs: Bool - if let carbQuantity = carbQuantity, carbQuantity.doubleValue(for: .gram()) > 0 { + if let carbQuantity = carbQuantity, carbQuantity.doubleValue(for: .gram) > 0 { isAddingCarbs = true } else { isAddingCarbs = false @@ -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,9 +168,9 @@ 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) + manualGlucoseQuantity = LoopQuantity(unit: cachedDisplayGlucoseUnit, doubleValue: manualGlucoseValue) updateNotice() } } @@ -171,43 +179,50 @@ class SimpleBolusViewModel: ObservableObject { @Published var enteredBolusString: String { didSet { if let enteredBolusAmount = Self.doseAmountFormatter.number(from: enteredBolusString)?.doubleValue, enteredBolusAmount > 0 { - bolus = HKQuantity(unit: .internationalUnit(), doubleValue: enteredBolusAmount) + bolus = LoopQuantity(unit: .internationalUnit, doubleValue: enteredBolusAmount) } else { bolus = nil } updateNotice() } } + var didEditBolusAmount: Bool = false - private var carbQuantity: HKQuantity? = nil + private var carbQuantity: LoopQuantity? = nil - private var manualGlucoseQuantity: HKQuantity? = nil { + private var manualGlucoseQuantity: LoopQuantity? = nil { didSet { updateRecommendation() } } - private var bolus: HKQuantity? = nil + private var bolus: LoopQuantity? = nil var bolusRecommended: Bool { - if let bolus = bolus, bolus.doubleValue(for: .internationalUnit()) > 0 { + if let bolus = bolus, bolus.doubleValue(for: .internationalUnit) > 0 { return true } return false } + + let displayGlucosePreference: DisplayGlucosePreference + + var displayGlucoseUnit: LoopUnit { return displayGlucosePreference.unit } - var displayGlucoseUnit: HKUnit { return delegate.displayGlucosePreference.unit } - - var suspendThreshold: HKQuantity { return delegate.suspendThreshold } + var suspendThreshold: LoopQuantity? { 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))! + if didEditBolusAmount { + enteredBolusString = "0" + } else { + enteredBolusString = Self.doseAmountFormatter.string(from: min(recommendation, maxBolus))! + } } else { - recommendedBolus = ("–") // do not localize this, comment: "String denoting lack of a recommended bolus amount in the simple bolus calculator" - enteredBolusString = Self.doseAmountFormatter.string(from: 0.0)! + recommendedBolus = NSLocalizedString("–", comment: "String denoting lack of a recommended bolus amount in the simple bolus calculator") + enteredBolusString = "" } } } @@ -224,7 +239,7 @@ class SimpleBolusViewModel: ObservableObject { }() private static let carbAmountFormatter: NumberFormatter = { - let quantityFormatter = QuantityFormatter(for: .gram()) + let quantityFormatter = QuantityFormatter(for: .gram) return quantityFormatter.numberFormatter }() @@ -268,17 +283,21 @@ class SimpleBolusViewModel: ObservableObject { private let delegate: SimpleBolusViewModelDelegate private let log = OSLog(category: "SimpleBolusViewModel") - private lazy var bolusVolumeFormatter = QuantityFormatter(for: .internationalUnit()) + 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 = LoopQuantity(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 +342,80 @@ 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 { + let storedManualGlucoseSample = try await delegate.saveGlucose(sample: manualGlucoseSample) + self.dosingDecision?.manualGlucoseSample = storedManualGlucoseSample + } 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, decisionId: dosingDecision?.id, 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/StatusTableViewModel.swift b/Loop/View Models/StatusTableViewModel.swift new file mode 100644 index 0000000000..06756c006c --- /dev/null +++ b/Loop/View Models/StatusTableViewModel.swift @@ -0,0 +1,56 @@ +// +// StatusTableViewModel.swift +// Loop +// +// Created by Pete Schwamb on 3/19/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopKit +import LoopCore + +@MainActor +@Observable +class StatusTableViewModel { + let alertPermissionsChecker: AlertPermissionsChecker + let alertMuter: AlertMuter + let deviceDataManager: DeviceDataManager + let supportManager: SupportManager + let testingScenariosManager: TestingScenariosManager? + let loopDataManager: LoopDataManager + let diagnosticReportGenerator: DiagnosticReportGenerator + let simulatedData: SimulatedData + let analyticsServicesManager: AnalyticsServicesManager + let servicesManager: ServicesManager + let carbStore: CarbStore + let doseStore: DoseStore + let criticalEventLogExportManager: CriticalEventLogExportManager + let bluetoothStateManager: BluetoothStateManager + let settingsManager: SettingsManager + let onboardingManager: OnboardingManager + let temporaryPresetsManager: TemporaryPresetsManager + let settingsViewModel: SettingsViewModel + + var pendingPreset: SelectablePreset? + + init(alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, deviceDataManager: DeviceDataManager, onboardingManager: OnboardingManager, supportManager: SupportManager, testingScenariosManager: TestingScenariosManager?, settingsManager: SettingsManager, temporaryPresetsManager: TemporaryPresetsManager, loopDataManager: LoopDataManager, diagnosticReportGenerator: DiagnosticReportGenerator, simulatedData: SimulatedData, analyticsServicesManager: AnalyticsServicesManager, servicesManager: ServicesManager, carbStore: CarbStore, doseStore: DoseStore, criticalEventLogExportManager: CriticalEventLogExportManager, bluetoothStateManager: BluetoothStateManager, settingsViewModel: SettingsViewModel) { + self.alertPermissionsChecker = alertPermissionsChecker + self.alertMuter = alertMuter + self.deviceDataManager = deviceDataManager + self.onboardingManager = onboardingManager + self.supportManager = supportManager + self.testingScenariosManager = testingScenariosManager + self.temporaryPresetsManager = temporaryPresetsManager + self.settingsManager = settingsManager + self.loopDataManager = loopDataManager + self.diagnosticReportGenerator = diagnosticReportGenerator + self.simulatedData = simulatedData + self.analyticsServicesManager = analyticsServicesManager + self.servicesManager = servicesManager + self.carbStore = carbStore + self.doseStore = doseStore + self.criticalEventLogExportManager = criticalEventLogExportManager + self.bluetoothStateManager = bluetoothStateManager + self.settingsViewModel = settingsViewModel + } +} diff --git a/Loop/View Models/VersionUpdateViewModel.swift b/Loop/View Models/VersionUpdateViewModel.swift index fa2b87e6c5..4745842060 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? @@ -33,7 +34,9 @@ public class VersionUpdateViewModel: ObservableObject { func footer(appName: String) -> String { switch versionUpdate { - case .required, .recommended: + case .required: + return NSLocalizedString("A critical update is available. Your app may not function correctly until you update to the latest version.", comment: "Software update description for required update") + case .recommended: return String(format: NSLocalizedString("A new version of %@ is available and is recommended to continue using the app.", comment: "Software update available section footer (1: app name)"), appName) case .available: return String(format: NSLocalizedString("A new version of %@ is available.", comment: "Required software update section footer (1: app name)"), appName) diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index 94e542a6ab..a500c26b7f 100644 --- a/Loop/Views/AlertManagementView.swift +++ b/Loop/Views/AlertManagementView.swift @@ -19,8 +19,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() @@ -33,7 +43,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 @@ -43,10 +53,6 @@ struct AlertManagementView: View { } ) } - - private var formatterDurations: [String] { - AlertMuter.allowedDurations.compactMap { formatter.string(from: $0) } - } private var missedMealNotificationsEnabled: Binding { Binding( @@ -71,177 +77,109 @@ 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") } } } - - NavigationLink(destination: LiveActivityManagementView()) - { - Text(NSLocalizedString("Live activity", comment: "Alert Permissions live activity")) - } } } @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(isPresented: $showHowMuteAlertWork) { - HowMuteAlertWorkView() + .sheet(item: $sheet) { sheet in + switch sheet { + case .durationSelection: + DurationSheet( + allowedDurations: AlertMuter.allowedDurations, + duration: $durationSelection, + durationWasSelected: $durationWasSelection + ) + case .confirmation(let resumeDate): + ConfirmationSheet(resumeDate: resumeDate) + } + } + .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.critical) + + 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 { @@ -249,6 +187,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 fecd2365c4..91042043a3 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -7,7 +7,7 @@ // import Combine -import HealthKit +import LoopAlgorithm import SwiftUI import LoopKit import LoopKitUI @@ -23,6 +23,7 @@ struct BolusEntryView: View { @ObservedObject var viewModel: BolusEntryViewModel @State private var enteredBolusString = "" + @State private var isInteractingWithChart = false @State private var editedBolusAmount = false @@ -33,42 +34,47 @@ struct BolusEntryView: View { } var body: some View { - GeometryReader { geometry in - VStack(spacing: 0) { - List { - self.chartSection - self.summarySection - } - .insetGroupedListStyle() - - } - .navigationBarTitle(self.title) - .supportedInterfaceOrientations(.portrait) - .alert(item: self.$viewModel.activeAlert, content: self.alert(for:)) - .onReceive(self.viewModel.$recommendedBolus) { recommendation in - // If the recommendation changes, and the user has not edited the bolus amount, update the bolus amount - let amount = recommendation?.doubleValue(for: .internationalUnit()) ?? 0 - if !editedBolusAmount { - var newEnteredBolusString: String - if amount == 0 { - newEnteredBolusString = "" - } else { - newEnteredBolusString = viewModel.formatBolusAmount(amount) - } - enteredBolusStringBinding.wrappedValue = newEnteredBolusString - } + VStack(spacing: 0) { + List { + self.chartSection + self.summarySection } - .safeAreaInset(edge: .bottom, spacing: 0) { - if bolusFieldFocused { - // Reserve space so the toolbar doesn’t overlap the field - Color.clear.frame(height: accessoryClearance) + .padding(.top, -28) + .insetGroupedListStyle() + } + .navigationBarTitle(self.title) + .supportedInterfaceOrientations(.portrait) + .alert(item: self.$viewModel.activeAlert, content: self.alert(for:)) + .onReceive(self.viewModel.$recommendedBolus) { recommendation in + // If the recommendation changes, and the user has not edited the bolus amount, update the bolus amount + let amount = recommendation?.doubleValue(for: .internationalUnit) ?? 0 + if !editedBolusAmount { + var newEnteredBolusString: String + if amount == 0 { + newEnteredBolusString = "" } else { - actionArea + newEnteredBolusString = viewModel.formatBolusAmount(amount) } + enteredBolusStringBinding.wrappedValue = newEnteredBolusString + } else { + // If the recommendation changes, and the user has edited the bolus amount, set the bolus amount to 0 + enteredBolusStringBinding.wrappedValue = "0" + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + if bolusFieldFocused { + // Reserve space so the toolbar doesn't overlap the field + Color.clear.frame(height: accessoryClearance) + } else { + actionArea } } + .edgesIgnoringSafeArea(self.bolusFieldFocused ? [] : .bottom) + .task { + await self.viewModel.generateRecommendationAndStartObserving() + } } - + private var title: Text { if viewModel.potentialCarbEntry == nil { return Text("Bolus", comment: "Title for bolus entry screen") @@ -80,7 +86,7 @@ struct BolusEntryView: View { Section { VStack(spacing: 8) { HStack(spacing: 0) { - activeCarbsLabel + activeCarbsLabel.accessibilityIdentifier("text_ActiveCarbs") Spacer(minLength: 8) activeInsulinLabel } @@ -122,6 +128,14 @@ struct BolusEntryView: View { } .padding(.top, 12) .padding(.bottom, 8) + } header: { + if let scheduleOverride = viewModel.scheduleOverride ?? viewModel.preMealOverride { + ActivePresetBanner(override: scheduleOverride) + .listRowInsets(EdgeInsets(top: 30, leading: 0, bottom: 12, trailing: 0)) + .padding(.horizontal, -20) + .padding(.bottom, 8) + .textCase(nil) + } } } @@ -130,16 +144,16 @@ struct BolusEntryView: View { LabeledQuantity( label: Text("Active Carbs", comment: "Title describing quantity of still-absorbing carbohydrates"), quantity: viewModel.activeCarbs, - unit: .gram() + unit: .gram ) } - + @ViewBuilder private var activeInsulinLabel: some View { LabeledQuantity( label: Text("Active Insulin", comment: "Title describing quantity of still-absorbing insulin"), quantity: viewModel.activeInsulin, - unit: .internationalUnit(), + unit: .internationalUnit, maxFractionDigits: 2 ) } @@ -158,6 +172,8 @@ struct BolusEntryView: View { ) } + @State private var expandedPresetSummary: Bool = false + private var summarySection: some View { Section { VStack(spacing: 16) { @@ -165,6 +181,31 @@ struct BolusEntryView: View { .bold() .frame(maxWidth: .infinity, alignment: .leading) + if (viewModel.scheduleOverride ?? viewModel.preMealOverride) != nil, let presetEffectedRecommendation = viewModel.presetEffectedRecommendation, presetEffectedRecommendation.showPredictionDifference { + HStack(alignment: .top, spacing: 12) { + Text(Image(systemName: "info.circle")) + .foregroundStyle(Color.accentColor) + + VStack(alignment: .leading, spacing: 8) { + Text("Recommended bolus adjusted due to preset") + .frame(maxWidth: .infinity, alignment: .leading) + + if expandedPresetSummary, let differenceString = presetEffectedRecommendation.differenceString, let originalAmountString = presetEffectedRecommendation.originalAmountString { + Text("This reflects a \(differenceString) \(presetEffectedRecommendation.direction) from the original \(originalAmountString) due to preset adjustments.") + .foregroundStyle(.secondary) + } + } + .font(.subheadline) + + Text(Image(systemName: "chevron.up")) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(expandedPresetSummary ? 180 : 0)) + } + .onTapGesture { + expandedPresetSummary.toggle() + } + } + if viewModel.isManualGlucoseEntryEnabled { ManualGlucoseEntryRow(quantity: $viewModel.manualGlucoseQuantity) } else if viewModel.potentialCarbEntry != nil { @@ -174,7 +215,7 @@ struct BolusEntryView: View { } } .padding(.top, 8) - + if viewModel.isManualGlucoseEntryEnabled && viewModel.potentialCarbEntry != nil { potentialCarbEntryRow } @@ -186,7 +227,7 @@ struct BolusEntryView: View { bolusEntryRow } } - + private var titleText: Text { return Text("Bolus Summary", comment: "Title for card displaying carb entry and bolus recommendation") } @@ -222,6 +263,7 @@ struct BolusEntryView: View { Text(viewModel.recommendedBolusString) .font(.title) .foregroundColor(Color(.label)) + .accessibilityIdentifier("staticText_RecommendedBolus") bolusUnitsLabel } } @@ -248,12 +290,12 @@ struct BolusEntryView: View { .multilineTextAlignment(.trailing) .foregroundColor(.loopAccent) .focused($bolusFieldFocused) - .onChange(of: bolusFieldFocused) { focused in + .onChange(of: bolusFieldFocused) { oldValue, focused in if focused { didBeginEditing() } } - .onChange(of: enteredBolusString) { newValue in + .onChange(of: enteredBolusString) { oldValue, newValue in if newValue.count > 5 { enteredBolusString = String(newValue.prefix(5)) viewModel.updateEnteredBolus(enteredBolusString) @@ -267,12 +309,12 @@ struct BolusEntryView: View { } bolusUnitsLabel } + .accessibilityIdentifier("textField_Bolus") } - .accessibilityElement(children: .combine) } private var bolusUnitsLabel: some View { - Text(QuantityFormatter(for: .internationalUnit()).localizedUnitStringWithPlurality()) + Text(QuantityFormatter(for: .internationalUnit).localizedUnitStringWithPlurality()) .foregroundColor(Color(.secondaryLabel)) } @@ -298,7 +340,6 @@ struct BolusEntryView: View { enterManualGlucoseButton .transition(AnyTransition.opacity.combined(with: .move(edge: .bottom))) } - actionButton } .padding(.bottom) // FIXME: unnecessary on iPhone 8 size devices @@ -336,7 +377,7 @@ struct BolusEntryView: View { ) } } - + private var enterManualGlucoseButton: some View { Button( action: { @@ -348,6 +389,7 @@ struct BolusEntryView: View { ) .buttonStyle(ActionButtonStyle(viewModel.primaryButton == .manualGlucoseEntry ? .primary : .secondary)) .padding([.top, .horizontal]) + .accessibilityIdentifier("button_EnterFingerstickGlucose") } private var actionButton: some View { @@ -379,6 +421,7 @@ struct BolusEntryView: View { .buttonStyle(ActionButtonStyle(viewModel.primaryButton == .actionButton ? .primary : .secondary)) .disabled(viewModel.enacting) .padding() + .accessibilityIdentifier("button_bolusAction") } private func alert(for alert: BolusEntryViewModel.Alert) -> SwiftUI.Alert { @@ -428,11 +471,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"), @@ -444,8 +482,8 @@ struct BolusEntryView: View { struct LabeledQuantity: View { var label: Text - var quantity: HKQuantity? - var unit: HKUnit + var quantity: LoopQuantity? + var unit: LoopUnit var maxFractionDigits: Int? var body: some View { @@ -463,9 +501,9 @@ struct LabeledQuantity: View { var valueText: Text { guard let quantity = quantity else { - return Text(verbatim: "– –") + return Text(verbatim: "- -") } - + let formatter = QuantityFormatter(for: unit) if let maxFractionDigits = maxFractionDigits { @@ -480,15 +518,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..61f3e25340 100644 --- a/Loop/Views/BolusProgressTableViewCell.swift +++ b/Loop/Views/BolusProgressTableViewCell.swift @@ -9,11 +9,22 @@ import Foundation import LoopKit import LoopUI -import HealthKit +import LoopAlgorithm 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,28 +39,14 @@ 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()) + let formatter = QuantityFormatter(for: .internationalUnit) formatter.numberFormatter.minimumFractionDigits = 2 return formatter }() @@ -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,75 @@ 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") + progressLabel.accessibilityIdentifier = "text_BolusStarting" + case let .bolusing(delivered, totalVolume): + progressIndicator.isHidden = false + activityIndicator.isHidden = true + tapToStopLabel.isHidden = false + tapToStopLabel.accessibilityIdentifier = "text_TapToStop" + + let totalUnitsQuantity = LoopQuantity(unit: .internationalUnit, doubleValue: totalVolume) + let totalUnitsString = insulinFormatter.string(from: totalUnitsQuantity) ?? "" + + if let delivered { + let deliveredUnitsQuantity = LoopQuantity(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) + progressLabel.accessibilityIdentifier = "text_BolusingProgress" + + 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) + progressLabel.accessibilityIdentifier = "text_BolusingProgress" } - } 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") + progressLabel.accessibilityIdentifier = "text_BolusCanceling" + case let .canceled(delivered, totalVolume): + progressIndicator.isHidden = true + activityIndicator.isHidden = true + tapToStopLabel.isHidden = true + + let totalUnitsQuantity = LoopQuantity(unit: .internationalUnit, doubleValue: totalVolume) + let totalUnitsString = insulinFormatter.string(from: totalUnitsQuantity) ?? "" + + let deliveredUnitsQuantity = LoopQuantity(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) + progressLabel.accessibilityIdentifier = "text_BolusCanceled" } } 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/CarbEntryTableViewCell.swift b/Loop/Views/CarbEntryTableViewCell.swift index 1d332808c9..b6f26dfff2 100644 --- a/Loop/Views/CarbEntryTableViewCell.swift +++ b/Loop/Views/CarbEntryTableViewCell.swift @@ -91,13 +91,6 @@ class CarbEntryTableViewCell: UITableViewCell { } } - override func layoutSubviews() { - super.layoutSubviews() - - contentView.layoutMargins.left = separatorInset.left - contentView.layoutMargins.right = separatorInset.left - } - override func awakeFromNib() { super.awakeFromNib() diff --git a/Loop/Views/CarbEntryView.swift b/Loop/Views/CarbEntryView.swift index 5831836fd6..4492102c9d 100644 --- a/Loop/Views/CarbEntryView.swift +++ b/Loop/Views/CarbEntryView.swift @@ -9,11 +9,11 @@ import SwiftUI import LoopKit import LoopKitUI -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 +21,7 @@ struct CarbEntryView: View, HorizontalSizeClassOverride { @State private var showHowAbsorptionTimeWorks = false @State private var showAddFavoriteFood = false + @State private var showFavoriteFoodInsights = false private let isNewEntry: Bool @@ -45,16 +46,18 @@ struct CarbEntryView: View, HorizontalSizeClassOverride { ToolbarItem(placement: .navigationBarTrailing) { continueButton + .accessibilityIdentifier("button_Continue") } } - } + .navigationViewStyle(.stack) } else { content .toolbar { ToolbarItem(placement: .navigationBarTrailing) { continueButton + .accessibilityIdentifier("button_Continue") } } } @@ -66,6 +69,11 @@ struct CarbEntryView: View, HorizontalSizeClassOverride { .edgesIgnoringSafeArea(.all) ScrollView { + if let currentOverride = viewModel.currentOverride { + ActivePresetBanner(override: currentOverride) + .padding(.bottom, 8) + } + warningsCard mainCard @@ -77,6 +85,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 +106,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 +124,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 +141,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 +160,7 @@ struct CarbEntryView: View, HorizontalSizeClassOverride { BolusEntryView(viewModel: viewModel) .environmentObject(displayGlucosePreference) .environment(\.dismissAction, dismiss) + .environment(\.guidanceColors, guidanceColors) } } @@ -165,8 +197,8 @@ extension CarbEntryView { switch warning { case .entryIsMissedMeal: return .critical - case .overrideInProgress: - return .warning + case .glucoseRisingRapidly: + return .critical } } @@ -174,8 +206,8 @@ extension CarbEntryView { switch warning { case .entryIsMissedMeal: 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 +281,32 @@ extension CarbEntryView { } } - CardSectionDivider() + if viewModel.selectedFavoriteFood == nil { + CardSectionDivider() + } } - Button(action: saveAsFavoriteFood) { - Text("Save as favorite food", comment: "Button label for saving current carb entry as a new 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..9a98113535 --- /dev/null +++ b/Loop/Views/Charts/CarbEffectChartView.swift @@ -0,0 +1,31 @@ +// +// CarbEffectChartView.swift +// Loop +// +// Created by Noah Brauner on 7/25/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI +import LoopAlgorithm + +struct CarbEffectChartView: View { + let chartManager: ChartsManager + var glucoseUnit: LoopUnit + 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..9161436813 --- /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..70f1f5c4ad --- /dev/null +++ b/Loop/Views/Charts/GlucoseCarbChartView.swift @@ -0,0 +1,32 @@ +// +// GlucoseCarbChartView.swift +// Loop +// +// Created by Noah Brauner on 7/29/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI +import LoopAlgorithm + +struct GlucoseCarbChartView: View { + let chartManager: ChartsManager + var glucoseUnit: LoopUnit + 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..e223a2c3cc --- /dev/null +++ b/Loop/Views/Charts/PredictedGlucoseChartView.swift @@ -0,0 +1,36 @@ +// +// PredictedGlucoseChartView.swift +// Loop +// +// Created by Michael Pangburn on 7/22/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI +import LoopAlgorithm + +struct PredictedGlucoseChartView: View { + let chartManager: ChartsManager + var glucoseUnit: LoopUnit + 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 0e2d9ebaa4..73f8176c2a 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..160adb3514 --- /dev/null +++ b/Loop/Views/Favorite Foods/FavoriteFoodDetailView.swift @@ -0,0 +1,104 @@ +// +// FavoriteFoodDetailView.swift +// Loop +// +// Created by Noah Brauner on 8/2/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI + +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..8a67b7c519 --- /dev/null +++ b/Loop/Views/Favorite Foods/FavoriteFoodInsightsChartsView.swift @@ -0,0 +1,138 @@ +// +// 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 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 d3042208d8..17631d2b54 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 @@ -46,13 +50,13 @@ 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(String(localized: "Favorite Foods", comment: "Title for Favorite Foods view"), 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 8c76e1fe8f..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)] = [ - (String(localized: "Name", comment: "Label for name row on add favorite food screen"), food.name), - (String(localized: "Carb Quantity", comment: "Label for carb quantity row on add favorite food screen"), food.carbsString(formatter: carbFormatter)), - (String(localized:"Food Type", comment: "Label for food type entry on add favorite food screen"), food.foodType), - (String(localized: "Absorption Time", comment: "Label for food absorption entry on add favorite food screen"), 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/HealthAccessView.swift b/Loop/Views/HealthAccessView.swift new file mode 100644 index 0000000000..b0f1103e83 --- /dev/null +++ b/Loop/Views/HealthAccessView.swift @@ -0,0 +1,108 @@ +// +// HealthAccessView.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopKit +import LoopKitUI +import SwiftUI + +/// Shows the app's Apple Health access for glucose and insulin. +/// +/// HealthKit only exposes *sharing* (write) authorization, so this screen can show +/// write status definitively. Read authorization is intentionally hidden by the system +/// for privacy and cannot be displayed — the screen explains that and points the user at +/// the Settings / Health apps, which are the only place permissions can be changed (the +/// app cannot re-prompt once the user has responded). +struct HealthAccessView: View { + + @Environment(\.appName) private var appName + + /// Closures so status is re-read each time the screen appears (e.g. after the user + /// returns from the Settings app having changed a permission). + let glucoseSharingStatus: () -> HKAuthorizationStatus + let insulinSharingStatus: () -> HKAuthorizationStatus + + @State private var glucoseStatus: HKAuthorizationStatus = .notDetermined + @State private var insulinStatus: HKAuthorizationStatus = .notDetermined + + var body: some View { + List { + Section { + statusRow(label: NSLocalizedString("Glucose", comment: "Health access row label for glucose"), status: glucoseStatus) + statusRow(label: NSLocalizedString("Insulin", comment: "Health access row label for insulin"), status: insulinStatus) + } header: { + Text("Sharing to Apple Health", comment: "Health access section header for write access") + } footer: { + Text(String(format: NSLocalizedString("Whether %1$@ is allowed to save glucose and insulin data to Apple Health.", comment: "Health access write section footer (1: app name)"), appName)) + } + + Section { + Text(String(format: NSLocalizedString("%1$@ also reads insulin data from other apps in Apple Health. Apple Health does not report whether an app may read data, so read access can't be shown here.", comment: "Health access reading explanation (1: app name)"), appName)) + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(String(format: NSLocalizedString("Once you have allowed or denied a data type, %1$@ cannot ask again from within the app. To review or change access, open the Health app, tap your profile, then Apps → %1$@.", comment: "Health access change instructions (1: app name)"), appName)) + .font(.subheadline) + .foregroundStyle(.secondary) + } header: { + Text("Reading & Changing Access", comment: "Health access section header for reading and changing access") + } + } + .listStyle(.insetGrouped) + .navigationTitle(Text("Apple Health", comment: "Title of the Apple Health access screen")) + .onAppear(perform: refresh) + } + + private func refresh() { + glucoseStatus = glucoseSharingStatus() + insulinStatus = insulinSharingStatus() + } + + private func statusRow(label: String, status: HKAuthorizationStatus) -> some View { + HStack { + Text(label) + Spacer() + Image(systemName: status.iconSystemName) + .foregroundStyle(status.tintColor) + Text(status.localizedDescription) + .foregroundStyle(.secondary) + } + } +} + +private extension HKAuthorizationStatus { + var localizedDescription: String { + switch self { + case .sharingAuthorized: + return NSLocalizedString("Allowed", comment: "Health sharing status: authorized") + case .sharingDenied: + return NSLocalizedString("Denied", comment: "Health sharing status: denied") + case .notDetermined: + return NSLocalizedString("Not Set", comment: "Health sharing status: not determined") + @unknown default: + return NSLocalizedString("Unknown", comment: "Health sharing status: unknown") + } + } + + var iconSystemName: String { + switch self { + case .sharingAuthorized: return "checkmark.circle.fill" + case .sharingDenied: return "xmark.circle.fill" + case .notDetermined: return "circle" + @unknown default: return "questionmark.circle" + } + } + + var tintColor: Color { + switch self { + case .sharingAuthorized: return .green + case .sharingDenied: return .red + case .notDetermined: return .secondary + @unknown default: return .secondary + } + } +} 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/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift new file mode 100644 index 0000000000..841e0f55b7 --- /dev/null +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift @@ -0,0 +1,236 @@ +// +// InsulinDeliveryEventDetailsView.swift +// Loop +// +// Created by Cameron Ingham on 7/7/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit +import SwiftUI + +struct InsulinDeliveryEventDetailsView: View { + + @Environment(\.dismiss) private var dismiss + + let basalUnitsFormatter = QuantityFormatter(for: .internationalUnitsPerHour) + let bolusUnitsFormatter = QuantityFormatter(for: .internationalUnit) + let durationFormatter = DateComponentsFormatter() + + let pumpEventType: InsulinDeliveryLogEvent.EventType.PumpEventType + let doseEntry: DoseEntry + let onTapGesture: (DoseEntry) -> Void + let onDelete: ((DoseEntry) async -> Void)? + + @State private var showingDeleteConfirmation = false + + var doseTypeValue: String { + if case .bolus(.external, _, _) = pumpEventType { + return NSLocalizedString("External Insulin", comment: "Dose type label for a manually-entered bolus") + } + switch pumpEventType { + case .basal(let basalEventType, _): + switch basalEventType { + case .automatedPresetBasal: + return NSLocalizedString("Temp Basal", comment: "") + case .automationOff: + return NSLocalizedString("Scheduled Basal", comment: "") + case .automationOn(basalStatus: let basalStatus): + switch basalStatus { + case .lessThanScheduled: + return NSLocalizedString("Temp Basal", comment: "") + case .moreThanScheduled: + return NSLocalizedString("Temp Basal", comment: "") + case .scheduled: + return NSLocalizedString("Scheduled Basal", comment: "") + } + case .manualTempBasal: + return NSLocalizedString("Temp Basal", comment: "") + } + case .bolus: + return NSLocalizedString("Bolus", comment: "") + case .insulin(let insulinEventType): + switch insulinEventType { + case .resumed: + return NSLocalizedString("Insulin Resumed", comment: "") + case .suspended: + return NSLocalizedString("Insulin Suspended", comment: "") + } + } + } + + var startTimeValue: String? { + doseEntry.startDate.formatted(date: .omitted, time: .standard) + } + + var endTimeValue: String? { + guard doseEntry.startDate != doseEntry.endDate, !doseEntry.isMutable else { return nil } + return doseEntry.endDate.formatted(date: .omitted, time: .standard) + } + + var durationValue: String? { + durationFormatter.unitsStyle = .abbreviated + + return durationFormatter.string(from: doseEntry.duration) + } + + var deliveredUnitsValue: String? { + switch pumpEventType { + case .basal(_, let rate): + return basalUnitsFormatter.string(from: rate) + case .bolus(_, _, let deliveryAmount): + return bolusUnitsFormatter.string(from: deliveryAmount) + case .insulin: + return basalUnitsFormatter.string(from: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: 0)) + } + } + + private var isExternalDose: Bool { + doseEntry.manuallyEntered + } + + private var isDeletableDoseType: Bool { + switch pumpEventType { + case .bolus, .basal: + return true + case .insulin: + return false + } + } + + // External (manually-entered) doses can always be deleted; deleting any other + // (Loop-recorded) dose is gated behind the doseDeletion feature flag. In-progress + // (mutable) doses can never be deleted — you can't delete a dose still being delivered. + private var showDeleteButton: Bool { + onDelete != nil && isDeletableDoseType && !doseEntry.isMutable && (isExternalDose || FeatureFlags.doseDeletionEnabled) + } + + /// True while the dose still contributes active insulin (within the ~6h insulin activity window). + private var doseHasActiveInsulin: Bool { + doseEntry.endDate > Date().addingTimeInterval(-.hours(6)) + } + + private var deleteButtonTitle: String { + isExternalDose + ? NSLocalizedString("Delete External Insulin", comment: "Button to delete a manually-entered insulin dose") + : NSLocalizedString("Delete Dose", comment: "Button to delete a dose") + } + + private var deleteConfirmationTitle: String { + isExternalDose + ? NSLocalizedString("Delete this manually-entered insulin entry?", comment: "Confirmation title for deleting a manually-entered insulin dose") + : NSLocalizedString("Delete this dose?", comment: "Confirmation title for deleting a dose") + } + + /// Extra warning shown when deleting a Loop-recorded dose that still has active insulin. + private var deleteConfirmationMessage: String? { + guard !isExternalDose, doseHasActiveInsulin else { return nil } + return NSLocalizedString("This dose still has active insulin. Deleting it may cause Loop to make up for the reduced active insulin by dosing more.", comment: "Warning when deleting a Loop-recorded dose that still has active insulin") + } + + var body: some View { + List { + Section { + VStack(alignment: .leading) { + Text("Dose Type") + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(doseTypeValue) + } + + if let startTimeValue { + VStack(alignment: .leading) { + Text("Start Time") + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(startTimeValue) + } + } + + if let endTimeValue { + VStack(alignment: .leading) { + Text("End Time") + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(endTimeValue) + } + } + + VStack(alignment: .leading) { + Text("Mutable") + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(doseEntry.isMutable ? "Yes" : "No") + } + + switch pumpEventType { + case .basal, .bolus: + if let durationValue { + VStack(alignment: .leading) { + Text("Duration") + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(durationValue) + } + } + + if let deliveredUnitsValue { + VStack(alignment: .leading) { + Text("Insulin Delivery") + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(deliveredUnitsValue) + } + } + case .insulin: + EmptyView() + } + } header: { + Text("Delivery Details") + } + .contentShape(Rectangle()) + .onTapGesture { + onTapGesture(doseEntry) + } + + if showDeleteButton, let onDelete { + Section { + Button(role: .destructive) { + showingDeleteConfirmation = true + } label: { + HStack { + Spacer() + Text(deleteButtonTitle) + Spacer() + } + } + } + .confirmationDialog( + Text(deleteConfirmationTitle), + isPresented: $showingDeleteConfirmation, + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + Task { + await onDelete(doseEntry) + dismiss() + } + } + Button("Cancel", role: .cancel) { } + } message: { + if let deleteConfirmationMessage { + Text(deleteConfirmationMessage) + } + } + } + } + .navigationTitle(Text("Insulin Event")) + } +} diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLog.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLog.swift new file mode 100644 index 0000000000..d8b1fd04e7 --- /dev/null +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLog.swift @@ -0,0 +1,175 @@ +// +// InsulinDeliveryLog.swift +// Loop +// +// Created by Cameron Ingham on 3/25/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit +import LoopKitUI +import SwiftUI + +struct InsulinDeliveryLog: View { + + @State private var viewModel: InsulinDeliveryLogViewModel + @State var showingFilterMenu = false + + let onTapGesture: (DoseEntry) -> Void + let onEnterManualDose: (() -> Void)? + + init(viewModel: InsulinDeliveryLogViewModel, onTapGesture: @escaping (DoseEntry) -> Void, onEnterManualDose: (() -> Void)? = nil) { + self.viewModel = viewModel + self.onTapGesture = onTapGesture + self.onEnterManualDose = onEnterManualDose + } + + private func totalInsulinDeliveredLabel(from total: LoopQuantity) -> some View { + LabeledContent { + Text(viewModel.totalDeliveredFormatter.string(from: total) ?? "Unknown") + .foregroundStyle(.secondary) + } label: { + VStack(alignment: .leading, spacing: 0) { + Text("Total Insulin Delivery") + + Text("since \(Calendar.current.startOfDay(for: Date()).formatted(date: .omitted, time: .shortened))") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + + private var filterMenu: some View { + Menu("Filter") { + Button { } label: { + Text("Filter") + Text("Event") + } + + Picker("Filter", selection: $viewModel.selectedFilterOption) { + ForEach(InsulinDeliveryLogViewModel.FilterOptions.allCases, id: \.self) { option in + Text(option.localizedMenuTitle) + .tag(option) + } + } + } + } + + private var deliveryLogHeader: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 0) { + Text("Insulin Delivery Log") + .font(.headline.weight(.semibold)) + .foregroundStyle(Color(UIColor.label)) + + Spacer() + + filterMenu + } + + if viewModel.selectedFilterOption != .all { + HStack(spacing: 8) { + Text("Filtered by:") + .foregroundStyle(Color(UIColor.systemGray)) + + HStack(spacing: 4) { + Text(viewModel.selectedFilterOption.localizedMenuTitle) + + Button { + viewModel.selectedFilterOption = .all + } label: { + Image(systemName: "xmark.circle.fill") + } + } + .padding(4) + .padding(.leading, 4) + .background(Color.accentColor.clipShape(Capsule())) + .foregroundStyle(Color(UIColor.systemBackground)) + } + .font(.subheadline) + } + } + .textCase(nil) + .padding(.bottom, 4) + } + + private var deliveryLog: some View { + ForEach(viewModel.logEventDisplays) { displayEvent in + switch displayEvent { + case .title(_, let title): + Text(title) + .padding(.vertical) + .frame(maxWidth: .infinity) + .background(Color(UIColor.systemGray5)) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets()) + case .event(let event): + ZStack { + InsulinDeliveryLogEventRow(event: event) + + if case let .pumpEvent(pumpEventType, doseEntry) = event.type, let doseEntry { + NavigationLink { + InsulinDeliveryEventDetailsView( + pumpEventType: pumpEventType, + doseEntry: doseEntry, + onTapGesture: onTapGesture, + onDelete: { entry in + await viewModel.deleteDose(entry) + } + ) + } label: { + EmptyView() + } + .opacity(0) + } + } + } + } + .alignmentGuide(.listRowSeparatorLeading) { _ in + return 0 + } + } + + var body: some View { + List { + switch viewModel.state { + case .loading: + ActivityIndicator(isAnimating: .constant(true), style: .default) + .frame(maxWidth: .infinity) + case .fetched(let data), .refreshing(let data): + Section { + InsulinDeliveryOverview( + state: data.insulinDeliveryState, + time: data.insulinDeliveryStateUpdatedDate, + currentBasalRate: data.currentBasalRate, + lastAutoBolus: data.lastAutoBolus + ) + } + + Section { + totalInsulinDeliveredLabel(from: data.totalInsulinDelivered) + } + } + + Section { + deliveryLog + } header: { + deliveryLogHeader + } + } + .refreshable { + await viewModel.fetchData() + } + .toolbar { + if FeatureFlags.manualDoseEntryEnabled, let onEnterManualDose { + ToolbarItem(placement: .topBarTrailing) { + Button(action: onEnterManualDose) { + Image(systemName: "plus") + } + .accessibilityLabel(Text("Log Dose", comment: "Accessibility label for the manual dose entry button on the insulin delivery screen")) + } + } + } + } +} diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogEventRow.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogEventRow.swift new file mode 100644 index 0000000000..17d02c99ed --- /dev/null +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogEventRow.swift @@ -0,0 +1,446 @@ +// +// InsulinDeliveryLogEventRow.swift +// Loop +// +// Created by Cameron Ingham on 3/26/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit +import LoopKitUI +import SwiftUI + +struct InsulinDeliveryLogEventRow: View { + + @Environment(\.colorPalette) private var colorPalette + + @ScaledMetric private var dateFontSize: Double = 14 + + private let rateFormatter = QuantityFormatter(for: .internationalUnitsPerHour) + private let bolusFormatter = QuantityFormatter(for: .internationalUnit) + private let carbFormatter = QuantityFormatter(for: .gram) + + private let event: InsulinDeliveryLogEvent + + init(event: InsulinDeliveryLogEvent) { + self.event = event + } + + @ViewBuilder + var icon: some View { + switch event.type { + case .pumpEvent(.basal, _): + Image("basal-delivery-log") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + case .pumpEvent(.bolus(let bolusEventType, _, _), _): + Group { + switch bolusEventType { + case .automated: + Image("autobolus-delivery-log") + .resizable() + .scaledToFit() + default: + Image("bolus-delivery-log") + .resizable() + .scaledToFit() + } + } + .frame(width: 24, height: 24) + case .pumpEvent(.insulin(let insulinEventType), _): + Group { + switch insulinEventType { + case .suspended: + Image(systemName: "pause.circle.fill") + .resizable() + .foregroundStyle(colorPalette.guidanceColors.warning) + case .resumed: + Image(systemName: "play.circle") + .resizable() + .foregroundStyle(Color.accentColor) + } + } + .frame(width: 24, height: 24) + case .automation(let automationEventType): + Group { + switch automationEventType { + case .on: + Image("automation-on-delivery-log") + .resizable() + .scaledToFit() + case .off(let endDate): + if endDate == nil { + Image("automation-off-delivery-log") + .resizable() + .scaledToFit() + } else { + Image("automation-off-range-delivery-log") + .resizable() + .scaledToFit() + } + case .unavailable: + Image("automation-unavailable-delivery-log") + .resizable() + .scaledToFit() + } + } + .frame(width: 24, height: 24) + case .preset: + Image("presets") + .resizable() + .renderingMode(.template) + .foregroundStyle(Color.presets) + .frame(width: 24, height: 24) + } + } + + func bolusTitle(deliveryAmount: LoopQuantity, programmedAmount: LoopQuantity) -> some View { + if deliveryAmount != programmedAmount { + Text("Bolus: ") + Text(bolusFormatter.string(from: deliveryAmount, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(deliveryAmount.unit.localizedUnitString(in: .short) ?? "U") + Text(" of ") + Text(bolusFormatter.string(from: programmedAmount, includeUnit: false) ?? "Unknown") + Text(" ") + Text(programmedAmount.unit.localizedUnitString(in: .short) ?? "U") + } else { + Text("Bolus: ") + Text(bolusFormatter.string(from: deliveryAmount, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(deliveryAmount.unit.localizedUnitString(in: .short) ?? "U") + } + } + + + @ViewBuilder + var title: some View { + switch event.type { + case .pumpEvent(let pumpEventType, _): + switch pumpEventType { + case .basal(let basalEventType, let rate): + switch basalEventType { + case .automationOn(let basalStatus): + switch basalStatus { + case .scheduled: + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + Text("Basal: ") + Text(rateFormatter.string(from: rate, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(rate.unit.localizedUnitString(in: .short) ?? "U/hr") + + Text("Automated (Scheduled)") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + case .moreThanScheduled: + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + Text("Basal: ") + Text(rateFormatter.string(from: rate, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(rate.unit.localizedUnitString(in: .short) ?? "U/hr") + + Text("Automated (↑ Increase)") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + case .lessThanScheduled: + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + Text("Basal: ") + Text(rateFormatter.string(from: rate, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(rate.unit.localizedUnitString(in: .short) ?? "U/hr") + + Text("Automated (↓ Decrease)") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + } + case .automationOff: + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + Text("Basal: ") + Text(rateFormatter.string(from: rate, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(rate.unit.localizedUnitString(in: .short) ?? "U/hr") + + Text("Scheduled") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + case .automatedPresetBasal: + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + Text("Basal: ") + Text(rateFormatter.string(from: rate, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(rate.unit.localizedUnitString(in: .short) ?? "U/hr") + + Text("Automated (Preset Basal Rate)") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + case .manualTempBasal(let endDate): + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + Text("Temp Basal: ") + Text(rateFormatter.string(from: rate, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(rate.unit.localizedUnitString(in: .short) ?? "U") + + Text("Manual") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 0) { + Text(event.date.formatted(date: .omitted, time: .shortened)) + Text(" -") + Text(endDate.formatted(date: .omitted, time: .shortened)) + } + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + } + case .bolus(let bolusEventType, let programmedAmount, let deliveryAmount): + let programmedAmount = programmedAmount ?? deliveryAmount + + switch bolusEventType { + case .automated: + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + bolusTitle(deliveryAmount: deliveryAmount, programmedAmount: programmedAmount) + Text("Automated") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + case .meal(let recommendedAmount as LoopQuantity?, _, _), .correction(let recommendedAmount): + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + bolusTitle(deliveryAmount: deliveryAmount, programmedAmount: programmedAmount) + + if let recommendedAmount { + Group { + Text("Recommended: ") + Text(bolusFormatter.string(from: recommendedAmount, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(recommendedAmount.unit.localizedUnitString(in: .short) ?? "U") + } + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + case .external: + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + bolusTitle(deliveryAmount: deliveryAmount, programmedAmount: programmedAmount) + + Text("External Insulin") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + } + case .insulin(let insulinEventType): + switch insulinEventType { + case .suspended: + HStack(spacing: 0) { + Text("Insulin Suspended") + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + case .resumed: + HStack(spacing: 0) { + Text("Insulin Resumed") + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + } + } + case .automation(let automationEventType): + switch automationEventType { + case .on: + HStack(spacing: 0) { + Text("Automation ON") + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + case .off(let endDate): + HStack(spacing: 0) { + Text("Automation OFF") + + Spacer() + + VStack(alignment: .trailing, spacing: 0) { + Text(event.date.formatted(date: .omitted, time: .shortened)) + Text(endDate != nil ? " -" : "") + + if let endDate { + Text(endDate.formatted(date: .omitted, time: .shortened)) + } + } + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + case .unavailable: + HStack(spacing: 0) { + Text("Automation unavailable") + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + } + case .preset(let presetEventType, _, _): + switch presetEventType { + case .enabled: + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + Text("Preset Enabled") + + Text("Automation") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + case .disabled: + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + Text("Preset Disabled") + + Text("Automation") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + } + } + } + + @ViewBuilder + var content: some View { + switch event.type { + case .pumpEvent(.basal, _): + EmptyView() + case .pumpEvent(.bolus(let bolusEventType, _, _), _): + switch bolusEventType { + case .automated, .correction, .external: + EmptyView() + case .meal(_, let carbAmount, let emoji): + VStack(alignment: .leading, spacing: 8) { + Divider() + .padding(.trailing, -20) + + VStack(alignment: .leading, spacing: 2) { + Text("Meal Summary") + .font(.caption) + .foregroundStyle(.secondary) + + Group { + Text(carbFormatter.string(from: carbAmount) ?? "Unknown") + .foregroundStyle(colorPalette.carbTintColor) + + Text(" ") + + Text(emoji) + } + .font(.title2.weight(.semibold)) + } + } + } + case .pumpEvent(.insulin, _), .automation: + EmptyView() + case .preset(_, let icon, let name): + VStack(alignment: .leading, spacing: 8) { + Divider() + .padding(.trailing, -20) + + VStack(alignment: .leading, spacing: 2) { + Text("Preset Summary") + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 6) { + if let icon, !icon.isEmpty { + PresetSymbolView(icon) + } + + Text(name) + .fontWeight(.semibold) + } + } + } + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 8){ + HStack(spacing: 12) { + icon + + title + } + + content + .padding(.leading, 36) + } + } +} + +#Preview { + InsulinDeliveryLogEventRow(event: InsulinDeliveryLogEvent(id: UUID().uuidString, type: .pumpEvent(.bolus(.correction(recommendedAmount: nil), programmedAmount: nil, deliveryAmount: LoopQuantity(unit: .internationalUnit, doubleValue: 5)), nil), date: Date())) + .environment(\.colorPalette, .default) +} diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift new file mode 100644 index 0000000000..a0f563103d --- /dev/null +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift @@ -0,0 +1,594 @@ +// +// InsulinDeliveryLogViewModel.swift +// Loop +// +// Created by Cameron Ingham on 7/16/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit +import os.log + +@MainActor +@Observable +class InsulinDeliveryLogViewModel { + + enum FilterOptions: Hashable, CaseIterable { + case userInitiated + case all + + var localizedMenuTitle: String { + switch self { + case .userInitiated: + NSLocalizedString("Self-Initiated Events", comment: "") + case .all: + NSLocalizedString("All Events", comment: "") + } + } + } + + enum LogEventDisplay: Hashable, Identifiable { + case title(id: UUID, String) + case event(InsulinDeliveryLogEvent) + + var id: Int { + hashValue + } + } + + struct DisplayData: Hashable { + let insulinDeliveryState: InsulinDeliveryOverview.State, insulinDeliveryStateUpdatedDate: Date, currentBasalRate: DatedQuantity, lastAutoBolus: DatedQuantity?, totalInsulinDelivered: LoopQuantity, events: [InsulinDeliveryLogEvent] + } + + enum State: Hashable { + case loading + case fetched(DisplayData) + case refreshing(DisplayData) + } + + let totalDeliveredFormatter: QuantityFormatter = { + let formatter = QuantityFormatter(for: .internationalUnit) + + formatter.numberFormatter.maximumFractionDigits = 1 + + return formatter + }() + + private let loopDataManager: LoopDataManager + private let pumpManager: PumpManager + + private let log = OSLog(category: "InsulinDeliveryLogViewModel") + + private(set) var state: State + + var selectedFilterOption: FilterOptions = .all + + var logEventDisplays: [LogEventDisplay] { + var displayEvents: [LogEventDisplay] = [] + + switch state { + case .fetched(let data), .refreshing(let data): + data.events.filter { + switch selectedFilterOption { + case .userInitiated: + switch $0.type { + case .automation, + .preset, + .pumpEvent(.basal(.manualTempBasal, rate: _), _), + .pumpEvent(.insulin, _), + .pumpEvent(.bolus(.correction, _, _), _), + .pumpEvent(.bolus(.meal, _, _), _), + .pumpEvent(.bolus(.external, _, _), _): + return true + default: + return false + } + case .all: + return true + } + }.segmentItemsByHour().forEach { events in + displayEvents.append(.title(id: UUID(), "\(events.start.formatted(date: .omitted, time: .shortened)) - \(events.end.formatted(date: .omitted, time: .shortened))")) + events.events.forEach { event in + displayEvents.append(.event(event)) + } + } + case .loading: + break + } + + return displayEvents + } + + var eventCount: Int { + logEventDisplays.filter { display in + switch display { + case .event: + return true + case .title: + return false + } + }.count + } + + private var doseStoreObserver: Any? { + willSet { + if let observer = doseStoreObserver { + NotificationCenter.default.removeObserver(observer) + } + } + } + + private var doseStore: DoseStore! { + didSet { + if let doseStore = doseStore { + doseStoreObserver = NotificationCenter.default.addObserver(forName: nil, object: doseStore, queue: OperationQueue.main, using: { [weak self] note in + + switch note.name { + case DoseStore.valuesDidChange: + Task { @MainActor in + await self?.fetchData() + } + default: + break + } + }) + } else { + doseStoreObserver = nil + } + } + } + + init( + loopDataManager: LoopDataManager, + pumpManager: PumpManager, + initialState: State = .loading + ) { + self.loopDataManager = loopDataManager + self.pumpManager = pumpManager + self.state = initialState + + self.doseStore = (loopDataManager.doseStore as? DoseStore) + + Task { + await fetchData() + } + } + + func deleteDose(_ doseEntry: DoseEntry) async { + await withCheckedContinuation { continuation in + doseStore.deleteDose(doseEntry) { error in + if let error { + self.log.error("Error deleting dose: %{public}@", String(describing: error)) + } + continuation.resume() + } + } + } + + func fetchData() async { + if case let .fetched(data) = state { + state = .refreshing(data) + } + + // fetch all events within the last 24hrs + let fetchedDate = Date() + let startDate = fetchedDate.addingTimeInterval(.days(-1)) + + let statusState = fetchStatusState() + let totalInsulinDelivered = await fetchTotalInsulinDeliveredToday() + let doses = await fetchDoses(since: startDate) + let lastAutoBolus = fetchLastAutoBolus(doses: doses) + let decisions = await fetchDosingDecisions(doses.compactMap(\.decisionId)) + + // map raw event data into delivery log events for display + var events = [InsulinDeliveryLogEvent]() + handleDoseEvents(doses: doses, decisions: decisions, fetchedDate: fetchedDate, events: &events) + handleAutomationEvents(&events) + handlePresetEvents(startDate: startDate, &events) + + // update the state of delivery log with the fetched & mapped data + state = .fetched( + .init( + insulinDeliveryState: statusState, + insulinDeliveryStateUpdatedDate: fetchedDate, + currentBasalRate: fetchCurrentBasal() ?? DatedQuantity(date: TestingDate.currentTestingDate(), quantity: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: 0)), + lastAutoBolus: lastAutoBolus, + totalInsulinDelivered: totalInsulinDelivered, + events: events + ) + ) + } + + private func fetchStatusState() -> InsulinDeliveryOverview.State { + var insulinSuspended = false + if case .suspended = pumpManager.status.basalDeliveryState { + insulinSuspended = true + } + + let automationEnabled = loopDataManager.settingsProvider.dosingEnabled + let automatedTreatmentState = pumpManager.pumpManagerDelegate?.automatedTreatmentState ?? .neutralNoOverride + + if insulinSuspended { + return .error(status: .suspended) + } else if fetchCurrentBasal() == nil { + return .error(status: .noDelivery) + } else if automationEnabled { + let basalStatus: InsulinDeliveryOverview.State.AutomatedBasalStatus + switch automatedTreatmentState { + case .neutralNoOverride, .neutralOverride: + basalStatus = .scheduled + case .increasedInsulin: + basalStatus = .increased + case .decreasedInsulin, .minimumDelivery: + basalStatus = .decreased + } + + return .automationOn(basalStatus: basalStatus, preset: loopDataManager.temporaryPresetsManager.activePreset) + } else { + return .automationOff + } + } + + private func fetchCurrentBasal() -> DatedQuantity? { + let date = loopDataManager.lastLoopCompleted ?? Date() + + guard let scheduledBasalRate = loopDataManager.settings.basalRateSchedule?.value(at: date) else { + return nil + } + + guard let currentBasalRate = pumpManager.status.basalDeliveryState?.currentBasalRate(currentScheduledBasalRate: scheduledBasalRate) else { + return nil + } + + return DatedQuantity( + date: date, + quantity: LoopQuantity( + unit: .internationalUnitsPerHour, + doubleValue: currentBasalRate + ) + ) + } + + private func fetchLastAutoBolus(doses: [DoseEntry]) -> DatedQuantity? { + guard let lastAutoBolusDose = doses.last(where: { $0.type == .bolus && $0.automatic == true }) else { + return nil + } + + return DatedQuantity(date: lastAutoBolusDose.startDate, quantity: LoopQuantity(unit: .internationalUnit, doubleValue: lastAutoBolusDose.deliveredUnits ?? lastAutoBolusDose.value)) + } + + private func fetchDoses(since startDate: Date) async -> [DoseEntry] { + (try? await loopDataManager.doseStore.getNormalizedDoseEntries(start: startDate, end: nil)) ?? [] + } + + private func fetchDosingDecisions(_ ids: [UUID]) async -> [LightDosingDecision] { + (try? await loopDataManager.dosingDecisionStore.findDosingDecisionsByIds(ids)) ?? [] + } + + private func fetchTotalInsulinDeliveredToday() async -> LoopQuantity { + await LoopQuantity(unit: .internationalUnit, doubleValue: loopDataManager.totalDeliveredToday()?.value ?? 0) + } + + private func handleBasalEvent(dose: DoseEntry, decision: LightDosingDecision?, events: inout [InsulinDeliveryLogEvent]) { + let automationEnabledDuringDose = loopDataManager.automationHistory.automationEnabled(at: dose.startDate) ?? loopDataManager.settingsProvider.dosingEnabled + + if dose.type == .tempBasal && dose.automatic == false { + events.append( + InsulinDeliveryLogEvent( + id: dose.syncIdentifier ?? UUID().uuidString, + type: .pumpEvent( + .basal( + .manualTempBasal(endDate: dose.endDate), + rate: LoopQuantity( + unit: .internationalUnitsPerHour, + doubleValue: dose.unitsPerHour + ) + ), + dose + ), + date: dose.startDate + ) + ) + } else if automationEnabledDuringDose { + if let decision { + if decision.scheduleOverride != nil { + events.append( + InsulinDeliveryLogEvent( + id: dose.syncIdentifier ?? UUID().uuidString, + type: .pumpEvent( + .basal( + .automatedPresetBasal, + rate: LoopQuantity( + unit: .internationalUnitsPerHour, + doubleValue: dose.unitsPerHour + ) + ), + dose + ), + date: dose.startDate + ) + ) + } else { + if let direction = decision.automaticDoseRecommendation?.direction { + switch direction { + case .decrease: + events.append( + InsulinDeliveryLogEvent( + id: dose.syncIdentifier ?? UUID().uuidString, + type: .pumpEvent( + .basal( + .automationOn(basalStatus: .lessThanScheduled), + rate: LoopQuantity( + unit: .internationalUnitsPerHour, + doubleValue: dose.unitsPerHour + ) + ), + dose + ), + date: dose.startDate + ) + ) + case .neutral: + events.append( + InsulinDeliveryLogEvent( + id: dose.syncIdentifier ?? UUID().uuidString, + type: .pumpEvent( + .basal( + .automationOn(basalStatus: .scheduled), + rate: LoopQuantity( + unit: .internationalUnitsPerHour, + doubleValue: dose.unitsPerHour + ) + ), + dose + ), + date: dose.startDate + ) + ) + case .increase: + events.append( + InsulinDeliveryLogEvent( + id: dose.syncIdentifier ?? UUID().uuidString, + type: .pumpEvent( + .basal( + .automationOn(basalStatus: .moreThanScheduled), + rate: LoopQuantity( + unit: .internationalUnitsPerHour, + doubleValue: dose.unitsPerHour + ) + ), + dose + ), + date: dose.startDate + ) + ) + } + } else { + log.error("No `decision.automaticDoseRecommendation`") + } + } + } else if let scheduledBasalRate = dose.scheduledBasalRate, scheduledBasalRate.doubleValue(for: .internationalUnitsPerHour) == dose.value { + events.append( + InsulinDeliveryLogEvent( + id: dose.syncIdentifier ?? UUID().uuidString, + type: .pumpEvent( + .basal( + .automationOn(basalStatus: .scheduled), + rate: LoopQuantity( + unit: .internationalUnitsPerHour, + doubleValue: dose.unitsPerHour + ) + ), + dose + ), + date: dose.startDate + ) + ) + } else { + log.error("No `decision` or `scheduledBasalRate`") + } + } else { + events.append( + InsulinDeliveryLogEvent( + id: dose.syncIdentifier ?? UUID().uuidString, + type: .pumpEvent( + .basal( + .automationOff, + rate: LoopQuantity( + unit: .internationalUnitsPerHour, + doubleValue: dose.unitsPerHour + ) + ), + dose + ), + date: dose.startDate + ) + ) + } + } + + private func handleBolusEvents(dose: DoseEntry, decision: LightDosingDecision?, events: inout [InsulinDeliveryLogEvent]) { + if dose.manuallyEntered { + events.append( + InsulinDeliveryLogEvent( + id: dose.syncIdentifier ?? UUID().uuidString, + type: .pumpEvent( + .bolus( + .external, + programmedAmount: LoopQuantity( + unit: .internationalUnit, + doubleValue: dose.programmedUnits + ), + deliveryAmount: LoopQuantity( + unit: .internationalUnit, + doubleValue: dose.deliveredUnits ?? dose.programmedUnits + ) + ), + dose + ), + date: dose.startDate + ) + ) + } else if dose.automatic == true { + events.append( + InsulinDeliveryLogEvent( + id: dose.syncIdentifier ?? UUID().uuidString, + type: .pumpEvent( + .bolus( + .automated, + programmedAmount: LoopQuantity( + unit: .internationalUnit, + doubleValue: dose.programmedUnits + ), + deliveryAmount: LoopQuantity( + unit: .internationalUnit, + doubleValue: dose.deliveredUnits ?? dose.programmedUnits + ) + ), + dose + ), + date: dose.startDate + ) + ) + } else { + if let recommendedUnits = decision?.manualBolusRecommendation?.recommendation.amount { + if let carbEntry = decision?.carbEntry { + events.append( + InsulinDeliveryLogEvent( + id: decision?.syncIdentifier.uuidString ?? UUID().uuidString, + type: .pumpEvent( + .bolus( + .meal( + recommendedAmount: LoopQuantity( + unit: .internationalUnit, + doubleValue: recommendedUnits + ), + carbAmount: LoopQuantity( + unit: .gram, + doubleValue: carbEntry.amount + ), + emoji: carbEntry.foodType ?? "" + ), + programmedAmount: LoopQuantity( + unit: .internationalUnit, + doubleValue: decision?.manualBolusRequested ?? 0 + ), + deliveryAmount: LoopQuantity( + unit: .internationalUnit, + doubleValue: dose.deliveredUnits ?? dose.programmedUnits + ) + ), + dose + ), + date: dose.startDate + ) + ) + } else { + events.append( + InsulinDeliveryLogEvent( + id: decision?.syncIdentifier.uuidString ?? UUID().uuidString, + type: .pumpEvent( + .bolus( + .correction( + recommendedAmount: LoopQuantity( + unit: .internationalUnit, + doubleValue: recommendedUnits + ) + ), + programmedAmount: LoopQuantity( + unit: .internationalUnit, + doubleValue: decision?.manualBolusRequested ?? 0 + ), + deliveryAmount: LoopQuantity( + unit: .internationalUnit, + doubleValue: dose.deliveredUnits ?? dose.programmedUnits + ) + ), + dose + ), + date: dose.startDate + ) + ) + } + } else { + events.append( + InsulinDeliveryLogEvent( + id: dose.syncIdentifier ?? UUID().uuidString, + type: .pumpEvent( + .bolus( + .correction(recommendedAmount: nil), + programmedAmount: LoopQuantity( + unit: .internationalUnit, + doubleValue: dose.programmedUnits + ), + deliveryAmount: LoopQuantity( + unit: .internationalUnit, + doubleValue: dose.deliveredUnits ?? dose.programmedUnits + ) + ), + dose + ), + date: dose.startDate + ) + ) + } + } + } + + private func handleDoseEvents(doses: [DoseEntry], decisions: [LightDosingDecision], fetchedDate: Date, events: inout [InsulinDeliveryLogEvent]) { + let isPumpSuspended: Bool = { + if case .suspended = pumpManager.status.basalDeliveryState { + return true + } + return false + }() + + let latestSuspendStartDate = doses.last(where: { $0.type == .suspend })?.startDate + + for dose in doses { + let decision = decisions.first(where: { $0.id == dose.decisionId }) + switch dose.type { + case .basal, .tempBasal: + handleBasalEvent(dose: dose, decision: decision, events: &events) + case .bolus: + handleBolusEvents(dose: dose, decision: decision, events: &events) + case .resume, .suspend: + let isActiveSuspension = isPumpSuspended && dose.type == .suspend && dose.startDate == latestSuspendStartDate + handleSuspendResumeEvents(dose: dose, fetchedDate: fetchedDate, isActiveSuspension: isActiveSuspension, events: &events) + } + } + } + + private func handleSuspendResumeEvents(dose: DoseEntry, fetchedDate: Date, isActiveSuspension: Bool, events: inout [InsulinDeliveryLogEvent]) { + guard dose.type == .suspend else { return } + + events.append(InsulinDeliveryLogEvent(id: dose.syncIdentifier ?? UUID().uuidString, type: .pumpEvent(.insulin(.suspended), dose), date: dose.startDate)) + + if !isActiveSuspension && (!dose.isMutable || dose.endDate <= fetchedDate) { + events.append(InsulinDeliveryLogEvent(id: dose.syncIdentifier ?? UUID().uuidString, type: .pumpEvent(.insulin(.resumed), dose), date: dose.endDate)) + } + } + + private func handleAutomationEvents(_ events: inout [InsulinDeliveryLogEvent]) { + loopDataManager.automationHistory.forEach { event in + if event.enabled { + events.append(InsulinDeliveryLogEvent(id: String(event.hashValue), type: .automation(.on), date: event.startDate)) + } else { + events.append(InsulinDeliveryLogEvent(id: String(event.hashValue), type: .automation(.off(endDate: nil)), date: event.startDate)) + } + } + } + + private func handlePresetEvents(startDate: Date, _ events: inout [InsulinDeliveryLogEvent]) { + loopDataManager.temporaryPresetsManager.presetHistory.recentEvents.filter({ $0.override.actualEndDate >= startDate }).forEach { event in + if let preset = loopDataManager.temporaryPresetsManager.selectablePresets.first(where: { $0.id == event.override.presetId }) { + events.append(InsulinDeliveryLogEvent(id: String(event.hashValue), type: .preset(.enabled, icon: preset.icon, name: preset.name), date: event.override.startDate)) + + if event.override.hasFinished() { + events.append(InsulinDeliveryLogEvent(id: String(event.hashValue), type: .preset(.disabled, icon: preset.icon, name: preset.name), date: event.override.actualEndDate)) + } + } + } + } +} diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryOverview.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryOverview.swift new file mode 100644 index 0000000000..0d9a78cc77 --- /dev/null +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryOverview.swift @@ -0,0 +1,366 @@ +// +// InsulinDeliveryOverview.swift +// Loop +// +// Created by Cameron Ingham on 3/26/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit +import SwiftUI +import LoopCore + +struct DatedQuantity: Hashable { + let date: Date + let quantity: LoopQuantity +} + +struct InsulinDeliveryOverview: View { + enum State: Hashable { + enum AutomatedBasalStatus: Hashable { + case scheduled + case increased + case decreased + } + + case automationOn(basalStatus: AutomatedBasalStatus, preset: SelectablePreset?) + + case automationOff + + enum ErrorStatus: Hashable { + case noDelivery + case suspended + } + + case error(status: ErrorStatus) + } + + @Environment(\.colorPalette) private var colorPalette + + @ScaledMetric private var iconSize: Double = 26 + + private let rateFormatter = QuantityFormatter(for: .internationalUnitsPerHour) + private let bolusFormatter = QuantityFormatter(for: .internationalUnit) + + private let state: State + private let time: Date + private let currentBasalRate: DatedQuantity + private let lastAutoBolus: DatedQuantity? + + init(state: State, time: Date, currentBasalRate: DatedQuantity, lastAutoBolus: DatedQuantity?) { + self.state = state + self.time = time + self.currentBasalRate = currentBasalRate + self.lastAutoBolus = lastAutoBolus + } + + @ViewBuilder + var icon: some View { + VStack { + switch state { + case .automationOn(let basalStatus, _): + VStack { + switch basalStatus { + case .scheduled: + Text(Image(systemName: "arrow.right.square.fill")) + case .increased: + Text(Image(systemName: "arrow.up.square.fill")) + case .decreased: + Text(Image(systemName: "arrow.down.square.fill")) + } + } + .foregroundStyle(Color.accentColor) + case .automationOff: + Text(Image(systemName: "arrow.right.square.fill")) + .foregroundStyle(Color.accentColor) + case .error(let status): + VStack { + switch status { + case .noDelivery: + Text(Image(systemName: "xmark.circle.fill")) + .foregroundStyle(colorPalette.guidanceColors.critical) + case .suspended: + Text(Image(systemName: "pause.circle.fill")) + .foregroundStyle(colorPalette.guidanceColors.warning) + } + } + } + } + .font(.system(size: iconSize)) + } + + var statusTitle: Text { + switch state { + case .automationOn(let basalStatus, let preset): + switch basalStatus { + case .scheduled: + if let preset, preset.insulinNeedsScaleFactor != 1.0 { + Text("Preset Delivery") + } else { + Text("Scheduled Basal") + } + case .increased: + Text("Increased Delivery") + case .decreased: + Text("Decreased Delivery") + } + case .automationOff: + Text("Scheduled basal") + case .error(let status): + switch status { + case .noDelivery: + Text("No Delivery") + case .suspended: + Text("Insulin Suspended") + } + } + } + + var statusSubtitle: Text? { + switch state { + case .automationOn(let basalStatus, let preset): + if let preset, preset.insulinNeedsScaleFactor != 1.0 { + switch basalStatus { + case .scheduled: + Text("A preset with \(preset.insulinNeedsScaleFactor.formatted(.percent)) overall insulin is on. This is your new preset baseline and it overrides your Scheduled Basal.") + case .increased: + Text("A preset with \(preset.insulinNeedsScaleFactor.formatted(.percent)) overall insulin is on. The system is currently delivering more than your preset baseline.") + case .decreased: + Text("A preset with \(preset.insulinNeedsScaleFactor.formatted(.percent)) overall insulin is on. The system is currently delivering less than your preset baseline.") + } + } else if basalStatus == .increased { + Text("Includes basal and automated boluses") + } else { + nil + } + default: + nil + } + } + + private var errorAdjustedBasalRate: LoopQuantity { + if case .error = state { + return LoopQuantity(unit: currentBasalRate.quantity.unit, doubleValue: 0) + } else { + return currentBasalRate.quantity + } + } + + private var currentBasalRateForegroundColor: Color { + switch state { + case .error: + return .secondary + default: + return .primary + } + } + + var currentBasalRateSection: some View { + VStack(alignment: .leading, spacing: 2) { + Text("Current Basal Rate") + + Group { + Text(rateFormatter.string(from: errorAdjustedBasalRate, includeUnit: false) ?? "Unknown").fontWeight(.semibold) + Text(" ") + Text(errorAdjustedBasalRate.unit.localizedUnitString(in: .short) ?? "U/hr") + } + .font(.title2) + + Text("since \(currentBasalRate.date.formatted(date: .omitted, time: .shortened))") + .font(.footnote) + .foregroundStyle(.secondary) + } + .foregroundStyle(currentBasalRateForegroundColor) + } + + private var lastAutoBolusForegroundColor: Color { + guard lastAutoBolus != nil else { + return .secondary + } + + switch state { + case .automationOff, .error: + return .secondary + default: + return .primary + } + } + + private var isAutomationOff: Bool { + if case .automationOff = state { + return true + } else { + return false + } + } + + var lastAutoBolusSection: some View { + VStack(alignment: .leading, spacing: 2) { + Text("Last Auto Bolus") + + Group { + if let lastAutoBolus, !isAutomationOff { + Text(bolusFormatter.string(from: lastAutoBolus.quantity, includeUnit: false) ?? "Unknown").fontWeight(.semibold) + Text(" ") + Text(lastAutoBolus.quantity.unit.localizedUnitString(in: .short) ?? "U") + } else { + Text("-.--") + Text(" ") + Text(LoopUnit.internationalUnit.localizedUnitString(in: .short) ?? "U") + } + } + .font(.title2) + + Group { + if state == .automationOff { + Text("Automation is off") + .italic() + } else if let lastAutoBolus { + Text("at \(lastAutoBolus.date.formatted(date: .omitted, time: .shortened))") + } else { + Text("None in last 24 hours") + } + } + .font(.footnote) + .foregroundStyle(.secondary) + } + .foregroundStyle(lastAutoBolusForegroundColor) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Current Delivery") + + HStack(spacing: 4) { + icon + + statusTitle + .font(.title3.weight(.heavy)) + } + + if let statusSubtitle { + statusSubtitle + .font(.caption.italic()) + .foregroundStyle(.secondary) + } + + Text("at \(time.formatted(date: .omitted, time: .shortened))") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Divider() + + ViewThatFits { + HStack(spacing: 0) { + currentBasalRateSection + + Spacer() + + lastAutoBolusSection + } + } + } + } +} + +let time = Date() +let currentBasalRate = DatedQuantity(date: Date(), quantity: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: 0.5)) +let lastAutoBolus = DatedQuantity(date: Date().addingTimeInterval(-57600), quantity: LoopQuantity(unit: .internationalUnit, doubleValue: 0.05)) + +let preset = SelectablePreset.custom(TemporaryPreset(symbol: "🏃", name: "Running", settings: .init(targetRange: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 80)...LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), insulinNeedsScaleFactor: 0.5), duration: .indefinite)) + +#Preview("Automated Delivery (scheduled)", traits: .sizeThatFitsLayout) { + InsulinDeliveryOverview( + state: .automationOn(basalStatus: .scheduled, preset: nil), + time: time, + currentBasalRate: currentBasalRate, + lastAutoBolus: lastAutoBolus + ) + .environment(\.guidanceColors, .default) + .padding() +} + +#Preview("Automated Delivery (less than scheduled)", traits: .sizeThatFitsLayout) { + InsulinDeliveryOverview( + state: .automationOn(basalStatus: .decreased, preset: nil), + time: time, + currentBasalRate: currentBasalRate, + lastAutoBolus: lastAutoBolus + ) + .environment(\.guidanceColors, .default) + .padding() +} + +#Preview("Automated Delivery (more than scheduled)", traits: .sizeThatFitsLayout) { + InsulinDeliveryOverview( + state: .automationOn(basalStatus: .increased, preset: nil), + time: time, + currentBasalRate: currentBasalRate, + lastAutoBolus: nil + ) + .environment(\.guidanceColors, .default) + .padding() +} + +#Preview("Preset (scheduled)", traits: .sizeThatFitsLayout) { + InsulinDeliveryOverview( + state: .automationOn(basalStatus: .scheduled, preset: preset), + time: time, + currentBasalRate: currentBasalRate, + lastAutoBolus: lastAutoBolus + ) + .environment(\.guidanceColors, .default) + .padding() +} + +#Preview("Preset (less than scheduled)", traits: .sizeThatFitsLayout) { + InsulinDeliveryOverview( + state: .automationOn(basalStatus: .decreased, preset: preset), + time: time, + currentBasalRate: currentBasalRate, + lastAutoBolus: lastAutoBolus + ) + .environment(\.guidanceColors, .default) + .padding() +} + +#Preview("Preset (more than scheduled)", traits: .sizeThatFitsLayout) { + InsulinDeliveryOverview( + state: .automationOn(basalStatus: .increased, preset: preset), + time: time, + currentBasalRate: currentBasalRate, + lastAutoBolus: lastAutoBolus + ) + .environment(\.guidanceColors, .default) + .padding() +} + +#Preview("Automation OFF", traits: .sizeThatFitsLayout) { + InsulinDeliveryOverview( + state: .automationOff, + time: time, + currentBasalRate: currentBasalRate, + lastAutoBolus: nil + ) + .environment(\.guidanceColors, .default) + .padding() +} + +#Preview("Error (No Delivery)", traits: .sizeThatFitsLayout) { + InsulinDeliveryOverview( + state: .error(status: .noDelivery), + time: time, + currentBasalRate: currentBasalRate, + lastAutoBolus: lastAutoBolus + ) + .environment(\.guidanceColors, .default) + .padding() +} + +#Preview("Error (Suspended)", traits: .sizeThatFitsLayout) { + InsulinDeliveryOverview( + state: .error(status: .suspended), + time: time, + currentBasalRate: currentBasalRate, + lastAutoBolus: lastAutoBolus + ) + .environment(\.guidanceColors, .default) + .padding() +} diff --git a/Loop/Views/InsulinSuspendedTableViewCell.swift b/Loop/Views/InsulinSuspendedTableViewCell.swift new file mode 100644 index 0000000000..4fd26027a5 --- /dev/null +++ b/Loop/Views/InsulinSuspendedTableViewCell.swift @@ -0,0 +1,28 @@ +// +// InsulinSuspendedTableViewCell.swift +// Loop +// +// Created by Nathaniel Hamming on 2025-10-27. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import UIKit +import LoopUI + +public class InsulinSuspendedTableViewCell: UITableViewCell { + @IBOutlet weak var activityIndicator: UIActivityIndicatorView! + @IBOutlet weak var paddedView: UIView! + @IBOutlet weak var label: UILabel! + @IBOutlet weak var tapToResumeLabel: UILabel! + + override public func awakeFromNib() { + super.awakeFromNib() + + paddedView.layer.masksToBounds = true + paddedView.layer.cornerRadius = 10 + paddedView.layer.borderWidth = 1 + paddedView.layer.borderColor = UIColor.systemGray5.cgColor + } +} + +extension InsulinSuspendedTableViewCell: NibLoadable { } diff --git a/Loop/Views/InsulinSuspendedTableViewCell.xib b/Loop/Views/InsulinSuspendedTableViewCell.xib new file mode 100644 index 0000000000..8da610b1e2 --- /dev/null +++ b/Loop/Views/InsulinSuspendedTableViewCell.xib @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Loop/Views/LiveActivityManagementView.swift b/Loop/Views/LiveActivityManagementView.swift index fac5576c55..84dba8590d 100644 --- a/Loop/Views/LiveActivityManagementView.swift +++ b/Loop/Views/LiveActivityManagementView.swift @@ -40,9 +40,13 @@ struct LiveActivityManagementView: View { .foregroundStyle(viewModel.isEditingMode ? .blue : .primary) }, expandedContent: { - ResizeablePicker(selection: self.$viewModel.mode.animation(), - data: LiveActivityMode.all, - formatter: { $0.name() }) + Picker(selection: $viewModel.mode.animation(), label: EmptyView()) { + ForEach(LiveActivityMode.all, id: \.self) { mode in + Text(mode.name()).tag(mode) + } + } + .pickerStyle(.wheel) + .labelsHidden() } ) .onChange(of: viewModel.mode) { _ in diff --git a/Loop/Views/LoopStatusModalView.swift b/Loop/Views/LoopStatusModalView.swift new file mode 100644 index 0000000000..57d0c7d497 --- /dev/null +++ b/Loop/Views/LoopStatusModalView.swift @@ -0,0 +1,305 @@ +// +// LoopStatusModalView.swift +// Loop +// +// Created by Nathaniel Hamming on 2025-10-09. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI + +struct LoopStatusModalView: View { + @Environment(\.loopStatusColorPalette) private var loopStatusColors + + @State private var appear = false + + @Bindable var viewModel: LoopStatusModalViewModel + + let onDismiss: () -> Void + let onNavigateToSettings: () -> Void + + private var freshnessColor: Color { + switch viewModel.freshness { + case .fresh: return .primary + case .aging: return Color(loopStatusColors.warning) + case .stale: return Color(loopStatusColors.error) + } + } + + private var deviceIssue: Bool { + viewModel.isCGMInoperable || viewModel.isPumpInoperable || viewModel.hasBluetoothIssue + } + + var body: some View { + VStack { + closeButton + .padding(5) + .frame(maxWidth: .infinity, alignment: .trailing) + + LoopCircleView(closedLoop: viewModel.loopIconClosed, freshness: viewModel.freshness, deviceIssue: deviceIssue) + .environment(\.loopStatusColorPalette, loopStatusColors) + .padding(.bottom) + + if viewModel.loopIconClosed, + let lastLoopCompletedFormattedTime = viewModel.lastLoopCompletedFormattedTime + { + lastLoopCompleted(lastLoopCompletedString: lastLoopCompletedFormattedTime) + } + + automationDetails + .padding([.top, .horizontal]) + .padding(.bottom, 10) + } + .padding(10) + .background(Color(UIColor.systemGroupedBackground)) + .cornerRadius(10) + .shadow(radius: 5) + .frame(maxWidth: 340) + .animation(.spring(), value: appear) + .onAppear { + withAnimation { + appear = true + } + } + } + + private var closeButton: some View { + Button("\(Image(systemName: "xmark"))") { + withAnimation(.spring()) { + appear = false + } + // Dismiss after animation delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + onDismiss() + } + } + .foregroundStyle(.primary) + } + + private func lastLoopCompleted(lastLoopCompletedString: String) -> some View { + Group { + Text("Last loop completed") + // ⚠️ arrow.triangle.2.circlepath is deprecated -- replace with "arrow.trianglehead.2.clockwise.rotate.90" once iOS 17 is dropped as a supported platform. + Text("\(Image(systemName: "arrow.triangle.2.circlepath")) \(lastLoopCompletedString)") + .foregroundStyle(freshnessColor) + if viewModel.includeDateTimeStamp { + Text(viewModel.formattedLastLoopCompletedDateTime) + .foregroundStyle(freshnessColor) + } else if viewModel.freshness != .fresh { + Text(viewModel.formattedLastLoopCompletedTime) + .foregroundStyle(freshnessColor) + } + } + .font(.footnote) + .fontWeight(.semibold) + } + + private var automationDetails: some View { + VStack(alignment: .center) { + automationTitle + automationMessage + } + } + + private var automationTitle: some View { + Text(viewModel.copy.title) + .font(.title2) + .bold() + .multilineTextAlignment(.center) + } + + @ViewBuilder + private var automationMessage: some View { + let message = viewModel.copy.message + + // Use a localized search for the "Settings" word within the message + let settingsWord = viewModel.localizedSettingsWord + + if let range = message.range(of: settingsWord) { + let prefix = String(message[.. 5 { - enteredBolusString = String(newValue.prefix(5)) + .keyboardType(.decimalPad) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.title) + .multilineTextAlignment(.trailing) + .foregroundColor(.loopAccent) + .focused($bolusFieldFocused) + .onChange(of: enteredBolusString) { oldValue, newValue in + if newValue.count > 5 { + enteredBolusString = String(newValue.prefix(5)) + } } - } - .toolbar { - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button("Done") { bolusFieldFocused = false } + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { bolusFieldFocused = false } + } } - } bolusUnitsLabel } } .accessibilityElement(children: .combine) + .accessibilityIdentifier("textField_Bolus") } private var bolusUnitsLabel: some View { - Text(QuantityFormatter(for: .internationalUnit()).localizedUnitStringWithPlurality()) + Text(QuantityFormatter(for: .internationalUnit).localizedUnitStringWithPlurality()) .foregroundColor(Color(.secondaryLabel)) } @@ -211,7 +212,7 @@ struct ManualEntryDoseView: View { Binding( get: { self.enteredBolusString }, set: { newValue in - self.viewModel.enteredBolus = HKQuantity(unit: .internationalUnit(), doubleValue: Self.doseAmountFormatter.number(from: newValue)?.doubleValue ?? 0) + self.viewModel.enteredBolus = LoopQuantity(unit: .internationalUnit, doubleValue: Self.doseAmountFormatter.number(from: newValue)?.doubleValue ?? 0) self.enteredBolusString = newValue } ) @@ -236,7 +237,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/ManualGlucoseEntryRow.swift b/Loop/Views/ManualGlucoseEntryRow.swift index 3850637a5e..30e569cec9 100644 --- a/Loop/Views/ManualGlucoseEntryRow.swift +++ b/Loop/Views/ManualGlucoseEntryRow.swift @@ -8,17 +8,17 @@ import Foundation import SwiftUI +import LoopAlgorithm import LoopKit import LoopKitUI import Combine -import HealthKit struct ManualGlucoseEntryRow: View { @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference @State private var valueText = "" - @Binding var quantity: HKQuantity? + @Binding var quantity: LoopQuantity? @State private var isManualGlucoseEntryRowVisible = false @@ -42,7 +42,7 @@ struct ManualGlucoseEntryRow: View { ) .onChange(of: valueText, perform: { value in if let manualGlucoseValue = displayGlucosePreference.formatter.numberFormatter.number(from: valueText)?.doubleValue { - quantity = HKQuantity(unit: displayGlucosePreference.unit, doubleValue: manualGlucoseValue) + quantity = LoopQuantity(unit: displayGlucosePreference.unit, doubleValue: manualGlucoseValue) } else { quantity = nil } @@ -50,7 +50,8 @@ struct ManualGlucoseEntryRow: View { .onChange(of: displayGlucosePreference.unit, perform: { value in unitsChanged() }) - + .accessibilityIdentifier("textField_FingerstickGlucose") + Text(displayGlucosePreference.formatter.localizedUnitStringWithPlurality()) .foregroundColor(Color(.secondaryLabel)) } diff --git a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift index b9e1552036..af6bf95491 100644 --- a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift +++ b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift @@ -53,22 +53,18 @@ public struct NotificationsCriticalAlertPermissionsView: View { { manageNotifications notificationsEnabledStatus - if #available(iOS 15.0, *) { - if !checker.notificationCenterSettings.notificationsDisabled { - notificationDelivery - } + if !checker.notificationCenterSettings.notificationsDisabled { + notificationDelivery } criticalAlertsStatus - if #available(iOS 15.0, *) { - if !checker.notificationCenterSettings.notificationsDisabled { - timeSensitiveStatus - } + if !checker.notificationCenterSettings.notificationsDisabled { + timeSensitiveStatus } } 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 +85,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 +93,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 +124,7 @@ extension NotificationsCriticalAlertPermissionsView { HStack { Text("Time Sensitive Notifications", comment: "Time Sensitive Status text") Spacer() - onOff(!checker.notificationCenterSettings.timeSensitiveNotificationsDisabled) + onOff(!checker.notificationCenterSettings.timeSensitiveDisabled) } } @@ -137,9 +143,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/Presets/Components/ActivePresetBanner.swift b/Loop/Views/Presets/Components/ActivePresetBanner.swift new file mode 100644 index 0000000000..53031b1bda --- /dev/null +++ b/Loop/Views/Presets/Components/ActivePresetBanner.swift @@ -0,0 +1,119 @@ +// +// ActivePresetBanner.swift +// Loop +// +// Created by Cameron Ingham on 8/7/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopKit +import SwiftUI + +struct ActivePresetBanner: View { + + @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager + + let override: TemporaryScheduleOverride + + var symbol: Text? { + switch override.context { + case .preMeal: + return Text(Image("Pre-Meal-symbol")) + case .preset(let preset): + guard let symbol = preset.symbol else { + return nil + } + + switch symbol.symbolType { + case .emoji: + return Text(symbol.value) + case .systemImage: + return Text(Image(systemName: symbol.value)) + case .image: + return Text(Image(symbol.value)) + } + case .activity(let activity): + guard let symbol = activity.preset.symbol else { + return nil + } + + switch symbol.symbolType { + case .emoji: + return Text(symbol.value) + case .systemImage: + return Text(Image(systemName: symbol.value)) + case .image: + return Text(Image(symbol.value)) + } + case .custom: + return nil + } + } + + var titleText: Text { + Text(override.createPreset().name) + } + + var accessibilityIdentifier: String { + switch override.context { + case .preMeal: + "text_PreMealPresetCellTitle" + case .preset: + "text_CustomPresetCellTitle" + case .activity: + "text_ActivityPresetCellTitle" + case .custom: + "text_OneTimePresetCellTitle" + } + } + + @ViewBuilder + var title: some View { + Group { + if let symbol { + symbol + Text(" ") + titleText + } else { + titleText + } + } + .accessibilityIdentifier(accessibilityIdentifier) + } + + @ViewBuilder + var subtitle: some View { + if override.isActive() { + if let preset = temporaryPresetsManager.selectablePresets.first(where: { $0.id == override.presetId }), case .preMeal(_) = preset { + Text(NSLocalizedString("on until carbs added", comment: "The format for the description of a premeal preset end date")) + .accessibilityIdentifier("text_PresetActiveOn") + } else { + switch override.duration { + case .finite: + let endTimeText = DateFormatter.localizedString(from: override.activeInterval.end, dateStyle: .none, timeStyle: .short) + Text(String(format: NSLocalizedString("on until %@", comment: "The format for the description of a finite custom preset end date"), endTimeText)) + .accessibilityIdentifier("text_PresetActiveOn") + case .indefinite: + Text(NSLocalizedString("on until turned off", comment: "The format for the description of an indefinite custom preset end date")) + .accessibilityIdentifier("text_PresetActiveOn") + } + } + } else { + let startTimeText = DateFormatter.localizedString(from: override.startDate, dateStyle: .none, timeStyle: .short) + Text(String(format: NSLocalizedString("starting at %@", comment: "The format for the description of a custom preset start date"), startTimeText)) + } + } + + var body: some View { + HStack { + title + .font(.body.weight(.semibold)) + + Spacer() + + subtitle + .font(.subheadline) + } + .padding() + .foregroundStyle(Color(UIColor.systemBackground)) + .background(Color.presets) + } +} diff --git a/Loop/Views/Presets/Components/EditPresetDurationView.swift b/Loop/Views/Presets/Components/EditPresetDurationView.swift new file mode 100644 index 0000000000..12c659ca03 --- /dev/null +++ b/Loop/Views/Presets/Components/EditPresetDurationView.swift @@ -0,0 +1,78 @@ +// +// EditPresetDurationView.swift +// Loop +// +// Created by Cameron Ingham on 12/12/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopKit +import LoopKitUI +import SwiftUI +import LoopCore + +struct EditPresetDurationView: View { + @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager + @Environment(\.settingsManager) private var settingsManager + @Environment(\.dismiss) private var dismiss + + @State var dateSelection: Date = Date() + + private let currentDate: Date = Date() + + var preset: SelectablePreset? { + temporaryPresetsManager.selectablePresets.first { $0.id == temporaryPresetsManager.activeOverride?.presetId } + } + + var buttonDisabled: Bool { + if case .finite = temporaryPresetsManager.activeOverride?.duration { + return dateSelection == temporaryPresetsManager.activeOverride?.actualEndDate + } else if case .indefinite = temporaryPresetsManager.activeOverride?.duration { + return false + } else { + return dateSelection == currentDate + } + } + + var body: some View { + ZStack { + Color(UIColor.secondarySystemBackground) + .edgesIgnoringSafeArea(.all) + + VStack(spacing: 0) { + VStack(spacing: 24) { + preset?.title(font: .largeTitle, iconSize: 36) + .fontWeight(.bold) + .frame(maxWidth: .infinity, alignment: .leading) + + DatePicker("On until", selection: $dateSelection, displayedComponents: .hourAndMinute) + .padding(6) + .padding(.leading, 10) + .background(Color(UIColor.systemBackground).cornerRadius(10)) + + Spacer() + } + .padding(.horizontal) + + Button("Save") { + temporaryPresetsManager.updateActivePresetDuration(newEndDate: dateSelection) + dismiss() + } + .buttonStyle(ActionButtonStyle()) + .padding([.top, .horizontal]) + .background(Color(UIColor.secondarySystemBackground)) + .disabled(buttonDisabled) + .accessibilityIdentifier("button_Save") + } + } + .onAppear { + if let activeOverride = temporaryPresetsManager.activeOverride { + if case let .finite(timeInterval) = activeOverride.duration { + dateSelection = activeOverride.startDate.addingTimeInterval(timeInterval) + } else { + dateSelection = currentDate + } + } + } + } +} diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift new file mode 100644 index 0000000000..0c74ad9f03 --- /dev/null +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -0,0 +1,224 @@ +// +// PresetDetentView.swift +// Loop +// +// Created by Cameron Ingham on 12/11/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopKit +import LoopKitUI +import SwiftUI +import LoopCore +import LoopAlgorithm + +struct PresetDetentView: View { + + enum Operation { + case start + case end + } + + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + @Environment(\.settingsManager) private var settingsManager + @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager + @Environment(\.dismiss) private var dismiss + @Environment(\.appName) private var appName + + let preset: SelectablePreset + let roundBasalRate: ((Double) -> Double)? + let didTapEdit: () -> Void + + var operation: Operation? { + if temporaryPresetsManager.activeOverride?.presetId == preset.id { + return .end + } else if case .custom(let temporaryPreset) = preset, + !settingsManager.settings.overridePresets.contains(where: { $0.id == temporaryPreset.id }) + { + // if a custom preset is not saved, it is single use and cannot start again + return nil + } else { + return .start + } + } + + @ViewBuilder + private var subtitle: some View { + Group { + switch operation { + case .start: + HStack { + if preset.isScheduled { + Text(Image(systemName: "alarm")) + .font(.footnote) + .foregroundColor(.carbs) + .accessibilityLabel(Text("Scheduled reminder")) + } + Text("Duration: \(preset.duration.localizedTitle)") + } + case .end: + if let activeOverride = temporaryPresetsManager.activeOverride { + if activeOverride.presetId == preset.id { + switch activeOverride.duration { + case .finite: + let endTimeText = DateFormatter.localizedString(from: activeOverride.activeInterval.end, dateStyle: .none, timeStyle: .short) + Text(String(format: NSLocalizedString("on until %@", comment: "The format for the description of a custom preset end date"), endTimeText)) + .accessibilityIdentifier("text_PresetActionSheetActiveOn") + case .indefinite: + EmptyView() + } + } else { + let startTimeText = DateFormatter.localizedString(from: activeOverride.startDate, dateStyle: .none, timeStyle: .short) + Text(String(format: NSLocalizedString("starting at %@", comment: "The format for the description of a custom preset start date"), startTimeText)) + } + } + default: + EmptyView() + } + } + .font(.subheadline) + } + + @ViewBuilder + var actionArea: some View { + VStack(spacing: 12) { + switch operation { + case .start: + Button("Start Preset") { + temporaryPresetsManager.startPreset(preset) + dismiss() + } + .buttonStyle(ActionButtonStyle()) + .disabled((temporaryPresetsManager.activeOverride != nil && preset.id != temporaryPresetsManager.activeOverride?.presetId) || (preset.isPreMeal && settingsManager.dosingEnabled == false)) + .accessibilityIdentifier("button_startPreset") + case .end: + Button("End Preset") { + temporaryPresetsManager.clearOverride() + dismiss() + } + .buttonStyle(ActionButtonStyle(.destructive)) + .accessibilityIdentifier("button_endPreset") + + if preset.duration != .untilCarbsEntered { + NavigationLink("Adjust Preset Duration") { + EditPresetDurationView() + } + .buttonStyle(ActionButtonStyle(.tertiary)) + .accessibilityIdentifier("button_adjustPresetDuration") + } + default: + EmptyView() + } + + Button("Close") { + dismiss() + } + .tint(.accentColor) + .fontWeight(.semibold) + .accessibilityIdentifier("button_close") + } + } + + @State var sheetContentHeight: Double = 0 + + var settingsImpact: TherapySettings.InsulinMultiplierImpact { + var settingsImpact = settingsManager.therapySettings.impact(for: preset.insulinNeedsScaleFactor) + guard let basalRate = settingsImpact.basalRate, + let roundBasalRate + else { + return settingsImpact + } + + settingsImpact.basalRate = LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: roundBasalRate(basalRate.doubleValue(for: .internationalUnitsPerHour))) + return settingsImpact + } + + var highInsulinNeedsWarningText: String { + switch operation { + case .start: + String(format: NSLocalizedString("%1$@ will set your correction range to 110 mg/dL or higher when this preset is enabled.", comment: "The format string for the high insulin needs preset warning text on the preset detent screen when starting a preset. (1: app name)"), appName) + case .end: + String(format: NSLocalizedString("%1$@ has set your correction range to 110 mg/dL or higher.", comment: "The format string for the high insulin needs preset warning text on the preset detent screen when stopping a preset. (1: app name)"), appName) + default: + "" + } + } + + var body: some View { + NavigationStack { + VStack(spacing: 24) { + VStack(spacing: 16) { + VStack(spacing: 4) { + preset.title(font: .title2, iconSize: 20) + subtitle + } + + if operation == .start { + Button { + didTapEdit() + } label: { + Group { + Text(Image(systemName: "pencil")) + Text(" ") + Text("Edit Preset") + } + .font(.subheadline) + } + .tint(.accentColor) + .padding(.bottom, -8) + .accessibilityIdentifier("button_EditPreset") + } + } + + Divider() + + PresetStatsView( + insulinMultiplier: preset.insulinNeedsScaleFactor, + correctionRange: preset.correctionRange, + guardrail: settingsManager.correctionRangeGuardrailForPreset(preset), + therapySettingsImpactDisplayState: operation == .end ? .show(settingsImpact) : .hide, + isScheduled: false, + isActive: temporaryPresetsManager.activePreset?.id == preset.id, + effectiveCorrectionRange: temporaryPresetsManager.effectiveCorrectionRange + ) + .padding(.horizontal) + + if case let .activity(activityPreset) = preset, !activityPreset.isModifiedFromDefault { + Text("\(Image(systemName: "checkmark.seal.fill")) Recommended starting values") + .font(.subheadline) + .foregroundStyle(Color.accentColor) + .frame(maxWidth: .infinity) + .padding(.bottom, 4) + } + + if preset.veryHighInsulinNeeds { + WarningPanel { + Text(highInsulinNeedsWarningText) + .font(.subheadline) + .fontWeight(.semibold) + .fixedSize(horizontal: false, vertical: true) + } + } + + actionArea + } + .toolbar(.hidden) + .padding(.top) + .padding(16) + .readContentHeight(to: $sheetContentHeight) + } + .sheetDetent(height: sheetContentHeight) + } +} + +extension SelectablePreset { + func title(font: Font, iconSize: Double) -> some View { + HStack(spacing: 6) { + if let icon, !icon.isEmpty { + PresetSymbolView(icon) + } + + Text(name) + .font(font) + .fontWeight(.semibold) + } + } +} diff --git a/Loop/Views/Presets/CorrectionRangeInformationView.swift b/Loop/Views/Presets/CorrectionRangeInformationView.swift new file mode 100644 index 0000000000..ea20e70ec0 --- /dev/null +++ b/Loop/Views/Presets/CorrectionRangeInformationView.swift @@ -0,0 +1,86 @@ +// +// InsulinScaleInformationView.swift +// Loop +// +// Created by Pete Schwamb on 2/25/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + + +import SwiftUI + +struct CorrectionRangeInformationView: View { + @State private var insulinPercentage: Double = 100 + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + // Close button + VStack { + Button("Close") { + dismiss() + } + .font(.title3) + .frame(maxWidth: .infinity, alignment: .trailing) + .padding() + } + .background(Color(.systemBackground)) + + // Header + VStack(alignment: .leading) { + Text("Correction Range") + .font(.largeTitle) + .fontWeight(.bold) + .padding(.vertical) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .background(Color(.systemBackground)) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 24) { + // Description Text + (Text("Correction range is a ") + Text("safety").fontWeight(.semibold) + Text(" setting. Adjusting it can help reduce the risk of low glucose if you expect unusual fluctuations.")) + .padding(.top) + Text("Set the glucose value (or values) you want Tidepool Loop to aim for in adjusting your basal insulin.") + Text("You do not have to set a new correction range for each preset, but before deciding to adjust your correction range, ") + + Text("ask yourself, am I more likely to go high or low during this event?") + .fontWeight(.semibold) + + // Tip section + CorrectionRangeTipSection() + } + .padding() + } + } + } +} + +struct CorrectionRangeTipSection: View { + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "lightbulb.fill") + .foregroundColor(.blue) + + Text("Tip") + .font(.headline) + .foregroundColor(.blue) + } + .padding(.bottom, 4) + + Text("To help avoid lows, set a range higher than your typical correction range.") + } + .padding() + .background(Color.blue.opacity(0.1)) + .cornerRadius(8) + } +} + +struct CorrectionRangeInformationView_Previews: PreviewProvider { + static var previews: some View { + CorrectionRangeInformationView() + } +} diff --git a/Loop/Views/Presets/EditPresetRangeView.swift b/Loop/Views/Presets/EditPresetRangeView.swift new file mode 100644 index 0000000000..1953793356 --- /dev/null +++ b/Loop/Views/Presets/EditPresetRangeView.swift @@ -0,0 +1,234 @@ +// +// EditPressRangeView.swift +// Loop +// +// Created by Pete Schwamb on 12/17/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// +import SwiftUI +import LoopAlgorithm +import LoopKit +import LoopKitUI + +struct EditPresetRangeView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.guidanceColors) private var guidanceColors + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + + @Binding var range: ClosedRange? + var guardrail: Guardrail + private var scheduledRange: ClosedRange + @State private var editedRange: ClosedRange? + private var isPreMeal: Bool + + init(range: Binding?>, guardrail: Guardrail, scheduledRange: ClosedRange, isPreMeal: Bool) { + self._range = range + self.guardrail = guardrail + self.scheduledRange = scheduledRange + self.isPreMeal = isPreMeal + } + + var displayedRange: ClosedRange { + return editedRange ?? range ?? scheduledRange + } + + func boundText(for bound: LoopQuantity) -> Text { + let color = guardrail.color(for: bound, guidanceColors: guidanceColors) + let text = displayGlucosePreference.format(bound, includeUnit: false) + switch guardrail.classification(for: bound) { + case .withinRecommendedRange: + return Text(text) + .foregroundColor(.accentColor) + .font(.system(size: 42, weight: .semibold)) + case .outsideRecommendedRange: + return ( + Text(Image(systemName: "exclamationmark.triangle.fill")) + .font(.system(size: 29, weight: .regular)) + .baselineOffset(3.0) + .foregroundColor(color) + + Text(text) + .foregroundColor(color) + .font(.system(size: 42, weight: .semibold)) + ) + } + } + + var body: some View { + VStack(spacing: 0) { + List { + VStack(spacing: 24) { + VStack(spacing: 8) { + HStack { + Text("Correction Range") + .foregroundColor(.secondary) + .font(.system(size: 14)) + Image(systemName: "info.circle") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.accentColor) + .frame(width: UIFontMetrics.default.scaledValue(for: 14), height: UIFontMetrics.default.scaledValue(for: 14)) + } + .padding(.top, 10) + + + Text("Set your correction range") + .font(.title2) + .fontWeight(.bold) + .multilineTextAlignment(.center) + .padding(.top, 10) + + Text("To reduce the risk of highs or lows, you may want to set an adjusted range if you think your glucose will vary more than usual.") + .multilineTextAlignment(.center) + } + + VStack(spacing: 0) { + Text("Adjusted Range") + + ( + boundText(for: (displayedRange).lowerBound) + + Text("-").foregroundColor(.secondary) + .font(.system(size: 42, weight: .light)) + + + boundText(for: (displayedRange).upperBound) + ) + + + Text("mg/dL") + .foregroundColor(.secondary) + } + + Divider() + + GlucoseRangePicker(range: Binding( + get: { displayedRange }, + set: { editedRange = $0 }), + unit: displayGlucosePreference.unit, + minValue: nil, + guardrail: guardrail) + .padding(.vertical, -20) + + HStack(spacing: 8) { + Image(systemName: "info.circle") + .foregroundColor(.accentColor) + + tipText.font(.system(size: 14)) + } + .padding() + .overlay( /// apply a rounded border + RoundedRectangle(cornerRadius: 8) + .stroke(.gray, lineWidth: 1) + ) + } + } + actionArea + } + .navigationBarBackButtonHidden(editedRange != nil) + .navigationBarItems( + trailing: cancelButton + ) + .navigationBarTitleDisplayMode(.inline) + .navigationTitle("Edit Preset") + .edgesIgnoringSafeArea(.bottom) + } + + private var tipText: some View { + Group { + if isPreMeal { + Text("To help avoid post-meal highs, set a range ") + + Text("lower") + .italic() + .bold() + + Text(" than your typical correction range.") + } else { + Text("To help avoid lows, set a range ") + + Text("higher") + .italic() + .bold() + + Text(" than your typical correction range.") + } + } + } + + private var cancelButton: some View { + Group { + if editedRange != nil { + Button("Cancel") { + dismiss() + } + .foregroundColor(.blue) + } + } + } + + + private var actionArea: some View { + VStack(spacing: 0) { + guardrailWarningIfNecessary + actionButton + } + .background(Color(.secondarySystemGroupedBackground).shadow(radius: 5)) + } + + private var actionButton: some View { + Button("Save") { + range = editedRange + dismiss() + } + .disabled(editedRange == nil) + .buttonStyle(ActionButtonStyle(.primary)) + .padding() + } + + + var crossedThresholds: [SafetyClassification.Threshold] { + if let range = editedRange ?? range { + let lowerBound = range.lowerBound + let upperBound = range.upperBound + return [lowerBound, upperBound].compactMap { (bound) -> SafetyClassification.Threshold? in + switch guardrail.classification(for: bound) { + case .withinRecommendedRange: + return nil + case .outsideRecommendedRange(let threshold): + return threshold + } + } + } else { + return [] + } + } + + var guardrailWarningIfNecessary: some View { + let crossedThresholds = self.crossedThresholds + return Group { + if !crossedThresholds.isEmpty { + CorrectionRangeGuardrailWarning(crossedThresholds: crossedThresholds) + } + }.padding() + } +} + +private struct CorrectionRangeGuardrailWarning: View { + var crossedThresholds: [SafetyClassification.Threshold] + + var body: some View { + assert(!crossedThresholds.isEmpty) + return GuardrailWarning( + therapySetting: .glucoseTargetRange, + title: crossedThresholds.count == 1 ? singularWarningTitle(for: crossedThresholds.first!) : multipleWarningTitle, + thresholds: crossedThresholds + ) + } + + private func singularWarningTitle(for threshold: SafetyClassification.Threshold) -> Text { + switch threshold { + case .minimum, .belowRecommended: + return Text("Low Correction Value", comment: "Title text for the low correction value warning") + case .aboveRecommended, .maximum: + return Text("High Correction Value", comment: "Title text for the high correction value warning") + } + } + + private var multipleWarningTitle: Text { + Text("Correction Values", comment: "Title text for multi-value correction value warning") + } +} diff --git a/Loop/Views/Presets/Media Player/AudioPlayer.swift b/Loop/Views/Presets/Media Player/AudioPlayer.swift new file mode 100644 index 0000000000..272e130360 --- /dev/null +++ b/Loop/Views/Presets/Media Player/AudioPlayer.swift @@ -0,0 +1,90 @@ +// +// AudioPlayer.swift +// Podcast Demo +// +// Created by Cameron Ingham on 3/20/25. +// + +import AVKit +import LoopKit +import SwiftUI + +struct AudioPlayerView: View { + var fileName: String? + var url: URL? + + @State private var player: AVAudioPlayer? + @State private var isPlaying = false + @State private var totalTime: TimeInterval = 0.0 + @State private var currentTime: TimeInterval = 0.0 + + var body: some View { + VStack { + if let player = player { + Text(fileName ?? "File") + + HStack { + Button(action: { + isPlaying.toggle() + if isPlaying { + player.play() + } else { + player.pause() + } + }) { + Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.largeTitle) + } + .buttonStyle(PlainButtonStyle()) + + Slider(value: Binding(get: { + currentTime + }, set: { newValue in + player.currentTime = newValue + currentTime = newValue + }), in: 0...totalTime) + .accentColor(.blue) + } + + HStack { + Text("\(formatTime(currentTime))") + Spacer() + Text("\(formatTime(totalTime))") + } + .padding(.horizontal) + } + } + .onAppear { + if let url = url { + setupAudio(withURL: url) + } + } + .onReceive(Timer.publish(every: 0.01, on: .main, in: .common).autoconnect()) { _ in + updateProgress() + } + .onDisappear { + player?.stop() + } + } + + private func formatTime(_ time: TimeInterval) -> String { + let seconds = Int(time) % 60 + let minutes = Int(time) / 60 + return String(format: "%02d:%02d", minutes, seconds) + } + + private func setupAudio(withURL url: URL) { + do { + player = try AVAudioPlayer(contentsOf: url) + player?.prepareToPlay() + totalTime = player?.duration ?? 0.0 + } catch { + print("Error loading audio: \(error)") + } + } + + private func updateProgress() { + guard let player = player, player.isPlaying else { return } + currentTime = player.currentTime + } +} diff --git a/Loop/Views/Presets/Media Player/CaptionsView.swift b/Loop/Views/Presets/Media Player/CaptionsView.swift new file mode 100644 index 0000000000..fe5bee4bee --- /dev/null +++ b/Loop/Views/Presets/Media Player/CaptionsView.swift @@ -0,0 +1,31 @@ +// +// CaptionsView.swift +// Podcast Demo +// +// Created by Cameron Ingham on 3/21/25. +// + +import LoopKit +import SwiftUI + +struct CaptionsView: View { + + @Binding var currentTime: TimeInterval + + let captions: ClosedCaptions + + private var currentCaptionFragment: ClosedCaptionFragment? { + captions.currentFragment(at: currentTime) + } + + var body: some View { + if let currentCaptionFragment { + Text(currentCaptionFragment.text) + .multilineTextAlignment(.leading) + .foregroundStyle(.white) + .font(.subheadline) + .padding(8) + .background(Color.black.opacity(0.66).clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))) + } + } +} diff --git a/Loop/Views/Presets/Media Player/MediaPlayerView.swift b/Loop/Views/Presets/Media Player/MediaPlayerView.swift new file mode 100644 index 0000000000..fa430a2b82 --- /dev/null +++ b/Loop/Views/Presets/Media Player/MediaPlayerView.swift @@ -0,0 +1,203 @@ +// +// MediaPlayerView.swift +// Podcast Demo +// +// Created by Cameron Ingham on 3/11/25. +// + +import AVKit +import LoopKit +import SwiftUI + +struct MediaPlayerView: View { + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.dismiss) private var dismiss + + private let media: MediaContent + + @State private var player: AVAudioPlayer + @State private var minHeight: Double + @State private var sheetHeight: Double + @State private var miniPlayer: Bool + @State private var captionsEnabled: Bool + @State private var isPaused: Bool + @State private var currentTime: TimeInterval + + init( + media: MediaContent, + minHeight: Double = 0, + sheetHeight: Double = 0, + miniPlayer: Bool = false, + captionsEnabled: Bool = false + ) { + self.player = try! AVAudioPlayer(contentsOf: media.audio) + self.media = media + self.minHeight = minHeight + self.sheetHeight = sheetHeight + self.miniPlayer = miniPlayer + self.captionsEnabled = captionsEnabled + self.isPaused = true + self.currentTime = 0 + } + + private var dragGesture: some Gesture { + DragGesture(coordinateSpace: .global) + .onChanged { value in + withAnimation(.default.speed(10)) { + sheetHeight = max(minHeight, UIScreen.main.bounds.height - value.location.y) + } + } + .onEnded { value in + withAnimation(reduceMotion ? nil : .default) { + sheetHeight = (UIScreen.main.bounds.height - value.location.y).findClosest(in: [minHeight, UIScreen.main.bounds.height / 2, UIScreen.main.bounds.height]) + } + } + } + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .bottom) { + ZStack(alignment: .topTrailing) { + Group { + VideoView(isPaused: $isPaused, media: media) + .centerCropped() + } + .edgesIgnoringSafeArea(.all) + .padding(.bottom, sheetHeight) + + Button { + dismiss() + } label: { + ZStack { + Circle() + .fill(Color.secondary) + .frame(width: 24, height: 24) + + Image(systemName: "xmark") + .font(.caption.weight(.semibold)) + .fontDesign(.rounded) + .foregroundColor(Color(UIColor.secondarySystemBackground)) + } + .padding(20) + .contentShape(Circle()) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityLabel(Text("Close")) + .opacity(sheetHeight <= UIScreen.main.bounds.height - 130 ? 1 : 0) + .animation(.default, value: sheetHeight) + .padding(.top, -16) + } + + VStack(spacing: 16) { + if captionsEnabled && sheetHeight <= UIScreen.main.bounds.height - 190 { + CaptionsView(currentTime: $currentTime, captions: media.closedCaptions) + .padding(.horizontal) + } + + SheetView( + minHeight: $minHeight, + sheetHeight: $sheetHeight, + miniPlayer: $miniPlayer, + isPaused: $isPaused, + currentTime: $currentTime, + captionsEnabled: $captionsEnabled, + media: media, + player: player, + topSafeAreaInset: miniPlayer ? geometry.safeAreaInsets.top : 0 + ) + .frame(height: sheetHeight) + .gesture(media.transcript != nil ? dragGesture : nil) + } + } + .onChange(of: minHeight) { _, newValue in + if sheetHeight == 0 { + sheetHeight = newValue + } + } + .onChange(of: sheetHeight) { _, newValue in + withAnimation(reduceMotion ? nil : .default) { + miniPlayer = newValue >= (UIScreen.main.bounds.height - geometry.safeAreaInsets.top) + } + } + } + .ignoresSafeArea(edges: .bottom) + } +} + +struct SheetView: View { + + @Environment(\.accessibilityReduceMotion) var reduceMotion + + @Binding private var minHeight: Double + @Binding private var sheetHeight: Double + @Binding private var miniPlayer: Bool + @Binding private var isPaused: Bool + @Binding private var currentTime: TimeInterval + @Binding private var captionsEnabled: Bool + + @State private var scrollPosition: TranscriptExcerpt? + + private let media: MediaContent + private let player: AVAudioPlayer + private let topSafeAreaInset: Double + + init( + minHeight: Binding, + sheetHeight: Binding, + miniPlayer: Binding, + isPaused: Binding, + currentTime: Binding, + captionsEnabled: Binding, + media: MediaContent, + player: AVAudioPlayer, + topSafeAreaInset: Double + ) { + self._minHeight = minHeight + self._sheetHeight = sheetHeight + self._miniPlayer = miniPlayer + self._isPaused = isPaused + self._currentTime = currentTime + self._captionsEnabled = captionsEnabled + self.media = media + self.player = player + self.topSafeAreaInset = topSafeAreaInset + } + + var body: some View { + VStack(spacing: 0) { + PlayerControls(player: player, height: $minHeight, mini: $miniPlayer, isPaused: $isPaused, currentTime: $currentTime, captionsEnabled: $captionsEnabled, media: media) + .onTapGesture { + if miniPlayer { + withAnimation(reduceMotion ? nil : .default) { + sheetHeight = UIScreen.main.bounds.height / 2 + } + } + } + + if let transcript = media.transcript { + ScrollViewReader { proxy in + ScrollView { + TranscriptView( + currentTime: $currentTime, + transcript: transcript, + onExcerptTap: { + player.currentTime = $0.startTime + }, + onExcerptChanged: { + proxy.scrollTo($0.text, anchor: .top) + } + ) + .padding(.horizontal, 20) + .padding(.top, 32) + .padding(.bottom, topSafeAreaInset + 32) + } + } + } + } + .onDisappear { + isPaused = true + } + .persistentSystemOverlays(.hidden) + } +} diff --git a/Loop/Views/Presets/Media Player/PlayMediaButton.swift b/Loop/Views/Presets/Media Player/PlayMediaButton.swift new file mode 100644 index 0000000000..4b58783f3a --- /dev/null +++ b/Loop/Views/Presets/Media Player/PlayMediaButton.swift @@ -0,0 +1,75 @@ +// +// PlayMediaButton.swift +// Loop +// +// Created by Cameron Ingham on 9/4/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopKit +import SwiftUI + +struct PlayMediaButton: View { + + let mediaContent: MediaContent + var onPlay: (MediaContent) -> Void = { _ in } + + @State private var duration: TimeInterval = 0 + + private let formatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.minute, .second] + formatter.unitsStyle = .abbreviated + return formatter + }() + + private var image: Image { + Image(mediaContent.staticImage.name, bundle: mediaContent.staticImage.bundle) + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + image + .resizable() + .scaledToFill() + .frame(height: 160) + .frame(maxWidth: .infinity) + .clipped() + .padding([.top, .horizontal], -8) + .overlay { + Image("Play") + .resizable() + .scaledToFill() + .frame(width: 64, height: 64) + } + + Text(mediaContent.metadata.title) + .font(.headline.weight(.semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + + ViewThatFits { + HStack(spacing: 4) { + Text("Tap to listen") + Text("•") + Text(formatter.string(from: duration) ?? "") + } + + VStack(alignment: .leading, spacing: 4) { + Text("Tap to listen") + Text(formatter.string(from: duration) ?? "") + } + } + .foregroundStyle(.secondary) + } + .padding(8) + .background(Color(UIColor.systemBackground)) + .cornerRadius(10) + .shadow(color: .primary.opacity(0.2), radius: 3, x: 0, y: 0) + .task { + self.duration = (try? await mediaContent.duration) ?? 0 + } + .onTapGesture { + onPlay(mediaContent) + } + } +} diff --git a/Loop/Views/Presets/Media Player/PlayerControls.swift b/Loop/Views/Presets/Media Player/PlayerControls.swift new file mode 100644 index 0000000000..b7eb3f269c --- /dev/null +++ b/Loop/Views/Presets/Media Player/PlayerControls.swift @@ -0,0 +1,353 @@ +// +// PlayerControls.swift +// Podcast Demo +// +// Created by Cameron Ingham on 2/27/25. +// + +import AVKit +import LoopKit +import SwiftUI + +struct PlayerControls: View { + + @Namespace private var animation + + @State private var totalTime: TimeInterval + @State private var playbackSpeed: Double + + @Binding private var height: Double + @Binding private var mini: Bool + @Binding private var isPaused: Bool + @Binding private var currentTime: TimeInterval + @Binding private var captionsEnabled: Bool + + private let media: MediaContent + private let player: AVAudioPlayer + + init( + player: AVAudioPlayer, + totalTime: TimeInterval = 0, + playbackSpeed: Double = 1, + height: Binding, + mini: Binding, + isPaused: Binding, + currentTime: Binding, + captionsEnabled: Binding, + media: MediaContent + ) { + self.player = player + self.totalTime = totalTime + self.playbackSpeed = playbackSpeed + self._height = height + self._mini = mini + self._isPaused = isPaused + self._currentTime = currentTime + self._captionsEnabled = captionsEnabled + self.media = media + } + + @ViewBuilder + private func playbackSpeedLabel(_ speed: Double) -> some View { + ZStack(alignment: .leading) { + // Added so the menu button takes the width of the largest option so the parent HStack doesn't shift the other elements. + Group { Text("0.5") + Text(Image(systemName: "xmark")).font(.caption2) }.opacity(0) + + switch speed { + case 0.5: Text("0.5") + Text(Image(systemName: "xmark")).font(.caption2) + case 1: Text("1") + Text(Image(systemName: "xmark")).font(.caption2) + case 2: Text("2") + Text(Image(systemName: "xmark")).font(.caption2) + default: Text("\(Int(speed))") + Text(Image(systemName: "xmark")).font(.caption2) + } + } + } + + @ViewBuilder + private func playbackSpeedText(_ speed: Double) -> some View { + switch speed { + case 0.5: Text("0.5x") + case 1: Text("1x") + case 2: Text("2x") + default: Text("\(Int(speed))x") + } + } + + private func playbackSpeedMenuOptions() -> [Double] { + if playbackSpeed == 1 { + return [2, 0.5] + } else if playbackSpeed == 0.5 { + return [2, 1] + } else { + return [1, 0.5] + } + } + + @ViewBuilder + private var fullMetadata: some View { + VStack(alignment: .leading, spacing: 0) { + Text(media.metadata.title) + .multilineTextAlignment(.leading) + .font(.title3.bold()) + .frame(maxWidth: .infinity, alignment: .leading) + .matchedGeometryEffect(id: "title", in: animation) + .fixedSize(horizontal: false, vertical: true) + + Text("by \(media.metadata.author)") + .multilineTextAlignment(.leading) + .matchedGeometryEffect(id: "subtitle", in: animation) + .fixedSize(horizontal: false, vertical: true) + } + .foregroundStyle(.primary) + } + + @ViewBuilder + private var miniMetadata: some View { + VStack(alignment: .leading, spacing: 0) { + Text(media.metadata.title) + .multilineTextAlignment(.leading) + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + .matchedGeometryEffect(id: "title", in: animation) + .fixedSize(horizontal: false, vertical: true) + + Text("by \(media.metadata.author)") + .multilineTextAlignment(.leading) + .matchedGeometryEffect(id: "subtitle", in: animation) + .fixedSize(horizontal: false, vertical: true) + } + .foregroundStyle(.white) + } + + @ScaledMetric private var skipIconSize: Double = 24 + @ScaledMetric private var largePlayPauseIconSize: Double = 48 + @ScaledMetric private var miniPlayPauseIconSize: Double = 27 + + @ViewBuilder + private var fullControls: some View { + HStack { + Menu { + ForEach(playbackSpeedMenuOptions(), id: \.self) { speed in + Button { + playbackSpeed = speed + } label: { + playbackSpeedText(speed) + } + } + } label: { + playbackSpeedLabel(playbackSpeed) + } + .tint(Color(UIColor.systemGray)) + + Spacer() + + HStack(spacing: 32) { + Button { + player.currentTime -= 15 + } label: { + Text(Image(systemName: "15.arrow.trianglehead.counterclockwise")) + .font(.system(size: skipIconSize)) + } + .tint(Color(UIColor.systemGray)) + + Button { + isPaused.toggle() + } label: { + Text(Image(systemName: isPaused ? "play.circle.fill" : "pause.circle.fill")) + .font(.system(size: largePlayPauseIconSize)) + .transition(.symbolEffect) + } + .matchedGeometryEffect(id: "playButton", in: animation) + + Button { + player.currentTime += 15 + } label: { + Text(Image(systemName: "15.arrow.trianglehead.clockwise")) + .font(.system(size: skipIconSize)) + } + .tint(Color(UIColor.systemGray)) + } + + Spacer() + + Button { + withAnimation { + captionsEnabled.toggle() + } + } label: { + Image(systemName: "captions.bubble") + } + .tint(captionsEnabled ? .accentColor : Color(UIColor.systemGray)) + } + } + + @ViewBuilder + private var miniControls: some View { + Button { + isPaused.toggle() + } label: { + Text(Image(systemName: isPaused ? "play.circle.fill" : "pause.circle.fill")) + .font(.system(size: miniPlayPauseIconSize)) + .transition(.symbolEffect) + } + .tint(.white) + .matchedGeometryEffect(id: "playButton", in: animation) + } + + var body: some View { + Group { + if mini { + VStack(alignment: .leading, spacing: 24) { + HStack(spacing: 0) { + miniMetadata + + Spacer() + + miniControls + } + .padding(.horizontal, 20) + + TimelineView( + mini: true, + totalTime: $totalTime, + currentTime: $currentTime, + player: player + ) + } + .padding(.top, 24) + } else { + VStack(alignment: .leading, spacing: 0) { + fullMetadata + .padding(.bottom, 16) + + TimelineView( + totalTime: $totalTime, + currentTime: $currentTime, + player: player + ) + .padding(.bottom, 4) + + fullControls + } + .padding(20) + .padding(.bottom, 12) + .background { + GeometryReader { proxy in + Color.clear + .onAppear { + height = proxy.size.height + } + } + } + } + } + .background { + Group { + mini ? Color.accentColor : Color(UIColor.systemBackground) + } + .ignoresSafeArea(edges: .top) + .shadow(color: .secondary.opacity(0.2) , radius: 3, y: 2) + } + .onAppear { + player.prepareToPlay() + player.enableRate = true + try? AVAudioSession.sharedInstance().setCategory(.playback) + try? AVAudioSession.sharedInstance().setActive(true) + totalTime = player.duration + } + .onChange(of: isPaused) { _, newValue in + if isPaused { + player.pause() + } else { + player.play() + } + } + .onChange(of: playbackSpeed) { _, newValue in + player.rate = Float(newValue) + } + .onReceive(Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()) { _ in + if player.isPlaying == false { + isPaused = true + } + } + } +} + +private struct TimelineView: View { + + @Namespace private var animation + + @State var mini: Bool = false + + @Binding var totalTime: TimeInterval + @Binding var currentTime: TimeInterval + + let player: AVAudioPlayer + + private var progress: Double { + guard totalTime > 0 else { + return 1.0 + } + + let percentage = currentTime / totalTime + + return min(max(percentage, 0.0), 1.0) + } + + private var timeRemaining: TimeInterval { + totalTime - currentTime + } + + var body: some View { + Group { + if mini { + Color.black.opacity(0.3) + .frame(height: 4) + .containerRelativeFrame(.horizontal) { size, axis in + size * progress + } + .matchedGeometryEffect(id: "timeline", in: animation) + } else { + VStack(spacing: 2) { + Slider( + value: Binding( + get: { + progress + }, + set: { newValue, _ in + player.currentTime = min(max(newValue, 0.0), 1.0) * totalTime + } + ), + in: 0...1 + ) + .onAppear { + let size = CGSize(width: 12, height: 12) + let image = UIGraphicsImageRenderer(size: size).image { _ in + UIImage(systemName: "circle.fill")?.draw(in: CGRect(origin: .zero, size: size)) + }.withRenderingMode(.alwaysTemplate) + + UISlider.appearance().setThumbImage(image, for: .normal) + } + .matchedGeometryEffect(id: "timeline", in: animation) + HStack { + Text(formatTime(currentTime)) + + Spacer() + + Text("-") + Text(formatTime(timeRemaining)) + } + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .onReceive(Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()) { _ in + currentTime = player.currentTime + } + } + + private func formatTime(_ time: TimeInterval) -> String { + let seconds = Int(time) % 60 + let minutes = Int(time) / 60 + return String(format: "%02d:%02d", minutes, seconds) + } +} diff --git a/Loop/Views/Presets/Media Player/TranscriptView.swift b/Loop/Views/Presets/Media Player/TranscriptView.swift new file mode 100644 index 0000000000..d63100a822 --- /dev/null +++ b/Loop/Views/Presets/Media Player/TranscriptView.swift @@ -0,0 +1,119 @@ +// +// TranscriptView.swift +// Podcast Demo +// +// Created by Cameron Ingham on 7/16/25. +// + +import LoopKit +import SwiftUI + +struct TranscriptView: View { + @Binding var currentTime: TimeInterval + + let transcript: Transcript + let onExcerptTap: (TranscriptExcerpt) -> Void + let onExcerptChanged: (TranscriptExcerpt) -> Void + + private var currentTranscriptExcerpt: TranscriptExcerpt { + transcript.currentExcerpt(at: currentTime) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + ForEach(transcript.paragraphs, id: \.self) { paragraph in + paragraph.excerpts.reduce(AttributedText("")) { partialResult, excerpt in + if partialResult != AttributedText("") { + return partialResult + AttributedText(" ") + excerptText(excerpt: excerpt) + } else { + return partialResult + excerptText(excerpt: excerpt) + } + } + } + } + .font(.title3.weight(.medium)) + .fontDesign(.serif) + .onChange(of: currentTranscriptExcerpt) { _, newValue in + onExcerptChanged(newValue) + } + } + + private func excerptText(excerpt: TranscriptExcerpt) -> AttributedText { + AttributedText(excerpt.text) { attributedText in + attributedText.foregroundColor = (currentTranscriptExcerpt == excerpt && currentTime != 0) ? .accentColor : .primary + } onTap: { + onExcerptTap(excerpt) + } + } +} + +public struct AttributedText: View, Equatable { + private var id: String + private var attributedString: AttributedString + private var onTap: (() -> Void)? = nil + private var tapHandlers: [String: () -> Void] = [:] + private var currentId: Int = 0 + + private mutating func registerTapHandler(_ handler: @escaping () -> Void) -> String { + let _id = "tappable-\(currentId)" + currentId += 1 + tapHandlers[_id] = handler + return _id + } + + private func getTapHandler(for _id: String) -> (() -> Void)? { + return tapHandlers[_id] + } + + public init( + _ string: String = "", + modifier: ((_ text: inout AttributedString) -> Void)? = nil, + onTap: (() -> Void)? = nil + ) { + var attributedString = AttributedString(string) + + modifier?(&attributedString) + + self.id = string + self.attributedString = attributedString + self.onTap = onTap + } + + public static func + (lhs: Self, rhs: Self) -> Self { + var result = lhs + var rhsString = rhs.attributedString + + if let onTap = rhs.onTap { + let id = result.registerTapHandler(onTap) + rhsString.link = URL(string: "tappable://\(id)") + } + + result.attributedString.append(rhsString) + return result + } + + public var body: some View { + Text(attributedString) + .id(id) + .environment(\.openURL, OpenURLAction { url in + if let _id = url.host { + getTapHandler(for: _id)?() + } + return .discarded + }) + } + + public func onTap(_ action: @escaping () -> Void) -> Self { + var copy = self + copy.onTap = action + return copy + } + + public static func += (lhs: inout Self, rhs: Self) { + lhs = lhs + rhs + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.attributedString == rhs.attributedString + } +} diff --git a/Loop/Views/Presets/Media Player/VideoView.swift b/Loop/Views/Presets/Media Player/VideoView.swift new file mode 100644 index 0000000000..66f4ff4f52 --- /dev/null +++ b/Loop/Views/Presets/Media Player/VideoView.swift @@ -0,0 +1,93 @@ +// +// VideoView.swift +// Podcast Demo +// +// Created by Cameron Ingham on 3/20/25. +// + +import AVKit +import LoopKit +import SwiftUI + +struct VideoView: View { + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + @Binding var isPaused: Bool + + let media: MediaContent + + var body: some View { + _VideoPlayer(media: media, isPaused: $isPaused) + } +} + +struct _VideoPlayer : UIViewControllerRepresentable { + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private let player: AVPlayer + + @Binding private var isPaused: Bool + + init(media: MediaContent, isPaused: Binding) { + self.player = AVPlayer(url: media.animation) + self._isPaused = .init(projectedValue: isPaused) + } + + func makeCoordinator() -> Coordinator { + Coordinator(player: player, reduceMotion: reduceMotion) + } + + func makeUIViewController(context: UIViewControllerRepresentableContext<_VideoPlayer>) -> AVPlayerViewController { + let controller = AVPlayerViewController() + controller.player = player + controller.showsPlaybackControls = false + controller.videoGravity = .resizeAspectFill + controller.allowsPictureInPicturePlayback = false + return controller + } + + func updateUIViewController(_ uiViewController: AVPlayerViewController, context: UIViewControllerRepresentableContext<_VideoPlayer>) { + if !reduceMotion { + if isPaused { + uiViewController.player?.pause() + } else { + uiViewController.player?.play() + } + } else { + uiViewController.player?.pause() + } + } + + class Coordinator: NSObject { + + private let player: AVPlayer + private let reduceMotion: Bool + + init(player: AVPlayer, reduceMotion: Bool) { + self.player = player + self.reduceMotion = reduceMotion + + super.init() + + NotificationCenter.default.addObserver( + self, + selector: #selector(playerItemDidReachEnd(notification:)), + name: AVPlayerItem.didPlayToEndTimeNotification, + object: player.currentItem + ) + } + + @objc + private func playerItemDidReachEnd(notification: Notification) { + if let playerItem: AVPlayerItem = notification.object as? AVPlayerItem { + playerItem.seek(to: .zero) { _ in } + + if !reduceMotion { + player.play() + } + } + } + } +} diff --git a/Loop/Views/Presets/PresetPerformanceHistoryView.swift b/Loop/Views/Presets/PresetPerformanceHistoryView.swift new file mode 100644 index 0000000000..c0eec2209d --- /dev/null +++ b/Loop/Views/Presets/PresetPerformanceHistoryView.swift @@ -0,0 +1,404 @@ +// +// PresetPerformanceHistoryView.swift +// Loop +// +// Created by Cameron Ingham on 3/12/26. +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit +import LoopKitUI +import SwiftUI + +struct PresetPerformanceHistoryView: View { + + private enum DateRange: Hashable { + case preset + case presetPlus6Hours + + var localizedTitle: String { + switch self { + case .preset: return NSLocalizedString("During Preset", comment: "") + case .presetPlus6Hours: return NSLocalizedString("Preset +6 Hours", comment: "") + } + } + + static func allCases(allowPlus6Hours: Bool) -> [DateRange] { + if allowPlus6Hours { + return [.preset, .presetPlus6Hours] + } else { + return [.preset] + } + } + } + + private enum DataState { + case loading + case loaded(PresetsPerformanceHistoryViewModel.PerformanceData, plus6Hours: PresetsPerformanceHistoryViewModel.PerformanceData) + } + + + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + + @Environment(\.colorPalette) private var colorPalette + @Environment(\.settingsManager) private var settingsManager + + @State private var state: DataState = .loading + @State private var selectedDateRange: DateRange = .preset + + private let insulinFormatter = QuantityFormatter(for: .internationalUnit) + private let carbFormatter = QuantityFormatter(for: .gram) + + let preset: SelectablePreset + let override: TemporaryScheduleOverride + let presetsPerformanceHistoryViewModel: PresetsPerformanceHistoryViewModel + + private var show6hrData: Bool { + guard !override.isActive() else { + return false + } + + return override.actualEndDate.addingTimeInterval(.hours(6)) <= Date() + } + + private var title: some View { + HStack(spacing: 4) { + if let icon = preset.icon, !icon.isEmpty { + PresetSymbolView(icon, iconSize: UIFontMetrics.default.scaledValue(for: 28)) + } + + Text(preset.name) + .fontWeight(.bold) + } + .font(.largeTitle) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + title + + switch state { + case .loading: + ActivityIndicator(isAnimating: .constant(true), style: .medium) + .frame(maxWidth: .infinity) + case .loaded(let data, let dataPlus6Hours): + VStack(spacing: 16) { + if data.minimalData { + minimalDataSection + } + + dateAndSettingsSection(performanceData: data) + + Picker("", selection: $selectedDateRange) { + ForEach(DateRange.allCases(allowPlus6Hours: true), id: \.self) { option in + Text(option.localizedTitle) + } + } + .pickerStyle(SegmentedPickerStyle()) + + detailsSection( + performanceData: selectedDateRange == .preset ? data : dataPlus6Hours, + showNoData: selectedDateRange == .presetPlus6Hours && !show6hrData + ) + } + } + } + .padding() + } + .animation(.default, value: selectedDateRange) + .background(Color(UIColor.secondarySystemBackground)) + .task { + await fetch() + } + } + + private var minimalDataSection: some View { + GroupBox { + HStack(spacing: 8) { + Image(systemName: "info.circle") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + + Text("This summary is based on a preset with less than 30 minutes of CGM readings.") + .font(.subheadline) + } + .foregroundStyle(Color.accentColor) + } + .backgroundStyle(Color(UIColor.systemBackground)) + } + + private func dateAndSettingsSection(performanceData: PresetsPerformanceHistoryViewModel.PerformanceData) -> some View { + GroupBox { + VStack(alignment: .leading, spacing: 12) { + if override.isActive(), let expectedEndTime = override.expectedEndTime { + HStack(spacing: 8) { + Text(Image(systemName: "timer")) + + + Text(" \(expectedEndTime.localizedTitle)") + .accessibilityLabel(Text(expectedEndTime.accessibilityLabel)) + } + .font(.footnote) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 5) + .background(Color(colorPalette.chartColorPalette.presetTint)) + .cornerRadius(8) + } else { + Text(performanceData.dateRange()) + .fontWeight(.semibold) + } + + Divider() + + PresetStatsView( + insulinMultiplier: performanceData.overallInsulin, + correctionRange: performanceData.correctionRange, + guardrail: settingsManager.correctionRangeGuardrailForPreset(preset), + therapySettingsImpactDisplayState: .hide, + isScheduled: false, // Not needed for hidden impact + isActive: false, // Not needed for hidden impact + effectiveCorrectionRange: { nil } // Not needed for hidden impact + ) + } + } + .backgroundStyle(Color(UIColor.systemBackground)) + } + + private func detailsSection(performanceData: PresetsPerformanceHistoryViewModel.PerformanceData, showNoData: Bool) -> some View { + GroupBox { + if showNoData || performanceData.allGlucoseValues.isEmpty { + Image("performance-history-empty") + .resizable() + .scaledToFit() + .frame(width: 64, height: 64) + .padding(20) + .background(Color(UIColor.systemBackground).clipShape(Circle())) + + VStack(spacing: 4) { + Text("No performance history available yet") + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + + if showNoData { + Text("You can see this summary 6 hours after the preset ends.") + .multilineTextAlignment(.center) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } else { + VStack(alignment: .leading, spacing: 24) { + Text(performanceData.dateRange(overrideEndDate: override.isActive() ? Date() : nil)) + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + + VStack(alignment: .leading, spacing: 8) { + Text("Glucose Summary") + .fontWeight(.semibold) + + VStack(alignment: .leading, spacing: 4) { + if let startingGlucose = performanceData.startingGlucose { + LabeledContent("Starting Glucose") { + Group { Text(displayGlucosePreference.format(startingGlucose, includeUnit: false)).fontWeight(.semibold).foregroundStyle(.primary) + Text(" ") + Text(displayGlucosePreference.unit.localizedShortUnitString).foregroundStyle(.secondary) }.contentTransition(.numericText()) + } + } + + if let averageGlucose = performanceData.averageGlucose { + LabeledContent("Average Glucose") { + Group { Text(displayGlucosePreference.format(averageGlucose, includeUnit: false)).fontWeight(.semibold).foregroundStyle(.primary) + Text(" ") + Text(displayGlucosePreference.unit.localizedShortUnitString).foregroundStyle(.secondary) }.contentTransition(.numericText()) + } + } + } + } + + HStack(spacing: 24) { + StackedBarView( + segments: [ + .init(color: .glucoseVeryHigh, fraction: performanceData.timeInRange[.veryHigh] ?? 0), + .init(color: .glucoseHigh, fraction: performanceData.timeInRange[.high] ?? 0), + .init(color: .glucoseNormal, fraction: performanceData.timeInRange[.normal] ?? 0), + .init(color: .glucoseLow, fraction: performanceData.timeInRange[.low] ?? 0), + .init(color: .glucoseVeryLow, fraction: performanceData.timeInRange[.veryLow] ?? 0), + ] + ) + .frame(maxHeight: .infinity) + .accessibilityHidden(true) + + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 24) { + GridRow { + Group { Text(String(format: "%.0f", (performanceData.timeInRange[.veryHigh] ?? 0) * 100)).font(.title2).bold().fontDesign(.monospaced) + Text(" %").font(.footnote) } + .foregroundStyle(Color.glucoseVeryHigh) + .contentTransition(.numericText()) + + Text("Very High").font(.subheadline) + Text(" ") + Text(">\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 250), includeUnit: true))").font(.caption).foregroundStyle(.secondary) + } + + GridRow { + Group { Text(String(format: "%.0f", (performanceData.timeInRange[.high] ?? 0) * 100)).font(.title2).bold().fontDesign(.monospaced) + Text(" %").font(.footnote) } + .foregroundStyle(Color.glucoseHigh) + .contentTransition(.numericText()) + + Text("High").font(.subheadline) + Text(" ") + Text("\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 181), includeUnit: false))-\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 250), includeUnit: true))").font(.caption).foregroundStyle(.secondary) + } + + GridRow { + Group { Text(String(format: "%.0f", (performanceData.timeInRange[.normal] ?? 0) * 100)).font(.title2).bold().fontDesign(.monospaced) + Text(" %").font(.footnote) } + .foregroundStyle(Color.glucoseNormal) + .contentTransition(.numericText()) + + Text("Target").font(.subheadline).fontWeight(.semibold) + Text(" ") + Text("\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 70), includeUnit: false))-\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), includeUnit: true))").font(.caption).foregroundStyle(.secondary) + } + + GridRow { + Group { Text(String(format: "%.0f", (performanceData.timeInRange[.low] ?? 0) * 100)).font(.title2).bold().fontDesign(.monospaced) + Text(" %").font(.footnote) } + .foregroundStyle(Color.glucoseLow) + .contentTransition(.numericText()) + + Text("Low").font(.subheadline) + Text(" ") + Text("\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 54), includeUnit: false))-\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 69), includeUnit: true))").font(.caption).foregroundStyle(.secondary) + } + + GridRow { + Group { Text(String(format: "%.0f", (performanceData.timeInRange[.veryLow] ?? 0) * 100)).font(.title2).bold().fontDesign(.monospaced) + Text(" %").font(.footnote) } + .foregroundStyle(Color.glucoseVeryLow) + .contentTransition(.numericText()) + + Text("Very Low").font(.subheadline) + Text(" ") + Text("<\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 54), includeUnit: true))").font(.caption).foregroundStyle(.secondary) + } + } + } + .frame(minHeight: 240) + .frame(maxWidth: .infinity) + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text("Overview") + .fontWeight(.semibold) + + Grid(horizontalSpacing: 16) { + GridRow(alignment: .top) { + if let carbString = carbFormatter.string(from: performanceData.totalCarbs, includeUnit: false) { + VStack(spacing: 8) { + Image("carbs") + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + .foregroundStyle(Color.carbs) + + VStack { + Group { + Text(carbString).fontWeight(.semibold) + Text(" \(LoopUnit.gram.localizedShortUnitString)").font(.footnote) + } + .foregroundStyle(Color.carbs) + .contentTransition(.numericText()) + + Text("Total\nCarbs") + .multilineTextAlignment(.center) + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + } + } + } + + if let bolusString = insulinFormatter.string(from: performanceData.totalBolus, includeUnit: false) { + VStack(spacing: 8) { + Image("bolus") + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + .foregroundStyle(Color.insulin) + + VStack { + Group { + Text(bolusString).fontWeight(.semibold) + Text(" \(LoopUnit.internationalUnit.localizedShortUnitString)").font(.footnote) + } + .foregroundStyle(Color.insulin) + .contentTransition(.numericText()) + + Text("Total\nBolus") + .multilineTextAlignment(.center) + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + } + } + } + + VStack(spacing: 8) { + Image("automation-on-delivery-log") + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + + VStack { + Group { + Text(String(format: "%.0f", performanceData.timeInAutomation * 100)).fontWeight(.semibold) + Text(" %").font(.footnote) + } + .contentTransition(.numericText()) + + Text("Time in\nAutomation") + .multilineTextAlignment(.center) + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + } + } + } + } + } + } + } + } + .backgroundStyle(Color(UIColor.systemBackground)) + } + + private func fetch() async { + async let data = try? await presetsPerformanceHistoryViewModel.fetchData(from: override, add6Hours: false) + async let dataPlus6Hours = try? await presetsPerformanceHistoryViewModel.fetchData(from: override, add6Hours: true) + + let combined = await (data, dataPlus6Hours) + + if let data = combined.0, let dataPlus6Hours = combined.1 { + self.state = .loaded(data, plus6Hours: dataPlus6Hours) + } + } +} + +struct StackedBarView: View { + struct Segment { + let color: Color + let fraction: Double + } + + let segments: [Segment] + let cornerRadius: CGFloat = 8 + let width: CGFloat = 40 + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .top) { + ForEach(Array(segments.enumerated()), id: \.offset) { index, segment in + segment.color + .frame(height: heightFrom(index: index, totalHeight: geo.size.height)) + .frame(maxWidth: .infinity) + .offset(y: offsetFor(index: index, totalHeight: geo.size.height)) + } + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + } + .frame(width: width) + } + + private func offsetFor(index: Int, totalHeight: CGFloat) -> CGFloat { + segments[0.. CGFloat { + segments[index...].reduce(0.0) { $0 + $1.fraction } * totalHeight + } +} diff --git a/Loop/Views/Presets/PresetsHistoryView.swift b/Loop/Views/Presets/PresetsHistoryView.swift new file mode 100644 index 0000000000..4d55c29b41 --- /dev/null +++ b/Loop/Views/Presets/PresetsHistoryView.swift @@ -0,0 +1,141 @@ +// +// PresetsHistoryView.swift +// Loop +// +// Created by Cameron Ingham on 11/27/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit +import LoopKitUI +import SwiftUI + +struct PresetsHistoryView: View { + @Environment(\.colorPalette) private var colorPalette + @Environment(\.settingsManager) private var settingsManager + @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager + + let presetsPerformanceHistoryViewModel: PresetsPerformanceHistoryViewModel + + init( + temporaryPresetsManager: TemporaryPresetsManager, + glucoseStore: GlucoseStoreProtocol, + carbStore: CarbStoreProtocol, + doseStore: DoseStoreProtocol, + automationHistory: @escaping () -> [AutomationHistoryEntry] + ) { + presetsPerformanceHistoryViewModel = PresetsPerformanceHistoryViewModel( + temporaryPresetsManager: temporaryPresetsManager, + glucoseStore: glucoseStore, + carbStore: carbStore, + doseStore: doseStore, + automationHistory: automationHistory + ) + } + + let formatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute, .second] + formatter.unitsStyle = .abbreviated + return formatter + }() + + let now = Date() + + var overrides: Dictionary { + Dictionary( + grouping: temporaryPresetsManager.presetHistory.recentEvents + .map(\.override) + .filter({ $0.actualEndDate > now.addingTimeInterval(.days(-7)) }) + .sorted(by: { $0.startDate > $1.startDate }) + ) { override in + override.isActive() || override.actualEndDate > now.addingTimeInterval(.days(-1)) + } + } + + var body: some View { + Group { + if overrides.values.flatMap({ $0 }).isEmpty { + ZStack { + Color(UIColor.secondarySystemBackground) + .ignoresSafeArea(edges: .all) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + VStack(spacing: 16) { + Spacer() + + Image("performance-history-empty") + .resizable() + .scaledToFit() + .frame(width: 64, height: 64) + .padding(20) + .background(Color(UIColor.systemBackground).clipShape(Circle())) + + VStack(spacing: 4) { + Text("No performance history available yet") + .multilineTextAlignment(.center) + + Text("To see how presets can support you, review the training.") + .multilineTextAlignment(.center) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + } + .padding(.horizontal, 32) + } + } else { + List { + ForEach(Array(overrides.keys).sorted { $0 && !$1 }, id: \.self) { isLast24Hrs in + Section(isLast24Hrs ? NSLocalizedString("LAST 24 HOURS", comment: "Preset Performance History, Last 24 hrs, Section title") : NSLocalizedString("LAST 7 DAYS", comment: "Preset Performance History, Last 7 days, Section title")) { + ForEach(overrides[isLast24Hrs] ?? [], id: \.self) { override in + if let preset = temporaryPresetsManager.selectablePresets.first(where: { $0.id == override.presetId }) { + NavigationLink { + PresetPerformanceHistoryView( + preset: preset, + override: override, + presetsPerformanceHistoryViewModel: presetsPerformanceHistoryViewModel + ) + } label: { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 4) { + if let icon = preset.icon, !icon.isEmpty { + PresetSymbolView(icon) + } + + Text(preset.name) + .fontWeight(.semibold) + } + + if override.isActive(), let expectedEndTime = override.expectedEndTime { + HStack(spacing: 8) { + Text(Image(systemName: "timer")) + + + Text(" \(expectedEndTime.localizedTitle)") + .accessibilityLabel(Text(expectedEndTime.accessibilityLabel)) + } + .font(.footnote) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 5) + .background(Color(colorPalette.chartColorPalette.presetTint)) + .cornerRadius(8) + } else { + Text(PresetsPerformanceHistoryViewModel.dateRange(from: override.startDate, to: override.actualEndDate)) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + } + } + } + } + } + } + } + .navigationTitle("Performance History") + } +} diff --git a/Loop/Views/Presets/PresetsPerformanceHistoryViewModel.swift b/Loop/Views/Presets/PresetsPerformanceHistoryViewModel.swift new file mode 100644 index 0000000000..5d04392672 --- /dev/null +++ b/Loop/Views/Presets/PresetsPerformanceHistoryViewModel.swift @@ -0,0 +1,195 @@ +// +// PresetsPerformanceHistoryViewModel.swift +// Loop +// +// Created by Cameron Ingham on 3/5/26. +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit + +@MainActor +@Observable +class PresetsPerformanceHistoryViewModel { + private let temporaryPresetsManager: TemporaryPresetsManager + private let glucoseStore: GlucoseStoreProtocol + private let carbStore: CarbStoreProtocol + private let doseStore: DoseStoreProtocol + private let automationHistory: () -> [AutomationHistoryEntry] + + private static let calendar = Calendar.current + + private static var timeFormatter: DateFormatter = { + let timeFormatter = DateFormatter() + timeFormatter.dateFormat = "h:mm a" + return timeFormatter + }() + + private static var dayFormatter: DateFormatter = { + let dayFormatter = DateFormatter() + dayFormatter.dateFormat = "EEE M/d" + return dayFormatter + }() + + init(temporaryPresetsManager: TemporaryPresetsManager, glucoseStore: GlucoseStoreProtocol, carbStore: CarbStoreProtocol, doseStore: DoseStoreProtocol, automationHistory: @escaping () -> [AutomationHistoryEntry]) { + self.temporaryPresetsManager = temporaryPresetsManager + self.glucoseStore = glucoseStore + self.carbStore = carbStore + self.doseStore = doseStore + self.automationHistory = automationHistory + } + + func fetchData(from override: TemporaryScheduleOverride, add6Hours: Bool) async throws -> PerformanceData { + let overallInsulin = override.settings.effectiveInsulinNeedsScaleFactor + let correctionRange = override.settings.targetRange + let startDate = override.startDate + let endDate = override.actualEndDate + + let calculatedEndDate = add6Hours ? endDate.addingTimeInterval(.hours(6)) : endDate + + let allGlucoseValues = try await glucoseStore.getGlucoseSamples(start: startDate.addingTimeInterval(.minutes(-5)), end: calculatedEndDate) + + let totalCarbs = LoopQuantity( + unit: .gram, + doubleValue: try await carbStore.getCarbEntries(start: startDate, end: calculatedEndDate) + .map({ $0.quantity.doubleValue(for: .gram) }) + .reduce(0, +) + ) + + let totalBolus = LoopQuantity( + unit: .internationalUnit, + doubleValue: try await ((doseStore as? DoseStore)?.insulinDeliveryStore.getBoluses(start: startDate, end: calculatedEndDate) ?? []) + .map({ $0.deliveredUnits ?? 0 }) + .reduce(0, +) + ) + + let timeInAutomation = automationHistory() + .toTimeline(from: startDate, to: calculatedEndDate) + .percentageTrue(from: startDate, to: calculatedEndDate) + + return PerformanceData( + overallInsulin: overallInsulin, + correctionRange: correctionRange, + startDate: startDate, + endDate: calculatedEndDate, + startingGlucose: allGlucoseValues.last(where: { $0.endDate < startDate })?.quantity, + allGlucoseValues: Array(allGlucoseValues.drop(while: { $0.endDate < startDate })), + totalCarbs: totalCarbs, + totalBolus: totalBolus, + timeInAutomation: timeInAutomation + ) + } + + struct PerformanceData { + enum GlucoseRange { + case veryLow, low, normal, high, veryHigh + } + + func classifyValue(_ value: Double) -> GlucoseRange { + switch value { + case ..<54: return .veryLow + case ..<70: return .low + case ..<181: return .normal + case ..<251: return .high + default: return .veryHigh + } + } + + let overallInsulin: Double + let correctionRange: ClosedRange? + let startDate: Date + let endDate: Date + let startingGlucose: LoopQuantity? + let allGlucoseValues: [StoredGlucoseSample] + let totalCarbs: LoopQuantity + let totalBolus: LoopQuantity + let timeInAutomation: Double + + var averageGlucose: LoopQuantity? { + guard !allGlucoseValues.isEmpty else { return nil } + return LoopQuantity( + unit: .milligramsPerDeciliter, + doubleValue: ( + allGlucoseValues + .map({ $0.quantity.doubleValue(for: .milligramsPerDeciliter) }) + .reduce(0, +) / Double(allGlucoseValues.count) + ) + ) + } + + var timeInRange: [GlucoseRange: Double] { + guard allGlucoseValues.count > 0 else { return [:] } + + let sorted = allGlucoseValues.sorted { $0.startDate < $1.startDate } + var durations: [GlucoseRange: TimeInterval] = [:] + + for (index, sample) in sorted.enumerated() { + let nextDate = index + 1 < sorted.count ? sorted[index + 1].startDate : endDate + let duration = nextDate.timeIntervalSince(sample.startDate) + let range = classifyValue(sample.quantity.doubleValue(for: .milligramsPerDeciliter)) + durations[range, default: 0] += duration + } + + let total = durations.values.reduce(0, +) + return durations.mapValues { $0 / total } + } + + @MainActor + func dateRange(overrideEndDate: Date? = nil) -> String { + PresetsPerformanceHistoryViewModel.dateRange(from: startDate, to: overrideEndDate ?? endDate) + } + + var minimalData: Bool { + let sorted = allGlucoseValues.sorted(by: { $0.startDate < $1.startDate }) + guard let first = sorted.first, let last = sorted.last, first != last else { + return true + } + + return abs(first.startDate.timeIntervalSince(last.startDate)) < .minutes(30) + } + } + + static func dateRange(from startDate: Date, to endDate: Date) -> String { + let startIsToday = calendar.isDateInToday(startDate) + let endIsToday = calendar.isDateInToday(endDate) + let sameDay = calendar.isDate(startDate, inSameDayAs: endDate) + + let startTime = timeFormatter.string(from: startDate) + let endTime = timeFormatter.string(from: endDate) + + if startIsToday && endIsToday { + // Today: "Today, 1:32 PM - 2:32 PM" + return String(format: NSLocalizedString("Today, %1$@ - %2$@", comment: "The format string for the same day date range (1: start date)(2: end date)"), startTime, endTime) + + } else if sameDay { + // Single Day: "Sun 5/21, 1:32 PM - 2:32 PM" + return "\(dayFormatter.string(from: startDate)), \(startTime) - \(endTime)" + + } else { + // Multi Day: "Sun 5/21 2:05 PM – Mon 5/22 4:45 PM" + let startDay = dayFormatter.string(from: startDate) + let endDay = dayFormatter.string(from: endDate) + return "\(startDay) \(startTime) – \(endDay) \(endTime)" + } + } +} + +private extension [AbsoluteScheduleValue] { + func percentageTrue(from windowStart: Date, to windowEnd: Date) -> Double { + let windowDuration = windowEnd.timeIntervalSince(windowStart) + guard windowDuration > 0 else { return 0 } + + var totalTrueTime: TimeInterval = 0 + for entry in self { + let overlapStart = Swift.max(entry.startDate, windowStart) + let overlapEnd = Swift.min(entry.endDate, windowEnd) + let overlap = Swift.max(0, overlapEnd.timeIntervalSince(overlapStart)) + if entry.value { + totalTrueTime += overlap + } + } + + return totalTrueTime / windowDuration + } +} diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift new file mode 100644 index 0000000000..e31efd6219 --- /dev/null +++ b/Loop/Views/Presets/PresetsView.swift @@ -0,0 +1,384 @@ +// +// PresetsView.swift +// Loop +// +// Created by Cameron Ingham on 10/23/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit +import LoopKitUI +import SwiftUI +import LoopCore + +enum PresetSortOption: Int, CaseIterable { + case name + case lastUsed + case dateCreated + + var description: String { + switch self { + case .name: + return NSLocalizedString("Name", comment: "Preset sorting option description for sorting by name") + case .lastUsed: + return NSLocalizedString("Last Used", comment: "Preset sorting option description for sorting by last used") + case .dateCreated: + return NSLocalizedString("Date Created", comment: "Preset sorting option description for sorting by date created") + } + } +} + +// Define an enum to represent the active sheet +enum ActiveSheet: Identifiable { + case editPreset(SelectablePreset) // For EditPresetView + case presetDetent(SelectablePreset) // For PresetDetentView + case training(navigationPath: [PresetsTraining.Step] = [], startingAt: PresetsTraining.Chapter? = nil, editPresetWhenComplete: SelectablePreset? = nil) + + var id: String { + switch self { + case .editPreset(let preset): + return "edit_\(preset.id)" // Assuming Preset has an id + case .presetDetent(let preset): + return "detent_\(preset.id)" + case .training: + return "training" + } + } +} + +struct PresetsView: View { + + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + @Environment(\.appName) private var appName + @Environment(\.settingsManager) private var settingsManager + @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager + @Environment(\.dismiss) private var dismiss + + @State private var trainingCompletion: PresetsTrainingCompletion + @State private var editMode: EditMode = .inactive + @State private var showingMenu: Bool = false + @State private var presentCreateView: Bool = false + @State private var presentTrainingNeededAlert: Bool = false + @State private var showPresetsTrainingSheet: Bool = false + @State private var activeSheet: ActiveSheet? + @State private var navigationPath = NavigationPath() + + private let carbStore: CarbStore + private let doseStore: DoseStore + private let glucoseStore: GlucoseStore + private let trainingContent: [MediaContent] + private let automationHistory: () -> [AutomationHistoryEntry] + + @AppStorage("presetsSortAscending") private var presetsSortAscending: Bool = true + @AppStorage("presetsSortOrder") private var selectedSortOption: PresetSortOption = .name + + init( + roundBasalRate: ((Double) -> Double)?, + carbStore: CarbStore, + doseStore: DoseStore, + glucoseStore: GlucoseStore, + trainingContent: [MediaContent], + automationHistory: @escaping () -> [AutomationHistoryEntry] + ) { + self.trainingCompletion = PresetsTrainingCompletion(allowDebugFeatures: FeatureFlags.allowDebugFeatures) + self.roundBasalRate = roundBasalRate + self.carbStore = carbStore + self.doseStore = doseStore + self.glucoseStore = glucoseStore + self.trainingContent = trainingContent + self.automationHistory = automationHistory + } + + var isDescending: Bool { !presetsSortAscending } + + var presetsSorted: [SelectablePreset] { + temporaryPresetsManager.selectablePresets + .filter { $0.id != temporaryPresetsManager.activeOverride?.presetId } + .sorted(by: { + switch (selectedSortOption) { + case .name: + return ($0.name.lowercased() < $1.name.lowercased()) != isDescending + case .dateCreated: + return ($0.dateCreated > $1.dateCreated) != isDescending + default: + return ((temporaryPresetsManager.lastUsed(id: $0.id) ?? .distantPast) > (temporaryPresetsManager.lastUsed(id: $1.id) ?? .distantPast)) != isDescending + } + }) + } + + var scheduledRange: ClosedRange? { + settingsManager.therapySettings.glucoseTargetRangeSchedule?.quantityRange(at: Date()) + } + + let roundBasalRate: ((Double) -> Double)? + + var body: some View { + NavigationStack(path: $navigationPath) { + ScrollView { + VStack(spacing: 20) { + if let activePreset = temporaryPresetsManager.selectablePresets.first(where: { $0.id == temporaryPresetsManager.activePreset?.id }) + { + PresetCard( + activePreset, + guardrail: settingsManager.correctionRangeGuardrailForPreset(activePreset), + expectedEndTime: temporaryPresetsManager.activeOverride?.expectedEndTime, + activePresetId: { temporaryPresetsManager.activePreset?.id }, + effectiveCorrectionRange: temporaryPresetsManager.effectiveCorrectionRange + ) + .onTapGesture { + activeSheet = .presetDetent(activePreset) + } + } + + // All Presets Section + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("All Presets") + .font(.headline.weight(.semibold)) + .accessibilityIdentifier("text_AllPresets") + Spacer() + + Button("Sort") { + showingMenu.toggle() + } + .popover(isPresented: $showingMenu) { + sortMenu + } + + Button(action: { + if trainingCompletion.isComplete { + presentCreateView = true + } else { + presentTrainingNeededAlert = true + } + }) { + Image(systemName: "plus") + } + .foregroundStyle(trainingCompletion.isComplete ? Color.accentColor : Color.secondary) + } + .padding(.horizontal, 10) + + LazyVStack(spacing: 12) { + if !trainingCompletion.isComplete { + PresetsTrainingCard(trainingCompletion: trainingCompletion) + .onTapGesture { + activeSheet = .training() + } + } + + ForEach(presetsSorted) { preset in + PresetCard( + preset, + guardrail: settingsManager.correctionRangeGuardrailForPreset(preset), + activePresetId: { temporaryPresetsManager.activePreset?.id }, + effectiveCorrectionRange: temporaryPresetsManager.effectiveCorrectionRange + ) + .cornerRadius(12) + .onTapGesture { + activeSheet = .presetDetent(preset) + } + } + } + } + + if trainingCompletion.isComplete { + // Support Section + VStack(alignment: .leading, spacing: 16) { + Text("Support") + .font(.headline.weight(.semibold)) + .padding(.horizontal, 10) + + Button { + activeSheet = .training() + } label: { + HStack { + Image("book") + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + + Text("Learning Hub") + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.gray) + } + } + .padding(10) + .foregroundStyle(.primary) + .background(RoundedRectangle(cornerRadius: 8) + .fill(Color(UIColor.tertiarySystemBackground)) + .stroke(Color(UIColor.secondarySystemBackground), lineWidth: 1) + .frame(maxWidth: .infinity)) + } + } + } + .padding() + .animation(.default, value: temporaryPresetsManager.activeOverride) + } + .background(Color(UIColor.secondarySystemBackground)) + .navigationTitle(Text("Presets", comment: "Presets screen title")) + .navigationBarItems(trailing: dismissButton) + } + .sheet(item: $activeSheet) { sheet in + switch sheet { + case .presetDetent(let preset): + PresetDetentView(preset: preset, roundBasalRate: roundBasalRate, didTapEdit: { + activeSheet = .editPreset(preset) + }) + case .editPreset(let preset): + Group { + if let scheduledRange { + EditPresetView( + preset: preset, + scheduledRange: scheduledRange, + trainingCompletion: trainingCompletion, + onSave: { updatedPreset in + settingsManager.savePreset(updatedPreset) + Task { + await temporaryPresetsManager.scheduleNextPresetReminder() + } + }, + onDelete: { preset in + settingsManager.deletePreset(preset) + Task { + await temporaryPresetsManager.unschedulePresetReminderIfNeeded(preset) + await temporaryPresetsManager.scheduleNextPresetReminder() + } + }, + correctionRangeGuardrailForPreset: settingsManager.correctionRangeGuardrailForPreset, + impactForInsulinMultiplier: { settingsManager.therapySettings.impact(for: $0) }, + showPresetsTrainingSheet: { showPresetsTrainingSheet = true }, + suspendThreshold: { settingsManager.settings.suspendThreshold } + ) + .sheet(isPresented: $showPresetsTrainingSheet) { + PresetsTrainingView( + trainingCompletionConfiguration: .trainingCompletion(trainingCompletion), + trainingContent: trainingContent + ) + } + } + } + case .training(let navigationPath, let startingAt, let editPresetWhenComplete): + PresetsTrainingView( + navigationPath: navigationPath, + startingAt: startingAt, + trainingCompletionConfiguration: .trainingCompletion(trainingCompletion), + trainingContent: trainingContent + ) { + if let editPresetWhenComplete { + activeSheet = .editPreset(editPresetWhenComplete) + } + } + } + } + .sheet(isPresented: $presentCreateView) { + CreatePresetView( + createPreset: settingsManager.createPreset, + impactForInsulinMultiplier: { settingsManager.therapySettings.impact(for: $0) }, + scheduleNextPresetReminder: temporaryPresetsManager.scheduleNextPresetReminder, + scheduledRange: { scheduledRange }, + setScheduleOverride: { temporaryPresetsManager.scheduleOverride = $0 }, + suspendThreshold: { settingsManager.settings.suspendThreshold } + ) + } + .alert(isPresented: $presentTrainingNeededAlert) { + trainingNeededAlert + } + } + + private var trainingNeededAlert: SwiftUI.Alert { + Alert(title: Text("Training Required for New Presets", comment: "Preset training needed alert title"), + message: Text("To create a new preset, you must complete the required training.", comment: "Preset training needed alert message"), + primaryButton: startNeededTrainingButton, + secondaryButton: closeButton) + } + + private var startNeededTrainingButton: SwiftUI.Alert.Button { + .cancel(Text("Close", comment: "Preset training needed alert cancel button")) + } + + private var closeButton: SwiftUI.Alert.Button { + .default(Text("Start Required Training", comment: "CPreset training needed alert start training button")) { + activeSheet = .training() + } + } + + private var sortMenu: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Sort By") + .font(.headline) + Spacer() + Button(action: { + presetsSortAscending.toggle() + }) { + Image(systemName: "arrow.up.arrow.down") + } + } + .padding(.horizontal) + .padding(.top, 20) + Divider() + + ForEach(PresetSortOption.allCases, id: \.self) { option in + Button(action: { + selectedSortOption = option + showingMenu = false + }) { + HStack { + if selectedSortOption == option { + Image(systemName: "checkmark") + } else { + Image(systemName: "checkmark") + .hidden() + } + Text(option.description) + .font(.body) + } + .padding(.horizontal) + } + .buttonStyle(PlainButtonStyle()) + .padding(.bottom, option == PresetSortOption.allCases.last ? 12 : 0) + if option != PresetSortOption.allCases.last { + Divider() + } + } + } + .frame(width: 200) + .background(Color(UIColor.secondarySystemBackground)) + .cornerRadius(12) + .presentationCompactAdaptation(.popover) + } + + private var dismissButton: some View { + Button("Done") { + dismiss() + } + .bold() + .accessibilityIdentifier("button_done") + } +} + +extension PresetCard { + init (_ preset: SelectablePreset, guardrail: Guardrail, expectedEndTime: PresetExpectedEndTime? = nil, activePresetId: @escaping () -> String?, effectiveCorrectionRange: @escaping () -> ClosedRange?) { + var activityPresetIsModified: Bool? = nil + if case let .activity(activityPreset) = preset { + activityPresetIsModified = activityPreset.isModifiedFromDefault + } + + self.init( + presetId: preset.id, + icon: preset.icon, + presetName: preset.name, + duration: preset.duration, + insulinMultiplier: preset.insulinNeedsScaleFactor, + correctionRange: preset.correctionRange, + guardrail: guardrail, + expectedEndTime: expectedEndTime, + isScheduled: preset.isScheduled, + activityPresetIsModified: activityPresetIsModified, + activePresetId: activePresetId, + effectiveCorrectionRange: effectiveCorrectionRange + ) + } +} diff --git a/Loop/Views/Presets/Training/Components/CommonUseStep.swift b/Loop/Views/Presets/Training/Components/CommonUseStep.swift new file mode 100644 index 0000000000..ef0ef53db5 --- /dev/null +++ b/Loop/Views/Presets/Training/Components/CommonUseStep.swift @@ -0,0 +1,89 @@ +// +// CommonUseStep.swift +// Loop +// +// Created by Cameron Ingham on 9/2/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct CommonUseStep: View { + + @Environment(\.isEnabled) private var isEnabled + + private let title: Text + private let readTimeString: String + private let onTapGesture: (() -> Void)? + + private let formatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.minute] + formatter.unitsStyle = .short + return formatter + }() + + init?( + title: Text, + readTime: TimeInterval, + onTapGesture: (() -> Void)? = nil + ) { + self.title = title + + guard let readTimeString = formatter.string(from: readTime) else { + return nil + } + + self.readTimeString = readTimeString + self.onTapGesture = onTapGesture + } + + init?( + title: String, + readTime: TimeInterval, + onTapGesture: (() -> Void)? = nil + ) { + self.init( + title: Text(title), + readTime: readTime, + onTapGesture: onTapGesture + ) + } + + var body: some View { + HStack(alignment: .top, spacing: 0) { + VStack(alignment: .leading, spacing: 8) { + title + .fontWeight(.semibold) + .frame(maxWidth: .infinity, alignment: .leading) + + Text("\(readTimeString) read") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 16) + + Image(systemName: isEnabled && onTapGesture == nil ? "checkmark.circle.fill" : "circle") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundStyle(Color.accentColor) + } + .padding(16) + .background( + Color(UIColor.systemBackground) + .clipShape(RoundedRectangle(cornerRadius: 10)) + ) + .shadow( + color: .primary.opacity(0.03), + radius: 6, + x: 0, + y: 4 + ) + .opacity(isEnabled ? 1 : 0.6) + .onTapGesture { + onTapGesture?() + } + } +} diff --git a/Loop/Views/Presets/Training/Components/EstimatedReadTime.swift b/Loop/Views/Presets/Training/Components/EstimatedReadTime.swift new file mode 100644 index 0000000000..08aa8ca412 --- /dev/null +++ b/Loop/Views/Presets/Training/Components/EstimatedReadTime.swift @@ -0,0 +1,40 @@ +// +// EstimatedReadTime.swift +// Loop +// +// Created by Cameron Ingham on 8/26/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct EstimatedReadTime: View { + + private let readTimeString: String + + private let formatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.minute] + formatter.unitsStyle = .short + return formatter + }() + + init?(_ readTime: TimeInterval) { + guard let readTimeString = formatter.string(from: readTime) else { + return nil + } + + self.readTimeString = readTimeString + } + + var body: some View { + Text(Image(systemName: "clock")) + .foregroundStyle(Color.accentColor) + + Text(" \(readTimeString) read") + .foregroundStyle(Color.secondary) + } +} + +#Preview { + EstimatedReadTime(.minutes(3)) +} diff --git a/Loop/Views/Presets/Training/Components/IntensitySlider.swift b/Loop/Views/Presets/Training/Components/IntensitySlider.swift new file mode 100644 index 0000000000..b57ec127d1 --- /dev/null +++ b/Loop/Views/Presets/Training/Components/IntensitySlider.swift @@ -0,0 +1,283 @@ +// +// IntensitySlider.swift +// Loop +// +// Created by Cameron Ingham on 9/4/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopKitUI +import SwiftUI + +struct IntensitySlider: UIViewRepresentable { + + static let thumbnailSize: Double = 32 + static let trackHeight: Double = 8 + + private struct ThumbnailView: View { + let color: Color + + var body: some View { + Circle() + .fill(color) + .frame(width: IntensitySlider.thumbnailSize, height: IntensitySlider.thumbnailSize) + } + } + + private struct TrackView: View { + var body: some View { + Rectangle() + .foregroundColor(.clear) + .frame(height: IntensitySlider.trackHeight) + .frame(maxWidth: .infinity) + .background(DerivedGradientView()) + } + } + + class RoundedSlider: UISlider { + override func layoutSubviews() { + super.layoutSubviews() + self.subviews.first?.subviews.forEach { subview in + if subview.bounds.height == IntensitySlider.trackHeight { + subview.layer.cornerRadius = subview.bounds.height / 2 + subview.clipsToBounds = true + } + } + } + } + + class Coordinator { + let minimumValue: Double + let maximumValue: Double + + let value: Binding + + init(value: Binding, minimumValue: Double = 0, maximumValue: Double = 10) { + self.value = value + self.minimumValue = minimumValue + self.maximumValue = maximumValue + } + + @objc + func sliderValueChanged(_ slider: UISlider) { + value.wrappedValue = Double(slider.value) + } + + @objc + func sliderEditingEnding(_ slider: UISlider) { + slider.value = Float(Int(value.wrappedValue.rounded())) + } + } + + @Binding var value: Double + let snapToInteger: Bool = true + + private let trackImage: UIImage? = TrackView().snapshot() + + func makeUIView(context: Context) -> RoundedSlider { + let sliderView = RoundedSlider() + + sliderView.minimumValue = Float(context.coordinator.minimumValue) + sliderView.maximumValue = Float(context.coordinator.maximumValue) + sliderView.value = Float(value) + sliderView.setThumbImage( + ThumbnailView( + color: thumbColorForValue( + value, + minimum: context.coordinator.minimumValue, + maximum: context.coordinator.maximumValue + ) + ).snapshot(), + for: .normal + ) + sliderView.setMinimumTrackImage(trackImage, for: .normal) + sliderView.setMaximumTrackImage(trackImage, for: .normal) + + sliderView.addTarget(context.coordinator, action: #selector(context.coordinator.sliderValueChanged(_:)), for: .valueChanged) + + if snapToInteger { + sliderView.addTarget(context.coordinator, action: #selector(context.coordinator.sliderEditingEnding(_:)), for: .touchUpInside) + sliderView.addTarget(context.coordinator, action: #selector(context.coordinator.sliderEditingEnding(_:)), for: .touchUpOutside) + } + + return sliderView + } + + func updateUIView(_ uiView: RoundedSlider, context: Context) { + uiView.value = Float(value) + uiView.setThumbImage( + ThumbnailView( + color: thumbColorForValue( + value, + minimum: context.coordinator.minimumValue, + maximum: context.coordinator.maximumValue + ) + ).snapshot(), + for: .normal + ) + } + + func makeCoordinator() -> Coordinator { + Coordinator(value: $value) + } + + private func thumbColorForValue(_ value: Double, minimum: Double, maximum: Double) -> Color { + DerivedGradientView().color(at: value / (maximum - minimum)) + } +} + +extension View { + func snapshot() -> UIImage? { + let render = ImageRenderer(content: self) + render.scale = UIScreen.main.scale + return render.uiImage + } +} + +private struct DerivedGradientView: View { + let stops: [Gradient.Stop] = [ + Gradient.Stop(color: Color(red: 0, green: 0.48, blue: 1), location: 0.00), + Gradient.Stop(color: Color(red: 0.2, green: 0.78, blue: 0.35), location: 0.33), + Gradient.Stop(color: Color(red: 1, green: 0.58, blue: 0), location: 0.69), + Gradient.Stop(color: Color(red: 1, green: 0.23, blue: 0.19), location: 1.00), + ] + + let startPoint: UnitPoint = UnitPoint(x: 0, y: 0.5) + let endPoint: UnitPoint = UnitPoint(x: 1, y: 0.5) + + var body: some View { + LinearGradient( + stops: stops, + startPoint: startPoint, + endPoint: endPoint, + ) + } + + func color(at value: Double) -> Color { + let clampedLocation = min(max(value, 0.0), 1.0) + + guard let upper = stops.first(where: { $0.location >= clampedLocation }) else { + return stops.last?.color ?? .clear + } + + guard let lower = stops.last(where: { $0.location <= clampedLocation }) else { + return stops.first?.color ?? .clear + } + + if lower.location == upper.location { + return lower.color + } + + let progress = (clampedLocation - lower.location) / (upper.location - lower.location) + + let lowerUIColor = UIColor(lower.color) + let upperUIColor = UIColor(upper.color) + + var lowerRed: CGFloat = 0 + var lowerGreen: CGFloat = 0 + var lowerBlue: CGFloat = 0 + var lowerAlpha: CGFloat = 0 + + var upperRed: CGFloat = 0 + var upperGreen: CGFloat = 0 + var upperBlue: CGFloat = 0 + var upperAlpha: CGFloat = 0 + + lowerUIColor.getRed(&lowerRed, green: &lowerGreen, blue: &lowerBlue, alpha: &lowerAlpha) + upperUIColor.getRed(&upperRed, green: &upperGreen, blue: &upperBlue, alpha: &upperAlpha) + + return Color( + red: (1 - progress) * lowerRed + progress * upperRed, + green: (1 - progress) * lowerGreen + progress * upperGreen, + blue: (1 - progress) * lowerBlue + progress * upperBlue, + opacity: (1 - progress) * lowerAlpha + progress * upperAlpha + ) + } +} + +struct IntensityInfo: View { + + @State private var lastValue: Double = 0 + @State private var value: Double = 0 + + var body: some View { + VStack(spacing: 2) { + Text(value.rounded().formatted()) + .contentTransition(.numericText(countsDown: lastValue > value)) + .font(.system(size: UIFontMetrics.default.scaledValue(for: 64)).weight(.heavy)) + .padding(.bottom, 16) + + HStack { + Text("0") + + Spacer() + + Text("10") + } + .font(.subheadline) + .foregroundStyle(.secondary) + + IntensitySlider(value: $value.animation(.easeInOut)) + .sensoryFeedback(lastValue > value ? .decrease : .increase, trigger: Int(value.rounded())) + + HStack { + Text("Very Easy") + + Spacer() + + Text("All Out") + } + .font(.subheadline) + .foregroundStyle(.secondary) + } + .onChange(of: value) { oldValue, _ in + lastValue = oldValue + } + + InsetContent { + VStack(spacing: 0) { + title + .font(.title2.bold()) + .padding(.bottom, 16) + + message + .padding(.bottom, 8) + + glucoseChange + } + .multilineTextAlignment(.center) + } + } + + var title: Text { + switch Int(value.rounded()) { + case 0: Text("No Activity") + case 1...2: Text("Light Intensity (Aerobic)") + case 3...8: Text("Medium Intensity (Aerobic)") + case 9: Text("High Intensity (Anaerobic)") + case 10: Text("Maximum Intensity (Anaerobic)") + default: Text("Unsupported") + } + } + + var message: Text { + switch Int(value.rounded()) { + case 0: Text("Sitting or laying down, no change in breathing.") + case 1...2: Text("Easy breath. Can carry on a conversation.") + case 3...5: Text("Breathing more heavily. Can carry on a conversation, but requires more effort.") + case 6...8: Text("Breathing is slightly uncomfortable. Conversation requires maximal effort.") + case 9: Text("Difficulty maintaining exercise or holding a conversation.") + case 10: Text("Full out effort. No conversation possible.") + default: Text("Unsupported") + } + } + + var glucoseChange: Text { + switch Int(value.rounded()) { + case 0: Text("No change in glucose.") + case 1...8: Text("May experience drops in glucose.") + case 9...10: Text("May experience a rise in glucose.") + default: Text("Unsupported") + } + } +} diff --git a/Loop/Views/Presets/Training/Components/PresetsTrainingCard.swift b/Loop/Views/Presets/Training/Components/PresetsTrainingCard.swift new file mode 100644 index 0000000000..ddd91eafac --- /dev/null +++ b/Loop/Views/Presets/Training/Components/PresetsTrainingCard.swift @@ -0,0 +1,34 @@ +// +// PresetsTrainingCard.swift +// Loop +// +// Created by Cameron Ingham on 8/27/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopKit +import SwiftUI + +public struct PresetsTrainingCard: View { + + let imageName: String? + + public init(trainingCompletion: PresetsTrainingCompletion) { + if trainingCompletion.completedChapters[.customizingPresets] != true { + self.imageName = "PresetsTrainingCreditEditStartCard" + } else if trainingCompletion.completedChapters[.trainingComplete] != true { + self.imageName = "PresetsTrainingCreditEditResumeCard" + } else { + self.imageName = nil + } + } + + public var body: some View { + if let imageName, let image = Image.optional(imageName) { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + } +} diff --git a/Loop/Views/Presets/Training/Components/ReferencesView.swift b/Loop/Views/Presets/Training/Components/ReferencesView.swift new file mode 100644 index 0000000000..33cea79dc2 --- /dev/null +++ b/Loop/Views/Presets/Training/Components/ReferencesView.swift @@ -0,0 +1,84 @@ +// +// ReferencesView.swift +// Loop +// +// Created by Cameron Ingham on 4/2/26. +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import LoopKitUI +import SwiftUI + +public struct ReferencesView: View { + + @State private var isExpanded: Bool = false + @State private var selectedURL: URL? = nil + + private let references: [Text] + + init(_ references: [Text]) { + self.references = references + } + + public var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 0) { + Text("References") + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer() + + Text(Image(systemName: "chevron.up")) + .rotationEffect(.degrees(isExpanded ? 0 : 180)) + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + .onTapGesture { + isExpanded.toggle() + } + + if isExpanded { + Grid(horizontalSpacing: 4, verticalSpacing: 12) { + ForEach(references.indices, id: \.self) { referenceId in + GridRow(alignment: .top) { + Text("\(referenceId + 1).") + + references[referenceId] + .frame(maxWidth: .infinity, alignment: .leading) + .accentColor(.secondary) + } + } + } + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .animation(.default, value: isExpanded) + .environment(\.openURL, OpenURLAction { url in + self.selectedURL = url + return .handled + }) + .sheet( + isPresented: Binding( + get: { selectedURL != nil }, + set: { _,_ in selectedURL = nil } + ), + content: { + NavigationStack { + WebView(url: selectedURL!) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + selectedURL = nil + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 24)) + .foregroundColor(.secondary) + } + } + } + } + } + ) + } +} diff --git a/Loop/Views/Presets/Training/Components/TherapySettingsExampleView.swift b/Loop/Views/Presets/Training/Components/TherapySettingsExampleView.swift new file mode 100644 index 0000000000..ec4593809e --- /dev/null +++ b/Loop/Views/Presets/Training/Components/TherapySettingsExampleView.swift @@ -0,0 +1,183 @@ +// +// TherapySettingsExampleView.swift +// Loop +// +// Created by Cameron Ingham on 10/23/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit +import LoopKitUI +import SwiftUI + +struct TherapySettingsExampleView: View { + + enum Component: Hashable { + case basalRate(Double) + case carbRatio(Double) + case isf(Double) + case correctionRange(ClosedRange) + case bolusRecommendation(starting: Double, ending: Double, action: String) + + var title: Text? { + switch self { + case .basalRate: Text("Basal Rate") + case .carbRatio: Text("Carb Ratio") + case .isf: Text("ISF") + case .correctionRange: Text("Correction Range") + case .bolusRecommendation: nil + } + } + } + + enum Style: Hashable { + case `default` + case adjusted + } + + @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference + + let title: String + let components: [Component] + let style: Style + + init(title: String, components: [Component], style: Style = .default) { + self.title = title + self.components = components + self.style = style + } + + init(title: String, component: Component, style: Style = .default) { + self.title = title + self.components = [component] + self.style = style + } + + let numberFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter + }() + + private let basalRateFormatter = QuantityFormatter(for: .internationalUnitsPerHour) + private let bolusFormatter = QuantityFormatter(for: .internationalUnit) + + var body: some View { + VStack(alignment: .center, spacing: 16) { + Text(title) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .font(.headline) + .fixedSize(horizontal: false, vertical: true) + + ViewThatFits { + HStack(spacing: 0) { + ForEach(components, id: \.self) { component in + VStack(alignment: .leading, spacing: 4) { + value(for: component) + .foregroundStyle(Color.accentColor) + + if let title = component.title { + title + } + } + + if component != components.last { + Spacer(minLength: 16) + } + } + } + + VStack(spacing: 8) { + ForEach(components, id: \.self) { component in + VStack(alignment: .leading, spacing: 4) { + value(for: component) + .foregroundStyle(Color.accentColor) + + if let title = component.title { + title + } + } + } + } + } + } + .inContainer(style: style) + } + + @ViewBuilder + func value(for component: Component) -> some View { + switch component { + case .basalRate(let double): + if let basalRateValue = basalRateFormatter.string(from: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: double), includeUnit: false) { + Text(basalRateValue).bold() + Text("\u{00a0}\(LoopUnit.internationalUnitsPerHour.shortLocalizedUnitString())") + } + case .carbRatio(let double): + Text("\(numberFormatter.string(from: double) ?? "0")").bold() + Text("\u{00a0}\(LoopUnit.gramsPerUnit.shortLocalizedUnitString())") + case .isf(let double): + Text(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: double), includeUnit: false)).bold() + Text("\u{00a0}\(displayGlucosePreference.unitRate.shortLocalizedUnitString())") + case .correctionRange(let closedRange): + Text("\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: closedRange.lowerBound), includeUnit: false))").bold() + Text("\u{00a0}-\u{00a0}") + Text("\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: closedRange.upperBound), includeUnit: false))").bold() + Text("\u{00a0}\(displayGlucosePreference.unit.shortLocalizedUnitString())") + case .bolusRecommendation(let starting, let ending, let action): + ZStack(alignment: .top) { + HStack(alignment: .top, spacing: 0) { + if let startingValue = bolusFormatter.string(from: LoopQuantity(unit: .internationalUnit, doubleValue: starting)), let endingValue = bolusFormatter.string(from: LoopQuantity(unit: .internationalUnit, doubleValue: ending)) { + VStack(spacing: 6) { + Text(startingValue) + .font(.title2.bold()) + .foregroundStyle(Color.accentColor) + + Text("Before") + .font(.subheadline) + .foregroundStyle(Color.primary) + } + .containerRelativeFrame(.horizontal, count: 2, spacing: 80, alignment: .center) + + VStack(spacing: 6) { + Text(endingValue) + .font(.title2.bold()) + .foregroundStyle(Color.accentColor) + + Text(action) + .font(.subheadline) + .foregroundStyle(Color.primary) + } + .containerRelativeFrame(.horizontal, count: 2, spacing: 80, alignment: .center) + } + } + + Image(systemName: "arrow.forward") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + .foregroundStyle(Color.primary) + } + } + } +} + +private extension View { + @ViewBuilder + func inContainer(style: TherapySettingsExampleView.Style) -> some View { + switch style { + case .default: + InsetContent { + self + } + case .adjusted: + self + .padding(16) + .background( + Color(UIColor.secondarySystemBackground) + .clipShape(RoundedRectangle(cornerRadius: 10)) + ) + } + } +} + +extension DisplayGlucosePreference { + var unitRate: LoopUnit { + unit.unitDivided(by: .internationalUnit) + } +} diff --git a/Loop/Views/Presets/Training/Components/TintedContent.swift b/Loop/Views/Presets/Training/Components/TintedContent.swift new file mode 100644 index 0000000000..be761aa96d --- /dev/null +++ b/Loop/Views/Presets/Training/Components/TintedContent.swift @@ -0,0 +1,84 @@ +// +// TintedContent.swift +// Loop +// +// Created by Cameron Ingham on 9/8/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +@Observable +private class TintColor { + let color: Color + + init(color: Color) { + self.color = color + } +} + +struct TintedContent: View { + + let tint: Color + let icon: Image + let title: Text + let content: () -> Content + + init( + tint: Color, + icon: Image, + title: Text, + @ViewBuilder content: @escaping () -> Content + ) { + self.tint = tint + self.icon = icon + self.title = title + self.content = content + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label { + title + .font(.title2).bold() + .frame(maxWidth: .infinity, alignment: .leading) + } icon: { + icon + .font(.title3) + .foregroundStyle(tint) + } + + VStack(alignment: .leading, spacing: 16) { + content() + .environment(TintColor(color: tint)) + } + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(tint.opacity(0.1)) + ) + } +} + +struct TintedTip: View { + + @Environment(TintColor.self) private var tintColor + + let text: Text + + var body: some View { + text + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + tintColor.color + .cornerRadius(10) + .overlay( + Color(UIColor.systemBackground) + .cornerRadius(10) + .padding(.leading, 3) + ) + ) + } +} diff --git a/Loop/Views/Presets/Training/PresetsTrainingContent.swift b/Loop/Views/Presets/Training/PresetsTrainingContent.swift new file mode 100644 index 0000000000..623bc849b9 --- /dev/null +++ b/Loop/Views/Presets/Training/PresetsTrainingContent.swift @@ -0,0 +1,1147 @@ +// +// PresetsTrainingContent.swift +// Loop +// +// Created by Cameron Ingham on 8/26/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit +import LoopKitUI +import SwiftUI + +extension PresetsTraining { + enum CTA { + case start + case `continue` + case close + case closeOrContinue(_ to: String, chapter: Chapter) + } +} + +protocol PresetsTrainingContent { + associatedtype B: View + func content(appName: String, displayGlucosePreference: DisplayGlucosePreference, colorPalette: LoopUIColorPalette, dynamicTypeSize: DynamicTypeSize, trainingContent: [MediaContent], next: @escaping () -> Void, onPlayMedia: @escaping (MediaContent) -> Void) -> B + var cta: PresetsTraining.CTA? { get } +} + +extension PresetsTraining.Step: PresetsTrainingContent { + + @ViewBuilder + func content(appName: String, displayGlucosePreference: DisplayGlucosePreference, colorPalette: LoopUIColorPalette, dynamicTypeSize: DynamicTypeSize, trainingContent: [MediaContent], next: @escaping () -> Void, onPlayMedia: @escaping (MediaContent) -> Void) -> some View { + switch self { + case .customizingPresets(let customizingPresets): + switch customizingPresets { + case .customizingPresets: + if let image = Image.optional("PresetsTrainingCustomizingPresetsHero") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + EstimatedReadTime(.minutes(10)) + + VStack(alignment: .leading) { + Text("This required training will show you how to change your settings with confidence and create custom presets that fit your needs.\n\nThis training covers:") + .fixedSize(horizontal: false, vertical: true) + + BulletedListView { + Text("How to configure each setting") + Text("How to use Presets when you are sick") + Text("How to use Presets for everyday activities") + Text("How to use Presets for exercise") + } + .padding(.leading, 8) + } + + case .overallInsulin: + if let image = Image.optional("PresetsTrainingOverallInsulinHero") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + VStack(alignment: .leading) { + Text("The \"Overall Insulin\" percentage controls total insulin delivery by adjusting your:") + .fixedSize(horizontal: false, vertical: true) + + BulletedListView { + Text("Basal Rate") + Text("Carb Ratio") + Text("Insulin Sensitivity Factor (ISF)") + } + .padding(.leading, 8) + } + + Text("At 100%, \(appName) assumes your insulin needs are the same as usual.") + + Text("When deciding to adjust your overall insulin, **ask yourself, does my body need more or less than usual?**") + + Callout(.note) { + BulletedListView { + Text("A percentage **below 100%** tells the system you need **less** insulin") + + Text("A percentage **above 100%** tells the system you need **more** insulin") + } + .font(.footnote) + .padding(.top, 8) + } + .padding(.horizontal, -16) + + case .correctionRange: + if let image = Image.optional("PresetsTrainingCorrectionRangeHero") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("Correction range is a **safety setting**. Changing it can help lower your risk of going low if you expect unusual changes.") + + Text("Changing it can lower the chance of your glucose levels going too low if you expect unusual changes.") + + Text("Choose the glucose value (or values) you want \(appName) to target when changing how much basal insulin you get.") + + Text("You don’t need to change the correction range for every preset. But before you decide to change it, ask yourself: *Am I more likely to go high or low during this time?*") + + Callout(.note) { + Text("To help avoid lows, set a range **higher** than your typical correction range.") + .font(.footnote) + .padding(.top, 8) + } + .padding(.horizontal, -16) + } + case .illness(let illness): + switch illness { + case .commonUses: + Text("Complete each section below to learn how presets can be used for a variety of situations.") + + VStack(spacing: 16) { + CommonUseStep( + title: Text("Presets for Illness"), + readTime: .minutes(3), + onTapGesture: next + ) + + CommonUseStep( + title: Text("Presets for Daily Activity"), + readTime: .minutes(2) + ) + .disabled(true) + + CommonUseStep( + title: Text("Presets for Exercise"), + readTime: .minutes(5) + ) + .disabled(true) + } + + case .presetsForIllness: + Text("Physical stress, like illness, can cause glucose to rise.") + + Text("Let’s walk through a simple example to show how a preset might be used in this situation.") + + InsetContent(alignment: .center, spacing: 10) { + Text("Example").fontWeight(.semibold).foregroundColor(.accentColor) + + Text("Paloma Porpoise sees her glucose is running higher than usual. She creates a preset to help manage her glucose while she is sick.").multilineTextAlignment(.center) + } + + Text("Next, we’ll look at settings you can change and how they affect Paloma’s insulin.") + + case .overallInsulin: + Text("Paloma wants \(appName) to know she needs more insulin than usual.") + .fixedSize(horizontal: false, vertical: true) + + TherapySettingsExampleView( + title: NSLocalizedString("Paloma’s Current Therapy Settings", comment: ""), + components: [ + .basalRate(0.5), + .carbRatio(13), + .isf(50) + ] + ) + + Text("She can do this by raising her **Overall Insulin** setting. This tells \(appName) to deliver more than her usual amount, making her insulin settings stronger.") + .fixedSize(horizontal: false, vertical: true) + + if let image = Image.optional("PresetsTrainingIllnessOverallInsulin") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + TherapySettingsExampleView( + title: NSLocalizedString("Paloma’s Adjusted Therapy Settings", comment: ""), + components: [ + .basalRate(0.6), + .carbRatio(12), + .isf(45) + ], + style: .adjusted + ) + + case .correctionRange: + Text("While sick, Paloma expects to eat less or not absorb everything she eats.") + + TherapySettingsExampleView( + title: NSLocalizedString("Paloma’s Current Therapy Settings", comment: ""), + component: .correctionRange(105...110) + ) + + Text("To help prevent lows, she will increase her correction range.") + + if let image = Image.optional("PresetsTrainingIllnessCorrectionRange") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + TherapySettingsExampleView( + title: NSLocalizedString("Paloma’s Adjusted Therapy Settings", comment: ""), + component: .correctionRange(130...140), + style: .adjusted + ) + + case .duration: + Text("You can choose how long your preset lasts.") + + Text("Since Paloma doesn't know when she'll feel better, she sets hers to “Until I Turn Off”.") + + if let image = Image.optional("PresetsTrainingIllnessDuration1") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("To be safe, \(appName) will remind her after 24 hours that the preset is still running.") + + if let image = Image.optional("PresetsTrainingIllnessDuration2") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("While turned on, Paloma’s preset will display on the home screen and in her Presets list.") + + case .impactOnBolusing: + Text("Later that day, Paloma eats a meal with about 30g of carbs.") + + Text("How does her preset impact her bolus recommendation?") + + Text("Her preset is set to **110%**, which is more than she usually needs. This means \(appName) will make her basal rates, carb ratio, and insulin sensitivity factor (ISF) stronger. ") + + TherapySettingsExampleView( + title: NSLocalizedString("Her bolus recommendation is higher than usual because her overall insulin is set higher.", comment: ""), + component: .bolusRecommendation( + starting: 3.9, + ending: 4.3, + action: NSLocalizedString("With Preset On", comment: "") + ), + style: .adjusted + ) + } + case .dailyActivities(let dailyActivities): + switch dailyActivities { + case .commonUses: + Text("You can use presets for a variety of situations. Explore the uses below to learn tips for these common scenarios.") + + VStack(spacing: 16) { + CommonUseStep( + title: Text("Presets for Illness"), + readTime: .minutes(3) + ) + + CommonUseStep( + title: Text("Presets for Daily Activity"), + readTime: .minutes(2), + onTapGesture: next + ) + + CommonUseStep( + title: Text("Presets for Exercise"), + readTime: .minutes(5) + ) + .disabled(true) + } + + case .presetsForDailyActivities: + Text("For some people, routine chores and everyday activities can affect glucose levels similar to exercise.") + + Text("Let’s walk through a simple example to show how a preset might be used in this situation.") + + InsetContent(alignment: .center, spacing: 10) { + Text("Example").fontWeight(.semibold).foregroundColor(.accentColor) + + Text("Omar Octopus wants to create a preset for some yard work he’ll be doing around the house.").multilineTextAlignment(.center) + } + + Text("Next, we’ll look at settings you can change and how they affect Omar’s insulin.") + + if let adls = trainingContent.first(where: { $0.fileName == "ADLs" }) { + VStack(alignment: .leading, spacing: 16) { + Text("Learn More") + .font(.headline.weight(.semibold)) + + PlayMediaButton(mediaContent: adls, onPlay: onPlayMedia) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(colorPalette.chartColorPalette.presetTint).opacity(0.1)) + ) + } + + case .overallInsulin: + Text("Omar asks himself, **do I expect I will need more or less insulin than usual?**") + + Text("Since he doesn’t plan to push himself too hard, he expects his insulin needs to stay the same, so he leaves the setting at 100%.") + + if let image = Image.optional("PresetsTrainingDailyActivityOverallInsulin") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Callout(.note) { + Text("Pay attention to your insulin needs before and after exercising, playing sports, or doing unusually hard physical labor.") + } + .padding(.horizontal, -16) + + case .correctionRange: + Text("For activities that raise your risk of going low, you can set a higher temporary correction range.") + + Text("This range is usually higher than your correction range when you are not exercising.") + + Text("Because Omar has gone low while working outdoors in the past, he raises his preset correction range to help prevent another low.") + + TherapySettingsExampleView( + title: NSLocalizedString("Omar’s Current Therapy Settings", comment: ""), + component: .correctionRange(110...120) + ) + + Text("Omar sets his correction range a little higher, to \(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 140), includeUnit: false))-\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 160), includeUnit: false)) \(displayGlucosePreference.unit.localizedShortUnitString). This tells \(appName) to step in sooner.") + + if let image = Image.optional("PresetsTrainingDailyActivityCorrectionRange") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + case .savedPresets: + if let image = Image.optional("PresetsTrainingDailyActivitySavedPresets") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("Once saved, Omar’s new preset will display in his Presets lists.") + + Callout(.note) { + Text("If your activity has a higher risk of low glucose, start a physical activity preset at least **1 hour before you begin** and keep it on until you finish.") + + Text("If you expect your glucose to rise during the activity, you may not need a preset.") + } + .padding(.horizontal, -16) + + } + case .exercise(let exercise): + switch exercise { + case .commonUses: + Text("Presets can be used for a variety of situations. Explore the uses below to learn tips for these common scenarios.") + + VStack(spacing: 16) { + CommonUseStep( + title: Text("Presets for Illness"), + readTime: .minutes(3) + ) + + CommonUseStep( + title: Text("Presets for Daily Activity"), + readTime: .minutes(2) + ) + + CommonUseStep( + title: Text("Presets for Exercise"), + readTime: .minutes(5), + onTapGesture: next + ) + } + case .presetsForExercise: + Text("Exercise is a common reason to use a preset. Different kinds of exercise and their intensity levels can affect your glucose levels in different ways.") + + Text("Depending on the activity, you may notice a few common patterns when it comes to your insulin needs:") + + BulletedListView { + Text("no change needed") + Text("you need **less** insulin than usual") + Text("you need **more** insulin than usual") + } + + Text("Let’s walk through some examples to show how presets might be used in these situations.") + + Callout(.note) { + Text("These patterns are based on published exercise consensus guidelines and are meant to be used as a starting point. What works for one person may not work for you.") + } + .padding(.horizontal, -16) + + case .perceivedIntensity: + Text("Recognizing how hard you feel you're working during exercise can help you understand its impact on your glucose levels.") + + Text("Consider an exercise you do regularly and think about how hard you push yourself.") + + Text("Use the slider to rate the effort on a scale of 0–10, with 10 being the hardest you’ve ever worked.") + + IntensityInfo() + + if let sameActivityDifferentIntensity = trainingContent.first(where: { $0.fileName == "Same Activity Different Intensity" }) { + VStack(alignment: .leading, spacing: 16) { + Text("Learn More") + .font(.headline.weight(.semibold)) + + PlayMediaButton(mediaContent: sameActivityDifferentIntensity, onPlay: onPlayMedia) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(colorPalette.chartColorPalette.presetTint).opacity(0.1)) + ) + } + + case .lightToModerateExercise: + if let image = Image.optional("PresetsTrainingExerciseLightToModerateHero") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("Light-to-moderate intensity exercise can cause a drop in glucose levels. This is because your body uses glucose (or sugar) for energy during physical activity.") + + InsetContent { + VStack(spacing: 4) { + Text("Aerobic") + .font(.title2.bold()) + + Text("Continuous or exercise without breaks") + .frame(maxWidth: .infinity) + } + + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 16) { + Bullet(color: .secondary) + + VStack(alignment: .leading, spacing: 4) { + Text("Walking") + + HStack(alignment: .center, spacing: 2) { + Text("\(Image(systemName: "lightbulb.max"))") + + Text(" **Tip** Use your \(Image(systemName: "figure.walk")) **Walking** preset") + } + .padding(.vertical, 4) + .padding(.horizontal, 6) + .background(Color(UIColor.secondarySystemBackground).cornerRadius(5)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(alignment: .firstTextBaseline, spacing: 16) { + Bullet(color: .secondary) + + Text("Hiking") + } + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(alignment: .firstTextBaseline, spacing: 16) { + Bullet(color: .secondary) + + VStack(alignment: .leading, spacing: 4) { + Text("Jogging") + + HStack(alignment: .center, spacing: 2) { + Text("\(Image(systemName: "lightbulb.max"))") + + Text(" **Tip** Use your \(Image(systemName: "figure.run")) **Jogging** preset") + } + .padding(.vertical, 4) + .padding(.horizontal, 6) + .background(Color(UIColor.secondarySystemBackground).cornerRadius(5)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(alignment: .firstTextBaseline, spacing: 16) { + Bullet(color: .secondary) + + Text("Swimming") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + Text("For these activities, consider setting your insulin needs to **less than 100%**.") + + if let image = Image.optional("PresetsTrainingExerciseLightToModerate") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Callout(.note) { + Text("These recommendations should be used as a starting point. Checking your glucose during exercise will help you find the settings that work best for you.") + } + .padding(.horizontal, -16) + + case .highIntensityExercise: + if let image = Image.optional("PresetsTrainingExerciseHighHero") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("High-intensity exercise means pushing yourself to your **maximum effort**. It is so hard that talking is nearly impossible, and you can’t keep it up for very long.") + + Text("During this kind of hard exercise, your body may release hormones that raise glucose. This is more common in the morning before eating.") + + InsetContent { + VStack(spacing: 4) { + Text("Aerobic") + .font(.title2.bold()) + + Text("Explosive sprints or bursts") + .frame(maxWidth: .infinity) + } + + BulletedListView(bulletColor: .secondary) { + Text("Power lifting") + Text("CrossFit") + Text("100m sprint") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + Text("For these activities, consider setting your insulin needs to **more than 100%**.") + + Text("That said, insulin needs vary from person to person. Some people find they don’t need to adjust their insulin at all for high-intensity exercise.") + + Text("If you haven’t noticed a rise in glucose with high-intensity exercise, it may be due to:") + + BulletedListView { + Text("Starting your exercise with high active insulin") + Text("Automated insulin adjustments by \(appName) reduce a noticeable rise in glucose") + Text("The exercise may not be vigorous enough to produce these results") + } + + if let image = Image.optional("PresetsTrainingExerciseHigh") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("When using high-insulin presets, **you may not need to start your preset 1 hour before**.") + + Callout(.note) { + Text("These recommendations should be used as a starting point. Checking your glucose during exercise will help you find the settings that work best for you.") + } + .padding(.horizontal, -16) + + case .mixedIntensityExercise: + if let image = Image.optional("PresetsTrainingExerciseMixedHero") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("Mixed-intensity exercise may cause only small changes in glucose levels. Your glucose may go up or down.") + + InsetContent { + VStack(spacing: 4) { + Text("Aerobic") + .font(.title2.bold()) + + Text("Combination of high and low intensity") + .frame(maxWidth: .infinity) + } + + BulletedListView(bulletColor: .secondary) { + Text("Soccer") + Text("Interval Training ") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + Text("For mixed-intensity activity:") + + BulletedListView { + Text("If your glucose goes up, you may only need a small increase in insulin — less than you would for high-intensity activity.") + + Text("If your glucose goes down, you may only need a small decrease in insulin — less than you would for low to moderate-intensity activity.") + } + + Callout(.note) { + Text("These recommendations should be used as a starting point. Checking your glucose during exercise will help you find the settings that work best for you.") + } + .padding(.horizontal, -16) + + if let mixedExercise = trainingContent.first(where: { $0.fileName == "Mixed Exercise" }) { + VStack(alignment: .leading, spacing: 16) { + Text("Learn More") + .font(.headline.weight(.semibold)) + + PlayMediaButton(mediaContent: mixedExercise, onPlay: onPlayMedia) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(colorPalette.chartColorPalette.presetTint).opacity(0.1)) + ) + } + + case .exerciseAndGlucoseActiveInsulin: + Text("When using a preset for activity, keep in mind four key factors that may impact your glucose.") + + TintedContent( + tint: colorPalette.insulinTintColor, + icon: Image(systemName: "cross.vial"), + title: Text("Active Insulin") + ) { + Text("If you have active insulin in your body when you start exercising, you generally have an increased risk of low glucose.") + + TintedTip(text: Text("**Tip:** Try exercising when your active insulin is close to zero at the start of an activity.")) + } + + case .exerciseAndGlucoseTimeOfDay: + Text("When using a preset for activity, keep in mind four key factors that may impact your glucose.") + + TintedContent( + tint: colorPalette.carbTintColor, + icon: Image(systemName: "clock"), + title: Text("Time of Day") + ) { + Text("Morning exercise before eating (like a fasted jog) usually causes a smaller drop in glucose levels and may even promote a rise, compared to afternoon exercise.") + + if dynamicTypeSize < .accessibility1 { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Morning Exercise") + .foregroundStyle(colorPalette.carbTintColor) + .font(.subheadline.weight(.semibold)) + + Text("Smaller glucose drop") + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemBackground))) + + VStack(alignment: .leading, spacing: 4) { + Text("Afternoon Exercise") + .foregroundStyle(colorPalette.guidanceColors.critical) + .font(.subheadline.weight(.semibold)) + + Text("Larger glucose drop") + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemBackground))) + } + } else { + VStack(spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("Morning Exercise") + .foregroundStyle(colorPalette.carbTintColor) + .font(.subheadline.weight(.semibold)) + + Text("Smaller glucose drop") + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemBackground))) + + VStack(alignment: .leading, spacing: 4) { + Text("Afternoon Exercise") + .foregroundStyle(colorPalette.guidanceColors.critical) + .font(.subheadline.weight(.semibold)) + + Text("Larger glucose drop") + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemBackground))) + } + } + + TintedTip(text: Text("**Try:** If you often experience low glucose, consider exercising earlier in the day before eating.")) + } + + case .exerciseAndGlucoseMealTiming: + Text("When using a preset for activity, keep in mind four key factors that may impact your glucose.") + + TintedContent( + tint: .orange, + icon: Image(systemName: "fork.knife"), + title: Text("Meal Timing") + ) { + Text("If you often experience low glucose, you may need to reduce how much insulin you deliver for meals eaten 1-2 hours before exercising.") + + VStack(spacing: 4) { + Text("Recommended Insulin Reduction") + .font(.headline.weight(.semibold)) + .frame(maxWidth: .infinity) + + Text("25-33%") + .font(.title.weight(.heavy)) + .foregroundStyle(Color.orange) + + Text("if eating less than 2 hours before exercise") + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemBackground))) + + TintedTip(text: Text("**Try:** Reducing your meal bolus if you expect your glucose to drop.")) + } + + case .exerciseAndGlucoseCompetitionStress: + Text("When using a preset for activity, keep in mind four key factors that may impact your glucose.") + + TintedContent( + tint: colorPalette.glucoseTintColor, + icon: Image(systemName: "trophy"), + title: Text("Competition Stress") + ) { + Text("Stress during a game, match or tournament causes your body to release hormones like adrenaline and cortisol, which may raise your glucose and cause \(appName) to increase insulin delivery.") + + BulletedListView(bulletColor: colorPalette.glucoseTintColor, bulletOpacity: 1) { + Text("Monitor your glucose and active insulin at the start of a competition day") + + Text("Stay hydrated") + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemBackground))) + + TintedTip(text: Text("**Tip: If glucose rises to >270 mg/dl,** check \(appName) to see if a bolus is recommended to bring your glucose back into range.")) + } + + case .preventingLows: + Text("If you usually experience lows while exercising, watch your glucose levels closely during exercise and consider eating around 3 to 20g of fast-acting carbs.") + + Group { + if dynamicTypeSize < .accessibility1 { + HStack(alignment: .bottom, spacing: 12) { + InsetContent(spacing: 8) { + Text("Stable Glucose") + .font(.footnote) + .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + .frame(maxHeight: .infinity) + + Image("glucose-stable") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + .foregroundStyle(colorPalette.glucoseTintColor) + + VStack(spacing: 0) { + Text("3-6") + .font(.headline.weight(.semibold)) + + Text("grams") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + InsetContent(spacing: 8) { + Text("Falling Slowly") + .font(.footnote) + .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + .frame(maxHeight: .infinity) + + Image("glucose-stable") + .resizable() + .scaledToFit() + .rotationEffect(.degrees(30)) + .frame(width: 28, height: 28) + .foregroundStyle(colorPalette.glucoseTintColor) + + VStack(spacing: 0) { + Text("6-9") + .font(.headline.weight(.semibold)) + + Text("grams") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + InsetContent(spacing: 8) { + Text("Falling / Falling Quickly") + .font(.footnote) + .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + .frame(maxHeight: .infinity) + + HStack(spacing: 6) { + Image("glucose-stable") + .resizable() + .scaledToFit() + .rotationEffect(.degrees(90)) + .frame(width: 28, height: 28) + + Image("glucose-falling-fast") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + } + .foregroundStyle(colorPalette.glucoseTintColor) + + VStack(spacing: 0) { + Text("9-20") + .font(.headline.weight(.semibold)) + + Text("grams") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + .fixedSize(horizontal: false, vertical: true) + } else { + VStack(spacing: 12) { + InsetContent(spacing: 8) { + Text("Stable Glucose") + .font(.footnote) + .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + .frame(maxHeight: .infinity) + + Image("glucose-stable") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + .foregroundStyle(colorPalette.glucoseTintColor) + + Text("3-6") + .font(.headline.weight(.semibold)) + + Text(" grams") + .font(.footnote) + .foregroundStyle(.secondary) + } + + InsetContent(spacing: 8) { + Text("Falling Slowly") + .font(.footnote) + .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + .frame(maxHeight: .infinity) + + Image("glucose-stable") + .resizable() + .scaledToFit() + .rotationEffect(.degrees(30)) + .frame(width: 28, height: 28) + .foregroundStyle(colorPalette.glucoseTintColor) + + Text("6-9") + .font(.headline.weight(.semibold)) + + Text(" grams") + .font(.footnote) + .foregroundStyle(.secondary) + } + + InsetContent(spacing: 8) { + Text("Falling / Falling Quickly") + .font(.footnote) + .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + .frame(maxHeight: .infinity) + + HStack(spacing: 6) { + Image("glucose-stable") + .resizable() + .scaledToFit() + .rotationEffect(.degrees(90)) + .frame(width: 28, height: 28) + + Image("glucose-falling-fast") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + } + .foregroundStyle(colorPalette.glucoseTintColor) + + Text("9-20") + .font(.headline.weight(.semibold)) + + Text(" grams") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .fixedSize(horizontal: false, vertical: true) + } + } + .multilineTextAlignment(.center) + + Text("Check your glucose levels around 20 to 30 min after eating. If you're still low, consider eating the same amount.") + + Callout(.note) { + Text("If your glucose isn't dropping, eating too many carbs can raise your blood sugar, trigger more insulin, and increase the risk of low blood sugar during or after the activity.") + } + .padding(.horizontal, -16) + + case .unplannedActivity: + Text("Planning for physical activity can be tough. If you forget to set a preset ahead of time, consider these strategies:") + + InsetContent(padding: 16) { + HStack(spacing: 16) { + Image("presets-selected") + .resizable() + .renderingMode(.template) + .scaledToFit() + .frame(width: 28, height: 28) + .foregroundStyle(Color(colorPalette.chartColorPalette.presetTint)) + + VStack(alignment: .leading, spacing: 2) { + Text("Start Preset") + .frame(maxWidth: .infinity, alignment: .leading) + .fontWeight(.semibold) + + Text("Turn on the preset as soon as you remember and keep it on until the activity ends") + } + } + } + + InsetContent(padding: 16) { + HStack(spacing: 16) { + Image("candy-icon") + .resizable() + .renderingMode(.template) + .scaledToFit() + .frame(width: 32, height: 32) + .foregroundStyle(colorPalette.carbTintColor) + + VStack(alignment: .leading, spacing: 2) { + Text("If glucose drops below 126 mg/dL") + .frame(maxWidth: .infinity, alignment: .leading) + .fontWeight(.semibold) + + Text("Consider eating around 10 to 20 grams of fast-acting carbs") + } + } + } + } + case .trainingComplete: + Text("Congratulations! You've finished the Presets training.") + + VStack(alignment: .leading, spacing: 8) { + Text("You can now:") + + BulletedListView { + Text("Edit presets") + Text("Create new presets") + } + .padding(.leading, 8) + } + + Text("You may review the training materials again at any time via the Learning Hub, located at the bottom of the Preset screen.") + } + } + + var references: [Text] { + switch self { + case .customizingPresets, .illness, .dailyActivities, .trainingComplete: + return [] + case .exercise(let exercise): + switch exercise { + case .commonUses: + return [] + case .presetsForExercise: + return [ + Text(verbatim: "Moser O, Zaharieva DP, Adolfsson A, Battelino T, Bracken RM, Buckingham BA, Danne T, Davis EA, Dovč K, Forlenza GP, et al. The use of automated insulin delivery around physical activity and exercise in type 1 diabetes: a position statement of the European Association for the Study of Diabetes (EASD) and the International Society for Pediatric and Adolescent Diabetes (ISPAD). ") + Text("[PMID: 39653802](https://pmc.ncbi.nlm.nih.gov/articles/PMC11732933/)").underline(), + Text(verbatim: "American Diabetes Association Professional Practice Committee. 14. Children and Adolescents: Standards of Care in Diabetes-2025. Diabetes Care. ") + Text("[PMID: 39651980](https://doi.org/10.2337/dc25-S014)").underline(), + Text(verbatim: "Adolfsson P, Taplin CE, Zaharieva DP, Pemberton J, Davis EA, Riddell MC, et al. ISPAD Clinical Practice Consensus Guidelines 2022: Exercise in children and adolescents with diabetes. ") + Text("[PMID: 36537529](https://doi.org/10.1111/pedi.13452)").underline(), + Text(verbatim: "Phillip M, Nimri R, Bergenstal RM, Barnard-Kelly K, Danne T, Hovorka R, et al. Consensus Recommendations for the Use of Automated Insulin Delivery Technologies in Clinical Practice. ") + Text("[PMID: 36066457](https://pmc.ncbi.nlm.nih.gov/articles/PMC9985411/)").underline(), + Text(verbatim: "Braune K, Lal RA, Petruželková L, Scheiner G, Winterdijk P, Schmidt S, et al. Open-source automated insulin delivery: international consensus statement and practical guidance for health-care professionals. ") + Text("[PMID: 34785000](https://www.thelancet.com/journals/landia/article/PIIS2213-8587(21)00267-9/abstract)").underline(), + Text(verbatim: "Moser O, Riddell MC, Eckstein ML, Adolfsson P, Rabasa-Lhoret R, van den Boom L, et al. Glucose management for exercise using continuous glucose monitoring (CGM) and intermittently scanned CGM (isCGM) systems in type 1 diabetes: position statement of the European Association for the Study of Diabetes (EASD) and of the International Society for Pediatric and Adolescent Diabetes (ISPAD) endorsed by JDRF and supported by the American Diabetes Association (ADA). ") + Text("[PMID: 33047169](https://link.springer.com/article/10.1007/s00125-020-05263-9)").underline(), + Text(verbatim: "Scott SN, Fontana FY, Cocks M, Morton JP, Jeukendrup A, Dragulin R, et al. Post-exercise recovery for the endurance athlete with type 1. ") + Text("[PMID: 33864810](https://pubmed.ncbi.nlm.nih.gov/33864810/)").underline(), + Text(verbatim: "Riddell MC, Gallen IW, Smart CE, Taplin CE, Adolfsson P, Lumb AN, et al. Exercise management in type 1 diabetes: a consensus statement. ") + Text("[PMID: 28126459](https://pubmed.ncbi.nlm.nih.gov/28126459/)").underline(), + Text(verbatim: "Yardley JE, Sigal RJ. Exercise strategies for hypoglycemia prevention in individuals with type 1 diabetes. ") + Text("[PMID: 25717276](https://pmc.ncbi.nlm.nih.gov/articles/PMC4334090/)").underline(), + Text(verbatim: "Colberg SR, Sigal RJ, Yardley JE, Riddell MC, Dunstan DW, Dempsey PC, et al. Physical Activity/Exercise and Diabetes: A Position Statement of the American Diabetes Association. ") + Text("[PMID: 27926890](https://pmc.ncbi.nlm.nih.gov/articles/PMC6908414/)").underline() + ] + case .perceivedIntensity: + return [ + Text(verbatim: "Zaharieva DP, Paldus B, Morrison D, Messer LH, O’Neal DN, Maahs DM, Riddell MC, et al. Practical aspects and exercise safety benefits of automated insulin delivery systems in type 1 diabetes. Diabetes Spectr. ") + Text("[PMID: 37193203](https://pmc.ncbi.nlm.nih.gov/articles/PMC10182962/)").underline(), + Text(verbatim: "Borg GA. Psychophysical bases of perceived exertion. Med Sci Sports Exerc. PMID: ") + Text("[PMID: 7154893](https://pubmed.ncbi.nlm.nih.gov/7154893/)").underline() + ] + case .lightToModerateExercise: + return [ + Text(verbatim: "Turner LV, Marak MC, Gal RL, Calhoun P, Li Z, Jacobs PG, Clements MA, Martin CK, Doyle FJ 3rd, Patton SR, Castle JR, Gillingham MB, Beck RW, Rickels MR, Riddell MC; T1DEXI Study Group. Associations between daily step count classifications and continuous glucose monitoring metrics in adults with type 1 diabetes: analysis of the Type 1 Diabetes Exercise Initiative (T1DEXI) cohort. ") + Text("[PMID: 38502241](https://pubmed.ncbi.nlm.nih.gov/38502241/)").underline(), + Text(verbatim: "Riddell MC, Gal RL, Bergford S, Patton SR, Clements MA, Calhoun P, Beaulieu LC, Sherr JL. The Acute Effects of Real-World Physical Activity on Glycemia in Adolescents With Type 1 Diabetes: The Type 1 Diabetes Exercise Initiative Pediatric (T1DEXIP) Study. ") + Text("[PMID: 37922335](https://pubmed.ncbi.nlm.nih.gov/37922335/)").underline(), + Text(verbatim: "Zaharieva DP, McGaugh S, Pooni R, Vienneau T, Ly T, Riddell MC. Improved Open-Loop Glucose Control With Basal Insulin Reduction 90 Minutes Before Aerobic Exercise in Patients With Type 1 Diabetes on Continuous Subcutaneous Insulin Infusion. ") + Text("[PMID: 30796112](https://pubmed.ncbi.nlm.nih.gov/30796112/)").underline(), + Text(verbatim: "Molveau J, Myette-Côté É, Guédet C, Tagougui S, St-Amand R, Suppère C, Heyman E, Messier V, Boudreau V, Legault L, Rabasa-Lhoret R. Impact of pre- and post-exercise strategies on hypoglycemic risk for two modalities of aerobic exercise among adults and adolescents living with type 1 diabetes using continuous subcutaneous insulin infusion: A randomized controlled trial. ") + Text("[PMID: 39653075](https://pubmed.ncbi.nlm.nih.gov/39653075/)").underline(), + Text(verbatim: "Tagougui S, Legault L, Heyman E, Messier V, Suppere C, Potter KJ, Pigny P, Berthoin S, Taleb N, Rabasa-Lhoret R. Anticipated Basal Insulin Reduction to Prevent Exercise-Induced Hypoglycemia in Adults and Adolescents Living with Type 1 Diabetes. ") + Text("[PMID: 35099281](https://pubmed.ncbi.nlm.nih.gov/35099281/)").underline() + ] + case .highIntensityExercise: + return [ + Text(verbatim: "Paldus B, Morrison D, Zaharieva DP, Lee MH, Jones H, Obeyesekere V, La Gerche A, et al. A randomized crossover trial comparing glucose control during moderate‑intensity, high‑intensity, and resistance exercise with hybrid closed‑loop insulin delivery while profiling potential additional signals in adults with type 1 diabetes. Diabetes Care. ") + Text("[PMID: 34789504](https://pubmed.ncbi.nlm.nih.gov/34789504/)").underline(), + Text(verbatim: "Aronson R, Brown RE, Li A, Riddell MC. Optimal insulin correction factor in post‑high‑intensity exercise hyperglycemia in adults with type 1 diabetes: The FIT Study. Diabetes Care. ") + Text("[PMID: 30455336](https://pubmed.ncbi.nlm.nih.gov/30455336/)").underline() + ] + case .mixedIntensityExercise: + return [ + Text(verbatim: "Riddell MC, Gal RL, Bergford S, Patton SR, Clements MA, Calhoun P, et al. The Acute Effects of Real‑World Physical Activity on Glycemia in Adolescents With Type 1 Diabetes: The Type 1 Diabetes Exercise Initiative Pediatric (T1DEXIP) Study. ") + Text("[PMID: 37922335](https://pubmed.ncbi.nlm.nih.gov/37922335/)").underline(), + Text(verbatim: "Zaharieva DP, Yavelberg L, Jamnik V, Cinar A, Turksoy K, Riddell MC. The Effects of Basal Insulin Suspension at the Start of Exercise on Blood Glucose Levels During Continuous Versus Circuit‑Based Exercise in Individuals with Type 1 Diabetes on Continuous Subcutaneous Insulin Infusion. ") + Text("[PMID: 28613947](https://pmc.ncbi.nlm.nih.gov/articles/PMC5510047/)").underline() + ] + case .exerciseAndGlucoseActiveInsulin: + return [ + Text(verbatim: "Riddell MC, Lewis DM, Turner LV, Lal RA, Shahid A, Zaharieva DP. Refining Insulin on Board with netIOB for Automated Insulin Delivery. ") + Text("[PMID: 39143692](https://pmc.ncbi.nlm.nih.gov/articles/PMC11571556/)").underline(), + Text(verbatim: "Li Z, Calhoun P, Rickels MR, Gal RL, Beck RW, Jacobs PG, et al. Factors Affecting Reproducibility of Change in Glucose During Exercise: Results From the Type 1 Diabetes and Exercise Initiative. ") + Text("[PMID: 38456512](https://pmc.ncbi.nlm.nih.gov/articles/PMC11571421/)").underline(), + Text(verbatim: "Zaharieva DP, Morrison D, Paldus B, Lal RA, Buckingham BA, O'Neal DN. Practical Aspects and Exercise Safety Benefits of Automated Insulin Delivery Systems in Type 1 Diabetes. ") + Text("[PMID: 37193203](https://pmc.ncbi.nlm.nih.gov/articles/PMC10182962/)").underline() + ] + case .exerciseAndGlucoseTimeOfDay: + return [ + Text(verbatim: "Riddell MC, Turner LV, Patton SR. Is There an Optimal Time of Day for Exercise? A Commentary on When to Exercise for People Living With Type 1 or Type 2 Diabetes. ") + Text("[PMID: 37193212](https://pmc.ncbi.nlm.nih.gov/articles/PMC10182965/)").underline(), + Text(verbatim: "Morrison D, Paldus B, Zaharieva DP, Lee MH, Vogrin S, Jenkins AJ, et al. Late Afternoon Vigorous Exercise Increases Postmeal but Not Overnight Hypoglycemia in Adults with Type 1 Diabetes Managed with Automated Insulin Delivery. ") + Text("[PMID: 36094458](https://pubmed.ncbi.nlm.nih.gov/36094458/)").underline(), + Text(verbatim: "Yardley JE. Fasting May Alter Blood Glucose Responses to High-Intensity Interval Exercise in Adults With Type 1 Diabetes: A Randomized, Acute Crossover Study. ") + Text("[PMID: 33160882](https://pubmed.ncbi.nlm.nih.gov/33160882/)").underline(), + Text(verbatim: "Toghi-Eshghi SR, Yardley JE. Morning (Fasting) vs Afternoon Resistance Exercise in Individuals With Type 1 Diabetes: A Randomized Crossover Study. ") + Text("[PMID: 31211392](https://academic.oup.com/jcem/article/104/11/5217/5519298)").underline() + ] + case .exerciseAndGlucoseMealTiming: + return [ + Text(verbatim: "McCarthy OM, Christensen MB, Kristensen KB, Schmidt S, Ranjan AG, Bain SC, Bracken RM, Nørgaard K. Automated Insulin Delivery Around Exercise in Adults with Type 1 Diabetes: A Pilot Randomized Controlled Study. ") + Text("[PMID: 37053529](https://pubmed.ncbi.nlm.nih.gov/37053529/)").underline(), + Text(verbatim: "Myette‑Côté É, Molveau J, Wu Z, Raffray M, Devaux M, Tagougui S, et al. A Randomized Crossover Pilot Study Evaluating Glucose Control During Exercise Initiated 1 or 2 h After a Meal in Adults with Type 1 Diabetes Treated with an Automated Insulin Delivery System. ") + Text("[PMID: 36399114](https://pmc.ncbi.nlm.nih.gov/articles/PMC9894601/)").underline(), + Text(verbatim: "Tagougui S, Taleb N, Legault L, Suppère C, Messier V, Boukabous I, Shohoudi A, Ladouceur M, Rabasa-Lhoret R. A single-blind, randomised, crossover study to reduce hypoglycaemia risk during postprandial exercise with closed-loop insulin delivery in adults with type 1 diabetes: announced (with or without bolus reduction) vs unannounced exercise strategies. ") + Text("[PMID: 32740723](https://link.springer.com/article/10.1007/s00125-020-05244-y)").underline() + ] + case .exerciseAndGlucoseCompetitionStress: + return [ + Text(verbatim: "Katz A, Shulkin A, Fortier MA, Yardley JE, Kichler J, Housni A, Talbo MK, Rabasa-Lhoret R, Brazeau AS. Strategies to reduce hyperglycemia-related anxiety in elite athletes with type 1 diabetes: A qualitative analysis. ") + Text("[PMID: 39823464](https://pubmed.ncbi.nlm.nih.gov/39823464/)").underline(), + Text(verbatim: "Riddell MC, Gallen IW, Smart CE, Taplin CE, Adolfsson P, Lumb AN, Kowalski A, Rabasa-Lhoret R, McCrimmon RJ, Hume C, Annan F, Fournier PA, Graham C, Bode B, Galassetti P, Jones TW, Millán IS, Heise T, Peters AL, Petz A, Laffel LM. Exercise management in type 1 diabetes: a consensus statement. ") + Text("[PMID: 28126459](https://pubmed.ncbi.nlm.nih.gov/28126459/)").underline(), + Text(verbatim: "Hobbs N, Brandt R, Maghsoudipour S, Sevil M, Rashid M, Quinn L, Cinar A. Observational Study of Glycemic Impact of Anticipatory and Early-Race Athletic Competition Stress in Type 1 Diabetes. ") + Text("[PMID: 36992757](https://pubmed.ncbi.nlm.nih.gov/36992757/)").underline(), + Text(verbatim: "Riddell MC, Scott SN, Fournier PA, Colberg SR, Gallen IW, Moser O, Stettler C, Yardley JE, Zaharieva DP, Adolfsson P, Bracken RM. The competitive athlete with type 1 diabetes. ") + Text("[PMID: 32533229](https://pubmed.ncbi.nlm.nih.gov/32533229/)").underline() + ] + case .preventingLows: + return [ + Text(verbatim: "Moser O, Zaharieva DP, Adolfsson P, Battelino T, Bracken RM, Buckingham BA, et al. The use of automated insulin delivery around physical activity and exercise in type 1 diabetes: a position statement of the European Association for the Study of Diabetes (EASD) and the International Society for Pediatric and Adolescent Diabetes (ISPAD). ") + Text("[PMID: 39653802](https://pubmed.ncbi.nlm.nih.gov/39653802/)").underline() + ] + case .unplannedActivity: + return [ + Text(verbatim: "Tagougui S, Taleb N, Legault L, Suppère C, Messier V, Boukabous I, Shohoudi A, Ladouceur M, Rabasa-Lhoret R. A single-blind, randomised, crossover study to reduce hypoglycaemia risk during postprandial exercise with closed-loop insulin delivery in adults with type 1 diabetes: announced (with or without bolus reduction) vs unannounced exercise strategies. ") + Text("[PMID: 32740723](https://pubmed.ncbi.nlm.nih.gov/32740723/)").underline(), + Text(verbatim: "Zimmer RT, Auth A, Schierbauer J, Haupt S, Wachsmuth N, Zimmermann P, Voit T, Battelino T, Sourij H, Moser O. (Hybrid) Closed-Loop Systems: From Announced to Unannounced Exercise. ") + Text("[PMID: 38133645](https://pubmed.ncbi.nlm.nih.gov/38133645/)").underline(), + Text(verbatim: "Dovc K, Piona C, Yeşiltepe Mutlu G, Bratina N, Jenko Bizjan B, Lepej D, Nimri R, Atlas E, Muller I, Kordonouri O, Biester T, Danne T, Phillip M, Battelino T. Faster Compared With Standard Insulin Aspart During Day-and-Night Fully Closed-Loop Insulin Therapy in Type 1 Diabetes: A Double-Blind Randomized Crossover Trial. ") + Text("[PMID: 31575640](https://pubmed.ncbi.nlm.nih.gov/31575640/)").underline(), + Text(verbatim: "Dovc K, Macedoni M, Bratina N, Lepej D, Nimri R, Atlas E, Muller I, Kordonouri O, Biester T, Danne T, Phillip M, Battelino T. Closed-loop glucose control in young people with type 1 diabetes during and after unannounced physical activity: a randomised controlled crossover trial. ") + Text("[PMID: 28840263](https://pubmed.ncbi.nlm.nih.gov/28840263/)").underline() + ] + } + } + } + + var cta: PresetsTraining.CTA? { + switch self { + case .customizingPresets: .continue + case .illness(let illness): + switch illness { + case .commonUses: nil + case .presetsForIllness, + .overallInsulin, + .correctionRange, + .duration, + .impactOnBolusing: .continue + } + case .dailyActivities(let dailyActivities): + switch dailyActivities { + case .commonUses: nil + case .presetsForDailyActivities, + .overallInsulin, + .correctionRange, + .savedPresets: .continue + } + case .exercise(let exercise): + switch exercise { + case .commonUses: nil + case .presetsForExercise, + .perceivedIntensity, + .lightToModerateExercise, + .highIntensityExercise, + .mixedIntensityExercise, + .exerciseAndGlucoseActiveInsulin, + .exerciseAndGlucoseTimeOfDay, + .exerciseAndGlucoseMealTiming, + .exerciseAndGlucoseCompetitionStress, + .preventingLows, + .unplannedActivity: .continue + } + case .trainingComplete: .close + } + } +} + +extension PresetsTraining.Chapter: PresetsTrainingContent { + @ViewBuilder + func content( + appName: String, + displayGlucosePreference: DisplayGlucosePreference, + colorPalette: LoopUIColorPalette, + dynamicTypeSize: DynamicTypeSize, + trainingContent: [MediaContent], + next: @escaping () -> Void, + onPlayMedia: @escaping (MediaContent) -> Void + ) -> some View { + firstStep.content( + appName: appName, + displayGlucosePreference: displayGlucosePreference, + colorPalette: colorPalette, + dynamicTypeSize: dynamicTypeSize, + trainingContent: trainingContent, + next: next, + onPlayMedia: onPlayMedia + ) + } + + var cta: PresetsTraining.CTA? { + firstStep.cta + } +} + +extension ActivityPreset.ActivityType { + func bulletItem(full: Bool) -> Text { + if full { + return Text(Image(systemName: systemImageName)) + .fontDesign(.monospaced) + + Text(" \(name) · ") + .fontWeight(.semibold) + + Text("\(defaultInsulinNeedsScaleFactor.formatted(.percent)) of insulin") + } else { + return Text(Image(systemName: systemImageName)) + .fontDesign(.monospaced) + + Text(" \(name)") + .fontWeight(.semibold) + } + } +} + +extension ActivityPreset { + @ViewBuilder + static func bulletList(full: Bool) -> some View { + BulletedListView { + ActivityPreset.ActivityType.jogging.bulletItem(full: full) + ActivityPreset.ActivityType.walking.bulletItem(full: full) + ActivityPreset.ActivityType.biking.bulletItem(full: full) + ActivityPreset.ActivityType.strengthTraining.bulletItem(full: full) + } + } +} diff --git a/Loop/Views/Presets/Training/PresetsTrainingView.swift b/Loop/Views/Presets/Training/PresetsTrainingView.swift new file mode 100644 index 0000000000..1f817d5a17 --- /dev/null +++ b/Loop/Views/Presets/Training/PresetsTrainingView.swift @@ -0,0 +1,196 @@ +// +// PresetsTrainingView.swift +// Loop +// +// Created by Cameron Ingham on 8/26/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopKit +import LoopKitUI +import SwiftUI + +public struct PresetsTrainingView: View { + + @Environment(\.appName) private var appName + @Environment(\.colorPalette) private var colorPalette + @Environment(\.dismiss) private var dismiss + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + + @State private var training: PresetsTraining + + @State private var confirmDismiss: Bool = false + @State private var showSkipToChapterSelector: Bool = false + @State private var selectedMedia: MediaContent? + + private let trainingContent: [MediaContent] + private let onComplete: (() -> Void)? + + public init( + navigationPath: [PresetsTraining.Step] = [], + startingAt: PresetsTraining.Chapter? = nil, + trainingCompletionConfiguration: PresetsTraining.TrainingCompletionConfiguration, + trainingContent: [MediaContent], + onComplete: (() -> Void)? = nil + ) { + self.training = PresetsTraining( + navigationPath: navigationPath, + startingAt: startingAt, + trainingCompletionConfiguration: trainingCompletionConfiguration + ) + + self.trainingContent = trainingContent + self.onComplete = onComplete + } + + @ViewBuilder + private var closeButton: some View { + Button("Close") { + if training.trainingCompletion.isComplete { + close() + } else { + confirmDismiss = true + } + } + } + + public var body: some View { + NavigationStack(path: $training.navigationPath) { + stepView(training.startingAt.firstStep) + .navigationDestination(for: PresetsTraining.Step.self) { step in + stepView(step) + } + } + .environment(training) + .interactiveDismissDisabled(!training.trainingCompletion.isComplete) + .fullScreenCover(item: $selectedMedia) { media in + MediaPlayerView(media: media) + } + } + + private func close() { + dismiss() + } + + @ViewBuilder + private func stepView(_ step: PresetsTraining.Step) -> some View { + GeometryReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 0) { + VStack(spacing: 8) { + Text(step.title(appName: appName)) + .font(.largeTitle.bold()) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 16) + .onLongPressGesture { + guard training.trainingCompletion.allowDebugFeatures else { + return + } + + showSkipToChapterSelector = true + } + + Divider() + } + .padding(.bottom, 24) + + step.content( + appName: appName, + displayGlucosePreference: displayGlucosePreference, + colorPalette: colorPalette, + dynamicTypeSize: dynamicTypeSize, + trainingContent: trainingContent, + next: training.next, + onPlayMedia: { selectedMedia = $0 } + ) + .padding(.bottom, 24) + .padding(.horizontal, 16) + + if !step.references.isEmpty { + ReferencesView(step.references) + .padding(.bottom, 24) + .padding(.horizontal, 16) + } + + if let cta = step.cta { + Spacer(minLength: 0) + + Group { + switch cta { + case .start: + Button("Start Required Training") { + training.next() + } + .buttonStyle(ActionButtonStyle()) + case .continue: + Button("Continue") { + training.next() + } + .buttonStyle(ActionButtonStyle()) + case .close: + Button("Close") { + close() + training.trainingCompletion.completedChapters[.trainingComplete] = true + onComplete?() + } + .buttonStyle(ActionButtonStyle()) + case .closeOrContinue(let continueTo, let chapter): + VStack(spacing: 12) { + Button("Close Training") { + if training.trainingCompletion.completedChapters[chapter] != true { + training.trainingCompletion.completedChapters[chapter] = true + } + + close() + } + .buttonStyle(ActionButtonStyle(.secondary)) + + Button("Continue to \(continueTo)") { + training.next() + } + .buttonStyle(ActionButtonStyle()) + } + } + } + .padding(.horizontal, 16) + } + } + .frame(maxWidth: .infinity) + .frame(minHeight: proxy.size.height, alignment: .top) + } + } + .background(step.contentBackground.ignoresSafeArea(.all)) + .navigationBarTitleDisplayMode(.inline) + .navigationBarHidden(false) + .toolbar { + if step != .trainingComplete { + ToolbarItem(placement: .topBarTrailing) { + closeButton + } + } + } + .alert(isPresented: $confirmDismiss) { + Alert( + title: Text("End Training?"), + message: Text("You'll have to restart this section and some features will be disabled until you complete the training."), + primaryButton: .cancel(), + secondaryButton: .destructive(Text("End"), action: { close() }) + ) + } + .confirmationDialog("Skip to Chapter", isPresented: $showSkipToChapterSelector) { + ForEach(PresetsTraining.Chapter.allCases, id: \.self) { chapter in + Button { + dismiss() + training.trainingCompletion.complete(to: chapter) + } label: { + chapter.title + } + } + } message: { + Text("Skip and complete training up to") + } + } +} diff --git a/Loop/Views/RecentGlucoseTableViewCell.swift b/Loop/Views/RecentGlucoseTableViewCell.swift new file mode 100644 index 0000000000..497a679025 --- /dev/null +++ b/Loop/Views/RecentGlucoseTableViewCell.swift @@ -0,0 +1,27 @@ +// +// RecentGlucoseTableViewCell.swift +// Loop +// +// Created by Nathaniel Hamming on 2025-10-27. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import UIKit +import LoopUI + +public class RecentGlucoseTableViewCell: UITableViewCell { + @IBOutlet weak var paddedView: UIView! + @IBOutlet weak var title: UILabel! + @IBOutlet weak var caption: UILabel! + + override public func awakeFromNib() { + super.awakeFromNib() + + paddedView.layer.masksToBounds = true + paddedView.layer.cornerRadius = 10 + paddedView.layer.borderWidth = 1 + paddedView.layer.borderColor = UIColor.systemGray5.cgColor + } +} + +extension RecentGlucoseTableViewCell: NibLoadable { } diff --git a/Loop/Views/RecentGlucoseTableViewCell.xib b/Loop/Views/RecentGlucoseTableViewCell.xib new file mode 100644 index 0000000000..ff2e0e2b98 --- /dev/null +++ b/Loop/Views/RecentGlucoseTableViewCell.xib @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Loop/Views/RequiredVersionUpdateView.swift b/Loop/Views/RequiredVersionUpdateView.swift new file mode 100644 index 0000000000..5017d5e577 --- /dev/null +++ b/Loop/Views/RequiredVersionUpdateView.swift @@ -0,0 +1,54 @@ +// +// RequiredVersionUpdateView.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct RequiredVersionUpdateView: View { + let appName: String + let openAppStore: () -> Void + + var body: some View { + ZStack { + Color.black.opacity(0.4) + .edgesIgnoringSafeArea(.all) + + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 40)) + .foregroundColor(.critical) + .padding(.top, 8) + + Text(String(format: NSLocalizedString("Required %1$@ App Update", comment: "Title for required version update modal (1: app name)"), appName)) + .font(.headline) + .multilineTextAlignment(.center) + + VStack(spacing: 12) { + Text(String(format: NSLocalizedString("A critical issue has been discovered in this version of %1$@. To continue using the app, you must update to the latest version.", comment: "Required update modal paragraph 1 (1: app name)"), appName)) + Text(String(format: NSLocalizedString("You will continue to receive your scheduled basal rate, but %1$@ will not make automated adjustments.", comment: "Required update modal paragraph 2 (1: app name)"), appName)) + Text(NSLocalizedString("Please go to the App Store now to update the app.", comment: "Required update modal paragraph 3")) + } + .font(.subheadline) + .multilineTextAlignment(.center) + + Divider() + .padding(.horizontal, -40) + + Button(action: openAppStore) { + Text(NSLocalizedString("App Store", comment: "Button title to open the App Store for a required update")) + .font(.title3) + .fontWeight(.semibold) + } + .padding(.bottom, 8) + } + .padding(24) + .background(Color(.systemBackground)) + .cornerRadius(14) + .shadow(radius: 10) + .padding(.horizontal, 40) + } + } +} diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index aa0da33134..e3bdd8c218 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -6,13 +6,14 @@ // Copyright © 2020 LoopKit Authors. All rights reserved. // +import HealthKit import LoopKit import LoopKitUI import MockKit import SwiftUI -import HealthKit +import LoopUI -public struct SettingsView: View { +struct SettingsView: View { @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference @Environment(\.dismissAction) private var dismiss @Environment(\.appName) private var appName @@ -20,8 +21,9 @@ 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 + @State var viewModel: SettingsViewModel @ObservedObject var versionUpdateViewModel: VersionUpdateViewModel enum Destination { @@ -32,6 +34,7 @@ public struct SettingsView: View { case deleteCGMData case deletePumpData + case deleteAllTestingData } enum ActionSheet: String, Identifiable { @@ -50,6 +53,7 @@ public struct SettingsView: View { } case favoriteFoods + case presets } } @@ -59,7 +63,7 @@ public struct SettingsView: View { var localizedAppNameAndVersion: String - public init(viewModel: SettingsViewModel, localizedAppNameAndVersion: String) { + init(viewModel: SettingsViewModel, localizedAppNameAndVersion: String) { self.viewModel = viewModel self.versionUpdateViewModel = viewModel.versionUpdateViewModel self.localizedAppNameAndVersion = localizedAppNameAndVersion @@ -73,14 +77,16 @@ public struct SettingsView: View { if versionUpdateViewModel.softwareUpdateAvailable { softwareUpdateSection } - if FeatureFlags.automaticBolusEnabled { + if FeatureFlags.dosingStrategySelectionEnabled { dosingStrategySection } alertManagementSection if viewModel.pumpManagerSettingsViewModel.isSetUp() { - configurationSection + therapySection } + presetsSection deviceSettingsSection + healthAccessSection if FeatureFlags.allowExperimentalFeatures { favoriteFoodsSection } @@ -93,8 +99,8 @@ public struct SettingsView: View { servicesSection } - ForEach(customSections) { customSectionName in - menuItemsForSection(name: customSectionName) + ForEach(pluginMenuItems.filter({ $0.section != .support })) { item in + item.view } supportSection @@ -132,13 +138,39 @@ public struct SettingsView: View { return makeDeleteAlert(for: self.viewModel.cgmManagerSettingsViewModel) case .deletePumpData: return makeDeleteAlert(for: self.viewModel.pumpManagerSettingsViewModel) + case .deleteAllTestingData: + return SwiftUI.Alert(title: Text("Delete All Testing Data"), + message: Text("Are you sure you want to delete all your testing Data?\n(This action is not reversible)"), + primaryButton: .cancel(), + secondaryButton: .destructive(Text("Delete"), action: viewModel.deleteAllTestingData)) } } .sheet(item: $sheet) { sheet in - switch sheet { - case .favoriteFoods: - FavoriteFoodsView() + Group { + switch sheet { + case .presets: + if let carbStore = viewModel.deviceManager?.carbStore, let doseStore = viewModel.deviceManager?.doseStore, let glucoseStore = viewModel.deviceManager?.glucoseStore { + PresetsView( + roundBasalRate: viewModel.deliveryDelegate?.roundBasalRate, + carbStore: carbStore, + doseStore: doseStore, + glucoseStore: glucoseStore, + trainingContent: viewModel.availableSupports.flatMap({ $0.trainingMedia(for: .presets) }), + automationHistory: { viewModel.delegate?.automationHistory ?? [] } + ) + } + case .favoriteFoods: + FavoriteFoodsView(insightsDelegate: viewModel.favoriteFoodInsightsDelegate) + } } + .environmentObject(displayGlucosePreference) + .environment(\.dismissAction, self.dismiss) + .environment(\.appName, self.appName) + .environment(\.chartColorPalette, .primary) + .environment(\.carbTintColor, self.carbTintColor) + .environment(\.glucoseTintColor, self.glucoseTintColor) + .environment(\.guidanceColors, self.guidanceColors) + .environment(\.insulinTintColor, self.insulinTintColor) } } .navigationViewStyle(.stack) @@ -152,31 +184,14 @@ public struct SettingsView: View { } } - private var customSections: [String] { - pluginMenuItems.compactMap { item in - if case .custom(let name) = item.section { - return name - } else { - return nil - } - } - } - private var closedLoopToggleState: Binding { Binding( - get: { self.viewModel.isClosedLoopAllowed && self.viewModel.closedLoopPreference }, + get: { self.viewModel.closedLoopPreference }, set: { self.viewModel.closedLoopPreference = $0 } ) } } -extension String: Identifiable { - public typealias ID = Int - public var id: Int { - return hash - } -} - struct PluginMenuItem: Identifiable { var id: String { return pluginIdentifier + String(describing: offset) @@ -193,33 +208,70 @@ extension SettingsView { private var dismissButton: some View { Button(action: dismiss) { Text("Done").bold() - } + }.accessibilityIdentifier("button_done") } 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.automaticDosingEnabled, + freshness: viewModel.loopStatusCircleFreshness, + deviceIssue: viewModel.deviceIssue + ) + .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) + .padding(.vertical) } } private var softwareUpdateSection: some View { Section(footer: Text(viewModel.versionUpdateViewModel.footer(appName: appName))) { NavigationLink(destination: viewModel.versionUpdateViewModel.softwareUpdateView) { - Text(NSLocalizedString("Software Update", comment: "Software update button link text")) - Spacer() - viewModel.versionUpdateViewModel.icon + HStack { + Text(NSLocalizedString("Software Update", comment: "Software update button link text")) + Spacer() + viewModel.versionUpdateViewModel.icon + } } } } @@ -241,12 +293,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.critical) .padding(5) - .background(guidanceColors.warning) - .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) } } @@ -262,8 +315,59 @@ 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") + } + NavigationLink(destination: LiveActivityManagementView()) { + LargeButton( + action: {}, + includeArrow: false, + imageView: Image(systemName: "rectangle.on.rectangle") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30), + label: NSLocalizedString("Live Activity", comment: "Live Activity settings button text"), + descriptiveText: NSLocalizedString("Lock Screen, Dynamic Island, and CarPlay display", comment: "Live Activity settings descriptive text") + ) + .accessibilityIdentifier("settingsViewLiveActivity") + } + } + } + + private func healthKitSharingStatus(for type: HKObjectType) -> HKAuthorizationStatus { + viewModel.deviceManager?.healthKitSharingStatus(for: type) ?? .notDetermined + } + + @ViewBuilder + private var healthAccessWarning: some View { + let denied = healthKitSharingStatus(for: HealthKitSampleStore.glucoseType) == .sharingDenied + || healthKitSharingStatus(for: HealthKitSampleStore.insulinQuantityType) == .sharingDenied + if denied { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.critical) + .accessibilityIdentifier("settingsViewHealthAccessWarning") + } + } + + private var healthAccessSection: some View { + Section { + NavigationLink(destination: HealthAccessView( + glucoseSharingStatus: { healthKitSharingStatus(for: HealthKitSampleStore.glucoseType) }, + insulinSharingStatus: { healthKitSharingStatus(for: HealthKitSampleStore.insulinQuantityType) } + )) { + LargeButton( + action: {}, + includeArrow: false, + imageView: Image(systemName: "heart.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30), + secondaryImageView: healthAccessWarning, + label: NSLocalizedString("Apple Health", comment: "Apple Health settings button text"), + descriptiveText: NSLocalizedString("Glucose and Insulin Data Access", comment: "Apple Health settings descriptive text") + ) + .accessibilityIdentifier("settingsViewHealthAccess") } } } @@ -273,8 +377,6 @@ extension SettingsView { mode: .settings, viewModel: TherapySettingsViewModel( therapySettings: viewModel.therapySettings(), - sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, - adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, delegate: viewModel.therapySettingsViewModelDelegate ) ) @@ -288,14 +390,15 @@ extension SettingsView { .environment(\.insulinTintColor, self.insulinTintColor) } - 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 { NavigationLink(destination: therapySettingsView) { - LargeButton(action: { }, + LargeButton(action: {}, includeArrow: false, imageView: Image("Therapy Icon"), label: NSLocalizedString("Therapy Settings", comment: "Title text for button to Therapy Settings"), descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) + .accessibilityIdentifier("button_TherapySettings") } ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in @@ -308,6 +411,18 @@ extension SettingsView { } } + private var presetsSection: some View { + Section { + LargeButton( + action: { sheet = .presets }, + includeArrow: true, + imageView: Image("Presets Icon"), + label: NSLocalizedString("Presets", comment: "Title text for button to Preset Settings"), + descriptiveText: NSLocalizedString("Temporary Settings Adjustments", comment: "Descriptive text for Preset Settings") + ).accessibilityIdentifier("button_Presets") + } + } + private var pluginMenuItems: [PluginMenuItem] { self.viewModel.availableSupports.flatMap { plugin in plugin.configurationMenuItems().enumerated().map { index, item in @@ -317,9 +432,12 @@ extension SettingsView { } private var deviceSettingsSection: some View { - Section { + Section(header: SectionHeader(label: NSLocalizedString("Devices", comment: ""))) { pumpSection + .accessibilityIdentifier("settingsViewInsulinPump") + cgmSection + .accessibilityIdentifier("settingsViewCGM") } } @@ -438,6 +556,17 @@ extension SettingsView { } } } + if viewModel.cgmManagerSettingsViewModel.isTestingDevice, + viewModel.pumpManagerSettingsViewModel.isTestingDevice + { + Button(action: { alert = .deleteAllTestingData }) { + HStack { + Spacer() + Text("Delete All Testing Data").accentColor(.destructive) + Spacer() + } + } + } } } @@ -550,7 +679,9 @@ extension SettingsView { private func serviceImage(uiImage: UIImage?) -> some View { deviceImage(uiImage: uiImage) } -} +} // end extension SettingsView + +// MARK: - LargeButton fileprivate struct LargeButton: View { diff --git a/Loop/Views/SimpleBolusView.swift b/Loop/Views/SimpleBolusView.swift index 044b3dc41e..b7efbfd119 100644 --- a/Loop/Views/SimpleBolusView.swift +++ b/Loop/Views/SimpleBolusView.swift @@ -9,14 +9,14 @@ import SwiftUI 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 @@ -28,6 +28,16 @@ struct SimpleBolusView: View { set: { newValue in viewModel.manualGlucoseString = newValue } ) } + + private var enteredBolusString: Binding { + Binding( + get: { return viewModel.enteredBolusString }, + set: { newValue in + viewModel.enteredBolusString = newValue + viewModel.didEditBolusAmount = true + } + ) + } init(viewModel: SimpleBolusViewModel) { self.viewModel = viewModel @@ -42,48 +52,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 { + private func formatGlucose(_ quantity: LoopQuantity) -> 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 +106,10 @@ struct SimpleBolusView: View { private var summarySection: some View { Section { + glucoseEntryRow if viewModel.displayMealEntry { carbEntryRow } - glucoseEntryRow recommendedBolusRow bolusEntryRow } @@ -136,6 +133,7 @@ struct SimpleBolusView: View { .padding([.top, .bottom], 5) .fixedSize() .modifier(LabelBackground()) + .accessibilityIdentifier("textField_Carbohydrates") } } @@ -150,9 +148,14 @@ struct SimpleBolusView: View { font: .heavy(.title1), textAlignment: .right, keyboardType: .decimalPad, + shouldBecomeFirstResponder: shouldGlucoseEntryBecomeFirstResponder, maxLength: 4, doneButtonColor: .loopAccent ) + .onAppear { + shouldGlucoseEntryBecomeFirstResponder = true + } + .accessibilityIdentifier("textField_CurrentGlucose") glucoseUnitsLabel } @@ -171,6 +174,7 @@ struct SimpleBolusView: View { .font(.title) .foregroundColor(Color(.label)) .padding([.top, .bottom], 4) + .accessibilityIdentifier("staticText_RecommendedBolus") bolusUnitsLabel } } @@ -201,13 +205,12 @@ struct SimpleBolusView: View { Spacer() HStack(alignment: .firstTextBaseline) { DismissibleKeyboardTextField( - text: $viewModel.enteredBolusString, - placeholder: "", + text: enteredBolusString, + placeholder: "0", font: .preferredFont(forTextStyle: .title1), textColor: .loopAccent, textAlignment: .right, keyboardType: .decimalPad, - shouldBecomeFirstResponder: shouldBolusEntryBecomeFirstResponder, maxLength: 5, doneButtonColor: .loopAccent ) @@ -216,11 +219,13 @@ struct SimpleBolusView: View { } .fixedSize() .modifier(LabelBackground()) + .accessibilityIdentifier("textField_Bolus") } } private var carbUnitsLabel: some View { - Text(QuantityFormatter(for: .gram()).localizedUnitStringWithPlurality()) + Text(QuantityFormatter(for: .gram).localizedUnitStringWithPlurality()) + .foregroundColor(Color(.secondaryLabel)) } private var glucoseUnitsLabel: some View { @@ -230,7 +235,7 @@ struct SimpleBolusView: View { } private var bolusUnitsLabel: Text { - Text(QuantityFormatter(for: .internationalUnit()).localizedUnitStringWithPlurality()) + Text(QuantityFormatter(for: .internationalUnit).localizedUnitStringWithPlurality()) .foregroundColor(Color(.secondaryLabel)) } @@ -250,14 +255,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: { @@ -276,6 +280,7 @@ struct SimpleBolusView: View { .disabled(viewModel.actionButtonDisabled) .buttonStyle(ActionButtonStyle(.primary)) .padding() + .accessibilityIdentifier("button_bolusAction") } private func alert(for alert: SimpleBolusViewModel.Alert) -> SwiftUI.Alert { @@ -306,7 +311,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)) @@ -343,7 +348,7 @@ struct SimpleBolusView: View { title: Text("Recommended Bolus Exceeds Maximum Bolus", comment: "Title for bolus screen warning when recommended bolus exceeds max bolus"), caption: Text(String(format: NSLocalizedString("Your recommended bolus exceeds your maximum bolus amount of %1$@.", comment: "Warning for simple bolus when recommended bolus exceeds max bolus. (1: maximum bolus)"), viewModel.maximumBolusAmountString ))) case .carbohydrateEntryTooLarge: - let maximumCarbohydrateString = QuantityFormatter(for: .gram()).string(from: LoopConstants.maxCarbEntryQuantity)! + let maximumCarbohydrateString = QuantityFormatter(for: .gram).string(from: LoopConstants.maxCarbEntryQuantity)! return WarningView( title: Text("Carbohydrate Entry Too Large", comment: "Title for bolus screen warning when carbohydrate entry is too large"), caption: Text(String(format: NSLocalizedString("The maximum amount allowed is %1$@.", comment: "Warning for simple bolus when carbohydrate entry is too large. (1: maximum carbohydrate entry)"), maximumCarbohydrateString))) @@ -362,13 +367,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,19 +384,22 @@ struct SimpleBolusCalculatorView_Previews: PreviewProvider { createdByCurrentApp: true, userCreatedDate: Date(), userUpdatedDate: nil) - completion(.success(storedCarbEntry)) } - func enactBolus(units: Double, activationType: BolusActivationType) { + func insulinOnBoard(at date: Date) async -> InsulinValue? { + return nil + } + + func enactBolus(units: Double, decisionId: UUID?, activationType: BolusActivationType) { } func insulinOnBoard(at date: Date, completion: @escaping (DoseStoreResult) -> Void) { completion(.success(InsulinValue(startDate: date, value: 2.0))) } - func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? { + func computeSimpleBolusRecommendation(at date: Date, mealCarbs: LoopQuantity?, manualGlucose: LoopQuantity?) -> 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 +411,24 @@ struct SimpleBolusCalculatorView_Previews: PreviewProvider { return DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) } - var maximumBolus: Double { + var maximumBolus: Double? { return 6 } - var suspendThreshold: HKQuantity { - return HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 75) + var suspendThreshold: LoopQuantity? { + return LoopQuantity(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/Views/StatusTableView.swift b/Loop/Views/StatusTableView.swift new file mode 100644 index 0000000000..56c1425907 --- /dev/null +++ b/Loop/Views/StatusTableView.swift @@ -0,0 +1,267 @@ +// +// StatusTableView.swift +// Loop +// +// Created by Cameron Ingham on 12/10/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit +import LoopKitUI +import SwiftUI +import UIKit + +private struct WrappedStatusTableViewController: UIViewControllerRepresentable { + + private let alertPermissionsChecker: AlertPermissionsChecker + private let alertMuter: AlertMuter + private let deviceDataManager: DeviceDataManager + private let onboardingManager: OnboardingManager + private let supportManager: SupportManager + private let testingScenariosManager: TestingScenariosManager? + private let settingsManager: SettingsManager + private let temporaryPresetsManager: TemporaryPresetsManager + private let loopDataManager: LoopDataManager + private let diagnosticReportGenerator: DiagnosticReportGenerator + private let simulatedData: SimulatedData + private let analyticsServicesManager: AnalyticsServicesManager + private let servicesManager: ServicesManager + private let carbStore: CarbStore + private let doseStore: DoseStore + private let criticalEventLogExportManager: CriticalEventLogExportManager + private let bluetoothStateManager: BluetoothStateManager + private let statusTableViewModel: StatusTableViewModel + + let viewController: StatusTableViewController + + init(alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, deviceDataManager: DeviceDataManager, onboardingManager: OnboardingManager, supportManager: SupportManager, testingScenariosManager: TestingScenariosManager?, settingsManager: SettingsManager, temporaryPresetsManager: TemporaryPresetsManager, loopDataManager: LoopDataManager, diagnosticReportGenerator: DiagnosticReportGenerator, simulatedData: SimulatedData, analyticsServicesManager: AnalyticsServicesManager, servicesManager: ServicesManager, carbStore: CarbStore, doseStore: DoseStore, criticalEventLogExportManager: CriticalEventLogExportManager, bluetoothStateManager: BluetoothStateManager, statusTableViewModel: StatusTableViewModel) { + self.alertPermissionsChecker = alertPermissionsChecker + self.alertMuter = alertMuter + self.deviceDataManager = deviceDataManager + self.onboardingManager = onboardingManager + self.supportManager = supportManager + self.testingScenariosManager = testingScenariosManager + self.settingsManager = settingsManager + self.temporaryPresetsManager = temporaryPresetsManager + self.loopDataManager = loopDataManager + self.diagnosticReportGenerator = diagnosticReportGenerator + self.simulatedData = simulatedData + self.analyticsServicesManager = analyticsServicesManager + self.servicesManager = servicesManager + self.carbStore = carbStore + self.doseStore = doseStore + self.criticalEventLogExportManager = criticalEventLogExportManager + self.bluetoothStateManager = bluetoothStateManager + self.statusTableViewModel = statusTableViewModel + + let storyboard = UIStoryboard(name: "Main", bundle: Bundle(for: StatusTableViewController.self)) + let statusTableViewController = storyboard.instantiateViewController(withIdentifier: "MainStatusViewController") as! StatusTableViewController + statusTableViewController.alertPermissionsChecker = alertPermissionsChecker + statusTableViewController.alertMuter = alertMuter + statusTableViewController.deviceManager = deviceDataManager + statusTableViewController.onboardingManager = onboardingManager + statusTableViewController.supportManager = supportManager + statusTableViewController.testingScenariosManager = testingScenariosManager + statusTableViewController.settingsManager = settingsManager + statusTableViewController.temporaryPresetsManager = temporaryPresetsManager + statusTableViewController.loopManager = loopDataManager + statusTableViewController.diagnosticReportGenerator = diagnosticReportGenerator + statusTableViewController.simulatedData = simulatedData + statusTableViewController.analyticsServicesManager = analyticsServicesManager + statusTableViewController.servicesManager = servicesManager + statusTableViewController.carbStore = carbStore + statusTableViewController.doseStore = doseStore + statusTableViewController.criticalEventLogExportManager = criticalEventLogExportManager + statusTableViewController.statusTableViewModel = statusTableViewModel + bluetoothStateManager.addBluetoothObserver(statusTableViewController) + + self.viewController = statusTableViewController + } + + func makeUIViewController(context: Context) -> some UIViewController { + viewController + } + + func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {} +} + +struct StatusTableView: View { + + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.verticalSizeClass) private var verticalSizeClass + + private var isLandscape: Bool { + UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height + } + + private let wrapped: WrappedStatusTableViewController + + var viewController: StatusTableViewController { + wrapped.viewController + } + + @ViewBuilder + var wrappedView: some View { wrapped } + + @Bindable var viewModel: StatusTableViewModel + + init(viewModel: StatusTableViewModel) { + self.viewModel = viewModel + + self.wrapped = WrappedStatusTableViewController( + alertPermissionsChecker: viewModel.alertPermissionsChecker, + alertMuter: viewModel.alertMuter, + deviceDataManager: viewModel.deviceDataManager, + onboardingManager: viewModel.onboardingManager, + supportManager: viewModel.supportManager, + testingScenariosManager: viewModel.testingScenariosManager, + settingsManager: viewModel.settingsManager, + temporaryPresetsManager: viewModel.temporaryPresetsManager, + loopDataManager: viewModel.loopDataManager, + diagnosticReportGenerator: viewModel.diagnosticReportGenerator, + simulatedData: viewModel.simulatedData, + analyticsServicesManager: viewModel.analyticsServicesManager, + servicesManager: viewModel.servicesManager, + carbStore: viewModel.carbStore, + doseStore: viewModel.doseStore, + criticalEventLogExportManager: viewModel.criticalEventLogExportManager, + bluetoothStateManager: viewModel.bluetoothStateManager, + statusTableViewModel: viewModel + ) + } + + var body: some View { + wrappedView + .ignoresSafeArea(edges: .bottom) + .onChange(of: viewModel.temporaryPresetsManager.activeOverride) { _, _ in + Task { + await viewController.reloadData(animated: true) + } + } + .sheet(item: $viewModel.pendingPreset) { preset in + // This is the active preset; edit disabled + PresetDetentView(preset: preset, roundBasalRate: viewModel.loopDataManager.deliveryDelegate?.roundBasalRate, didTapEdit: { }) + .accessibilityIdentifier("bar_Presets") + } + .toolbar { + if !isLandscape { + if #available(iOS 26, *) { + ToolbarItemGroup(placement: .bottomBar) { + carbTab + Spacer() + bolusTab + Spacer() + presetsTab + Spacer() + settingsTab + } + } else { + ToolbarItem(placement: .bottomBar) { + HStack { + carbTab + bolusTab + presetsTab + settingsTab + } + } + } + } + } + .toolbar(isLandscape ? .hidden : .visible, for: .bottomBar) + .toolbarBackground(.visible, for: .bottomBar) + } + + var carbTab: some View { + Button { + viewController.userTappedAddCarbs() + } label: { + VStack(spacing: 0) { + Image("carbs") + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(height: 32) + .frame(maxWidth: .infinity) + .foregroundStyle(Color.carbs) + + Text("Add Carbs") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize() + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("statusTableViewControllerCarbsButton") + } + + var bolusTab: some View { + Button { + viewController.presentBolusScreen() + } label: { + VStack(spacing: 0) { + Image("bolus") + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(height: 32) + .frame(maxWidth: .infinity) + .foregroundStyle(Color.insulin) + + Text("Bolus") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize() + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("statusTableViewControllerBolusButton") + } + + var presetsTab: some View { + Button { + viewController.presentPresets() + } label: { + VStack(spacing: 0) { + Image(viewModel.temporaryPresetsManager.activeOverride != nil ? "presets-selected" : "presets") + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(height: 32) + .frame(maxWidth: .infinity) + .foregroundStyle(Color.presets) + .animation(.default, value: viewModel.temporaryPresetsManager.activeOverride) + + Text("Presets") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize() + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("statusTableViewPresetsButton") + } + + var settingsTab: some View { + Button { + viewController.presentSettings() + } label: { + VStack(spacing: 0) { + Image("settings") + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(height: 32) + .frame(maxWidth: .infinity) + .foregroundStyle(Color.secondary) + + Text("Settings") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize() + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("statusTableViewControllerSettingsButton") + } +} diff --git a/Loop/Views/TitleSubtitleTableViewCell.swift b/Loop/Views/TitleSubtitleTableViewCell.swift index d9e24e7185..ce1dbe815a 100644 --- a/Loop/Views/TitleSubtitleTableViewCell.swift +++ b/Loop/Views/TitleSubtitleTableViewCell.swift @@ -25,7 +25,7 @@ class TitleSubtitleTableViewCell: UITableViewCell { gradient.frame = bounds } - private lazy var gradient = CAGradientLayer() + private(set) lazy var gradient = CAGradientLayer() override func awakeFromNib() { super.awakeFromNib() diff --git a/LoopCore/GetBolusRecommendationUserInfo.swift b/LoopCore/GetBolusRecommendationUserInfo.swift new file mode 100644 index 0000000000..63f45547ed --- /dev/null +++ b/LoopCore/GetBolusRecommendationUserInfo.swift @@ -0,0 +1,51 @@ +// +// GetBolusRecommendationUserInfo.swift +// Naterade +// +// Created by Nathan Racklyeft on 1/23/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation +import LoopKit + + +public struct GetBolusRecommendationUserInfo { + public let carbEntry: NewCarbEntry? + + public init(carbEntry: NewCarbEntry?) { + self.carbEntry = carbEntry + } +} + + +extension GetBolusRecommendationUserInfo: RawRepresentable { + public typealias RawValue = [String: Any] + + static let version = 1 + public static let name = "GetBolusRecommendationUserInfo" + + public init?(rawValue: RawValue) { + guard rawValue["v"] as? Int == type(of: self).version && rawValue["name"] as? String == GetBolusRecommendationUserInfo.name + else { + return nil + } + + if let value = rawValue["ce"] as? NewCarbEntry.RawValue, + let carbEntry = NewCarbEntry(rawValue: value) + { + self.carbEntry = carbEntry + } else { + self.carbEntry = nil + } + } + + public var rawValue: RawValue { + var rval: RawValue = [ + "v": type(of: self).version, + "name": GetBolusRecommendationUserInfo.name, + ] + rval["ce"] = carbEntry?.rawValue + return rval + } +} diff --git a/LoopCore/HKUnit.swift b/LoopCore/HKUnit.swift index 7f9a5e3009..da6d179e84 100644 --- a/LoopCore/HKUnit.swift +++ b/LoopCore/HKUnit.swift @@ -17,16 +17,4 @@ extension HKUnit { public static let millimolesPerLiter: HKUnit = { return HKUnit.moleUnit(with: .milli, molarMass: HKUnitMolarMassBloodGlucose).unitDivided(by: .liter()) }() - - public static let milligramsPerDeciliterPerMinute: HKUnit = { - return HKUnit.milligramsPerDeciliter.unitDivided(by: .minute()) - }() - - public static let millimolesPerLiterPerMinute: HKUnit = { - return HKUnit.millimolesPerLiter.unitDivided(by: .minute()) - }() - - public static let internationalUnitsPerHour: HKUnit = { - return HKUnit.internationalUnit().unitDivided(by: .hour()) - }() } 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..ba83a7aa55 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? @@ -35,35 +31,9 @@ public struct LoopSettings: Equatable { public var carbRatioSchedule: CarbRatioSchedule? - public var preMealTargetRange: ClosedRange? - - public var legacyWorkoutTargetRange: ClosedRange? - - public var overridePresets: [TemporaryScheduleOverridePreset] = [] + public var preMealTargetRange: ClosedRange? - 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 overridePresets: [TemporaryPreset] = [] public var maximumBasalRatePerHour: Double? @@ -75,7 +45,7 @@ public struct LoopSettings: Equatable { public var defaultRapidActingModel: ExponentialInsulinModelPreset? - public var glucoseUnit: HKUnit? { + public var glucoseUnit: LoopUnit? { return glucoseTargetRangeSchedule?.unit } @@ -85,11 +55,8 @@ public struct LoopSettings: Equatable { insulinSensitivitySchedule: InsulinSensitivitySchedule? = nil, basalRateSchedule: BasalRateSchedule? = nil, carbRatioSchedule: CarbRatioSchedule? = nil, - preMealTargetRange: ClosedRange? = nil, - legacyWorkoutTargetRange: ClosedRange? = nil, - overridePresets: [TemporaryScheduleOverridePreset]? = nil, - scheduleOverride: TemporaryScheduleOverride? = nil, - preMealOverride: TemporaryScheduleOverride? = nil, + preMealTargetRange: ClosedRange? = nil, + overridePresets: [TemporaryPreset]? = nil, maximumBasalRatePerHour: Double? = nil, maximumBolus: Double? = nil, suspendThreshold: GlucoseThreshold? = nil, @@ -102,10 +69,7 @@ public struct LoopSettings: Equatable { self.basalRateSchedule = basalRateSchedule self.carbRatioSchedule = carbRatioSchedule 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,109 +78,10 @@ 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 - fileprivate static let codingGlucoseUnit = HKUnit.milligramsPerDeciliter + fileprivate static let codingGlucoseUnit = LoopUnit.milligramsPerDeciliter public init?(rawValue: RawValue) { guard @@ -238,30 +103,15 @@ extension LoopSettings: RawRepresentable { if let preMealTargetRawValue = overrideRangesRawValue["preMeal"] { self.preMealTargetRange = DoubleRange(rawValue: preMealTargetRawValue)?.quantityRange(for: LoopSettings.codingGlucoseUnit) } - if let legacyWorkoutTargetRawValue = overrideRangesRawValue["workout"] { - self.legacyWorkoutTargetRange = DoubleRange(rawValue: legacyWorkoutTargetRawValue)?.quantityRange(for: LoopSettings.codingGlucoseUnit) - } } } if let rawPreMealTargetRange = rawValue["preMealTargetRange"] as? DoubleRange.RawValue { self.preMealTargetRange = DoubleRange(rawValue: rawPreMealTargetRange)?.quantityRange(for: LoopSettings.codingGlucoseUnit) } - - if let rawLegacyWorkoutTargetRange = rawValue["legacyWorkoutTargetRange"] as? DoubleRange.RawValue { - self.legacyWorkoutTargetRange = DoubleRange(rawValue: rawLegacyWorkoutTargetRange)?.quantityRange(for: LoopSettings.codingGlucoseUnit) - } - - if let rawPresets = rawValue["overridePresets"] as? [TemporaryScheduleOverridePreset.RawValue] { - 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) + + if let rawPresets = rawValue["overridePresets"] as? [TemporaryPreset.RawValue] { + self.overridePresets = rawPresets.compactMap(TemporaryPreset.init(rawValue:)) } self.maximumBasalRatePerHour = rawValue["maximumBasalRatePerHour"] as? Double @@ -288,9 +138,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/Models/AcknowledgeAlertUserInfo.swift b/LoopCore/Models/AcknowledgeAlertUserInfo.swift new file mode 100644 index 0000000000..5d14be4f41 --- /dev/null +++ b/LoopCore/Models/AcknowledgeAlertUserInfo.swift @@ -0,0 +1,46 @@ +// +// AcknowledgeAlertUserInfo.swift +// Loop +// +// Created by Pete Schwamb on 9/19/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +public struct AcknowledgeAlertUserInfo { + let version = 1 + public let alertIdentifier: String + public let managerIdentifier: String + + public init(alertIdentifier: String, managerIdentifier: String) { + self.alertIdentifier = alertIdentifier + self.managerIdentifier = managerIdentifier + } +} + +extension AcknowledgeAlertUserInfo: RawRepresentable { + public typealias RawValue = [String: Any] + + public static let name = "AcknowledgeAlertUserInfo" + + public init?(rawValue: RawValue) { + guard + rawValue["v"] as? Int == version, + let alertIdentifier = rawValue["ai"] as? String, + let managerIdentifier = rawValue["mi"] as? String + else { + return nil + } + + self.alertIdentifier = alertIdentifier + self.managerIdentifier = managerIdentifier + } + + public var rawValue: RawValue { + return [ + "v": version, + "name": AcknowledgeAlertUserInfo.name, + "ai": alertIdentifier, + "mi": managerIdentifier, + ] + } +} diff --git a/Common/Models/CarbBackfillRequestUserInfo.swift b/LoopCore/Models/CarbBackfillRequestUserInfo.swift similarity index 67% rename from Common/Models/CarbBackfillRequestUserInfo.swift rename to LoopCore/Models/CarbBackfillRequestUserInfo.swift index 83fce9c335..30c38cc012 100644 --- a/Common/Models/CarbBackfillRequestUserInfo.swift +++ b/LoopCore/Models/CarbBackfillRequestUserInfo.swift @@ -8,17 +8,21 @@ import Foundation -struct CarbBackfillRequestUserInfo { +public struct CarbBackfillRequestUserInfo { let version = 1 - let startDate: Date + public let startDate: Date + + public init(startDate: Date) { + self.startDate = startDate + } } extension CarbBackfillRequestUserInfo: RawRepresentable { - typealias RawValue = [String: Any] + public typealias RawValue = [String: Any] - static let name = "CarbBackfillRequestUserInfo" + public static let name = "CarbBackfillRequestUserInfo" - init?(rawValue: RawValue) { + public init?(rawValue: RawValue) { guard rawValue["v"] as? Int == version, rawValue["name"] as? String == CarbBackfillRequestUserInfo.name, @@ -30,7 +34,7 @@ extension CarbBackfillRequestUserInfo: RawRepresentable { self.startDate = startDate } - var rawValue: RawValue { + public var rawValue: RawValue { return [ "v": version, "name": CarbBackfillRequestUserInfo.name, diff --git a/Common/Models/GlucoseBackfillRequestUserInfo.swift b/LoopCore/Models/GlucoseBackfillRequestUserInfo.swift similarity index 67% rename from Common/Models/GlucoseBackfillRequestUserInfo.swift rename to LoopCore/Models/GlucoseBackfillRequestUserInfo.swift index 899a93434b..886129fe5d 100644 --- a/Common/Models/GlucoseBackfillRequestUserInfo.swift +++ b/LoopCore/Models/GlucoseBackfillRequestUserInfo.swift @@ -8,17 +8,21 @@ import Foundation -struct GlucoseBackfillRequestUserInfo { +public struct GlucoseBackfillRequestUserInfo { let version = 1 - let startDate: Date + public let startDate: Date + + public init(startDate: Date) { + self.startDate = startDate + } } extension GlucoseBackfillRequestUserInfo: RawRepresentable { - typealias RawValue = [String: Any] + public typealias RawValue = [String: Any] - static let name = "GlucoseBackfillRequestUserInfo" + public static let name = "GlucoseBackfillRequestUserInfo" - init?(rawValue: RawValue) { + public init?(rawValue: RawValue) { guard rawValue["v"] as? Int == version, rawValue["name"] as? String == GlucoseBackfillRequestUserInfo.name, @@ -30,7 +34,7 @@ extension GlucoseBackfillRequestUserInfo: RawRepresentable { self.startDate = startDate } - var rawValue: RawValue { + public var rawValue: RawValue { return [ "v": version, "name": GlucoseBackfillRequestUserInfo.name, diff --git a/Common/Models/IntentExtensionInfo.swift b/LoopCore/Models/IntentExtensionInfo.swift similarity index 64% rename from Common/Models/IntentExtensionInfo.swift rename to LoopCore/Models/IntentExtensionInfo.swift index 653fbe9afc..c3a6a507fb 100644 --- a/Common/Models/IntentExtensionInfo.swift +++ b/LoopCore/Models/IntentExtensionInfo.swift @@ -8,22 +8,22 @@ import Foundation -struct IntentExtensionInfo: RawRepresentable { - typealias RawValue = [String: Any] +public struct IntentExtensionInfo: RawRepresentable { + public typealias RawValue = [String: Any] + + public var overridePresetNames: [String]? - var overridePresetNames: [String]? - init() { } - init(rawValue: RawValue) { + public init(rawValue: RawValue) { overridePresetNames = rawValue["overridePresetNames"] as? [String] } - init(overridePresetNames: [String]?) { + public init(overridePresetNames: [String]?) { self.overridePresetNames = overridePresetNames } - var rawValue: RawValue { + public var rawValue: RawValue { var raw: RawValue = [:] raw["overridePresetNames"] = overridePresetNames diff --git a/LoopCore/Models/LastManualBolus.swift b/LoopCore/Models/LastManualBolus.swift new file mode 100644 index 0000000000..58185173b4 --- /dev/null +++ b/LoopCore/Models/LastManualBolus.swift @@ -0,0 +1,32 @@ +// +// LastManualBolus.swift +// Loop +// +// Created by Pete Schwamb on 10/10/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +public struct LastManualBolus: RawRepresentable { + public typealias RawValue = [String: Any] + + public let amount: Double + public let startDate: Date + + public init (amount: Double, startDate: Date) { + self.amount = amount + self.startDate = startDate + } + + public init?(rawValue: RawValue) { + guard let amount = rawValue["amount"] as? Double, + let startDate = rawValue["startDate"] as? Date else { + return nil + } + self.amount = amount + self.startDate = startDate + } + + public var rawValue: [String : Any] { + ["amount": amount, "startDate": startDate] + } +} diff --git a/LoopCore/Models/LoopSettingsUserInfo.swift b/LoopCore/Models/LoopSettingsUserInfo.swift new file mode 100644 index 0000000000..860e31bdf8 --- /dev/null +++ b/LoopCore/Models/LoopSettingsUserInfo.swift @@ -0,0 +1,54 @@ +// +// LoopSettingsUserInfo.swift +// Loop +// +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import LoopKit + +public struct LoopSettingsUserInfo: Equatable { + public var loopSettings: LoopSettings + public var scheduleOverride: TemporaryScheduleOverride? + + public init(loopSettings: LoopSettings, scheduleOverride: TemporaryScheduleOverride? = nil) { + self.loopSettings = loopSettings + self.scheduleOverride = scheduleOverride + } +} + +extension LoopSettingsUserInfo: RawRepresentable { + public typealias RawValue = [String: Any] + + public static let name = "LoopSettingsUserInfo" + static let version = 1 + + public init?(rawValue: RawValue) { + guard rawValue["v"] as? Int == LoopSettingsUserInfo.version, + rawValue["name"] as? String == LoopSettingsUserInfo.name, + let settingsRaw = rawValue["s"] as? LoopSettings.RawValue, + let loopSettings = LoopSettings(rawValue: settingsRaw) + else { + return nil + } + + self.loopSettings = loopSettings + + if let rawScheduleOverride = rawValue["o"] as? TemporaryScheduleOverride.RawValue { + self.scheduleOverride = TemporaryScheduleOverride(rawValue: rawScheduleOverride) + } else { + self.scheduleOverride = nil + } + } + + public var rawValue: RawValue { + var raw: RawValue = [ + "v": LoopSettingsUserInfo.version, + "name": LoopSettingsUserInfo.name, + "s": loopSettings.rawValue + ] + raw["o"] = scheduleOverride?.rawValue + + return raw + } +} diff --git a/LoopCore/Models/NotificationActionSelection.swift b/LoopCore/Models/NotificationActionSelection.swift new file mode 100644 index 0000000000..256e499c8f --- /dev/null +++ b/LoopCore/Models/NotificationActionSelection.swift @@ -0,0 +1,53 @@ +// +// NotificationActionSelection.swift +// Loop +// +// Created by Pete Schwamb on 7/16/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + + +public struct NotificationActionSelection { + let version = 1 + public let alertIdentifier: String + public let managerIdentifier: String + public let actionIdentifier: String + + public init(alertIdentifier: String, managerIdentifier: String, actionIdentifier: String) { + self.alertIdentifier = alertIdentifier + self.managerIdentifier = managerIdentifier + self.actionIdentifier = actionIdentifier + } +} + +extension NotificationActionSelection: RawRepresentable { + public typealias RawValue = [String: Any] + + public static let name = "NotificationActionSelection" + + public init?(rawValue: RawValue) { + guard + rawValue["v"] as? Int == version, + rawValue["name"] as? String == NotificationActionSelection.name, + let alertIdentifier = rawValue["alertIdentifier"] as? String, + let managerIdentifier = rawValue["managerIdentifier"] as? String, + let actionIdentifier = rawValue["actionIdentifier"] as? String + else { + return nil + } + + self.alertIdentifier = alertIdentifier + self.managerIdentifier = managerIdentifier + self.actionIdentifier = actionIdentifier + } + + public var rawValue: RawValue { + return [ + "v": version, + "name": NotificationActionSelection.name, + "alertIdentifier": alertIdentifier, + "managerIdentifier": managerIdentifier, + "actionIdentifier": actionIdentifier, + ] + } +} diff --git a/Common/Models/SetBolusUserInfo.swift b/LoopCore/Models/SetBolusUserInfo.swift similarity index 63% rename from Common/Models/SetBolusUserInfo.swift rename to LoopCore/Models/SetBolusUserInfo.swift index a12f7c2ef2..137e5659b8 100644 --- a/Common/Models/SetBolusUserInfo.swift +++ b/LoopCore/Models/SetBolusUserInfo.swift @@ -10,22 +10,30 @@ import Foundation import LoopKit -struct SetBolusUserInfo { - let value: Double - let startDate: Date - let contextDate: Date? - let carbEntry: NewCarbEntry? - let activationType: BolusActivationType +public struct SetBolusUserInfo { + public let value: Double + public let startDate: Date + public let contextDate: Date? + public let carbEntry: NewCarbEntry? + public let activationType: BolusActivationType + + public init(value: Double, startDate: Date, contextDate: Date?, carbEntry: NewCarbEntry?, activationType: BolusActivationType) { + self.value = value + self.startDate = startDate + self.contextDate = contextDate + self.carbEntry = carbEntry + self.activationType = activationType + } } extension SetBolusUserInfo: RawRepresentable { - typealias RawValue = [String: Any] + public typealias RawValue = [String: Any] - static let version = 1 - static let name = "SetBolusUserInfo" + public static let version = 1 + public static let name = "SetBolusUserInfo" - init?(rawValue: RawValue) { + public init?(rawValue: RawValue) { guard rawValue["v"] as? Int == type(of: self).version && rawValue["name"] as? String == SetBolusUserInfo.name, let value = rawValue["bv"] as? Double, @@ -43,7 +51,7 @@ extension SetBolusUserInfo: RawRepresentable { self.activationType = activationType } - var rawValue: RawValue { + public var rawValue: RawValue { var raw: RawValue = [ "v": type(of: self).version, "name": SetBolusUserInfo.name, diff --git a/LoopCore/Models/SetPresetUserInfo.swift b/LoopCore/Models/SetPresetUserInfo.swift new file mode 100644 index 0000000000..5a27b72ec0 --- /dev/null +++ b/LoopCore/Models/SetPresetUserInfo.swift @@ -0,0 +1,51 @@ +// +// SetPresetUserInfo.swift +// Loop +// +// Created by Pete Schwamb on 9/18/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +public struct SetPresetUserInfo { + let version = 1 + public let presetIdentifier: String? // nil = clear preset + public let alertIdentifier: String? // alertIdentifier to acknowledge, if any + + public init(presetIdentifier: String?, alertIdentifier: String? = nil) { + self.presetIdentifier = presetIdentifier + self.alertIdentifier = alertIdentifier + } +} + +extension SetPresetUserInfo: RawRepresentable { + public typealias RawValue = [String: Any] + + public static let name = "SetPresetUserInfo" + + public init?(rawValue: RawValue) { + guard + rawValue["v"] as? Int == version, + rawValue["name"] as? String == SetPresetUserInfo.name + else { + return nil + } + + self.presetIdentifier = rawValue["pi"] as? String + self.alertIdentifier = rawValue["aa"] as? String + } + + public var rawValue: RawValue { + var rVal: RawValue = [ + "v": version, + "name": SetPresetUserInfo.name + ] + + rVal["pi"] = presetIdentifier + rVal["aa"] = alertIdentifier + + return rVal + } +} diff --git a/LoopCore/Models/SettingsRequestUserInfo.swift b/LoopCore/Models/SettingsRequestUserInfo.swift new file mode 100644 index 0000000000..47dc45a2f7 --- /dev/null +++ b/LoopCore/Models/SettingsRequestUserInfo.swift @@ -0,0 +1,37 @@ +// +// SettingsRequestUserInfo.swift +// Loop +// +// Created by Pete Schwamb on 9/9/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import Foundation + +public struct SettingsRequestUserInfo { + let version = 1 + + public init() {} +} + +extension SettingsRequestUserInfo: RawRepresentable { + public typealias RawValue = [String: Any] + + public static let name = "SettingsRequestUserInfo" + + public init?(rawValue: RawValue) { + guard + rawValue["v"] as? Int == version, + rawValue["name"] as? String == SettingsRequestUserInfo.name + else { + return nil + } + } + + public var rawValue: RawValue { + return [ + "v": version, + "name": SettingsRequestUserInfo.name, + ] + } +} diff --git a/Common/Models/SupportedBolusVolumesUserInfo.swift b/LoopCore/Models/SupportedBolusVolumesUserInfo.swift similarity index 70% rename from Common/Models/SupportedBolusVolumesUserInfo.swift rename to LoopCore/Models/SupportedBolusVolumesUserInfo.swift index 077540e534..b724a65e76 100644 --- a/Common/Models/SupportedBolusVolumesUserInfo.swift +++ b/LoopCore/Models/SupportedBolusVolumesUserInfo.swift @@ -6,12 +6,16 @@ // Copyright © 2020 LoopKit Authors. All rights reserved. // -struct SupportedBolusVolumesUserInfo { - var supportedBolusVolumes: [Double] +public struct SupportedBolusVolumesUserInfo { + public var supportedBolusVolumes: [Double] + + public init(supportedBolusVolumes: [Double]) { + self.supportedBolusVolumes = supportedBolusVolumes + } } extension SupportedBolusVolumesUserInfo: RawRepresentable { - typealias RawValue = [String: Any] + public typealias RawValue = [String: Any] private enum Key: String { case version = "v" @@ -19,10 +23,10 @@ extension SupportedBolusVolumesUserInfo: RawRepresentable { case supportedBolusVolumes = "sbv" } - static let name = "SupportedBolusVolumesUserInfo" + public static let name = "SupportedBolusVolumesUserInfo" static let version = 1 - init?(rawValue: RawValue) { + public init?(rawValue: RawValue) { guard rawValue[Key.version.rawValue] as? Int == Self.version, rawValue[Key.name.rawValue] as? String == Self.name, @@ -34,7 +38,7 @@ extension SupportedBolusVolumesUserInfo: RawRepresentable { self.init(supportedBolusVolumes: supportedBolusVolumes) } - var rawValue: RawValue { + public var rawValue: RawValue { [ Key.version.rawValue: Self.version, Key.name.rawValue: Self.name, diff --git a/LoopCore/Models/WatchContext.swift b/LoopCore/Models/WatchContext.swift new file mode 100644 index 0000000000..adbfb92778 --- /dev/null +++ b/LoopCore/Models/WatchContext.swift @@ -0,0 +1,261 @@ +// +// WatchContext.swift +// Naterade +// +// Created by Nathan Racklyeft on 11/25/15. +// Copyright © 2015 Nathan Racklyeft. All rights reserved. +// + +import Foundation +import LoopKit +import LoopAlgorithm + +public enum InsulinDeliveryWatchState: Int, Equatable { + case neutralNoOverride + case neutralOverride + case increasedInsulin + case decreasedInsulin + case minimumDelivery + case suspended + case noDelivery +} + +@Observable +public final class WatchContext: RawRepresentable { + public typealias RawValue = [String: Any] + + private let version = 5 + + public var creationDate = Date() + + public var displayGlucoseUnit: LoopUnit? + + public var glucose: LoopQuantity? + public var glucoseCondition: GlucoseCondition? + public var glucoseTrend: GlucoseTrend? + public var glucoseTrendRate: LoopQuantity? + public var glucoseDate: Date? + public var glucoseIsDisplayOnly: Bool? + public var glucoseWasUserEntered: Bool? + public var glucoseSyncIdentifier: String? + + public var predictedGlucose: WatchPredictedGlucose? + public var eventualGlucose: LoopQuantity? { + return predictedGlucose?.values.last?.quantity + } + + public var loopLastRunDate: Date? + public var lastNetTempBasalDose: Double? + public var lastNetTempBasalDate: Date? + public var recommendedBolusDose: Double? + public var insulinDeliveryState: InsulinDeliveryWatchState? + public var lastManualBolus: LastManualBolus? + + public var potentialCarbEntry: NewCarbEntry? + + public var cob: Double? + public var iob: Double? + public var reservoir: Double? + public var reservoirPercentage: Double? + public var batteryPercentage: Double? + + public var cgmManagerState: CGMManager.RawStateValue? + + public var isClosedLoop: Bool? + public var deviceIssue: Bool? + public var isOnboardingCompleted: Bool? + + public init( + creationDate: Date = Date(), + glucose: LoopQuantity? = nil, + displayGlucoseUnit: LoopUnit? = nil, + glucoseCondition: GlucoseCondition? = nil, + glucoseTrend: GlucoseTrend? = nil, + glucoseTrendRate: LoopQuantity? = nil, + glucoseDate: Date? = nil, + glucoseIsDisplayOnly: Bool? = nil, + glucoseWasUserEntered: Bool? = nil, + glucoseSyncIdentifier: String? = nil, + predictedGlucose: WatchPredictedGlucose? = nil, + loopLastRunDate: Date? = nil, + lastNetTempBasalDose: Double? = nil, + lastNetTempBasalDate: Date? = nil, + recommendedBolusDose: Double? = nil, + potentialCarbEntry: NewCarbEntry? = nil, + cob: Double? = nil, + iob: Double? = nil, + reservoir: Double? = nil, + reservoirPercentage: Double? = nil, + batteryPercentage: Double? = nil, + cgmManagerState: CGMManager.RawStateValue? = nil, + insulinDeliveryState: InsulinDeliveryWatchState? = nil, + lastManualBolus: LastManualBolus? = nil, + isClosedLoop: Bool? = nil, + deviceIssue: Bool? = nil, + isOnboardingCompleted: Bool? = nil + ) { + self.creationDate = creationDate + self.displayGlucoseUnit = displayGlucoseUnit + self.glucose = glucose + self.glucoseCondition = glucoseCondition + self.glucoseTrend = glucoseTrend + self.glucoseTrendRate = glucoseTrendRate + self.glucoseDate = glucoseDate + self.glucoseIsDisplayOnly = glucoseIsDisplayOnly + self.glucoseWasUserEntered = glucoseWasUserEntered + self.glucoseSyncIdentifier = glucoseSyncIdentifier + self.predictedGlucose = predictedGlucose + self.loopLastRunDate = loopLastRunDate + self.lastNetTempBasalDose = lastNetTempBasalDose + self.lastNetTempBasalDate = lastNetTempBasalDate + self.recommendedBolusDose = recommendedBolusDose + self.potentialCarbEntry = potentialCarbEntry + self.cob = cob + self.iob = iob + self.reservoir = reservoir + self.reservoirPercentage = reservoirPercentage + self.batteryPercentage = batteryPercentage + self.cgmManagerState = cgmManagerState + self.insulinDeliveryState = insulinDeliveryState + self.lastManualBolus = lastManualBolus + self.isClosedLoop = isClosedLoop + self.deviceIssue = deviceIssue + self.isOnboardingCompleted = isOnboardingCompleted + } + + public required init?(rawValue: RawValue) { + guard rawValue["v"] as? Int == version, let creationDate = rawValue["cd"] as? Date else { + return nil + } + + self.creationDate = creationDate + isClosedLoop = rawValue["cl"] as? Bool + deviceIssue = rawValue["di"] as? Bool + isOnboardingCompleted = rawValue["oc"] as? Bool + + if let unitString = rawValue["gu"] as? String { + displayGlucoseUnit = LoopUnit(from: unitString) + } + let unit = displayGlucoseUnit ?? .milligramsPerDeciliter + if let glucoseValue = rawValue["gv"] as? Double { + glucose = LoopQuantity(unit: unit, doubleValue: glucoseValue) + } + + if let rawGlucoseCondition = rawValue["gc"] as? GlucoseCondition.RawValue { + glucoseCondition = GlucoseCondition(rawValue: rawGlucoseCondition) + } + if let rawGlucoseTrend = rawValue["gt"] as? GlucoseTrend.RawValue { + glucoseTrend = GlucoseTrend(rawValue: rawGlucoseTrend) + } + if let glucoseTrendRateValue = rawValue["gtrv"] as? Double { + glucoseTrendRate = LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: glucoseTrendRateValue) + } + glucoseDate = rawValue["gd"] as? Date + glucoseIsDisplayOnly = rawValue["gdo"] as? Bool + glucoseWasUserEntered = rawValue["gue"] as? Bool + glucoseSyncIdentifier = rawValue["gs"] as? String + iob = rawValue["iob"] as? Double + reservoir = rawValue["r"] as? Double + reservoirPercentage = rawValue["rp"] as? Double + batteryPercentage = rawValue["bp"] as? Double + + loopLastRunDate = rawValue["ld"] as? Date + lastNetTempBasalDose = rawValue["ba"] as? Double + lastNetTempBasalDate = rawValue["bad"] as? Date + + if let rawInsulinDeliveryState = rawValue["ids"] as? InsulinDeliveryWatchState.RawValue { + insulinDeliveryState = InsulinDeliveryWatchState(rawValue: rawInsulinDeliveryState) + } + + if let rawLastManualBolus = rawValue["lmb"] as? LastManualBolus.RawValue { + lastManualBolus = LastManualBolus(rawValue: rawLastManualBolus) + } + + recommendedBolusDose = rawValue["rbo"] as? Double + if let rawPotentialCarbEntry = rawValue["pce"] as? NewCarbEntry.RawValue { + potentialCarbEntry = NewCarbEntry(rawValue: rawPotentialCarbEntry) + } + cob = rawValue["cob"] as? Double + + cgmManagerState = rawValue["cgmManagerState"] as? CGMManager.RawStateValue + + if let rawValue = rawValue["pg"] as? WatchPredictedGlucose.RawValue { + predictedGlucose = WatchPredictedGlucose(rawValue: rawValue) + } + } + + public var rawValue: RawValue { + var raw: [String: Any] = [ + "v": version, + "cd": creationDate + ] + + raw["ba"] = lastNetTempBasalDose + raw["bad"] = lastNetTempBasalDate + raw["bp"] = batteryPercentage + raw["cl"] = isClosedLoop + raw["di"] = deviceIssue + raw["oc"] = isOnboardingCompleted + + raw["cgmManagerState"] = cgmManagerState + + raw["cob"] = cob + + let unit = displayGlucoseUnit ?? .milligramsPerDeciliter + raw["gu"] = displayGlucoseUnit?.unitString + raw["gv"] = glucose?.doubleValue(for: unit) + + raw["gc"] = glucoseCondition?.rawValue + raw["gt"] = glucoseTrend?.rawValue + if let glucoseTrendRate = glucoseTrendRate { + let unitPerMinute = unit.unitDivided(by: .minute) + raw["gtru"] = unitPerMinute.unitString + raw["gtrv"] = glucoseTrendRate.doubleValue(for: unitPerMinute) + } + raw["gd"] = glucoseDate + raw["gdo"] = glucoseIsDisplayOnly + raw["gue"] = glucoseWasUserEntered + raw["gs"] = glucoseSyncIdentifier + raw["iob"] = iob + raw["ld"] = loopLastRunDate + raw["r"] = reservoir + + raw["ids"] = insulinDeliveryState?.rawValue + raw["lmb"] = lastManualBolus?.rawValue + + raw["rbo"] = recommendedBolusDose + raw["pce"] = potentialCarbEntry?.rawValue + raw["rp"] = reservoirPercentage + + raw["pg"] = predictedGlucose?.rawValue + + return raw + } +} + + +extension WatchContext { + public func shouldReplace(_ other: WatchContext) -> Bool { + if let date = self.glucoseDate, let otherDate = other.glucoseDate { + return date >= otherDate + } else { + return true + } + } +} + +extension WatchContext { + public var newGlucoseSample: NewGlucoseSample? { + if let quantity = glucose, let date = glucoseDate, let syncIdentifier = glucoseSyncIdentifier { + return NewGlucoseSample(date: date, + quantity: quantity, + condition: glucoseCondition, + trend: glucoseTrend, + trendRate: glucoseTrendRate, + isDisplayOnly: glucoseIsDisplayOnly ?? false, + wasUserEntered: glucoseWasUserEntered ?? false, + syncIdentifier: syncIdentifier, syncVersion: 0) + } + return nil + } +} diff --git a/Common/Models/WatchContextRequestUserInfo.swift b/LoopCore/Models/WatchContextRequestUserInfo.swift similarity index 90% rename from Common/Models/WatchContextRequestUserInfo.swift rename to LoopCore/Models/WatchContextRequestUserInfo.swift index 6f7797ce9d..cb38408d17 100644 --- a/Common/Models/WatchContextRequestUserInfo.swift +++ b/LoopCore/Models/WatchContextRequestUserInfo.swift @@ -9,7 +9,9 @@ import Foundation -struct WatchContextRequestUserInfo { } +public struct WatchContextRequestUserInfo { + public init() {} +} extension WatchContextRequestUserInfo: RawRepresentable { public typealias RawValue = [String: Any] diff --git a/Common/Models/WatchPredictedGlucose.swift b/LoopCore/Models/WatchPredictedGlucose.swift similarity index 66% rename from Common/Models/WatchPredictedGlucose.swift rename to LoopCore/Models/WatchPredictedGlucose.swift index 080a824074..b17ca89c76 100644 --- a/Common/Models/WatchPredictedGlucose.swift +++ b/LoopCore/Models/WatchPredictedGlucose.swift @@ -8,13 +8,12 @@ import Foundation import LoopKit -import HealthKit +import LoopAlgorithm +public struct WatchPredictedGlucose: Equatable { + public let values: [PredictedGlucoseValue] -struct WatchPredictedGlucose: Equatable { - let values: [PredictedGlucoseValue] - - init?(values: [PredictedGlucoseValue]) { + public init?(values: [PredictedGlucoseValue]) { guard values.count > 1 else { return nil } @@ -24,18 +23,18 @@ struct WatchPredictedGlucose: Equatable { extension WatchPredictedGlucose: RawRepresentable { - typealias RawValue = [String: Any] + public typealias RawValue = [String: Any] - var rawValue: RawValue { + public 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) ] } - init?(rawValue: RawValue) { + public init?(rawValue: RawValue) { guard let values = rawValue["v"] as? [Int16], let firstDate = rawValue["d"] as? Date, @@ -46,7 +45,7 @@ extension WatchPredictedGlucose: RawRepresentable { self.values = values.enumerated().map { tuple in PredictedGlucoseValue(startDate: firstDate + Double(tuple.0) * interval, - quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: Double(tuple.1))) + quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: Double(tuple.1))) } } } diff --git a/LoopCore/NSUserDefaults.swift b/LoopCore/NSUserDefaults.swift index dacf2ecdc7..8ed715f823 100644 --- a/LoopCore/NSUserDefaults.swift +++ b/LoopCore/NSUserDefaults.swift @@ -8,8 +8,7 @@ import Foundation import LoopKit -import HealthKit - +import LoopAlgorithm extension UserDefaults { @@ -24,6 +23,7 @@ extension UserDefaults { case LastMissedMealNotification = "com.loopkit.Loop.lastMissedMealNotification" case userRequestedLoopReset = "com.loopkit.Loop.userRequestedLoopReset" case liveActivity = "com.loopkit.Loop.liveActivity" + case defaultEnvironment = "org.tidepool.TidepoolKit.DefaultEnvironment" } public static let appGroup = UserDefaults(suiteName: Bundle.main.appGroupSuiteName) @@ -78,18 +78,6 @@ extension UserDefaults { } } - public var overrideHistory: TemporaryScheduleOverrideHistory? { - get { - if let rawValue = object(forKey: Key.overrideHistory.rawValue) as? TemporaryScheduleOverrideHistory.RawValue { - return TemporaryScheduleOverrideHistory(rawValue: rawValue) - } else { - return nil - } - } - set { - set(newValue?.rawValue, forKey: Key.overrideHistory.rawValue) - } - } public var lastBedtimeQuery: Date? { get { @@ -190,6 +178,15 @@ extension UserDefaults { } } + 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") removeObject(forKey: "com.loudnate.Naterade.CarbRatioSchedule") diff --git a/LoopCore/NotificationManager.swift b/LoopCore/NotificationManager.swift new file mode 100644 index 0000000000..acee5ed57c --- /dev/null +++ b/LoopCore/NotificationManager.swift @@ -0,0 +1,16 @@ +// +// NotificationManager.swift +// Loop +// +// Created by Pete Schwamb on 9/16/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +public enum NotificationManager { + + public enum Action: String { + case retryBolus + case acknowledgeAlert + case startPreset + } +} diff --git a/LoopCore/PotentialCarbEntryUserInfo.swift b/LoopCore/PotentialCarbEntryUserInfo.swift deleted file mode 100644 index 701ee028f9..0000000000 --- a/LoopCore/PotentialCarbEntryUserInfo.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// PotentialCarbEntryUserInfo.swift -// Naterade -// -// Created by Nathan Racklyeft on 1/23/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation -import LoopKit - - -public struct PotentialCarbEntryUserInfo { - public let carbEntry: NewCarbEntry - - public init(carbEntry: NewCarbEntry) { - self.carbEntry = carbEntry - } -} - - -extension PotentialCarbEntryUserInfo: RawRepresentable { - public typealias RawValue = [String: Any] - - static let version = 1 - public static let name = "PotentialCarbEntryUserInfo" - - public init?(rawValue: RawValue) { - guard rawValue["v"] as? Int == type(of: self).version && rawValue["name"] as? String == PotentialCarbEntryUserInfo.name, - let value = rawValue["ce"] as? NewCarbEntry.RawValue, - let carbEntry = NewCarbEntry(rawValue: value) - else { - return nil - } - - self.carbEntry = carbEntry - } - - public var rawValue: RawValue { - return [ - "v": type(of: self).version, - "name": PotentialCarbEntryUserInfo.name, - "ce": carbEntry.rawValue, - ] - } -} 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..30fb535919 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) @@ -194,53 +50,53 @@ class AlertManagerTests: XCTestCase { mockAlertStore = nil } - func testIssueAlertOnHandlerCalled() { - alertManager.issueAlert(mockAlert) + func testIssueAlertOnHandlerCalled() async { + await alertManager.issueAlert(mockAlert) XCTAssertEqual(mockAlert.identifier, mockModalScheduler.scheduledAlert?.identifier) XCTAssertEqual(mockAlert.identifier, mockUserNotificationScheduler.scheduledAlert?.identifier) XCTAssertNil(mockModalScheduler.unscheduledAlertIdentifier) XCTAssertNil(mockUserNotificationScheduler.unscheduledAlertIdentifier) } - func testRetractAlertOnHandlerCalled() { - alertManager.retractAlert(identifier: mockAlert.identifier) + func testRetractAlertOnHandlerCalled() async { + await alertManager.retractAlert(identifier: mockAlert.identifier) XCTAssertNil(mockModalScheduler.scheduledAlert) XCTAssertNil(mockUserNotificationScheduler.scheduledAlert) XCTAssertEqual(mockAlert.identifier, mockModalScheduler.unscheduledAlertIdentifier) XCTAssertEqual(mockAlert.identifier, mockUserNotificationScheduler.unscheduledAlertIdentifier) } - func testAlertResponderAcknowledged() { + func testAlertResponderAcknowledged() async throws { let responder = MockResponder() alertManager.addAlertResponder(managerIdentifier: Self.mockManagerIdentifier, alertResponder: responder) XCTAssertTrue(responder.acknowledged.isEmpty) - alertManager.acknowledgeAlert(identifier: Self.mockIdentifier) + try await alertManager.acknowledgeAlert(identifier: Self.mockIdentifier) XCTAssert(responder.acknowledged[Self.mockTypeIdentifier] == true) } - func testAlertResponderNotAcknowledgedIfWrongManagerIdentifier() { + func testAlertResponderNotAcknowledgedIfWrongManagerIdentifier() async throws { let responder = MockResponder() alertManager.addAlertResponder(managerIdentifier: Self.mockManagerIdentifier, alertResponder: responder) XCTAssertTrue(responder.acknowledged.isEmpty) - alertManager.acknowledgeAlert(identifier: Alert.Identifier(managerIdentifier: "foo", alertIdentifier: Self.mockTypeIdentifier)) + try await alertManager.acknowledgeAlert(identifier: Alert.Identifier(managerIdentifier: "foo", alertIdentifier: Self.mockTypeIdentifier)) XCTAssertTrue(responder.acknowledged.isEmpty) } - func testRemovedAlertResponderDoesntAcknowledge() { + func testRemovedAlertResponderDoesntAcknowledge() async throws { let responder = MockResponder() alertManager.addAlertResponder(managerIdentifier: Self.mockManagerIdentifier, alertResponder: responder) XCTAssertTrue(responder.acknowledged.isEmpty) - alertManager.acknowledgeAlert(identifier: Self.mockIdentifier) + try await alertManager.acknowledgeAlert(identifier: Self.mockIdentifier) XCTAssert(responder.acknowledged[Self.mockTypeIdentifier] == true) responder.acknowledged[AlertManagerTests.mockTypeIdentifier] = false alertManager.removeAlertResponder(managerIdentifier: AlertManagerTests.mockManagerIdentifier) - alertManager.acknowledgeAlert(identifier: Self.mockIdentifier) + try await alertManager.acknowledgeAlert(identifier: Self.mockIdentifier) XCTAssert(responder.acknowledged[Self.mockTypeIdentifier] == false) } - func testAcknowledgedAlertsRemovedFromUserNotificationCenter() { - alertManager.acknowledgeAlert(identifier: Self.mockIdentifier) + func testAcknowledgedAlertsRemovedFromUserNotificationCenter() async throws { + try await alertManager.acknowledgeAlert(identifier: Self.mockIdentifier) } func testSoundVendorInitialization() { @@ -252,273 +108,222 @@ class AlertManagerTests: XCTestCase { XCTAssertEqual(["\(Self.mockManagerIdentifier)-doesntExist", "\(Self.mockManagerIdentifier)-existsOlder"], mockFileManager.copiedDstURLs.map { $0.lastPathComponent }) } - func testPlaybackPendingImmediateAlert() { - mockAlertStore.managedObjectContext.performAndWait { - let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") - let alert = Alert(identifier: Self.mockIdentifier, - foregroundContent: content, backgroundContent: content, trigger: .immediate) - mockAlertStore.storedAlerts = [StoredAlert(from: alert, context: mockAlertStore.managedObjectContext)] + func testPlaybackPendingImmediateAlert() async { + let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert = Alert(identifier: Self.mockIdentifier, + foregroundContent: content, backgroundContent: content, trigger: .immediate) + mockAlertStore.storedAlerts = [StoredAlert(from: alert, context: mockAlertStore.managedObjectContext)] - alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertScheduler: mockModalScheduler, - userNotificationAlertScheduler: mockUserNotificationScheduler, - fileManager: mockFileManager, - alertStore: mockAlertStore, - bluetoothProvider: MockBluetoothProvider(), - analyticsServicesManager: AnalyticsServicesManager()) - alertManager.playbackAlertsFromPersistence() - XCTAssertEqual(alert, mockModalScheduler.scheduledAlert) - XCTAssertNil(mockUserNotificationScheduler.scheduledAlert) - } + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager()) + mockModalScheduler.alertScheduledExpectation = expectation(description: "alert scheduled") + await alertManager.playbackAlertsFromPersistence() + await fulfillment(of: [mockModalScheduler.alertScheduledExpectation!]) + XCTAssertEqual(alert, mockModalScheduler.scheduledAlert) + XCTAssertNil(mockUserNotificationScheduler.scheduledAlert) } - func testPlaybackPendingExpiredDelayedNotification() { - mockAlertStore.managedObjectContext.performAndWait { - let date = Date.distantPast - let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") - let alert = Alert(identifier: Self.mockIdentifier, - foregroundContent: content, backgroundContent: content, trigger: .delayed(interval: 30.0)) - let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) - storedAlert.issuedDate = date - mockAlertStore.storedAlerts = [storedAlert] - alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertScheduler: mockModalScheduler, - userNotificationAlertScheduler: mockUserNotificationScheduler, - fileManager: mockFileManager, - alertStore: mockAlertStore, - bluetoothProvider: MockBluetoothProvider(), - analyticsServicesManager: AnalyticsServicesManager()) - alertManager.playbackAlertsFromPersistence() - let expected = Alert(identifier: Self.mockIdentifier, foregroundContent: content, backgroundContent: content, trigger: .immediate) - XCTAssertEqual(expected, mockModalScheduler.scheduledAlert) - XCTAssertNil(mockUserNotificationScheduler.scheduledAlert) - } + func testPlaybackPendingExpiredDelayedNotification() async { + let date = Date.distantPast + let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert = Alert(identifier: Self.mockIdentifier, + foregroundContent: content, backgroundContent: content, trigger: .delayed(interval: 30.0)) + let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) + storedAlert.issuedDate = date + mockAlertStore.storedAlerts = [storedAlert] + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager()) + await alertManager.playbackAlertsFromPersistence() + let expected = Alert(identifier: Self.mockIdentifier, foregroundContent: content, backgroundContent: content, trigger: .immediate) + XCTAssertEqual(expected, mockModalScheduler.scheduledAlert) + XCTAssertNil(mockUserNotificationScheduler.scheduledAlert) } - func testPlaybackPendingDelayedNotification() { - mockAlertStore.managedObjectContext.performAndWait { - let date = Date().addingTimeInterval(-15.0) // Pretend the 30-second-delayed alert was issued 15 seconds ago - let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") - let alert = Alert(identifier: Self.mockIdentifier, - foregroundContent: content, backgroundContent: content, trigger: .delayed(interval: 30.0)) - let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) - storedAlert.issuedDate = date - mockAlertStore.storedAlerts = [storedAlert] - alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertScheduler: mockModalScheduler, - userNotificationAlertScheduler: mockUserNotificationScheduler, - fileManager: mockFileManager, - alertStore: mockAlertStore, - bluetoothProvider: MockBluetoothProvider(), - analyticsServicesManager: AnalyticsServicesManager()) - alertManager.playbackAlertsFromPersistence() + func testPlaybackPendingDelayedNotification() async { + let date = Date().addingTimeInterval(-15.0) // Pretend the 30-second-delayed alert was issued 15 seconds ago + let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert = Alert(identifier: Self.mockIdentifier, + foregroundContent: content, backgroundContent: content, trigger: .delayed(interval: 30.0)) + let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) + storedAlert.issuedDate = date + mockAlertStore.storedAlerts = [storedAlert] + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager()) + await alertManager.playbackAlertsFromPersistence() - // The trigger for this should be `.delayed` by "something less than 15 seconds", - // but the exact value depends on the speed of executing this test. - // As long as it is <= 15 seconds, we call it good. - XCTAssertNotNil(mockModalScheduler.scheduledAlert) - switch mockModalScheduler.scheduledAlert?.trigger { - case .some(.delayed(let interval)): - XCTAssertLessThanOrEqual(interval, 15.0) - default: - XCTFail("Wrong trigger \(String(describing: mockModalScheduler.scheduledAlert?.trigger))") - } + // The trigger for this should be `.delayed` by "something less than 15 seconds", + // but the exact value depends on the speed of executing this test. + // As long as it is <= 15 seconds, we call it good. + XCTAssertNotNil(mockModalScheduler.scheduledAlert) + switch mockModalScheduler.scheduledAlert?.trigger { + case .some(.delayed(let interval)): + XCTAssertLessThanOrEqual(interval, 15.0) + default: + XCTFail("Wrong trigger \(String(describing: mockModalScheduler.scheduledAlert?.trigger))") } } - func testPlaybackPendingRepeatingNotification() { - mockAlertStore.managedObjectContext.performAndWait { - let date = Date.distantPast - let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") - let alert = Alert(identifier: Self.mockIdentifier, - foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) - let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) - storedAlert.issuedDate = date - mockAlertStore.storedAlerts = [storedAlert] - alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertScheduler: mockModalScheduler, - userNotificationAlertScheduler: mockUserNotificationScheduler, - fileManager: mockFileManager, - alertStore: mockAlertStore, - bluetoothProvider: MockBluetoothProvider(), - analyticsServicesManager: AnalyticsServicesManager()) - alertManager.playbackAlertsFromPersistence() + func testPlaybackPendingRepeatingNotification() async { + let date = Date.distantPast + let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert = Alert(identifier: Self.mockIdentifier, + foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) + let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) + storedAlert.issuedDate = date + mockAlertStore.storedAlerts = [storedAlert] + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager()) + await alertManager.playbackAlertsFromPersistence() - XCTAssertEqual(alert, mockModalScheduler.scheduledAlert) - XCTAssertNil(mockUserNotificationScheduler.scheduledAlert) - } + XCTAssertEqual(alert, mockModalScheduler.scheduledAlert) + XCTAssertNil(mockUserNotificationScheduler.scheduledAlert) } - func testPersistedAlertStoreLookupAllUnretracted() throws { - mockAlertStore.managedObjectContext.performAndWait { - let date = Date.distantPast - let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") - let alert = Alert(identifier: Self.mockIdentifier, - foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) - let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) - storedAlert.issuedDate = date - mockAlertStore.storedAlerts = [storedAlert] - alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertScheduler: mockModalScheduler, - userNotificationAlertScheduler: mockUserNotificationScheduler, - fileManager: mockFileManager, - alertStore: mockAlertStore, - bluetoothProvider: MockBluetoothProvider(), - analyticsServicesManager: AnalyticsServicesManager()) - alertManager.lookupAllUnretracted(managerIdentifier: Self.mockManagerIdentifier) { result in - try? XCTAssertEqual([PersistedAlert(alert: alert, issuedDate: date, retractedDate: nil, acknowledgedDate: nil)], - XCTUnwrap(result.successValue)) - } - } - } - - func testPersistedAlertStoreLookupAllUnacknowledgedUnretracted() throws { - mockAlertStore.managedObjectContext.performAndWait { - let date = Date.distantPast - let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") - let alert = Alert(identifier: Self.mockIdentifier, - foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) - let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) - storedAlert.issuedDate = date - mockAlertStore.storedAlerts = [storedAlert] - alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertScheduler: mockModalScheduler, - userNotificationAlertScheduler: mockUserNotificationScheduler, - fileManager: mockFileManager, - alertStore: mockAlertStore, - bluetoothProvider: MockBluetoothProvider(), - analyticsServicesManager: AnalyticsServicesManager()) - alertManager.lookupAllUnacknowledgedUnretracted(managerIdentifier: Self.mockManagerIdentifier) { result in - try? XCTAssertEqual([PersistedAlert(alert: alert, issuedDate: date, retractedDate: nil, acknowledgedDate: nil)], - XCTUnwrap(result.successValue)) - } - } + func testPersistedAlertStoreLookupAllUnretracted() async throws { + let date = Date.distantPast + let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert = Alert(identifier: Self.mockIdentifier, + foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) + let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) + storedAlert.issuedDate = date + mockAlertStore.storedAlerts = [storedAlert] + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager()) + let alerts = try await alertManager.lookupAllUnretracted(managerIdentifier: Self.mockManagerIdentifier) + XCTAssertEqual([PersistedAlert(alert: alert, issuedDate: date, retractedDate: nil, acknowledgedDate: nil)], alerts) } - func testPersistedAlertStoreDoesIssuedAlertExist() throws { - mockAlertStore.managedObjectContext.performAndWait { - let date = Date.distantPast - let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") - let alert = Alert(identifier: Self.mockIdentifier, - foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) - let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) - storedAlert.issuedDate = date - mockAlertStore.storedAlerts = [storedAlert] - alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertScheduler: mockModalScheduler, - userNotificationAlertScheduler: mockUserNotificationScheduler, - fileManager: mockFileManager, - alertStore: mockAlertStore, - bluetoothProvider: MockBluetoothProvider(), - analyticsServicesManager: AnalyticsServicesManager()) - let identifierExists = Self.mockIdentifier - let identifierDoesNotExist = Alert.Identifier(managerIdentifier: "TestManagerIdentifier", alertIdentifier: "TestAlertIdentifier") - alertManager.doesIssuedAlertExist(identifier: identifierExists) { result in - try? XCTAssertEqual(true, XCTUnwrap(result.successValue)) - } - alertManager.doesIssuedAlertExist(identifier: identifierDoesNotExist) { result in - try? XCTAssertEqual(false, XCTUnwrap(result.successValue)) - } - } + func testPersistedAlertStoreLookupAllUnacknowledgedUnretracted() async throws { + let date = Date.distantPast + let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert = Alert(identifier: Self.mockIdentifier, + foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) + let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) + storedAlert.issuedDate = date + mockAlertStore.storedAlerts = [storedAlert] + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager()) + let alerts = try await alertManager.lookupAllUnacknowledgedUnretracted(managerIdentifier: Self.mockManagerIdentifier) + XCTAssertEqual([PersistedAlert(alert: alert, issuedDate: date, retractedDate: nil, acknowledgedDate: nil)], alerts) } - func testReportRetractedAlert() throws { - mockAlertStore.managedObjectContext.performAndWait { - let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") - let alert = Alert(identifier: Self.mockIdentifier, - foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) - mockAlertStore.storedAlerts = [] - alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertScheduler: mockModalScheduler, - userNotificationAlertScheduler: mockUserNotificationScheduler, - fileManager: mockFileManager, - alertStore: mockAlertStore, - bluetoothProvider: MockBluetoothProvider(), - analyticsServicesManager: AnalyticsServicesManager()) - let now = Date() - alertManager.recordRetractedAlert(alert, at: now) - XCTAssertEqual(mockAlertStore.retractedAlert, alert) - XCTAssertEqual(mockAlertStore.retractedAlertDate, now) - } + func testPersistedAlertStoreDoesIssuedAlertExist() async throws { + let date = Date.distantPast + let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert = Alert(identifier: Self.mockIdentifier, + foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) + let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) + storedAlert.issuedDate = date + mockAlertStore.storedAlerts = [storedAlert] + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager()) + let identifierExists = Self.mockIdentifier + let identifierDoesNotExist = Alert.Identifier(managerIdentifier: "TestManagerIdentifier", alertIdentifier: "TestAlertIdentifier") + let result = try await alertManager.doesIssuedAlertExist(identifier: identifierExists) + XCTAssertEqual(true, result) + let result2 = try await alertManager.doesIssuedAlertExist(identifier: identifierDoesNotExist) + XCTAssertEqual(false, result2) } - func testScheduleAlertForWorkoutReminder() { - alertManager.presetActivated(context: .legacyWorkout, duration: .indefinite) - XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockModalScheduler.scheduledAlert?.identifier) - XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockUserNotificationScheduler.scheduledAlert?.identifier) - XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockAlertStore.issuedAlert?.identifier) - - alertManager.presetDeactivated(context: .legacyWorkout) - XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockModalScheduler.unscheduledAlertIdentifier) - XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockUserNotificationScheduler.unscheduledAlertIdentifier) - XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockAlertStore.retractededAlertIdentifier) + func testReportRetractedAlert() async throws { + let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert = Alert(identifier: Self.mockIdentifier, + foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) + mockAlertStore.storedAlerts = [] + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager()) + let now = Date() + try await alertManager.recordRetractedAlert(alert, at: now) + XCTAssertEqual(mockAlertStore.retractedAlert, alert) + XCTAssertEqual(mockAlertStore.retractedAlertDate, now) } - func testLoopDidCompleteRecordsNotifications() { - alertManager.loopDidComplete() + func testLoopDidCompleteRecordsNotifications() async { + await alertManager.loopDidComplete() XCTAssertEqual(4, UserDefaults.appGroup?.loopNotRunningNotifications.count) } - func testLoopFailureFor10MinutesDoesNotRecordAlert() { - alertManager.loopDidComplete() + func testLoopFailureFor10MinutesDoesNotRecordAlert() async { + await alertManager.loopDidComplete() XCTAssertNil(mockAlertStore.issuedAlert) alertManager.getCurrentDate = { return Date().addingTimeInterval(.minutes(10))} - alertManager.inferDeliveredLoopNotRunningNotifications() + await alertManager.inferDeliveredLoopNotRunningNotifications() XCTAssertNil(mockAlertStore.issuedAlert) } - func testLoopFailureFor30MinutesRecordsTimeSensitiveAlert() { - alertManager.loopDidComplete() + func testLoopFailureFor30MinutesRecordsTimeSensitiveAlert() async { + await alertManager.loopDidComplete() XCTAssertNil(mockAlertStore.issuedAlert) alertManager.getCurrentDate = { return Date().addingTimeInterval(.minutes(30))} - alertManager.inferDeliveredLoopNotRunningNotifications() + await alertManager.inferDeliveredLoopNotRunningNotifications() XCTAssertEqual(3, UserDefaults.appGroup?.loopNotRunningNotifications.count) XCTAssertNotNil(mockAlertStore.issuedAlert) XCTAssertEqual(.timeSensitive, mockAlertStore.issuedAlert!.interruptionLevel) } - func testLoopFailureFor65MinutesRecordsCriticalAlert() { - alertManager.loopDidComplete() + func testLoopFailureFor65MinutesRecordsCriticalAlert() async { + await alertManager.loopDidComplete() alertManager.getCurrentDate = { return Date().addingTimeInterval(.minutes(65))} - alertManager.inferDeliveredLoopNotRunningNotifications() + await alertManager.inferDeliveredLoopNotRunningNotifications() XCTAssertEqual(1, UserDefaults.appGroup?.loopNotRunningNotifications.count) XCTAssertNotNil(mockAlertStore.issuedAlert) XCTAssertEqual(.critical, mockAlertStore.issuedAlert!.interruptionLevel) } - func testRescheduleMutedLoopNotLoopingAlerts() { + func testRescheduleMutedLoopNotLoopingAlerts() async { UNUserNotificationCenter.current().removeAllPendingNotificationRequests() let lastLoopDate = Date() - alertManager.loopDidComplete(lastLoopDate) + await alertManager.loopDidComplete(lastLoopDate) alertManager.alertMuter.configuration.startTime = Date() alertManager.alertMuter.configuration.duration = .hours(4) - waitOnMain() - let testExpectation = expectation(description: #function) - var loopNotRunningRequests: [UNNotificationRequest] = [] - UNUserNotificationCenter.current().getPendingNotificationRequests() { notificationRequests in - loopNotRunningRequests = notificationRequests.filter({ - $0.content.categoryIdentifier == LoopNotificationCategory.loopNotRunning.rawValue - }) - testExpectation.fulfill() - } - - 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) - } + let loopNotRunningRequests = await UNUserNotificationCenter.current().pendingNotificationRequests().filter({ + $0.content.categoryIdentifier == LoopNotificationCategory.loopNotRunning.rawValue + }) + 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 +336,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..5f56baf124 100644 --- a/LoopTests/Managers/Alerts/AlertStoreTests.swift +++ b/LoopTests/Managers/Alerts/AlertStoreTests.swift @@ -12,13 +12,13 @@ import XCTest @testable import Loop class AlertStoreTests: XCTestCase { - + var alertStore: AlertStore! static let defaultTimeout: TimeInterval = 1.5 static let expiryInterval: TimeInterval = 24 /* hours */ * 60 /* minutes */ * 60 /* seconds */ static let historicDate = Date(timeIntervalSinceNow: -expiryInterval + TimeInterval.hours(4)) // Within default 24 hour expiration - + static let identifier1 = Alert.Identifier(managerIdentifier: "managerIdentifier1", alertIdentifier: "alertIdentifier1") static let backgroundContent = Alert.Content(title: "BACKGROUND", body: "background", acknowledgeActionButtonLabel: "OK") let alert1 = Alert(identifier: identifier1, foregroundContent: nil, backgroundContent: backgroundContent, trigger: .immediate, sound: nil) @@ -35,11 +35,11 @@ class AlertStoreTests: XCTestCase { override func setUp() { alertStore = AlertStore(expireAfter: Self.expiryInterval) } - + override func tearDown() { alertStore = nil } - + func testTriggerTypeIntervalConversion() { let immediate = Alert.Trigger.immediate let delayed = Alert.Trigger.delayed(interval: 1.0) @@ -49,7 +49,7 @@ class AlertStoreTests: XCTestCase { XCTAssertEqual(repeating, try? Alert.Trigger(storedType: repeating.storedType, storedInterval: repeating.storedInterval)) XCTAssertNil(immediate.storedInterval) } - + func testTriggerTypeIntervalConversionAdjustedForStorageTime() { let immediate = Alert.Trigger.immediate let delayed = Alert.Trigger.delayed(interval: 10.0) @@ -66,14 +66,14 @@ class AlertStoreTests: XCTestCase { XCTAssertEqual(repeating, try? Alert.Trigger(storedType: repeating.storedType, storedInterval: repeating.storedInterval, storageDate: Self.historicDate)) XCTAssertNil(immediate.storedInterval) } - + func testStoredAlertSerialization() { alertStore.managedObjectContext.performAndWait { let object = StoredAlert(from: alert2, context: alertStore.managedObjectContext, issuedDate: Self.historicDate) XCTAssertNil(object.acknowledgedDate) XCTAssertNil(object.retractedDate) - XCTAssertEqual("{\"acknowledgeActionButtonLabel\":\"label\",\"body\":\"body\",\"title\":\"title\"}", object.backgroundContent) - XCTAssertEqual("{\"acknowledgeActionButtonLabel\":\"label\",\"body\":\"body\",\"title\":\"title\"}", object.foregroundContent) + XCTAssertEqual("{\"actions\":[{\"identifier\":\"acknowledge\",\"label\":\"label\",\"style\":0}],\"body\":\"body\",\"title\":\"title\"}", object.backgroundContent) + XCTAssertEqual("{\"actions\":[{\"identifier\":\"acknowledge\",\"label\":\"label\",\"style\":0}],\"body\":\"body\",\"title\":\"title\"}", object.foregroundContent) XCTAssertEqual("managerIdentifier2.alertIdentifier2", object.identifier.value) XCTAssertEqual(Self.historicDate, object.issuedDate) XCTAssertEqual(1, object.modificationCounter) @@ -82,7 +82,7 @@ class AlertStoreTests: XCTestCase { XCTAssertEqual(Alert.InterruptionLevel.critical, object.interruptionLevel) } } - + func testQueryAnchorSerialization() { var anchor = AlertStore.QueryAnchor() anchor.modificationCounter = 999 @@ -90,694 +90,456 @@ class AlertStoreTests: XCTestCase { XCTAssertEqual(anchor, newAnchor) XCTAssertEqual(999, newAnchor?.modificationCounter) } - - func testRecordIssued() { - let expect = self.expectation(description: #function) - alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.identifier1, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(1, storedAlerts.count) - XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) - XCTAssertEqual(Self.historicDate, storedAlerts.first?.issuedDate) - XCTAssertNil(storedAlerts.first?.acknowledgedDate) - XCTAssertNil(storedAlerts.first?.retractedDate) - expect.fulfill() - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testRecordIssuedTwo() { - let expect = self.expectation(description: #function) - alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { - self.alertStore.recordIssued(alert: self.alert1, at: Self.historicDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.identifier1, completion: self.expectSuccess { storedAlerts in - self.assertEqual([self.alert1, self.alert1], storedAlerts) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testRecordAcknowledged() { - let expect = self.expectation(description: #function) + + func testRecordIssued() async throws { + await alertStore.recordIssued(alert: alert1, at: Self.historicDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.identifier1) + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) + XCTAssertEqual(Self.historicDate, storedAlerts.first?.issuedDate) + XCTAssertNil(storedAlerts.first?.acknowledgedDate) + XCTAssertNil(storedAlerts.first?.retractedDate) + } + + func testRecordIssuedTwo() async throws { + await alertStore.recordIssued(alert: alert1, at: Self.historicDate) + await alertStore.recordIssued(alert: self.alert1, at: Self.historicDate) + let storedAlerts = try await alertStore.fetch(identifier: Self.identifier1) + self.assertEqual([self.alert1, self.alert1], storedAlerts) + } + + func testRecordAcknowledged() async throws { let issuedDate = Self.historicDate let acknowledgedDate = issuedDate.addingTimeInterval(1) - alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { - self.alertStore.recordAcknowledgement(of: Self.identifier1, at: acknowledgedDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.identifier1, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(1, storedAlerts.count) - XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) - XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) - XCTAssertEqual(acknowledgedDate, storedAlerts.first?.acknowledgedDate) - XCTAssertNil(storedAlerts.first?.retractedDate) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testRecordAcknowledgedOfInvalid() { - let expect = self.expectation(description: #function) - self.alertStore.recordAcknowledgement(of: Self.identifier1, at: Self.historicDate) { - switch $0 { - case .failure: break - case .success: XCTFail("Unexpected success") - } - expect.fulfill() + await alertStore.recordIssued(alert: alert1, at: Self.historicDate) + try await alertStore.recordAcknowledgement(of: Self.identifier1, at: acknowledgedDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.identifier1) + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) + XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) + XCTAssertEqual(acknowledgedDate, storedAlerts.first?.acknowledgedDate) + XCTAssertNil(storedAlerts.first?.retractedDate) + } + + func testRecordAcknowledgedOfInvalid() async throws { + do { + try await self.alertStore.recordAcknowledgement(of: Self.identifier1, at: Self.historicDate) + XCTFail("Unexpected success") + } catch { + return } - wait(for: [expect], timeout: Self.defaultTimeout) } - func testRecordRetracted() { - let expect = self.expectation(description: #function) + func testRecordRetracted() async throws { let issuedDate = Self.historicDate let retractedDate = issuedDate.addingTimeInterval(2) - alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { - self.alertStore.recordRetraction(of: Self.identifier1, at: retractedDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.identifier1, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(1, storedAlerts.count) - XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) - XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) - XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) - XCTAssertNil(storedAlerts.first?.acknowledgedDate) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testRecordIssuedExpiresOld() { - let expect = self.expectation(description: #function) - alertStore.recordIssued(alert: alert1, at: Date.distantPast, completion: self.expectSuccess { - self.alertStore.recordIssued(alert: self.alert1, at: Self.historicDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.identifier1, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(1, storedAlerts.count) - XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) - XCTAssertEqual(Self.historicDate, storedAlerts.first?.issuedDate) - XCTAssertNil(storedAlerts.first?.acknowledgedDate) - XCTAssertNil(storedAlerts.first?.retractedDate) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - + await alertStore.recordIssued(alert: alert1, at: Self.historicDate) + try await self.alertStore.recordRetraction(of: Self.identifier1, at: retractedDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.identifier1) + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) + XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) + XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) + XCTAssertNil(storedAlerts.first?.acknowledgedDate) + } + + func testRecordIssuedExpiresOld() async throws { + await alertStore.recordIssued(alert: alert1, at: Date.distantPast) + await self.alertStore.recordIssued(alert: self.alert1, at: Self.historicDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.identifier1) + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) + XCTAssertEqual(Self.historicDate, storedAlerts.first?.issuedDate) + XCTAssertNil(storedAlerts.first?.acknowledgedDate) + XCTAssertNil(storedAlerts.first?.retractedDate) + } + func testRecordAcknowledgedExpiresOld() { // TODO: Not quite sure how to do this yet. } - + func testRecordRetractedExpiresOld() { // TODO: Not quite sure how to do this yet. } - func testRecordRetractedBeforeDelayShouldDelete() { - let expect = self.expectation(description: #function) + func testRecordRetractedBeforeDelayShouldDelete() async throws { let issuedDate = Self.historicDate let retractedDate = issuedDate + Self.delayedAlertDelay - 1.0 - alertStore.recordIssued(alert: delayedAlert, at: issuedDate, completion: self.expectSuccess { - self.alertStore.recordRetraction(of: Self.delayedAlertIdentifier, at: retractedDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.delayedAlertIdentifier, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(0, storedAlerts.count) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testRecordRetractedBeforeRepeatDelayShouldDelete() { - let expect = self.expectation(description: #function) + await alertStore.recordIssued(alert: delayedAlert, at: issuedDate) + try await self.alertStore.recordRetraction(of: Self.delayedAlertIdentifier, at: retractedDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.delayedAlertIdentifier) + XCTAssertEqual(0, storedAlerts.count) + } + + func testRecordRetractedBeforeRepeatDelayShouldDelete() async throws { let issuedDate = Self.historicDate let retractedDate = issuedDate + Self.repeatingAlertDelay - 1.0 - alertStore.recordIssued(alert: repeatingAlert, at: issuedDate, completion: self.expectSuccess { - self.alertStore.recordRetraction(of: Self.repeatingAlertIdentifier, at: retractedDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.repeatingAlertIdentifier, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(0, storedAlerts.count) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testRecordRetractedExactlyAtDelayShouldDelete() { - let expect = self.expectation(description: #function) + await alertStore.recordIssued(alert: repeatingAlert, at: issuedDate) + try await self.alertStore.recordRetraction(of: Self.repeatingAlertIdentifier, at: retractedDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.repeatingAlertIdentifier) + XCTAssertEqual(0, storedAlerts.count) + } + + func testRecordRetractedExactlyAtDelayShouldDelete() async throws { let issuedDate = Self.historicDate let retractedDate = issuedDate + Self.delayedAlertDelay - alertStore.recordIssued(alert: delayedAlert, at: issuedDate, completion: self.expectSuccess { - self.alertStore.recordRetraction(of: Self.delayedAlertIdentifier, at: retractedDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.delayedAlertIdentifier, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(0, storedAlerts.count) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testRecordRetractedExactlyAtRepeatDelayShouldDelete() { - let expect = self.expectation(description: #function) + await alertStore.recordIssued(alert: delayedAlert, at: issuedDate) + try await self.alertStore.recordRetraction(of: Self.delayedAlertIdentifier, at: retractedDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.delayedAlertIdentifier) + XCTAssertEqual(0, storedAlerts.count) + } + + func testRecordRetractedExactlyAtRepeatDelayShouldDelete() async throws { let issuedDate = Self.historicDate let retractedDate = issuedDate + Self.repeatingAlertDelay - alertStore.recordIssued(alert: repeatingAlert, at: issuedDate, completion: self.expectSuccess { - self.alertStore.recordRetraction(of: Self.repeatingAlertIdentifier, at: retractedDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.repeatingAlertIdentifier, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(0, storedAlerts.count) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - - func testRecordRetractedAfterDelayShouldRetract() { - let expect = self.expectation(description: #function) + await alertStore.recordIssued(alert: repeatingAlert, at: issuedDate) + try await self.alertStore.recordRetraction(of: Self.repeatingAlertIdentifier, at: retractedDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.repeatingAlertIdentifier) + XCTAssertEqual(0, storedAlerts.count) + } + + func testRecordRetractedAfterDelayShouldRetract() async throws { let issuedDate = Self.historicDate let retractedDate = issuedDate + Self.delayedAlertDelay + 1.0 - alertStore.recordIssued(alert: delayedAlert, at: issuedDate, completion: self.expectSuccess { - self.alertStore.recordRetraction(of: Self.delayedAlertIdentifier, at: retractedDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.delayedAlertIdentifier, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(1, storedAlerts.count) - XCTAssertEqual(Self.delayedAlertIdentifier, storedAlerts.first?.identifier) - XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) - XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) - XCTAssertNil(storedAlerts.first?.acknowledgedDate) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testRecordRetractedAfterRepeatDelayShouldRetract() { - let expect = self.expectation(description: #function) + await alertStore.recordIssued(alert: delayedAlert, at: issuedDate) + try await self.alertStore.recordRetraction(of: Self.delayedAlertIdentifier, at: retractedDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.delayedAlertIdentifier) + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.delayedAlertIdentifier, storedAlerts.first?.identifier) + XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) + XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) + XCTAssertNil(storedAlerts.first?.acknowledgedDate) + } + + func testRecordRetractedAfterRepeatDelayShouldRetract() async throws { let issuedDate = Self.historicDate let retractedDate = issuedDate + Self.repeatingAlertDelay + 1.0 - alertStore.recordIssued(alert: repeatingAlert, at: issuedDate, completion: self.expectSuccess { - self.alertStore.recordRetraction(of: Self.repeatingAlertIdentifier, at: retractedDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.repeatingAlertIdentifier, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(1, storedAlerts.count) - XCTAssertEqual(Self.repeatingAlertIdentifier, storedAlerts.first?.identifier) - XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) - XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) - XCTAssertNil(storedAlerts.first?.acknowledgedDate) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - + await alertStore.recordIssued(alert: repeatingAlert, at: issuedDate) + try await self.alertStore.recordRetraction(of: Self.repeatingAlertIdentifier, at: retractedDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.repeatingAlertIdentifier) + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.repeatingAlertIdentifier, storedAlerts.first?.identifier) + XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) + XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) + XCTAssertNil(storedAlerts.first?.acknowledgedDate) + } + // These next two tests are admittedly weird corner cases, but theoretically they might be race conditions, // and so are allowed - func testRecordRetractedThenAcknowledged() { - let expect = self.expectation(description: #function) + func testRecordRetractedThenAcknowledged() async throws { let issuedDate = Self.historicDate let retractedDate = issuedDate.addingTimeInterval(2) let acknowledgedDate = issuedDate.addingTimeInterval(4) - alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { - self.alertStore.recordRetraction(of: Self.identifier1, at: retractedDate, completion: self.expectSuccess { - self.alertStore.recordAcknowledgement(of: Self.identifier1, at: acknowledgedDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.identifier1, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(1, storedAlerts.count) - XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) - XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) - XCTAssertEqual(acknowledgedDate, storedAlerts.first?.acknowledgedDate) - XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) - expect.fulfill() - }) - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testRecordAcknowledgedThenRetracted() { - let expect = self.expectation(description: #function) + await alertStore.recordIssued(alert: alert1, at: Self.historicDate) + try await self.alertStore.recordRetraction(of: Self.identifier1, at: retractedDate) + try await self.alertStore.recordAcknowledgement(of: Self.identifier1, at: acknowledgedDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.identifier1) + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) + XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) + XCTAssertEqual(acknowledgedDate, storedAlerts.first?.acknowledgedDate) + XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) + } + + func testRecordAcknowledgedThenRetracted() async throws { let issuedDate = Self.historicDate let retractedDate = issuedDate.addingTimeInterval(2) let acknowledgedDate = issuedDate.addingTimeInterval(4) - alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { - self.alertStore.recordAcknowledgement(of: Self.identifier1, at: acknowledgedDate, completion: self.expectSuccess { - self.alertStore.recordRetraction(of: Self.identifier1, at: retractedDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.identifier1, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(1, storedAlerts.count) - XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) - XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) - XCTAssertEqual(acknowledgedDate, storedAlerts.first?.acknowledgedDate) - XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) - expect.fulfill() - }) - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testRecordRetractedAlert() { - let expect = self.expectation(description: #function) + await alertStore.recordIssued(alert: alert1, at: Self.historicDate) + try await self.alertStore.recordAcknowledgement(of: Self.identifier1, at: acknowledgedDate) + try await self.alertStore.recordRetraction(of: Self.identifier1, at: retractedDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.identifier1) + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) + XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) + XCTAssertEqual(acknowledgedDate, storedAlerts.first?.acknowledgedDate) + XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) + } + + func testRecordRetractedAlert() async throws { let alertDate = Self.historicDate - alertStore.recordRetractedAlert(alert1, at: alertDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.identifier1, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(1, storedAlerts.count) - XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) - XCTAssertEqual(alertDate, storedAlerts.first?.issuedDate) - XCTAssertNil(storedAlerts.first?.acknowledgedDate) - XCTAssertEqual(alertDate, storedAlerts.first?.retractedDate) - expect.fulfill() - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testEmptyQuery() { - let expect = self.expectation(description: #function) - alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { - self.alertStore.executeQuery(since: Date.distantPast, limit: 0, completion: self.expectSuccess { _, objects in - XCTAssertTrue(objects.isEmpty) - expect.fulfill() - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testSimpleQuery() { - let expect = self.expectation(description: #function) - alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { - self.alertStore.executeQuery(since: Date.distantPast, limit: 100, completion: self.expectSuccess { anchor, objects in - XCTAssertEqual(1, anchor.modificationCounter) - XCTAssertEqual(1, objects.count) - XCTAssertEqual(Self.identifier1, objects.first?.identifier) - XCTAssertEqual(Self.historicDate, objects.first?.issuedDate) - XCTAssertNil(objects.first?.acknowledgedDate) - XCTAssertNil(objects.first?.retractedDate) - expect.fulfill() - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testSimpleQueryThenRetraction() { - let expect = self.expectation(description: #function) + try await alertStore.recordRetractedAlert(alert1, at: alertDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.identifier1) + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) + XCTAssertEqual(alertDate, storedAlerts.first?.issuedDate) + XCTAssertNil(storedAlerts.first?.acknowledgedDate) + XCTAssertEqual(alertDate, storedAlerts.first?.retractedDate) + } + + func testEmptyQuery() async throws { + await alertStore.recordIssued(alert: alert1, at: Self.historicDate) + let (_, objects) = try await self.alertStore.executeQuery(since: Date.distantPast, limit: 0) + XCTAssertTrue(objects.isEmpty) + } + + func testSimpleQuery() async throws { + await alertStore.recordIssued(alert: alert1, at: Self.historicDate) + let (anchor, objects) = try await self.alertStore.executeQuery(since: Date.distantPast, limit: 100) + XCTAssertEqual(1, anchor.modificationCounter) + XCTAssertEqual(1, objects.count) + XCTAssertEqual(Self.identifier1, objects.first?.identifier) + XCTAssertEqual(Self.historicDate, objects.first?.issuedDate) + XCTAssertNil(objects.first?.acknowledgedDate) + XCTAssertNil(objects.first?.retractedDate) + } + + func testSimpleQueryThenRetraction() async throws { let issuedDate = Self.historicDate let retractedDate = issuedDate.addingTimeInterval(2) - alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { - self.alertStore.executeQuery(since: Date.distantPast, limit: 100, completion: self.expectSuccess { anchor, objects in - XCTAssertEqual(1, anchor.modificationCounter) - XCTAssertEqual(1, objects.count) - XCTAssertEqual(Self.identifier1, objects.first?.identifier) - XCTAssertEqual(Self.historicDate, objects.first?.issuedDate) - XCTAssertNil(objects.first?.acknowledgedDate) - XCTAssertNil(objects.first?.retractedDate) - self.alertStore.recordRetraction(of: Self.identifier1, at: retractedDate, completion: self.expectSuccess { - self.alertStore.executeQuery(since: Date.distantPast, limit: 100, completion: self.expectSuccess { anchor, objects in - XCTAssertEqual(2, anchor.modificationCounter) - XCTAssertEqual(1, objects.count) - XCTAssertEqual(Self.identifier1, objects.first?.identifier) - XCTAssertEqual(issuedDate, objects.first?.issuedDate) - XCTAssertEqual(retractedDate, objects.first?.retractedDate) - XCTAssertNil(objects.first?.acknowledgedDate) - expect.fulfill() - }) - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testQueryByDate() { - let expect = self.expectation(description: #function) - alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { - let now = Date() - self.alertStore.recordIssued(alert: self.alert2, at: now, completion: self.expectSuccess { - self.alertStore.executeQuery(since: now, limit: 100, completion: self.expectSuccess { anchor, objects in - XCTAssertEqual(2, anchor.modificationCounter) - XCTAssertEqual(1, objects.count) - XCTAssertEqual(Self.identifier2, objects.first?.identifier) - XCTAssertEqual(now, objects.first?.issuedDate) - XCTAssertNil(objects.first?.acknowledgedDate) - XCTAssertNil(objects.first?.retractedDate) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testQueryByDateExcludingFutureDelayed() { - let expect = self.expectation(description: #function) + await alertStore.recordIssued(alert: alert1, at: Self.historicDate) + let (anchor, objects) = try await self.alertStore.executeQuery(since: Date.distantPast, limit: 100) + XCTAssertEqual(1, anchor.modificationCounter) + XCTAssertEqual(1, objects.count) + XCTAssertEqual(Self.identifier1, objects.first?.identifier) + XCTAssertEqual(Self.historicDate, objects.first?.issuedDate) + XCTAssertNil(objects.first?.acknowledgedDate) + XCTAssertNil(objects.first?.retractedDate) + try await self.alertStore.recordRetraction(of: Self.identifier1, at: retractedDate) + let (anchor2, objects2) = try await self.alertStore.executeQuery(since: Date.distantPast, limit: 100) + XCTAssertEqual(2, anchor2.modificationCounter) + XCTAssertEqual(1, objects2.count) + XCTAssertEqual(Self.identifier1, objects2.first?.identifier) + XCTAssertEqual(issuedDate, objects2.first?.issuedDate) + XCTAssertEqual(retractedDate, objects2.first?.retractedDate) + XCTAssertNil(objects2.first?.acknowledgedDate) + } + + func testQueryByDate() async throws { + await alertStore.recordIssued(alert: alert1, at: Self.historicDate) let now = Date() - alertStore.recordIssued(alert: alert1, at: now, completion: self.expectSuccess { - self.alertStore.recordIssued(alert: self.delayedAlert, at: now, completion: self.expectSuccess { - self.alertStore.executeQuery(since: now, limit: 100, completion: self.expectSuccess { anchor, objects in - XCTAssertEqual(1, anchor.modificationCounter) - XCTAssertEqual(1, objects.count) - XCTAssertEqual(Self.identifier1, objects.first?.identifier) - XCTAssertEqual(now, objects.first?.issuedDate) - XCTAssertNil(objects.first?.acknowledgedDate) - XCTAssertNil(objects.first?.retractedDate) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testQueryByDateExcludingFutureRepeating() { - let expect = self.expectation(description: #function) + await self.alertStore.recordIssued(alert: self.alert2, at: now) + let (anchor, objects) = try await self.alertStore.executeQuery(since: now, limit: 100) + XCTAssertEqual(2, anchor.modificationCounter) + XCTAssertEqual(1, objects.count) + XCTAssertEqual(Self.identifier2, objects.first?.identifier) + XCTAssertEqual(now, objects.first?.issuedDate) + XCTAssertNil(objects.first?.acknowledgedDate) + XCTAssertNil(objects.first?.retractedDate) + } + + func testQueryByDateExcludingFutureDelayed() async throws { let now = Date() - alertStore.recordIssued(alert: alert1, at: now, completion: self.expectSuccess { - self.alertStore.recordIssued(alert: self.repeatingAlert, at: now, completion: self.expectSuccess { - self.alertStore.executeQuery(since: now, limit: 100, completion: self.expectSuccess { anchor, objects in - XCTAssertEqual(1, anchor.modificationCounter) - XCTAssertEqual(1, objects.count) - XCTAssertEqual(Self.identifier1, objects.first?.identifier) - XCTAssertEqual(now, objects.first?.issuedDate) - XCTAssertNil(objects.first?.acknowledgedDate) - XCTAssertNil(objects.first?.retractedDate) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testQueryByDateNotExcludingFutureDelayed() { - let expect = self.expectation(description: #function) + await alertStore.recordIssued(alert: alert1, at: now) + await self.alertStore.recordIssued(alert: self.delayedAlert, at: now) + let (anchor, objects) = try await self.alertStore.executeQuery(since: now, limit: 100) + XCTAssertEqual(1, anchor.modificationCounter) + XCTAssertEqual(1, objects.count) + XCTAssertEqual(Self.identifier1, objects.first?.identifier) + XCTAssertEqual(now, objects.first?.issuedDate) + XCTAssertNil(objects.first?.acknowledgedDate) + XCTAssertNil(objects.first?.retractedDate) + } + + func testQueryByDateExcludingFutureRepeating() async throws { let now = Date() - alertStore.recordIssued(alert: alert1, at: now, completion: self.expectSuccess { - self.alertStore.recordIssued(alert: self.delayedAlert, at: now, completion: self.expectSuccess { - self.alertStore.executeQuery(since: now, excludingFutureAlerts: false, limit: 100, completion: self.expectSuccess { anchor, objects in - XCTAssertEqual(2, anchor.modificationCounter) - self.assertEqual([self.alert1, self.delayedAlert], objects) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testQueryWithLimit() { - let expect = self.expectation(description: #function) - alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { - self.alertStore.recordIssued(alert: self.alert2, at: Date(), completion: self.expectSuccess { - self.alertStore.executeQuery(since: Date.distantPast, limit: 1, completion: self.expectSuccess { anchor, objects in - XCTAssertEqual(1, anchor.modificationCounter) - XCTAssertEqual(1, objects.count) - XCTAssertEqual(Self.identifier1, objects.first?.identifier) - XCTAssertEqual(Self.historicDate, objects.first?.issuedDate) - XCTAssertNil(objects.first?.acknowledgedDate) - XCTAssertNil(objects.first?.retractedDate) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testQueryThenContinue() { - let expect = self.expectation(description: #function) - alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: expectSuccess { - let now = Date() - self.alertStore.recordIssued(alert: self.alert2, at: now, completion: self.expectSuccess { - self.alertStore.executeQuery(since: Date.distantPast, limit: 1, completion: self.expectSuccess { anchor, _ in - self.alertStore.executeQuery(fromQueryAnchor: anchor, since: Date.distantPast, limit: 1, completion: self.expectSuccess { anchor, objects in - XCTAssertEqual(2, anchor.modificationCounter) - XCTAssertEqual(1, objects.count) - XCTAssertEqual(Self.identifier2, objects.first?.identifier) - XCTAssertEqual(now, objects.first?.issuedDate) - XCTAssertNil(objects.first?.acknowledgedDate) - XCTAssertNil(objects.first?.retractedDate) - expect.fulfill() - }) - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testAcknowledgeFindsCorrectOne() { - let expect = self.expectation(description: #function) + await alertStore.recordIssued(alert: alert1, at: now) + await self.alertStore.recordIssued(alert: self.repeatingAlert, at: now) + let (anchor, objects) = try await self.alertStore.executeQuery(since: now, limit: 100) + XCTAssertEqual(1, anchor.modificationCounter) + XCTAssertEqual(1, objects.count) + XCTAssertEqual(Self.identifier1, objects.first?.identifier) + XCTAssertEqual(now, objects.first?.issuedDate) + XCTAssertNil(objects.first?.acknowledgedDate) + XCTAssertNil(objects.first?.retractedDate) + } + + func testQueryByDateNotExcludingFutureDelayed() async throws { let now = Date() - fillWith(startDate: Self.historicDate, data: [ + await alertStore.recordIssued(alert: alert1, at: now) + await self.alertStore.recordIssued(alert: self.delayedAlert, at: now) + let (anchor, objects) = try await self.alertStore.executeQuery(since: now, excludingFutureAlerts: false, limit: 100) + XCTAssertEqual(2, anchor.modificationCounter) + self.assertEqual([self.alert1, self.delayedAlert], objects) + } + + func testQueryWithLimit() async throws { + await alertStore.recordIssued(alert: alert1, at: Self.historicDate) + await self.alertStore.recordIssued(alert: self.alert2, at: Date()) + let (anchor, objects) = try await self.alertStore.executeQuery(since: Date.distantPast, limit: 1) + XCTAssertEqual(1, anchor.modificationCounter) + XCTAssertEqual(1, objects.count) + XCTAssertEqual(Self.identifier1, objects.first?.identifier) + XCTAssertEqual(Self.historicDate, objects.first?.issuedDate) + XCTAssertNil(objects.first?.acknowledgedDate) + XCTAssertNil(objects.first?.retractedDate) + } + + func testQueryThenContinue() async throws { + await alertStore.recordIssued(alert: alert1, at: Self.historicDate) + let now = Date() + await self.alertStore.recordIssued(alert: self.alert2, at: now) + let (anchor, _) = try await self.alertStore.executeQuery(since: Date.distantPast, limit: 1) + let (anchor2, objects) = try await self.alertStore.executeQuery(fromQueryAnchor: anchor, since: Date.distantPast, limit: 1) + XCTAssertEqual(2, anchor2.modificationCounter) + XCTAssertEqual(1, objects.count) + XCTAssertEqual(Self.identifier2, objects.first?.identifier) + XCTAssertEqual(now, objects.first?.issuedDate) + XCTAssertNil(objects.first?.acknowledgedDate) + XCTAssertNil(objects.first?.retractedDate) + } + + func testAcknowledgeFindsCorrectOne() async throws { + let now = Date() + await fillWith(startDate: Self.historicDate, data: [ (alert1, true, false), (alert2, false, false), (alert1, false, false) - ]) { - self.alertStore.recordAcknowledgement(of: self.alert1.identifier, at: now, completion: self.expectSuccess { - self.alertStore.fetch(completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(3, storedAlerts.count) - // Last one is last-modified - XCTAssertNotNil(storedAlerts.last) - if let last = storedAlerts.last { - XCTAssertEqual(Self.identifier1, last.identifier) - XCTAssertEqual(Self.historicDate + 4, last.issuedDate) - XCTAssertEqual(now, last.acknowledgedDate) - XCTAssertNil(last.retractedDate) - } - expect.fulfill() - }) - }) + ]) + try await self.alertStore.recordAcknowledgement(of: self.alert1.identifier, at: now) + let storedAlerts = try await self.alertStore.fetch() + XCTAssertEqual(3, storedAlerts.count) + // Last one is last-modified + XCTAssertNotNil(storedAlerts.last) + if let last = storedAlerts.last { + XCTAssertEqual(Self.identifier1, last.identifier) + XCTAssertEqual(Self.historicDate + 4, last.issuedDate) + XCTAssertEqual(now, last.acknowledgedDate) + XCTAssertNil(last.retractedDate) } - wait(for: [expect], timeout: Self.defaultTimeout) } - - func testAcknowledgeMultiple() { - let expect = self.expectation(description: #function) + + func testAcknowledgeMultiple() async throws { let now = Date() - fillWith(startDate: Self.historicDate, data: [ + await fillWith(startDate: Self.historicDate, data: [ (alert1, false, false), (alert2, false, false), (alert1, false, false) - ]) { - self.alertStore.recordAcknowledgement(of: self.alert1.identifier, at: now, completion: self.expectSuccess { - self.alertStore.fetch(completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(3, storedAlerts.count) - for alert in storedAlerts where alert.identifier == Self.identifier1 { - XCTAssertEqual(now, alert.acknowledgedDate) - XCTAssertNil(alert.retractedDate) - } - expect.fulfill() - }) - }) - } - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testLookupAllUnacknowledgedUnretractedEmpty() { - let expect = self.expectation(description: #function) - alertStore.lookupAllUnacknowledgedUnretracted(completion: expectSuccess { alerts in - XCTAssertTrue(alerts.isEmpty) - expect.fulfill() - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testLookupAllUnacknowledgedUnretractedOne() { - let expect = self.expectation(description: #function) - fillWith(startDate: Self.historicDate, data: [(alert1, false, false)]) { - self.alertStore.lookupAllUnacknowledgedUnretracted(completion: self.expectSuccess { alerts in - self.assertEqual([self.alert1], alerts) - expect.fulfill() - }) + ]) + try await self.alertStore.recordAcknowledgement(of: self.alert1.identifier, at: now) + let storedAlerts = try await self.alertStore.fetch() + XCTAssertEqual(3, storedAlerts.count) + for alert in storedAlerts where alert.identifier == Self.identifier1 { + XCTAssertEqual(now, alert.acknowledgedDate) + XCTAssertNil(alert.retractedDate) } - wait(for: [expect], timeout: Self.defaultTimeout) - } - - - func testLookupAllUnacknowledgedUnretractedOneAcknowledged() { - let expect = self.expectation(description: #function) - fillWith(startDate: Self.historicDate, data: [(alert1, true, false)]) { - self.alertStore.lookupAllUnacknowledgedUnretracted(completion: self.expectSuccess { alerts in - self.assertEqual([], alerts) - expect.fulfill() - }) - } - wait(for: [expect], timeout: Self.defaultTimeout) } - - func testLookupAllUnacknowledgedUnretractedSomeNot() { - let expect = self.expectation(description: #function) - fillWith(startDate: Self.historicDate, data: [ + + func testLookupAllUnacknowledgedUnretractedEmpty() async throws { + let alerts = try await alertStore.lookupAllUnacknowledgedUnretracted() + XCTAssertTrue(alerts.isEmpty) + } + + func testLookupAllUnacknowledgedUnretractedOne() async throws { + await fillWith(startDate: Self.historicDate, data: [(alert1, false, false)]) + let alerts = try await self.alertStore.lookupAllUnacknowledgedUnretracted() + self.assertEqual([self.alert1], alerts) + } + + func testLookupAllUnacknowledgedUnretractedOneAcknowledged() async throws { + await fillWith(startDate: Self.historicDate, data: [(alert1, true, false)]) + let alerts = try await self.alertStore.lookupAllUnacknowledgedUnretracted() + self.assertEqual([], alerts) + } + + func testLookupAllUnacknowledgedUnretractedSomeNot() async throws { + await fillWith(startDate: Self.historicDate, data: [ (alert1, true, false), (alert2, false, false), (alert1, false, false), - ]) { - self.alertStore.lookupAllUnacknowledgedUnretracted(completion: self.expectSuccess { alerts in - self.assertEqual([self.alert2, self.alert1], alerts) - expect.fulfill() - }) - } - wait(for: [expect], timeout: Self.defaultTimeout) + ]) + let alerts = try await self.alertStore.lookupAllUnacknowledgedUnretracted() + self.assertEqual([self.alert2, self.alert1], alerts) } - - func testLookupAllUnacknowledgedUnretractedSomeRetracted() { - let expect = self.expectation(description: #function) - fillWith(startDate: Self.historicDate, data: [ + + func testLookupAllUnacknowledgedUnretractedSomeRetracted() async throws { + await fillWith(startDate: Self.historicDate, data: [ (alert1, false, true), (alert2, false, false), (alert1, false, true) - ]) { - self.alertStore.lookupAllUnacknowledgedUnretracted(completion: self.expectSuccess { alerts in - self.assertEqual([self.alert2], alerts) - expect.fulfill() - }) - } - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testLookupAllUnretractedEmpty() { - let expect = self.expectation(description: #function) - alertStore.lookupAllUnretracted(completion: expectSuccess { alerts in - XCTAssertTrue(alerts.isEmpty) - expect.fulfill() - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testLookupAllUnretractedOne() { - let expect = self.expectation(description: #function) - fillWith(startDate: Self.historicDate, data: [(alert1, false, false)]) { - self.alertStore.lookupAllUnretracted(completion: self.expectSuccess { alerts in - self.assertEqual([self.alert1], alerts) - expect.fulfill() - }) - } - wait(for: [expect], timeout: Self.defaultTimeout) - } - - - func testLookupAllUnretractedOneAcknowledged() { - let expect = self.expectation(description: #function) - fillWith(startDate: Self.historicDate, data: [(alert1, true, false)]) { - self.alertStore.lookupAllUnretracted(completion: self.expectSuccess { alerts in - self.assertEqual([self.alert1], alerts) - expect.fulfill() - }) - } - wait(for: [expect], timeout: Self.defaultTimeout) + ]) + let alerts = try await self.alertStore.lookupAllUnacknowledgedUnretracted() + self.assertEqual([self.alert2], alerts) + } + + func testLookupAllUnretractedEmpty() async throws { + let alerts = try await alertStore.lookupAllUnretracted() + XCTAssertTrue(alerts.isEmpty) } - - func testLookupAllUnretractedSomeAcknowledgedSomeNot() { - let expect = self.expectation(description: #function) - fillWith(startDate: Self.historicDate, data: [ + + func testLookupAllUnretractedOne() async throws { + await fillWith(startDate: Self.historicDate, data: [(alert1, false, false)]) + let alerts = try await self.alertStore.lookupAllUnretracted() + self.assertEqual([self.alert1], alerts) + } + + func testLookupAllUnretractedOneAcknowledged() async throws { + await fillWith(startDate: Self.historicDate, data: [(alert1, true, false)]) + let alerts = try await self.alertStore.lookupAllUnretracted() + self.assertEqual([self.alert1], alerts) + } + + func testLookupAllUnretractedSomeAcknowledgedSomeNot() async throws { + await fillWith(startDate: Self.historicDate, data: [ (alert1, true, false), (alert2, false, false), (alert1, false, false), - ]) { - self.alertStore.lookupAllUnretracted(completion: self.expectSuccess { alerts in - self.assertEqual([self.alert1, self.alert2, self.alert1], alerts) - expect.fulfill() - }) - } - wait(for: [expect], timeout: Self.defaultTimeout) + ]) + let alerts = try await self.alertStore.lookupAllUnretracted() + self.assertEqual([self.alert1, self.alert2, self.alert1], alerts) } - - func testLookupAllUnretractedSomeRetracted() { - let expect = self.expectation(description: #function) - fillWith(startDate: Self.historicDate, data: [ + + func testLookupAllUnretractedSomeRetracted() async throws { + await fillWith(startDate: Self.historicDate, data: [ (alert1, false, true), (alert2, false, false), (alert1, false, true) - ]) { - self.alertStore.lookupAllUnretracted(completion: self.expectSuccess { alerts in - self.assertEqual([self.alert2], alerts) - expect.fulfill() - }) - } - wait(for: [expect], timeout: Self.defaultTimeout) + ]) + let alerts = try await self.alertStore.lookupAllUnretracted() + self.assertEqual([self.alert2], alerts) } - func testLookupAllAcknowledgedUnretractedRepeatingAlertsAll() { - let expect = self.expectation(description: #function) - fillWith(startDate: Self.historicDate, data: [ + func testLookupAllAcknowledgedUnretractedRepeatingAlertsAll() async throws { + await fillWith(startDate: Self.historicDate, data: [ (repeatingAlert, true, false), (repeatingAlert, true, false) - ]) { - self.alertStore.lookupAllAcknowledgedUnretractedRepeatingAlerts(completion: self.expectSuccess { alerts in - XCTAssertEqual(alerts.count, 2) - self.assertEqual([self.repeatingAlert, self.repeatingAlert], alerts) - expect.fulfill() - }) - } - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testLookupAllAcknowledgedUnretractedRepeatingAlertsEmpty() { - let expect = self.expectation(description: #function) - alertStore.lookupAllAcknowledgedUnretractedRepeatingAlerts(completion: expectSuccess { alerts in - XCTAssertTrue(alerts.isEmpty) - expect.fulfill() - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testLookupAllAcknowledgedUnretractedRepeatingAlertsSome() { - let expect = self.expectation(description: #function) - fillWith(startDate: Self.historicDate, data: [ + ]) + let alerts = try await self.alertStore.lookupAllAcknowledgedUnretractedRepeatingAlerts() + XCTAssertEqual(alerts.count, 2) + self.assertEqual([self.repeatingAlert, self.repeatingAlert], alerts) + } + + func testLookupAllAcknowledgedUnretractedRepeatingAlertsEmpty() async throws { + let alerts = try await alertStore.lookupAllAcknowledgedUnretractedRepeatingAlerts() + XCTAssertTrue(alerts.isEmpty) + } + + func testLookupAllAcknowledgedUnretractedRepeatingAlertsSome() async throws { + await fillWith(startDate: Self.historicDate, data: [ (repeatingAlert, true, true), (repeatingAlert, true, false), (alert1, true, false) - ]) { - self.alertStore.lookupAllAcknowledgedUnretractedRepeatingAlerts(completion: self.expectSuccess { alerts in - XCTAssertEqual(alerts.count, 1) - self.assertEqual([self.repeatingAlert], alerts) - expect.fulfill() - }) - } - wait(for: [expect], timeout: Self.defaultTimeout) + ]) + let alerts = try await self.alertStore.lookupAllAcknowledgedUnretractedRepeatingAlerts() + XCTAssertEqual(alerts.count, 1) + self.assertEqual([self.repeatingAlert], alerts) } - func testLookUpAllMatching() { - let expect = self.expectation(description: #function) - fillWith(startDate: Self.historicDate, data: [ + func testLookUpAllMatching() async throws { + await fillWith(startDate: Self.historicDate, data: [ (alert1, true, false), (repeatingAlert, true, false) - ]) { - self.alertStore.lookupAllMatching(identifier: AlertStoreTests.repeatingAlertIdentifier, completion: self.expectSuccess { alerts in - XCTAssertEqual(alerts.count, 1) - self.assertEqual([self.repeatingAlert], alerts) - expect.fulfill() - }) - } - wait(for: [expect], timeout: Self.defaultTimeout) + ]) + let alerts = try await self.alertStore.lookupAllMatching(identifier: AlertStoreTests.repeatingAlertIdentifier) + XCTAssertEqual(alerts.count, 1) + self.assertEqual([self.repeatingAlert], alerts) } - private func fillWith(startDate: Date, data: [(alert: Alert, acknowledged: Bool, retracted: Bool)], _ completion: @escaping () -> Void) { + private func fillWith(startDate: Date, data: [(alert: Alert, acknowledged: Bool, retracted: Bool)]) async { let increment = 1.0 - if let value = data.first { - alertStore.recordIssued(alert: value.alert, at: startDate, completion: self.expectSuccess { - var next = startDate.addingTimeInterval(increment) - self.maybeRecordAcknowledge(acknowledged: value.acknowledged, identifier: value.alert.identifier, at: next) { - next = next.addingTimeInterval(increment) - self.maybeRecordRetracted(retracted: value.retracted, identifier: value.alert.identifier, at: next) { - self.fillWith(startDate: startDate.addingTimeInterval(increment).addingTimeInterval(increment), data: data.suffix(data.count - 1), completion) - } - } - }) - } else { - completion() - } - } - - private func maybeRecordAcknowledge(acknowledged: Bool, identifier: Alert.Identifier, at date: Date, _ completion: @escaping () -> Void) { - if acknowledged { - self.alertStore.recordAcknowledgement(of: identifier, at: date, completion: self.expectSuccess(completion)) - } else { - completion() - } - } - - private func maybeRecordRetracted(retracted: Bool, identifier: Alert.Identifier, at date: Date, _ completion: @escaping () -> Void) { - if retracted { - self.alertStore.recordRetraction(of: identifier, at: date, completion: self.expectSuccess(completion)) - } else { - completion() + for (index, value) in data.enumerated() { + let issuedDate = startDate.addingTimeInterval(Double(index) * increment * 2) + await alertStore.recordIssued(alert: value.alert, at: issuedDate) + + if value.acknowledged { + let acknowledgedDate = issuedDate.addingTimeInterval(increment) + try? await self.alertStore.recordAcknowledgement(of: value.alert.identifier, at: acknowledgedDate) + } + + if value.retracted { + let retractedDate = issuedDate.addingTimeInterval(increment) + try? await self.alertStore.recordRetraction(of: value.alert.identifier, at: retractedDate) + } } } @@ -789,7 +551,7 @@ class AlertStoreTests: XCTestCase { } } } - + private func assertEqual(_ alerts: [Alert], _ syncAlertObjects: [SyncAlertObject], file: StaticString = #file, line: UInt = #line) { XCTAssertEqual(alerts.count, syncAlertObjects.count, file: file, line: line) if alerts.count == syncAlertObjects.count { @@ -798,105 +560,4 @@ class AlertStoreTests: XCTestCase { } } } - - private func expectSuccess(file: StaticString = #file, line: UInt = #line, _ completion: @escaping (T) -> Void) -> ((Result) -> Void) { - return { - switch $0 { - case .failure(let error): XCTFail("Unexpected \(error)", file: file, line: line) - case .success(let value): completion(value) - } - } - } - - private func expectSuccess(file: StaticString = #file, line: UInt = #line, _ completion: @escaping (AlertStore.QueryAnchor, [SyncAlertObject]) -> Void) -> ((AlertStore.AlertQueryResult) -> Void) { - return { - switch $0 { - case .failure(let error): XCTFail("Unexpected \(error)", file: file, line: line) - case .success(let queryAnchor, let objects): completion(queryAnchor, objects) - } - } - } -} - -class AlertStoreLogCriticalEventLogTests: XCTestCase { - var alertStore: AlertStore! - var outputStream: MockOutputStream! - var progress: Progress! - - override func setUp() { - super.setUp() - - let alerts = [AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:08:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m1", alertIdentifier: "a1"), foregroundContent: nil, backgroundContent: AlertStoreTests.backgroundContent, trigger: .immediate), syncIdentifier: UUID(uuidString: "52A046F7-F449-49B2-B003-7A378D0002DE")!), - AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:10:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m2", alertIdentifier: "a2"), foregroundContent: nil, backgroundContent: AlertStoreTests.backgroundContent, trigger: .immediate), syncIdentifier: UUID(uuidString: "0929E349-972F-4B06-9808-68914A541515")!), - AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:04:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m3", alertIdentifier: "a3"), foregroundContent: nil, backgroundContent: AlertStoreTests.backgroundContent, trigger: .immediate), syncIdentifier: UUID(uuidString: "285AEA4B-0DEE-41F4-8669-800E9582A6E7")!), - AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:06:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m4", alertIdentifier: "a4"), foregroundContent: nil, backgroundContent: AlertStoreTests.backgroundContent, trigger: .immediate), syncIdentifier: UUID(uuidString: "4B3109BD-DE11-42BD-A777-D4783459C483")!), - AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:02:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m5", alertIdentifier: "a5"), foregroundContent: nil, backgroundContent: AlertStoreTests.backgroundContent, trigger: .immediate), syncIdentifier: UUID(uuidString: "48C8ACC7-9DB7-411D-B5A3-CD907D464B78")!)] - - alertStore = AlertStore() - XCTAssertNil(alertStore.addAlerts(alerts: alerts)) - - outputStream = MockOutputStream() - progress = Progress() - } - - override func tearDown() { - alertStore = nil - - super.tearDown() - } - - func testExportProgressTotalUnitCount() { - switch alertStore.exportProgressTotalUnitCount(startDate: dateFormatter.date(from: "2100-01-02T03:03:00Z")!, - endDate: dateFormatter.date(from: "2100-01-02T03:09:00Z")!) { - case .failure(let error): - XCTFail("Unexpected failure: \(error)") - case .success(let progressTotalUnitCount): - XCTAssertEqual(progressTotalUnitCount, 3 * 1) - } - } - - func testExportProgressTotalUnitCountEmpty() { - switch alertStore.exportProgressTotalUnitCount(startDate: dateFormatter.date(from: "2100-01-02T03:00:00Z")!, - endDate: dateFormatter.date(from: "2100-01-02T03:01:00Z")!) { - case .failure(let error): - XCTFail("Unexpected failure: \(error)") - case .success(let progressTotalUnitCount): - XCTAssertEqual(progressTotalUnitCount, 0) - } - } - - func testExport() { - XCTAssertNil(alertStore.export(startDate: dateFormatter.date(from: "2100-01-02T03:03:00Z")!, - 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} - ] - """#) - XCTAssertEqual(progress.completedUnitCount, 3 * 1) - } - - func testExportEmpty() { - XCTAssertNil(alertStore.export(startDate: dateFormatter.date(from: "2100-01-02T03:00:00Z")!, - endDate: dateFormatter.date(from: "2100-01-02T03:01:00Z")!, - to: outputStream, - progress: progress)) - XCTAssertEqual(outputStream.string, "[]") - XCTAssertEqual(progress.completedUnitCount, 0) - } - - func testExportCancelled() { - progress.cancel() - XCTAssertEqual(alertStore.export(startDate: dateFormatter.date(from: "2100-01-02T03:03:00Z")!, - endDate: dateFormatter.date(from: "2100-01-02T03:09:00Z")!, - to: outputStream, - progress: progress) as? CriticalEventLogError, CriticalEventLogError.cancelled) - } - - private let dateFormatter = ISO8601DateFormatter() } diff --git a/LoopTests/Managers/Alerts/InAppModalAlertSchedulerTests.swift b/LoopTests/Managers/Alerts/InAppModalAlertSchedulerTests.swift index ee2c47606b..f0d8965291 100644 --- a/LoopTests/Managers/Alerts/InAppModalAlertSchedulerTests.swift +++ b/LoopTests/Managers/Alerts/InAppModalAlertSchedulerTests.swift @@ -10,6 +10,7 @@ import LoopKit import XCTest @testable import Loop +@MainActor class InAppModalAlertSchedulerTests: XCTestCase { class MockAlertAction: UIAlertAction { @@ -34,42 +35,33 @@ class InAppModalAlertSchedulerTests: XCTestCase { } class MockAlertManagerResponder: AlertManagerResponder { + var alertAcknowledgedExectation: XCTestExpectation? var identifierAcknowledged: Alert.Identifier? func acknowledgeAlert(identifier: Alert.Identifier) { identifierAcknowledged = identifier + alertAcknowledgedExectation?.fulfill() } + func userDidSelectAction(alertIdentifier: LoopKit.Alert.Identifier, actionIdentifier: String) async throws { } } class MockViewController: UIViewController, AlertPresenter { + + var alertPresentedExpectation: XCTestExpectation? + var alertDismissedExpectation: XCTestExpectation? + + var viewControllerPresented: UIViewController? var alertDismissed: UIAlertController? - var autoComplete = true - var completion: (() -> Void)? - override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) { + + func present(_ viewControllerToPresent: UIViewController, animated flag: Bool) async { viewControllerPresented = viewControllerToPresent - if autoComplete { - completion?() - } else { - self.completion = completion - } - } - func dismissTopMost(animated: Bool, completion: (() -> Void)?) { - if autoComplete { - completion?() - } else { - self.completion = completion - } + alertPresentedExpectation?.fulfill() } - func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool, completion: (() -> Void)?) { + + func dismissTopMost(animated: Bool) async { } + func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool) async { alertDismissed = alertToDismiss - if autoComplete { - completion?() - } else { - self.completion = completion - } - } - func callCompletion() { - completion?() + alertDismissedExpectation?.fulfill() } } @@ -77,7 +69,9 @@ class InAppModalAlertSchedulerTests: XCTestCase { let alertIdentifier = Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: "bar") let foregroundContent = Alert.Content(title: "FOREGROUND", body: "foreground", acknowledgeActionButtonLabel: "") let backgroundContent = Alert.Content(title: "BACKGROUND", body: "background", acknowledgeActionButtonLabel: "") - + + var timerCreatedExepctation: XCTestExpectation? + var mockTimer: Timer? var mockTimerTimeInterval: TimeInterval? var mockTimerRepeats: Bool? @@ -85,7 +79,7 @@ class InAppModalAlertSchedulerTests: XCTestCase { var mockViewController: MockViewController! var inAppModalAlertScheduler: InAppModalAlertScheduler! - override func setUp() { + override func setUp() async throws { mockAlertManagerResponder = MockAlertManagerResponder() mockViewController = MockViewController() @@ -94,6 +88,7 @@ class InAppModalAlertSchedulerTests: XCTestCase { self.mockTimer = timer self.mockTimerTimeInterval = timeInterval self.mockTimerRepeats = repeats + self.timerCreatedExepctation?.fulfill() return timer } inAppModalAlertScheduler = InAppModalAlertScheduler(alertPresenter: mockViewController, @@ -141,29 +136,28 @@ class InAppModalAlertSchedulerTests: XCTestCase { XCTAssertEqual("FOREGROUND", alertController?.title) } - func testRemoveImmediateAlert() { + @MainActor + func testRemoveImmediateAlert() async { + mockViewController.alertPresentedExpectation = expectation(description: "alert presented") let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .immediate) inAppModalAlertScheduler.scheduleAlert(alert) - - waitOnMain() + + await fulfillment(of: [mockViewController.alertPresentedExpectation!]) let alertControllerPresented = mockViewController.viewControllerPresented as? UIAlertController XCTAssertNotNil(alertControllerPresented) - var dismissed = false - inAppModalAlertScheduler.removePresentedAlert(identifier: alert.identifier) { - dismissed = true - } + mockViewController.alertDismissedExpectation = expectation(description: "alert dismissed") - waitOnMain() + await inAppModalAlertScheduler.removePresentedAlert(identifier: alert.identifier) + + await fulfillment(of: [mockViewController.alertDismissedExpectation!]) let alertDimissed = mockViewController.alertDismissed XCTAssertNotNil(alertDimissed) - XCTAssertTrue(dismissed) } func testIssueImmediateAlertTwiceOnlyOneShows() { let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .immediate) - mockViewController.autoComplete = false inAppModalAlertScheduler.scheduleAlert(alert) waitOnMain() @@ -186,14 +180,15 @@ class InAppModalAlertSchedulerTests: XCTestCase { waitOnMain() let action = (mockViewController.viewControllerPresented as? UIAlertController)?.actions[0] as? MockAlertAction XCTAssertNotNil(action) + mockAlertManagerResponder.alertAcknowledgedExectation = expectation(description: "alert acknowledged") XCTAssertNil(mockAlertManagerResponder.identifierAcknowledged) action?.callHandler() + wait(for: [mockAlertManagerResponder.alertAcknowledgedExectation!]) XCTAssertEqual(alertIdentifier, mockAlertManagerResponder.identifierAcknowledged) } func testIssueDelayedAlert() { let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .delayed(interval: 0.1)) - mockViewController.autoComplete = false inAppModalAlertScheduler.scheduleAlert(alert) waitOnMain() @@ -212,7 +207,6 @@ class InAppModalAlertSchedulerTests: XCTestCase { func testIssueDelayedAlertTwiceOnlyOneWorks() { let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .delayed(interval: 0.1)) - mockViewController.autoComplete = false inAppModalAlertScheduler.scheduleAlert(alert) waitOnMain() @@ -239,21 +233,22 @@ class InAppModalAlertSchedulerTests: XCTestCase { XCTAssertNil(mockViewController.viewControllerPresented) } - func testRetractAlert() { + func testRetractAlert() async { + + timerCreatedExepctation = expectation(description: "Timer created") + let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .delayed(interval: 0.1)) inAppModalAlertScheduler.scheduleAlert(alert) - - waitOnMain() + + await fulfillment(of: [timerCreatedExepctation!]) XCTAssert(mockTimer?.isValid == true) - inAppModalAlertScheduler.unscheduleAlert(identifier: alert.identifier) - - waitOnMain() + + await inAppModalAlertScheduler.unscheduleAlert(identifier: alert.identifier) XCTAssert(mockTimer?.isValid == false) } func testIssueRepeatingAlert() { let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .repeating(repeatInterval: 0.1)) - mockViewController.autoComplete = false inAppModalAlertScheduler.scheduleAlert(alert) waitOnMain() diff --git a/LoopTests/Managers/Alerts/StoredAlertTests.swift b/LoopTests/Managers/Alerts/StoredAlertTests.swift index 85fe753c7d..e7b74f45fd 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" : "{\"actions\":[{\"identifier\":\"acknowledge\",\"label\":\"OK\",\"style\":0}],\"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" : "{\"actions\":[{\"identifier\":\"acknowledge\",\"label\":\"OK\",\"style\":0}],\"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..5971d75daa 100644 --- a/LoopTests/Managers/CGMStalenessMonitorTests.swift +++ b/LoopTests/Managers/CGMStalenessMonitorTests.swift @@ -9,7 +9,7 @@ import XCTest import Foundation import LoopKit -import HealthKit +import LoopAlgorithm @testable import Loop class CGMStalenessMonitorTests: XCTestCase { @@ -18,11 +18,11 @@ class CGMStalenessMonitorTests: XCTestCase { private var fetchExpectation: XCTestExpectation? private var storedGlucoseSample: StoredGlucoseSample { - return StoredGlucoseSample(uuid: UUID(), provenanceIdentifier: UUID().uuidString, syncIdentifier: "syncIdentifier", syncVersion: 1, startDate: Date().addingTimeInterval(-.minutes(5)), quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), condition: nil, trend: .flat, trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 0.0), isDisplayOnly: false, wasUserEntered: false, device: nil, healthKitEligibleDate: nil) + return StoredGlucoseSample(uuid: UUID(), provenanceIdentifier: UUID().uuidString, syncIdentifier: "syncIdentifier", syncVersion: 1, startDate: Date().addingTimeInterval(-.minutes(5)), quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), condition: nil, trend: .flat, trendRate: LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 0.0), isDisplayOnly: false, wasUserEntered: false, device: nil, healthKitEligibleDate: nil) } private var newGlucoseSample: NewGlucoseSample { - return NewGlucoseSample(date: Date().addingTimeInterval(-.minutes(1)), quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), condition: nil, trend: .flat, trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 0.0), isDisplayOnly: false, wasUserEntered: false, syncIdentifier: "syncIdentifier") + return NewGlucoseSample(date: Date().addingTimeInterval(-.minutes(1)), quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), condition: nil, trend: .flat, trendRate: LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 0.0), isDisplayOnly: false, wasUserEntered: false, syncIdentifier: "syncIdentifier") } func testInitialValue() { @@ -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..b3725a8545 --- /dev/null +++ b/LoopTests/Managers/DeviceDataManagerTests.swift @@ -0,0 +1,212 @@ +// +// DeviceDataManagerTests.swift +// LoopTests +// +// Created by Pete Schwamb on 10/31/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import XCTest +import HealthKit +import LoopAlgorithm +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 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, + 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, + decisionId: nil, + automatic: true + ) + pumpManager.status.basalDeliveryState = .tempBasal(dose) + + let newLimits = DeliveryLimits( + maximumBasalRate: LoopQuantity(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, + decisionId: nil + ) + pumpManager.status.basalDeliveryState = .tempBasal(dose) + + let newLimits = DeliveryLimits( + maximumBasalRate: LoopQuantity(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, + decisionId: nil + ) + 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 { + nonisolated var activeServices: [LoopKit.Service] { + return [] + } + + +} + +extension DeviceDataManagerTests: ActiveStatefulPluginsProvider { + nonisolated var activeStatefulPlugins: [LoopKit.StatefulPluggable] { + return [] + } +} + +extension DeviceDataManagerTests: DisplayGlucoseUnitBroadcaster { + func addDisplayGlucoseUnitObserver(_ observer: LoopKitUI.DisplayGlucoseUnitObserver) { + } + + func removeDisplayGlucoseUnitObserver(_ observer: LoopKitUI.DisplayGlucoseUnitObserver) { + } + + func notifyObserversOfDisplayGlucoseUnitChange(to displayGlucoseUnit: LoopUnit) { + } +} diff --git a/LoopTests/Managers/DoseEnactorTests.swift b/LoopTests/Managers/DoseEnactorTests.swift index bf722ec874..5609d03952 100644 --- a/LoopTests/Managers/DoseEnactorTests.swift +++ b/LoopTests/Managers/DoseEnactorTests.swift @@ -9,7 +9,7 @@ import XCTest import Foundation import LoopKit -import HealthKit +import LoopAlgorithm @testable import Loop @@ -21,139 +21,12 @@ 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) + let recommendation = AutomaticDoseRecommendation(basalAdjustment: tempBasalRecommendation, direction: .decrease, bolusUnits: 1.5) let pumpManager = MockPumpManager() let tempBasalExpectation = expectation(description: "enactTempBasal called") @@ -165,18 +38,16 @@ 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(decisionId: nil, bolus: recommendation.bolusUnits, tempBasal: recommendation.basalAdjustment, 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) + let recommendation = AutomaticDoseRecommendation(basalAdjustment: tempBasalRecommendation, direction: .decrease, bolusUnits: 1.5) let pumpManager = MockPumpManager() let tempBasalExpectation = expectation(description: "enactTempBasal called") @@ -190,17 +61,19 @@ class DoseEnactorTests: XCTestCase { pumpManager.enactTempBasalError = .configuration(MockPumpManagerError.failed) - enactor.enact(recommendation: recommendation, with: pumpManager) { error in - XCTAssertNotNil(error) + do { + try await enactor.enact(decisionId: nil, bolus: recommendation.bolusUnits, tempBasal: recommendation.basalAdjustment, 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) + let recommendation = AutomaticDoseRecommendation(basalAdjustment: tempBasalRecommendation, direction: .decrease, bolusUnits: 0) let pumpManager = MockPumpManager() let tempBasalExpectation = expectation(description: "enactTempBasal called") @@ -213,13 +86,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(decisionId: nil, bolus: recommendation.bolusUnits, tempBasal: recommendation.basalAdjustment, 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..1d40f9ab73 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -7,71 +7,14 @@ // import XCTest -import HealthKit import LoopKit +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,7 +37,17 @@ extension ISO8601DateFormatter { } } +@MainActor class LoopDataManagerTests: XCTestCase { + + class MockAlertIssuer: AlertIssuer { + func issueAlert(_ alert: LoopKit.Alert) { + } + + func retractAlert(identifier: LoopKit.Alert.Identifier) { + } + } + // MARK: Constants for testing let retrospectiveCorrectionEffectDuration = TimeInterval(hours: 1) let retrospectiveCorrectionGroupingInterval = 1.01 @@ -104,31 +57,37 @@ class LoopDataManagerTests: XCTestCase { let defaultAccuracy = 1.0 / 40.0 var suspendThreshold: GlucoseThreshold { - return GlucoseThreshold(unit: HKUnit.milligramsPerDeciliter, value: 75) + return GlucoseThreshold(unit: .milligramsPerDeciliter, value: 75) } var adultExponentialInsulinModel: InsulinModel = ExponentialInsulinModel(actionDuration: 21600.0, peakActivityTime: 4500.0) var glucoseTargetRangeSchedule: GlucoseRangeSchedule { - return GlucoseRangeSchedule(unit: HKUnit.milligramsPerDeciliter, dailyItems: [ + return GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: [ RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 100, maxValue: 110)), RepeatingScheduleValue(startTime: TimeInterval(28800), value: DoubleRange(minValue: 90, maxValue: 100)), RepeatingScheduleValue(startTime: TimeInterval(75600), value: DoubleRange(minValue: 100, maxValue: 110)) ], timeZone: .utcTimeZone)! } - // MARK: Mock stores - var now: Date! + // MARK: Stores + 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! + var temporaryPresetsManager: TemporaryPresetsManager! + + private var now: Date { TestingDate.currentTestingDate() } + + func d(_ interval: TimeInterval) -> Date { + TestingDate.currentTestingDate().addingTimeInterval(interval) + } + + override func setUp() async throws { let basalRateSchedule = loadBasalRateScheduleFixture("basal_profile") let insulinSensitivitySchedule = InsulinSensitivitySchedule( unit: .milligramsPerDeciliter, @@ -139,61 +98,393 @@ class LoopDataManagerTests: XCTestCase { timeZone: .utcTimeZone )! let carbRatioSchedule = CarbRatioSchedule( - unit: .gram(), + unit: .gram, dailyItems: [ RepeatingScheduleValue(startTime: 0.0, value: 10.0), ], timeZone: .utcTimeZone )! - let settings = LoopSettings( - dosingEnabled: false, + let settings = StoredSettings( + dosingEnabled: true, 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) + + TestingDate.setFixedTestingDate(dateFormatter.date(from: "2023-07-29T19:21:00Z")!) + + doseStore.lastAddedPumpData = now + dosingDecisionStore = MockDosingDecisionStore() - automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: true, isAutomaticDosingAllowed: true) + + temporaryPresetsManager = TemporaryPresetsManager(settingsProvider: settingsProvider, presetHistory: TemporaryScheduleOverrideHistory()) + 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, + crashRecoveryManager: CrashRecoveryManager(alertIssuer: MockAlertIssuer()), dosingDecisionStore: dosingDecisionStore, - latestStoredSettingsProvider: MockLatestStoredSettingsProvider(), - now: { currentDate }, - pumpInsulinType: .novolog, - 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: LoopQuantity(unit: LoopUnit(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: true, + 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 + TestingDate.setFixedTestingDate(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.bolus) + XCTAssertEqual(0, deliveryDelegate.lastEnact.tempBasal?.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.bolus!, 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.bolus!, 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.bolus!, 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.bolus!, accuracy: defaultAccuracy) + XCTAssertEqual(0, deliveryDelegate.lastEnact.tempBasal!.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.bolus!, accuracy: defaultAccuracy) + } + + 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, decisionId: nil) + deliveryDelegate.basalDeliveryState = .tempBasal(dose) + + dosingDecisionStore.storeExpectation = expectation(description: #function) + settingsProvider.dosingEnabled = false + + await fulfillment(of: [dosingDecisionStore.storeExpectation!], timeout: 1.0) + + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel, direction: .decrease) + XCTAssertEqual(deliveryDelegate.lastEnact.bolus, expectedAutomaticDoseRecommendation.bolusUnits) + XCTAssertEqual(deliveryDelegate.lastEnact.tempBasal, expectedAutomaticDoseRecommendation.basalAdjustment) + 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)), direction: .increase) + XCTAssertEqual(deliveryDelegate.lastEnact.bolus, expectedAutomaticDoseRecommendation.bolusUnits) + XCTAssertEqual(deliveryDelegate.lastEnact.tempBasal, expectedAutomaticDoseRecommendation.basalAdjustment) + 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, + decisionId: nil, + ) + 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 basalAdjustment = TempBasalRecommendation(unitsPerHour: 5.046818181818183, duration: .seconds(1800)) + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: basalAdjustment, direction: .increase) + 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)), + ] + settingsProvider.dosingEnabled = false + settingsProvider.settings.automaticDosingStrategy = .tempBasalOnly + + await loopDataManager.loop() + + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 3.0, duration: .minutes(30)), direction: .increase) + XCTAssertNil(deliveryDelegate.lastEnact.bolus) + XCTAssertNil(deliveryDelegate.lastEnact.tempBasal) + 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) + + } + + func testFetchDataWithHighInsulinNeedsPresetMitigation() async throws { + var input = try await loopDataManager.fetchData(for: now) + XCTAssertEqual(input.target.count, 1) + XCTAssertEqual(input.target[0].value.doubleRange(for: .milligramsPerDeciliter), DoubleRange(minValue: 90, maxValue: 100)) + XCTAssertEqual(input.suspendThreshold?.doubleValue(for: .milligramsPerDeciliter), 75.0) + + let override = TemporaryScheduleOverride( + context: .custom, + settings: TemporaryPresetSettings( + targetRange: nil, + insulinNeedsScaleFactor: 1.75 + ), + startDate: now.addingTimeInterval(.minutes(-1)), + duration: .finite(.hours(2)), + enactTrigger: .local, + syncIdentifier: UUID() + ) + + temporaryPresetsManager.scheduleOverride = override + + input = try await loopDataManager.fetchData(for: now) + XCTAssertEqual(input.target.count, 1) + XCTAssertEqual(input.target[0].value.doubleRange(for: .milligramsPerDeciliter), DoubleRange(minValue: 110, maxValue: 110)) + XCTAssertEqual(input.suspendThreshold?.doubleValue(for: .milligramsPerDeciliter), 110.0) + + } } extension LoopDataManagerTests { @@ -216,3 +507,57 @@ extension LoopDataManagerTests { return BasalRateSchedule(dailyItems: items, timeZone: .utcTimeZone)! } } + +extension LoopQuantity { + static func glucose(value: Double) -> LoopQuantity { + return .init(unit: .milligramsPerDeciliter, doubleValue: value) + } + + static func carbs(value: Double) -> LoopQuantity { + 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, + decisionId: nil + ) + } +} + +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..36f749c870 100644 --- a/LoopTests/Managers/MealDetectionManagerTests.swift +++ b/LoopTests/Managers/MealDetectionManagerTests.swift @@ -7,9 +7,10 @@ // import XCTest -import HealthKit import LoopCore import LoopKit +import LoopAlgorithm + @testable import Loop fileprivate class MockGlucoseSample: GlucoseSampleValue { @@ -17,11 +18,11 @@ fileprivate class MockGlucoseSample: GlucoseSampleValue { let provenanceIdentifier = "" let isDisplayOnly: Bool let wasUserEntered: Bool - let condition: LoopKit.GlucoseCondition? = nil - let trendRate: HKQuantity? = nil + let condition: GlucoseCondition? = nil + let trendRate: LoopQuantity? = nil var trend: LoopKit.GlucoseTrend? var syncIdentifier: String? - let quantity: HKQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 100) + let quantity: LoopQuantity = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 100) let startDate: Date init(startDate: Date, isDisplayOnly: Bool = false, wasUserEntered: Bool = false) { @@ -110,33 +111,33 @@ extension MissedMealTestType { switch self { case .missedMealWithCOB: return [ - NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 30), + NewCarbEntry(quantity: LoopQuantity(unit: .gram, doubleValue: 30), startDate: Self.dateFormatter.date(from: "2022-10-19T15:41:36")!, foodType: nil, absorptionTime: nil), - NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 10), + NewCarbEntry(quantity: LoopQuantity(unit: .gram, doubleValue: 10), startDate: Self.dateFormatter.date(from: "2022-10-19T17:36:58")!, foodType: nil, absorptionTime: nil) ] case .noMealWithCOB: return [ - NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 30), + NewCarbEntry(quantity: LoopQuantity(unit: .gram, doubleValue: 30), startDate: Self.dateFormatter.date(from: "2022-10-17T22:40:00")!, foodType: nil, absorptionTime: nil) ] case .manyMeals: return [ - NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 30), + NewCarbEntry(quantity: LoopQuantity(unit: .gram, doubleValue: 30), startDate: Self.dateFormatter.date(from: "2022-10-19T15:41:36")!, foodType: nil, absorptionTime: nil), - NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 10), + NewCarbEntry(quantity: LoopQuantity(unit: .gram, doubleValue: 10), startDate: Self.dateFormatter.date(from: "2022-10-19T17:36:58")!, foodType: nil, absorptionTime: nil), - NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 40), + NewCarbEntry(quantity: LoopQuantity(unit: .gram, doubleValue: 40), startDate: Self.dateFormatter.date(from: "2022-10-19T19:11:43")!, foodType: nil, absorptionTime: nil) @@ -148,7 +149,7 @@ extension MissedMealTestType { var carbSchedule: CarbRatioSchedule { CarbRatioSchedule( - unit: .gram(), + unit: .gram, dailyItems: [ RepeatingScheduleValue(startTime: 0.0, value: 15.0), ], @@ -161,16 +162,16 @@ extension MissedMealTestType { switch self { case .mmolUser: return InsulinSensitivitySchedule( - unit: HKUnit.millimolesPerLiter, + unit: LoopUnit.millimolesPerLiter, dailyItems: [ RepeatingScheduleValue(startTime: 0.0, - value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: value).doubleValue(for: .millimolesPerLiter)) + value: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: value).doubleValue(for: .millimolesPerLiter)) ], timeZone: .utcTimeZone )! default: return InsulinSensitivitySchedule( - unit: HKUnit.milligramsPerDeciliter, + unit: .milligramsPerDeciliter, dailyItems: [ RepeatingScheduleValue(startTime: 0.0, value: value) ], @@ -180,65 +181,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: LoopQuantity(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] { @@ -248,32 +286,11 @@ class MealDetectionManagerTests: XCTestCase { return fixture.map { 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), + quantity: LoopQuantity(unit: LoopUnit(from: $0["unit"] as! String), doubleValue:$0["value"] as! Double)) } } - 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 +299,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 +429,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 +444,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 +455,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 +469,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 +479,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 +490,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 +500,22 @@ 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, + decisionId: nil, + 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,106 @@ 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, + decisionId: nil, + 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, + decisionId: nil, + 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..77b5a6c27c 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 = [:] @@ -43,10 +44,11 @@ class SupportManagerTests: XCTestCase { func loopWillReset() {} func loopDidReset() {} func configurationMenuItems() -> [LoopKitUI.CustomMenuItem] { return [] } + func trainingMedia(for domain: TrainingMediaDomain) -> [MediaContent] { [] } } 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 = [:] @@ -55,6 +57,7 @@ class SupportManagerTests: XCTestCase { func loopWillReset() {} func loopDidReset() {} func configurationMenuItems() -> [LoopKitUI.CustomMenuItem] { return [] } + func trainingMedia(for domain: TrainingMediaDomain) -> [MediaContent] { [] } } class MockAlertIssuer: AlertIssuer { @@ -66,14 +69,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 +90,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..8c8392c97e --- /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 + +@MainActor +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.effectiveCorrectionRangeSchedule()?.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.effectiveCorrectionRangeSchedule(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: TemporaryPresetSettings( + unit: .milligramsPerDeciliter, + targetRange: overrideTargetRange + ), + startDate: overrideStart, + duration: .finite(3 /* hours */ * 60 * 60), + enactTrigger: .local, + syncIdentifier: UUID() + ) + manager.scheduleOverride = override + let actualOverrideRange = manager.effectiveCorrectionRangeSchedule()?.value(at: overrideStart.addingTimeInterval(30 /* minutes */ * 60)) + XCTAssertEqual(actualOverrideRange, overrideTargetRange) + } + + func testScheduleOverrideWithExpiredPreMealOverride() { + manager.scheduleOverride = TemporaryScheduleOverride( + context: .preMeal, + settings: TemporaryPresetSettings(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: TemporaryPresetSettings( + unit: .milligramsPerDeciliter, + targetRange: overrideTargetRange + ), + startDate: overrideStart, + duration: .finite(3 /* hours */ * 60 * 60), + enactTrigger: .local, + syncIdentifier: UUID() + ) + manager.scheduleOverride = override + + let actualOverrideRange = manager.effectiveCorrectionRangeSchedule()?.value(at: overrideStart.addingTimeInterval(2 /* hours */ * 60 * 60)) + XCTAssertEqual(actualOverrideRange, overrideTargetRange) + } +} diff --git a/LoopTests/Mock Stores/HKHealthStoreMock.swift b/LoopTests/Mock Stores/HKHealthStoreMock.swift index 6f8127d3e4..40d5fb5899 100644 --- a/LoopTests/Mock Stores/HKHealthStoreMock.swift +++ b/LoopTests/Mock Stores/HKHealthStoreMock.swift @@ -11,7 +11,7 @@ import Foundation import LoopKit -class HKHealthStoreMock: HKHealthStore { +class HKHealthStoreMock: HKHealthStore, @unchecked Sendable { var saveError: Error? var deleteError: Error? var queryResults: (samples: [HKSample]?, error: Error?)? diff --git a/LoopTests/Mock Stores/MockCarbStore.swift b/LoopTests/Mock Stores/MockCarbStore.swift index 4a5c016eb5..cce5401699 100644 --- a/LoopTests/Mock Stores/MockCarbStore.swift +++ b/LoopTests/Mock Stores/MockCarbStore.swift @@ -6,172 +6,39 @@ // Copyright © 2020 LoopKit Authors. All rights reserved. // -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..a53d30afd4 100644 --- a/LoopTests/Mock Stores/MockDosingDecisionStore.swift +++ b/LoopTests/Mock Stores/MockDosingDecisionStore.swift @@ -7,13 +7,42 @@ // 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, [])) + } + } + + func findDosingDecisionsById(_ id: UUID) async throws -> D? { + nil + } + + func findDosingDecisionsByIds(_ ids: [UUID]) async throws -> [D] { + [] } } diff --git a/LoopTests/Mock Stores/MockGlucoseStore.swift b/LoopTests/Mock Stores/MockGlucoseStore.swift index 19a6bc22e8..eb4c488841 100644 --- a/LoopTests/Mock Stores/MockGlucoseStore.swift +++ b/LoopTests/Mock Stores/MockGlucoseStore.swift @@ -6,110 +6,27 @@ // Copyright © 2020 LoopKit Authors. All rights reserved. // -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 +40,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..aed3174b1a --- /dev/null +++ b/LoopTests/Mocks/AlertMocks.swift @@ -0,0 +1,204 @@ +// +// 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 +import XCTest + +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? + + var alertScheduledExpectation: XCTestExpectation? + var alertUnscheduledExpectation: XCTestExpectation? + + override func scheduleAlert(_ alert: Alert) { + scheduledAlert = alert + alertScheduledExpectation?.fulfill() + } + var unscheduledAlertIdentifier: Alert.Identifier? + + override func unscheduleAlert(identifier: Alert.Identifier) async { + unscheduledAlertIdentifier = identifier + alertUnscheduledExpectation?.fulfill() + } +} + +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: LoopKit.Alert.AlertIdentifier) async throws { + 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 { + var presentedViewController: UIViewController? + + func present(_ viewControllerToPresent: UIViewController, animated flag: Bool) async { + presentedViewController = viewControllerToPresent + } + func dismissTopMost(animated: Bool) async { + presentedViewController = nil + } + func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool) async { + presentedViewController = nil + } +} + +class MockAlertManagerResponder: AlertManagerResponder { + func userDidSelectAction(alertIdentifier: LoopKit.Alert.Identifier, actionIdentifier: String) async throws { } + func acknowledgeAlert(identifier: LoopKit.Alert.Identifier) async { } +} + +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()) async { + issuedAlert = alert + } + + var retractedAlert: Alert? + var retractedAlertDate: Date? + override public func recordRetractedAlert(_ alert: Alert, at date: Date) async throws { + retractedAlert = alert + retractedAlertDate = date + } + + var acknowledgedAlertIdentifier: Alert.Identifier? + var acknowledgedAlertDate: Date? + override public func recordAcknowledgement(of identifier: Alert.Identifier, at date: Date = Date()) async throws { + acknowledgedAlertIdentifier = identifier + acknowledgedAlertDate = date + } + + var retractededAlertIdentifier: Alert.Identifier? + override public func recordRetraction(of identifier: Alert.Identifier, at date: Date = Date()) async throws { + retractededAlertIdentifier = identifier + retractedAlertDate = date + } + + var storedAlerts = [StoredAlert]() + override public func lookupAllUnacknowledgedUnretracted(managerIdentifier: String? = nil, filteredByTriggers triggersStoredType: [AlertTriggerStoredType]? = nil) async throws -> [StoredAlert] + { + return storedAlerts + } + + override public func lookupAllUnretracted(managerIdentifier: String?) async -> [StoredAlert] { + return 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..af720c315a --- /dev/null +++ b/LoopTests/Mocks/LoopControlMock.swift @@ -0,0 +1,33 @@ +// +// LoopControlMock.swift +// LoopTests +// +// Created by Pete Schwamb on 11/30/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import XCTest +import Foundation +import LoopAlgorithm +import LoopKit +@testable import Loop + + +class LoopControlMock: LoopControl { + var automatedTreatmentState: AutomatedTreatmentState? + + 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..756a6ba765 --- /dev/null +++ b/LoopTests/Mocks/MockCGMManager.swift @@ -0,0 +1,65 @@ +// +// 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 inSignalLoss: Bool = false + + var isInoperable: Bool = false + + 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: Alert.AlertIdentifier) async throws { } + + 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..50a9844f83 --- /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: (bolus: Double?, tempBasal: TempBasalRecommendation?) + + func enact(bolus: Double?, tempBasal: TempBasalRecommendation?, decisionId: UUID?) async throws { + lastEnact = (bolus, tempBasal) + } + + var lastBolus: Double? + var lastBolusActivationType: BolusActivationType? + + func enactBolus(units: Double, decisionId: UUID?, 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..2010d141cb --- /dev/null +++ b/LoopTests/Mocks/MockPumpManager.swift @@ -0,0 +1,143 @@ +// +// 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 inSignalLoss: Bool = false + + var isInoperable: Bool = false + + 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(decisionId: UUID?, units: Double, activationType: BolusActivationType, completion: @escaping (PumpManagerError?) -> Void) { + enactBolusCalled?(units, activationType) + completion(nil) + } + + func cancelBolus(completion: @escaping (PumpManagerResult) -> Void) { + completion(.success(nil)) + } + + func enactTempBasal(decisionId: UUID?, 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) async throws { } + + 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..7f9774ccee --- /dev/null +++ b/LoopTests/Mocks/MockSettingsProvider.swift @@ -0,0 +1,56 @@ +// +// MockSettingsProvider.swift +// LoopTests +// +// Created by Pete Schwamb on 11/28/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import LoopAlgorithm +@testable import Loop + +@Observable +class MockSettingsProvider: SettingsProvider { + var dosingEnabled: Bool = false + + 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, dosingEnabled: Bool = true) { + self.settings = settings + self.dosingEnabled = dosingEnabled + } +} 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/CarbBackfillRequestUserInfoTests.swift b/LoopTests/Models/CarbBackfillRequestUserInfoTests.swift index e794194333..13bce59548 100644 --- a/LoopTests/Models/CarbBackfillRequestUserInfoTests.swift +++ b/LoopTests/Models/CarbBackfillRequestUserInfoTests.swift @@ -7,7 +7,7 @@ // import XCTest - +@testable import LoopCore @testable import Loop class CarbBackfillRequestUserInfoTests: XCTestCase { diff --git a/LoopTests/Models/SetBolusUserInfoTests.swift b/LoopTests/Models/SetBolusUserInfoTests.swift index a486abff15..05c9d51838 100644 --- a/LoopTests/Models/SetBolusUserInfoTests.swift +++ b/LoopTests/Models/SetBolusUserInfoTests.swift @@ -7,8 +7,9 @@ // import XCTest -import HealthKit +import LoopAlgorithm import LoopKit +import LoopCore @testable import Loop class SetBolusUserInfoTests: XCTestCase { @@ -16,7 +17,7 @@ class SetBolusUserInfoTests: XCTestCase { private var startDate = dateFormatter.date(from: "2020-05-14T22:45:00Z")! private var contextDate = dateFormatter.date(from: "2020-05-14T22:38:14Z")! private var carbEntry = NewCarbEntry(date: dateFormatter.date(from: "2020-05-14T22:39:34Z")!, - quantity: HKQuantity(unit: .gram(), doubleValue: 17), + quantity: LoopQuantity(unit: .gram, doubleValue: 17), startDate: dateFormatter.date(from: "2020-05-14T22:00:00Z")!, foodType: "Pizza", absorptionTime: .hours(5)) @@ -99,7 +100,7 @@ class SetBolusUserInfoTests: XCTestCase { XCTAssertEqual(rawValue["at"] as? BolusActivationType.RawValue, activationType.rawValue) let carbEntryRawValue = rawValue["ce"] as? NewCarbEntry.RawValue XCTAssertEqual(carbEntryRawValue?["date"] as? Date, carbEntry.date) - XCTAssertEqual(carbEntryRawValue?["grams"] as? Double, carbEntry.quantity.doubleValue(for: .gram())) + XCTAssertEqual(carbEntryRawValue?["grams"] as? Double, carbEntry.quantity.doubleValue(for: .gram)) XCTAssertEqual(carbEntryRawValue?["startDate"] as? Date, carbEntry.startDate) XCTAssertEqual(carbEntryRawValue?["foodType"] as? String, carbEntry.foodType) XCTAssertEqual(carbEntryRawValue?["absorptionTime"] as? TimeInterval, carbEntry.absorptionTime) diff --git a/LoopTests/Models/SimpleBolusCalculatorTests.swift b/LoopTests/Models/SimpleBolusCalculatorTests.swift index 069cb54368..e143bfea83 100644 --- a/LoopTests/Models/SimpleBolusCalculatorTests.swift +++ b/LoopTests/Models/SimpleBolusCalculatorTests.swift @@ -7,9 +7,8 @@ // import Foundation - import XCTest -import HealthKit +import LoopAlgorithm import LoopKit @testable import Loop @@ -22,128 +21,128 @@ class SimpleBolusCalculatorTests: XCTestCase { RepeatingScheduleValue(startTime: 0, value: DoubleRange(minValue: 100.0, maxValue: 110.0)) ])! - let carbRatioSchedule = CarbRatioSchedule(unit: .gram(), dailyItems: [RepeatingScheduleValue(startTime: 0, value: 10)])! + let carbRatioSchedule = CarbRatioSchedule(unit: .gram, dailyItems: [RepeatingScheduleValue(startTime: 0, value: 10)])! let sensitivitySchedule = InsulinSensitivitySchedule(unit: .milligramsPerDeciliter, dailyItems: [RepeatingScheduleValue(startTime: .hours(0), value: 80)])! func testMealRecommendation() { let recommendation = SimpleBolusCalculator.recommendedInsulin( - mealCarbs: HKQuantity(unit: .gram(), doubleValue: 40), + mealCarbs: LoopQuantity(unit: .gram, doubleValue: 40), manualGlucose: nil, - activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 0), + activeInsulin: LoopQuantity(unit: .internationalUnit, doubleValue: 0), carbRatioSchedule: carbRatioSchedule, correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule) - XCTAssertEqual(4.0, recommendation.doubleValue(for: .internationalUnit())) + XCTAssertEqual(4.0, recommendation.doubleValue(for: .internationalUnit)) } func testCorrectionRecommendation() { let recommendation = SimpleBolusCalculator.recommendedInsulin( mealCarbs: nil, - manualGlucose: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), - activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 0), + manualGlucose: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), + activeInsulin: LoopQuantity(unit: .internationalUnit, doubleValue: 0), carbRatioSchedule: carbRatioSchedule, correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule) - XCTAssertEqual(0.94, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + XCTAssertEqual(0.94, recommendation.doubleValue(for: .internationalUnit), accuracy: 0.01) } func testCorrectionRecommendationWithIOB() { let recommendation = SimpleBolusCalculator.recommendedInsulin( mealCarbs: nil, - manualGlucose: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), - activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 10), + manualGlucose: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), + activeInsulin: LoopQuantity(unit: .internationalUnit, doubleValue: 10), carbRatioSchedule: carbRatioSchedule, correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule) - XCTAssertEqual(0.0, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + XCTAssertEqual(0.0, recommendation.doubleValue(for: .internationalUnit), accuracy: 0.01) } func testCorrectionRecommendationWithNegativeIOB() { let recommendation = SimpleBolusCalculator.recommendedInsulin( mealCarbs: nil, - manualGlucose: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), - activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: -1), + manualGlucose: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), + activeInsulin: LoopQuantity(unit: .internationalUnit, doubleValue: -1), carbRatioSchedule: carbRatioSchedule, correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule) - XCTAssertEqual(0.94, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + XCTAssertEqual(0.94, recommendation.doubleValue(for: .internationalUnit), accuracy: 0.01) } func testCorrectionRecommendationWhenInRange() { let recommendation = SimpleBolusCalculator.recommendedInsulin( mealCarbs: nil, - manualGlucose: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 110), - activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 0), + manualGlucose: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 110), + activeInsulin: LoopQuantity(unit: .internationalUnit, doubleValue: 0), carbRatioSchedule: carbRatioSchedule, correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule) - XCTAssertEqual(0.0, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + XCTAssertEqual(0.0, recommendation.doubleValue(for: .internationalUnit), accuracy: 0.01) } func testCorrectionAndCarbsRecommendationWhenBelowRange() { let recommendation = SimpleBolusCalculator.recommendedInsulin( - mealCarbs: HKQuantity(unit: .gram(), doubleValue: 40), - manualGlucose: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 70), - activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 0), + mealCarbs: LoopQuantity(unit: .gram, doubleValue: 40), + manualGlucose: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 70), + activeInsulin: LoopQuantity(unit: .internationalUnit, doubleValue: 0), carbRatioSchedule: carbRatioSchedule, correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule) - XCTAssertEqual(3.56, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + XCTAssertEqual(3.56, recommendation.doubleValue(for: .internationalUnit), accuracy: 0.01) } func testCarbsEntryWithActiveInsulinAndNoGlucose() { let recommendation = SimpleBolusCalculator.recommendedInsulin( - mealCarbs: HKQuantity(unit: .gram(), doubleValue: 20), + mealCarbs: LoopQuantity(unit: .gram, doubleValue: 20), manualGlucose: nil, - activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 4), + activeInsulin: LoopQuantity(unit: .internationalUnit, doubleValue: 4), carbRatioSchedule: carbRatioSchedule, correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule) - XCTAssertEqual(2, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + XCTAssertEqual(2, recommendation.doubleValue(for: .internationalUnit), accuracy: 0.01) } func testCarbsEntryWithActiveInsulinAndCarbsAndNoCorrection() { let recommendation = SimpleBolusCalculator.recommendedInsulin( - mealCarbs: HKQuantity(unit: .gram(), doubleValue: 20), - manualGlucose: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 100), - activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 4), + mealCarbs: LoopQuantity(unit: .gram, doubleValue: 20), + manualGlucose: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 100), + activeInsulin: LoopQuantity(unit: .internationalUnit, doubleValue: 4), carbRatioSchedule: carbRatioSchedule, correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule) - XCTAssertEqual(2, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + XCTAssertEqual(2, recommendation.doubleValue(for: .internationalUnit), accuracy: 0.01) } func testPredictionShouldBeZeroWhenGlucoseBelowMealBolusRecommendationLimit() { let recommendation = SimpleBolusCalculator.recommendedInsulin( - mealCarbs: HKQuantity(unit: .gram(), doubleValue: 20), - manualGlucose: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 54), - activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 4), + mealCarbs: LoopQuantity(unit: .gram, doubleValue: 20), + manualGlucose: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 54), + activeInsulin: LoopQuantity(unit: .internationalUnit, doubleValue: 4), carbRatioSchedule: carbRatioSchedule, correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule) - XCTAssertEqual(0, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + XCTAssertEqual(0, recommendation.doubleValue(for: .internationalUnit), accuracy: 0.01) } func testPredictionShouldBeZeroWhenGlucoseBelowBolusRecommendationLimit() { let recommendation = SimpleBolusCalculator.recommendedInsulin( mealCarbs: nil, - manualGlucose: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 69), - activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 4), + manualGlucose: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 69), + activeInsulin: LoopQuantity(unit: .internationalUnit, doubleValue: 4), carbRatioSchedule: carbRatioSchedule, correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule) - XCTAssertEqual(0, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + XCTAssertEqual(0, recommendation.doubleValue(for: .internationalUnit), accuracy: 0.01) } } 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/Models/WatchHistoricalGlucoseTest.swift b/LoopTests/Models/WatchHistoricalGlucoseTest.swift index 4478af76f7..6d0f85be19 100644 --- a/LoopTests/Models/WatchHistoricalGlucoseTest.swift +++ b/LoopTests/Models/WatchHistoricalGlucoseTest.swift @@ -8,6 +8,7 @@ import XCTest import HealthKit +import LoopAlgorithm import LoopKit @testable import Loop @@ -20,7 +21,7 @@ class WatchHistoricalGlucoseTests: XCTestCase { syncIdentifier: UUID().uuidString, syncVersion: 4, startDate: Date(timeIntervalSinceReferenceDate: .hours(100)), - quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 123.45), + quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 123.45), condition: nil, trend: nil, trendRate: nil, @@ -33,10 +34,10 @@ class WatchHistoricalGlucoseTests: XCTestCase { syncIdentifier: UUID().uuidString, syncVersion: 2, startDate: Date(timeIntervalSinceReferenceDate: .hours(99)), - quantity: HKQuantity(unit: .millimolesPerLiter, doubleValue: 7.2), + quantity: LoopQuantity(unit: .millimolesPerLiter, doubleValue: 7.2), condition: nil, trend: .up, - trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 1.0), + trendRate: LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 1.0), isDisplayOnly: true, wasUserEntered: false, device: device, @@ -46,10 +47,10 @@ class WatchHistoricalGlucoseTests: XCTestCase { syncIdentifier: nil, syncVersion: nil, startDate: Date(timeIntervalSinceReferenceDate: .hours(98)), - quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 187.65), + quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 187.65), condition: .aboveRange, trend: .downDownDown, - trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -4.0), + trendRate: LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -4.0), isDisplayOnly: false, wasUserEntered: false, device: nil, diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index 7f2c421ebf..6b70654c55 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,26 +23,26 @@ 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 let exampleManualGlucoseQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 123.4) + static fileprivate let exampleGlucoseValue = SimpleGlucoseValue(startDate: exampleStartDate, quantity: exampleManualGlucoseQuantity) + static let exampleManualGlucoseQuantity = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 123.4) static let exampleManualGlucoseSample = HKQuantitySample(type: HKQuantityType.quantityType(forIdentifier: .bloodGlucose)!, - quantity: exampleManualGlucoseQuantity, + quantity: exampleManualGlucoseQuantity.hkQuantity, start: exampleStartDate, end: exampleEndDate) static let exampleManualStoredGlucoseSample = StoredGlucoseSample(sample: exampleManualGlucoseSample) - static let exampleCGMGlucoseQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 100.4) + static let exampleCGMGlucoseQuantity = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 100.4) static let exampleCGMGlucoseSample = - HKQuantitySample(type: HKQuantityType.quantityType(forIdentifier: .bloodGlucose)!, - quantity: exampleCGMGlucoseQuantity, + HKQuantitySample(type: HKQuantityType.quantityType(forIdentifier: .bloodGlucose)!, + quantity: exampleCGMGlucoseQuantity.hkQuantity, start: exampleStartDate, end: exampleEndDate) - static let exampleCarbQuantity = HKQuantity(unit: .gram(), doubleValue: 234.5) + static let exampleCarbQuantity = LoopQuantity(unit: .gram, doubleValue: 234.5) - static let exampleBolusQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: 1.0) - static let noBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0) + static let exampleBolusQuantity = LoopQuantity(unit: .internationalUnit, doubleValue: 1.0) + static let noBolus = LoopQuantity(unit: .internationalUnit, doubleValue: 0.0) static let exampleGlucoseRangeSchedule = GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: [ RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 100, maxValue: 110)), @@ -50,14 +52,14 @@ class BolusEntryViewModelTests: XCTestCase { static let mockUUID = UUID() - static let exampleScheduleOverrideSettings = TemporaryScheduleOverrideSettings(unit: .millimolesPerLiter, targetRange: nil, insulinNeedsScaleFactor: nil) + static let exampleScheduleOverrideSettings = TemporaryPresetSettings(unit: .millimolesPerLiter, targetRange: nil, insulinNeedsScaleFactor: nil) static let examplePreMealOverride = TemporaryScheduleOverride(context: .preMeal, settings: exampleScheduleOverrideSettings, startDate: exampleStartDate, duration: .indefinite, enactTrigger: .local, syncIdentifier: mockUUID) static let exampleCustomScheduleOverride = TemporaryScheduleOverride(context: .custom, settings: exampleScheduleOverrideSettings, startDate: exampleStartDate, duration: .indefinite, enactTrigger: .local, syncIdentifier: mockUUID) 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() @@ -111,7 +115,9 @@ class BolusEntryViewModelTests: XCTestCase { selectedCarbAbsorptionTimeEmoji: selectedCarbAbsorptionTimeEmoji) bolusEntryViewModel.authenticationHandler = { _ in return true } - bolusEntryViewModel.maximumBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 10) + bolusEntryViewModel.maximumBolus = LoopQuantity(unit: .internationalUnit, doubleValue: 10) + + bolusEntryViewModel.deliveryDelegate = mockDeliveryDelegate await bolusEntryViewModel.generateRecommendationAndStartObserving() } @@ -128,8 +134,8 @@ class BolusEntryViewModelTests: XCTestCase { XCTAssertFalse(bolusEntryViewModel.isManualGlucoseEntryEnabled) XCTAssertNil(bolusEntryViewModel.manualGlucoseQuantity) - XCTAssertEqual(HKQuantity(unit: .internationalUnit(), doubleValue: 0), bolusEntryViewModel.recommendedBolus) - XCTAssertEqual(HKQuantity(unit: .internationalUnit(), doubleValue: 0), bolusEntryViewModel.enteredBolus) + XCTAssertEqual(LoopQuantity(unit: .internationalUnit, doubleValue: 0), bolusEntryViewModel.recommendedBolus) + XCTAssertEqual(LoopQuantity(unit: .internationalUnit, doubleValue: 0), bolusEntryViewModel.enteredBolus) XCTAssertNil(bolusEntryViewModel.activeAlert) XCTAssertNil(bolusEntryViewModel.activeNotice) @@ -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) }) } @@ -187,26 +184,30 @@ class BolusEntryViewModelTests: XCTestCase { func testManualEntryClearsEnteredBolus() throws { bolusEntryViewModel.enteredBolus = Self.exampleBolusQuantity bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity - XCTAssertEqual(HKQuantity(unit: .internationalUnit(), doubleValue: 0), bolusEntryViewModel.enteredBolus) + XCTAssertEqual(LoopQuantity(unit: .internationalUnit, doubleValue: 0), bolusEntryViewModel.enteredBolus) } 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, presumePresetEndingNow: 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, presumePresetEndingNow: 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()) + let settings = TemporaryPresetSettings(unit: .millimolesPerLiter, targetRange: nil, insulinNeedsScaleFactor: nil) + 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,114 +246,121 @@ 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) + XCTAssertEqual(LoopQuantity(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(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit)) + + 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 XCTAssertNotNil(recommendedBolus) - XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit())) + XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit)) XCTAssertEqual(BolusEntryViewModel.Notice.predictedGlucoseBelowSuspendThreshold(suspendThreshold: Self.exampleCGMGlucoseQuantity), bolusEntryViewModel.activeNotice) } 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 XCTAssertNotNil(recommendedBolus) - XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit())) + XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit)) XCTAssertNil(bolusEntryViewModel.activeNotice) } 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 XCTAssertNotNil(recommendedBolus) - XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit())) + XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit)) XCTAssertNil(bolusEntryViewModel.activeNotice) } 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 = LoopQuantity(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(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit)) + + XCTAssertEqual(delegate.potentialCarbEntryForBolusRecommendation, editedCarbEntry) + XCTAssertEqual(delegate.originalCarbEntryForBolusRecommendation, originalCarbEntry) + XCTAssertEqual(delegate.manualGlucoseSampleForBolusRecommendation?.quantity, manualGlucoseQuantity) + XCTAssertNil(bolusEntryViewModel.activeNotice) } @@ -437,7 +456,7 @@ class BolusEntryViewModelTests: XCTestCase { } func testBolusTooSmall() async throws { - bolusEntryViewModel.enteredBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0.01) + bolusEntryViewModel.enteredBolus = LoopQuantity(unit: .internationalUnit, doubleValue: 0.01) let success = await bolusEntryViewModel.saveAndDeliver() XCTAssertEqual(.bolusTooSmall, bolusEntryViewModel.activeAlert) XCTAssertNil(delegate.enactedBolusUnits) @@ -497,7 +516,7 @@ class BolusEntryViewModelTests: XCTestCase { XCTAssertTrue(delegate.bolusDosingDecisionsAdded.isEmpty) } - private func saveAndDeliver(_ bolus: HKQuantity, file: StaticString = #file, line: UInt = #line) async throws { + private func saveAndDeliver(_ bolus: LoopQuantity, file: StaticString = #file, line: UInt = #line) async throws { bolusEntryViewModel.enteredBolus = bolus self.saveAndDeliverSuccess = await bolusEntryViewModel.saveAndDeliver() @@ -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,160 @@ 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?, presumePresetEndingNow: Bool, ensureDosingCoverageStart: Date?) async throws -> StoredDataAlgorithmInput { + loopStateInput.predictionStart = baseTime ?? Date() + 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.asStoredGlucoseSample + } } 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, decisionId: UUID?, 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( + originalCarbEntry: StoredCarbEntry?, + potentialCarbEntry: NewCarbEntry?, + potentialDose: SimpleInsulinDose?, + manualGlucose: NewGlucoseSample? + ) async throws -> (historicGlucose: [StoredGlucoseSample], predictedGlucose: [PredictedGlucoseValue]) { + return (historicGlucose: loopStateInput.glucoseHistory, predictedGlucose: 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?, + truncatingActiveOverride: Bool + ) 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 { @@ -975,7 +976,7 @@ fileprivate struct MockInsulinModel: InsulinModel { } fileprivate struct MockGlucoseValue: GlucoseValue { - var quantity: HKQuantity + var quantity: LoopQuantity var startDate: Date } @@ -985,7 +986,7 @@ fileprivate extension TimeInterval { } } -extension BolusDosingDecision: Equatable { +extension BolusDosingDecision: @retroactive Equatable { init(for reason: Reason, originalCarbEntry: StoredCarbEntry? = nil, carbEntry: StoredCarbEntry? = nil, manualGlucoseSample: StoredGlucoseSample? = nil, manualBolusRequested: Double? = nil) { self.init(for: reason) self.originalCarbEntry = originalCarbEntry @@ -1007,8 +1008,45 @@ extension BolusDosingDecision: Equatable { } } -extension ManualBolusRecommendationWithDate: Equatable { +extension ManualBolusRecommendationWithDate: @retroactive Equatable { public static func == (lhs: ManualBolusRecommendationWithDate, rhs: ManualBolusRecommendationWithDate) -> Bool { 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..8f9bd9d3ac 100644 --- a/LoopTests/ViewModels/CGMStatusHUDViewModelTests.swift +++ b/LoopTests/ViewModels/CGMStatusHUDViewModelTests.swift @@ -7,19 +7,16 @@ // import XCTest -import HealthKit +import LoopAlgorithm import LoopKit @testable import LoopUI 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 { @@ -41,18 +38,17 @@ class CGMStatusHUDViewModelTests: XCTestCase { func testSetGlucoseQuantityCGM() { let glucoseDisplay = TestGlucoseDisplay(isStateValid: true, trendType: .down, - trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), + trendRate: LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), 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) @@ -60,24 +56,23 @@ class CGMStatusHUDViewModelTests: XCTestCase { XCTAssertEqual(viewModel.trend, .down) XCTAssertEqual(viewModel.glucoseTrendTintColor, glucoseDisplay.glucoseRangeCategory?.trendColor) XCTAssertEqual(viewModel.glucoseValueTintColor, glucoseDisplay.glucoseRangeCategory?.glucoseColor) - XCTAssertEqual(viewModel.unitsString, HKUnit.milligramsPerDeciliter.localizedShortUnitString) + XCTAssertEqual(viewModel.unitsString, LoopUnit.milligramsPerDeciliter.localizedShortUnitString) } func testSetGlucoseQuantityCGMStale() { let glucoseDisplay = TestGlucoseDisplay(isStateValid: true, trendType: .down, - trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), + trendRate: LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), 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) @@ -87,53 +82,23 @@ class CGMStatusHUDViewModelTests: XCTestCase { XCTAssertEqual(viewModel.glucoseTrendTintColor, .glucoseTintColor) XCTAssertNotEqual(viewModel.glucoseValueTintColor, glucoseDisplay.glucoseRangeCategory?.glucoseColor) XCTAssertEqual(viewModel.glucoseValueTintColor, .label) - 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) + XCTAssertEqual(viewModel.unitsString, LoopUnit.milligramsPerDeciliter.localizedShortUnitString) } func testSetGlucoseQuantityManualGlucose() { let glucoseDisplay = TestGlucoseDisplay(isStateValid: true, trendType: .down, - trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), + trendRate: LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), 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) @@ -142,31 +107,30 @@ class CGMStatusHUDViewModelTests: XCTestCase { XCTAssertNotEqual(viewModel.glucoseTrendTintColor, glucoseDisplay.glucoseRangeCategory?.trendColor) XCTAssertEqual(viewModel.glucoseTrendTintColor, .glucoseTintColor) XCTAssertEqual(viewModel.glucoseValueTintColor, glucoseDisplay.glucoseRangeCategory?.glucoseColor) - XCTAssertEqual(viewModel.unitsString, HKUnit.milligramsPerDeciliter.localizedShortUnitString) + XCTAssertEqual(viewModel.unitsString, LoopUnit.milligramsPerDeciliter.localizedShortUnitString) } func testSetGlucoseQuantityCalibrationDoesNotShow() { let glucoseDisplay = TestGlucoseDisplay(isStateValid: true, trendType: .down, - trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), + trendRate: LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), 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") XCTAssertEqual(viewModel.trend, .down) XCTAssertEqual(viewModel.glucoseTrendTintColor, glucoseDisplay.glucoseRangeCategory?.trendColor) XCTAssertEqual(viewModel.glucoseValueTintColor, glucoseDisplay.glucoseRangeCategory?.glucoseColor) - XCTAssertEqual(viewModel.unitsString, HKUnit.milligramsPerDeciliter.localizedShortUnitString) + XCTAssertEqual(viewModel.unitsString, LoopUnit.milligramsPerDeciliter.localizedShortUnitString) } func testSetManualGlucoseIconOverride() { @@ -187,18 +151,17 @@ class CGMStatusHUDViewModelTests: XCTestCase { // when there is a manual glucose override icon, the status highlight isn't returned to be presented let glucoseDisplay = TestGlucoseDisplay(isStateValid: true, trendType: .down, - trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), + trendRate: LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), 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) @@ -219,17 +182,16 @@ class CGMStatusHUDViewModelTests: XCTestCase { // add manual glucose let glucoseDisplay = TestGlucoseDisplay(isStateValid: true, trendType: .down, - trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), + trendRate: LoopQuantity(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 @@ -337,7 +294,7 @@ extension CGMStatusHUDViewModelTests { var trendType: GlucoseTrend? - var trendRate: HKQuantity? + var trendRate: LoopQuantity? var isLocal: Bool diff --git a/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift b/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift index 55104e5a1b..4eaa0fbb3b 100644 --- a/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift +++ b/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift @@ -6,12 +6,14 @@ // Copyright © 2021 LoopKit Authors. All rights reserved. // -import HealthKit import LoopCore import LoopKit import XCTest +import LoopAlgorithm + @testable import Loop +@MainActor class ManualEntryDoseViewModelTests: XCTestCase { static let now = Date.distantFuture @@ -20,17 +22,10 @@ class ManualEntryDoseViewModelTests: XCTestCase { var manualEntryDoseViewModel: ManualEntryDoseViewModel! - static let exampleBolusQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: 1.0) + static let exampleBolusQuantity = LoopQuantity(unit: .internationalUnit, doubleValue: 1.0) - 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 - } + static let noBolus = LoopQuantity(unit: .internationalUnit, doubleValue: 0.0) - var saveAndDeliverSuccess = false - fileprivate var delegate: MockManualEntryDoseViewModelDelegate! static let mockUUID = UUID() @@ -39,100 +34,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) - XCTAssertEqual(delegate.manualEntryBolusUnits, Self.exampleBolusQuantity.doubleValue(for: .internationalUnit())) + 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..0701b66117 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,15 +83,9 @@ 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(20, addedCarbEntry?.quantity.doubleValue(for: .gram)) XCTAssertEqual(180, addedGlucose.first?.quantity.doubleValue(for: .milligramsPerDeciliter)) XCTAssertEqual(2.5, enactedBolus?.units) @@ -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,15 +110,9 @@ 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())) + XCTAssertEqual(20, addedCarbEntry?.quantity.doubleValue(for: .gram)) XCTAssertEqual(0.1, enactedBolus?.units) @@ -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.asStoredGlucoseSample } - - 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, decisionId: UUID?, 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? { - + + + func computeSimpleBolusRecommendation(at date: Date, mealCarbs: LoopQuantity?, manualGlucose: LoopQuantity?) -> 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 { - return HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 80) + var suspendThreshold: LoopQuantity? { + return LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 80) } } diff --git a/LoopUI/Extensions/Color.swift b/LoopUI/Extensions/Color.swift index f12a2ba746..dbc6bd1fdd 100644 --- a/LoopUI/Extensions/Color.swift +++ b/LoopUI/Extensions/Color.swift @@ -10,18 +10,30 @@ import SwiftUI // MARK: - Color palette for common elements extension Color { - static let carbs = Color("carbs") + public static let carbs = Color("carbs") - static let fresh = Color("fresh") + public static let fresh = Color("fresh") - static let glucose = Color("glucose") - - static let insulin = Color("insulin") + public static let glucose = Color("glucose") + + public static let insulin = Color("insulin") + + public static let presets = Color("presets") // The loopAccent color is intended to be use as the app accent color. public static let loopAccent = Color("accent") public static let warning = Color("warning") + + public static let glucoseVeryHigh = Color("glucose-very-high") + + public static let glucoseHigh = Color("glucose-high") + + public static let glucoseNormal = Color("glucose-normal") + + public static let glucoseLow = Color("glucose-low") + + public static let glucoseVeryLow = Color("glucose-very-low") } diff --git a/LoopUI/Extensions/UIColor.swift b/LoopUI/Extensions/UIColor.swift index db638084f8..9f6885a536 100644 --- a/LoopUI/Extensions/UIColor.swift +++ b/LoopUI/Extensions/UIColor.swift @@ -18,6 +18,8 @@ extension UIColor { @nonobjc static let insulin = UIColor(named: "insulin") ?? systemOrange + @nonobjc public static let presets = UIColor(named: "presets") ?? systemTeal + // The loopAccent color is intended to be use as the app accent color. @nonobjc public static let loopAccent = UIColor(named: "accent") ?? systemBlue diff --git a/LoopUI/HUDView.xib b/LoopUI/HUDView.xib index 9da3d7b032..7092498a70 100644 --- a/LoopUI/HUDView.xib +++ b/LoopUI/HUDView.xib @@ -1,9 +1,8 @@ - + - - + @@ -74,13 +73,13 @@ - + @@ -154,8 +153,8 @@ - + diff --git a/LoopUI/Localizable.xcstrings b/LoopUI/Localizable.xcstrings index a21b1ce012..5d88af1067 100644 --- a/LoopUI/Localizable.xcstrings +++ b/LoopUI/Localizable.xcstrings @@ -3,6 +3,7 @@ "strings" : { "\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)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -80,6 +81,7 @@ }, "\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)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -157,6 +159,7 @@ }, "\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)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -234,6 +237,7 @@ }, "\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)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -309,6 +313,12 @@ } } }, + " %@ ago" : { + "comment" : "Format string describing the time interval since the last completion date, last cgm or last pump communication. (1: The localized date components" + }, + " >%@ ago" : { + "comment" : "Format string describing the time interval since the last completion date, last cgm or last pump communication. (1: The localized date components" + }, "– – –" : { "comment" : "No glucose value representation (3 dashes for mg/dL)", "localizations" : { @@ -442,6 +452,7 @@ }, "%@ ago" : { "comment" : "Format string describing the time interval since the last completion date. (1: The localized date components", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -965,6 +976,7 @@ }, "%1$@ units per hour at %2$@" : { "comment" : "Accessibility format string describing the basal rate. (1: localized basal rate value)(2: last updated time)", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -1531,6 +1543,7 @@ }, "Closed Loop OFF" : { "comment" : "Title of green open loop OFF message", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -1620,6 +1633,7 @@ }, "Closed Loop ON" : { "comment" : "Title of green closed loop ON message", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -1828,6 +1842,7 @@ }, "g" : { "comment" : "The short unit display string for grams", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -2147,8 +2162,12 @@ } } }, + "Last device communication ran %@ ago" : { + "comment" : "Accessbility format label describing the time interval since the last device communication date. (1: The localized date components)" + }, "Loop Failure" : { "comment" : "Title of red loop message", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -2387,6 +2406,7 @@ }, "Loop Warning" : { "comment" : "Title of yellow loop message", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -2577,6 +2597,7 @@ }, "mg/dL" : { "comment" : "The short unit display string for milligrams of glucose per decilter", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -2702,6 +2723,7 @@ }, "mmol/L" : { "comment" : "The short unit display string for millimoles of glucose per liter", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -3191,6 +3213,7 @@ }, "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.", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -3268,6 +3291,7 @@ }, "U" : { "comment" : "The short unit display string for international units of insulin", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -3385,6 +3409,9 @@ } } }, + "U/hr" : { + "comment" : "The format string describing the basal rate unit." + }, "Unknown" : { "comment" : "Accessibility value for an unknown value", "localizations" : { diff --git a/LoopUI/StatusBarHUDView.xib b/LoopUI/StatusBarHUDView.xib index f603d7cf91..d9c2050dd0 100644 --- a/LoopUI/StatusBarHUDView.xib +++ b/LoopUI/StatusBarHUDView.xib @@ -1,9 +1,8 @@ - + - - + @@ -35,13 +34,13 @@ diff --git a/LoopUI/ViewModel/CGMStatusHUDViewModel.swift b/LoopUI/ViewModel/CGMStatusHUDViewModel.swift index a26834e4b3..2d6f04bec7 100644 --- a/LoopUI/ViewModel/CGMStatusHUDViewModel.swift +++ b/LoopUI/ViewModel/CGMStatusHUDViewModel.swift @@ -6,7 +6,7 @@ // Copyright © 2020 LoopKit Authors. All rights reserved. // -import HealthKit +import LoopAlgorithm import LoopKit public class CGMStatusHUDViewModel { @@ -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, + unit: LoopUnit, 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/BasalRateHUDView.swift b/LoopUI/Views/BasalRateHUDView.swift index d8992f0169..7d45f661fa 100644 --- a/LoopUI/Views/BasalRateHUDView.swift +++ b/LoopUI/Views/BasalRateHUDView.swift @@ -7,6 +7,7 @@ // import UIKit +import LoopKit import LoopKitUI public final class BasalRateHUDView: BaseHUDView { @@ -15,7 +16,7 @@ public final class BasalRateHUDView: BaseHUDView { return 3 } - @IBOutlet private weak var basalStateView: BasalStateView! + @IBOutlet private weak var treatmentArrowStateView: TreatmentArrowStateView! @IBOutlet private weak var basalRateLabel: UILabel! { didSet { @@ -28,23 +29,13 @@ public final class BasalRateHUDView: BaseHUDView { public override func tintColorDidChange() { super.tintColorDidChange() + treatmentArrowStateView.tintColor = tintColor } private lazy var basalRateFormatString = LocalizedString("%@ U", comment: "The format string describing the basal rate.") - public func setNetBasalRate(_ rate: Double, percent: Double, at date: Date) { - let time = timeFormatter.string(from: date) - caption?.text = time - - if let rateString = decimalFormatter.string(from: rate) { - basalRateLabel?.text = String(format: basalRateFormatString, rateString) - accessibilityValue = String(format: LocalizedString("%1$@ units per hour at %2$@", comment: "Accessibility format string describing the basal rate. (1: localized basal rate value)(2: last updated time)"), rateString, time) - } else { - basalRateLabel?.text = nil - accessibilityValue = nil - } - - basalStateView.netBasalPercent = percent + public func setAutomatedTreatmentState(_ automatedTreatmentState: AutomatedTreatmentState) { + treatmentArrowStateView.automatedTreatmentState = automatedTreatmentState } private lazy var decimalFormatter: NumberFormatter = { diff --git a/LoopUI/Views/BasalStateView.swift b/LoopUI/Views/BasalStateView.swift deleted file mode 100644 index 15d51c7bf5..0000000000 --- a/LoopUI/Views/BasalStateView.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// BasalStateView.swift -// Naterade -// -// Created by Nathan Racklyeft on 5/12/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import UIKit - - -public final class BasalStateView: UIView { - - var netBasalPercent: Double = 0 { - didSet { - animateToPath(drawPath()) - } - } - - override public class var layerClass : AnyClass { - return CAShapeLayer.self - } - - private var shapeLayer: CAShapeLayer { - return layer as! CAShapeLayer - } - - override init(frame: CGRect) { - super.init(frame: frame) - - shapeLayer.lineWidth = 2 - updateTintColor() - } - - required public init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - - shapeLayer.lineWidth = 2 - updateTintColor() - } - - override public func layoutSubviews() { - super.layoutSubviews() - } - - public override func tintColorDidChange() { - super.tintColorDidChange() - updateTintColor() - } - - private func updateTintColor() { - shapeLayer.fillColor = tintColor.withAlphaComponent(0.5).cgColor - shapeLayer.strokeColor = tintColor.cgColor - } - - private func drawPath() -> CGPath { - let startX = bounds.minX - let endX = bounds.maxX - let midY = bounds.midY - - let path = UIBezierPath() - path.move(to: CGPoint(x: startX, y: midY)) - - let leftAnchor = startX + 1/6 * bounds.size.width - let rightAnchor = startX + 5/6 * bounds.size.width - - let yAnchor = bounds.midY - CGFloat(netBasalPercent) * (bounds.size.height - shapeLayer.lineWidth) / 2 - - path.addLine(to: CGPoint(x: leftAnchor, y: midY)) - path.addLine(to: CGPoint(x: leftAnchor, y: yAnchor)) - path.addLine(to: CGPoint(x: rightAnchor, y: yAnchor)) - path.addLine(to: CGPoint(x: rightAnchor, y: midY)) - path.addLine(to: CGPoint(x: endX, y: midY)) - - return path.cgPath - } - - private static let AnimationKey = "com.loudnate.Naterade.shapePathAnimation" - - private func animateToPath(_ path: CGPath) { - if shapeLayer.path != nil { - let animation = CABasicAnimation(keyPath: "path") - animation.fromValue = shapeLayer.path ?? drawPath() - animation.toValue = path - animation.duration = 1 - animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) - - shapeLayer.add(animation, forKey: type(of: self).AnimationKey) - } - - shapeLayer.path = path - } -} diff --git a/LoopUI/Views/CGMStatusHUDView.swift b/LoopUI/Views/CGMStatusHUDView.swift index ad659445fd..47af3dd61e 100644 --- a/LoopUI/Views/CGMStatusHUDView.swift +++ b/LoopUI/Views/CGMStatusHUDView.swift @@ -7,7 +7,7 @@ // import UIKit -import HealthKit +import LoopAlgorithm import LoopKit import LoopKitUI @@ -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() { @@ -109,19 +116,19 @@ public final class CGMStatusHUDView: DeviceStatusHUDView, NibLoadable { public func setGlucoseQuantity(_ glucoseQuantity: Double, at glucoseStartDate: Date, - unit: HKUnit, - staleGlucoseAge: TimeInterval, + unit: LoopUnit, 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() } @@ -133,6 +140,12 @@ public final class CGMStatusHUDView: DeviceStatusHUDView, NibLoadable { presentStatusHighlight(viewModel.statusHighlight) accessibilityValue = viewModel.accessibilityString + accessibilityIdentifier = + if viewModel.glucoseValueString == "LOW" || viewModel.glucoseValueString == "HIGH" { + "glucoseHUDView_\(viewModel.glucoseValueString)" + } else { + "glucoseHUDView" + } } func updateTrendIcon() { diff --git a/LoopUI/Views/DeviceStatusHUDView.swift b/LoopUI/Views/DeviceStatusHUDView.swift index 904f9d44cb..b0a4fcee7c 100644 --- a/LoopUI/Views/DeviceStatusHUDView.swift +++ b/LoopUI/Views/DeviceStatusHUDView.swift @@ -7,7 +7,6 @@ // import UIKit -import HealthKit import LoopKit import LoopKitUI @@ -52,10 +51,12 @@ import LoopKitUI resetProgress() return } - + progressView.isHidden = false progressView.progress = Float(lifecycleProgress.percentComplete.clamped(to: 0...1)) progressView.tintColor = lifecycleProgress.progressState.color + progressView.accessibilityIdentifier = + "progressBar_State_\(lifecycleProgress.progressState.rawValue)" } } @@ -98,8 +99,8 @@ import LoopKitUI } private func presentStatusHighlight(withMessage message: String, - image: UIImage?, - color: UIColor) + image: UIImage?, + color: UIColor) { statusHighlightView.messageLabel.text = message statusHighlightView.messageLabel.tintColor = .label diff --git a/LoopUI/Views/GlucoseHUDView.swift b/LoopUI/Views/GlucoseHUDView.swift index 8201e82a51..e54bdf2db1 100644 --- a/LoopUI/Views/GlucoseHUDView.swift +++ b/LoopUI/Views/GlucoseHUDView.swift @@ -7,7 +7,7 @@ // import UIKit -import HealthKit +import LoopAlgorithm import LoopKit import LoopKitUI @@ -127,7 +127,7 @@ public final class GlucoseHUDView: BaseHUDView { } } - public func setGlucoseQuantity(_ glucoseQuantity: Double, at glucoseStartDate: Date, unit: HKUnit, staleGlucoseAge: TimeInterval, sensor: GlucoseDisplayable?) { + public func setGlucoseQuantity(_ glucoseQuantity: Double, at glucoseStartDate: Date, unit: LoopUnit, staleGlucoseAge: TimeInterval, sensor: GlucoseDisplayable?) { var accessibilityStrings = [String]() let time = timeFormatter.string(from: glucoseStartDate) diff --git a/LoopUI/Views/GlucoseTrendHUDView.swift b/LoopUI/Views/GlucoseTrendHUDView.swift index c31dd812da..332e3b545f 100644 --- a/LoopUI/Views/GlucoseTrendHUDView.swift +++ b/LoopUI/Views/GlucoseTrendHUDView.swift @@ -7,7 +7,6 @@ // import UIKit -import HealthKit import LoopKit import LoopKitUI diff --git a/LoopUI/Views/GlucoseValueHUDView.swift b/LoopUI/Views/GlucoseValueHUDView.swift index 4a0858e746..1fe64e0b60 100644 --- a/LoopUI/Views/GlucoseValueHUDView.swift +++ b/LoopUI/Views/GlucoseValueHUDView.swift @@ -7,7 +7,6 @@ // import UIKit -import HealthKit import LoopKit import LoopKitUI diff --git a/LoopUI/Views/LoopCompletionHUDView.swift b/LoopUI/Views/LoopCompletionHUDView.swift index b0e6b1387b..28cf874f03 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,9 +20,22 @@ public final class LoopCompletionHUDView: BaseHUDView { private(set) var freshness = LoopCompletionFreshness.stale { didSet { - updateTintColor() + loopStateView.freshness = freshness + updateLabelColor() } } + + private var freshnessColor: UIColor { + switch freshness { + case .fresh: return .label + case .aging: return loopStatusColors.warning + case .stale: return loopStatusColors.error + } + } + + private func updateLabelColor() { + caption?.textColor = freshnessColor + } override public func awakeFromNib() { super.awakeFromNib() @@ -30,6 +43,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,11 +62,18 @@ public final class LoopCompletionHUDView: BaseHUDView { } } } + + public var deviceIssue: Bool = false { + didSet { + loopStateView.deviceIssue = deviceIssue + } + } + + public var mostRecentGlucoseDataDate: Date? + public var mostRecentPumpDataDate: Date? public var loopInProgress = false { didSet { - loopStateView.animated = loopInProgress - if !loopInProgress { updateTimer = nil assertTimer() @@ -57,6 +83,8 @@ public final class LoopCompletionHUDView: BaseHUDView { public var closedLoopDisallowedLocalizedDescription: String? + public var onAgoUpdate: ((TimeInterval?) -> Void)? + public func assertTimer(_ active: Bool = true) { if active && window != nil, let date = lastLoopCompleted { initTimer(date) @@ -65,26 +93,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) @@ -121,16 +129,6 @@ public final class LoopCompletionHUDView: BaseHUDView { private var lastLoopMessage: String = "" - private lazy var timeAgoFormatter: DateComponentsFormatter = { - let formatter = DateComponentsFormatter() - - formatter.allowedUnits = [.day, .hour, .minute] - formatter.maximumUnitCount = 1 - formatter.unitsStyle = .short - - return formatter - }() - private lazy var timeFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateStyle = .none @@ -148,21 +146,24 @@ public final class LoopCompletionHUDView: BaseHUDView { @objc private func updateDisplay(_: Timer?) { lastLoopMessage = "" + caption?.isHidden = !loopIconClosed let timeAgoToIncludeTimeStamp: TimeInterval = .minutes(20) let timeAgoToIncludeDate: TimeInterval = .hours(4) - if let date = lastLoopCompleted { - let ago = abs(min(0, date.timeIntervalSinceNow)) + if loopIconClosed, let date = lastLoopCompleted { + // restrict time ago from 0 to 7 days + let ago = min(abs(min(0, date.timeIntervalSinceNow)), TimeInterval.days(7)) freshness = LoopCompletionFreshness(age: ago) + onAgoUpdate?(ago) - if let timeString = timeAgoFormatter.string(from: ago) { + if let timeString = ago.truncatedTimeAgoString { 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 completion date. (1: The localized date components"), timeString) + caption?.attributedText = formattedTimeAgoString(timeString, includeGreaterThan: ago > .hours(1)) default: caption?.text = timeString } @@ -173,28 +174,79 @@ public final class LoopCompletionHUDView: BaseHUDView { if ago >= timeAgoToIncludeDate { fullTimeStr = String(format: LocalizedString("was at %1$@", comment: "Format string describing last completion. (1: the date"), timeDateFormatter.string(from: date)) } else if ago >= timeAgoToIncludeTimeStamp { - fullTimeStr = String(format: LocalizedString("%1$@ ago at %2$@", comment: "Format string describing last completion. (1: time ago, (2: the date"), timeAgoFormatter.string(from: ago)!, timeFormatter.string(from: date)) + fullTimeStr = String(format: LocalizedString("%1$@ ago at %2$@", comment: "Format string describing last completion. (1: time ago, (2: the date"), ago.truncatedTimeAgoString!, timeFormatter.string(from: date)) } else if ago < .minutes(1) { fullTimeStr = String(format: LocalizedString("<1 min ago", comment: "Format string describing last completion")) } else { - fullTimeStr = String(format: LocalizedString("%1$@ ago", comment: "Format string describing last completion. (1: time ago"), timeAgoFormatter.string(from: ago)!) + fullTimeStr = String(format: LocalizedString("%1$@ ago", comment: "Format string describing last completion. (1: time ago"), ago.truncatedTimeAgoString!) } lastLoopMessage = String(format: LocalizedString("Last completed loop %1$@.", comment: "Last loop time completed message (1: last loop time string)"), fullTimeStr) } else { caption?.text = "–" accessibilityLabel = nil } + } else if !loopIconClosed, let mostRecentPumpDataDate, let mostRecentGlucoseDataDate { + onAgoUpdate?(nil) + let ago = max(abs(min(0, mostRecentPumpDataDate.timeIntervalSinceNow)), abs(min(0, mostRecentGlucoseDataDate.timeIntervalSinceNow))) + + // when closed loop is off, always present fresh unless there is a device issue (handled else where) + freshness = .fresh + + if let timeString = ago.truncatedTimeAgoString { + switch traitCollection.preferredContentSizeCategory { + case UIContentSizeCategory.extraSmall, + UIContentSizeCategory.small, + UIContentSizeCategory.medium, + UIContentSizeCategory.large: + // Use a longer form only for smaller text sizes + caption?.attributedText = formattedTimeAgoString(timeString, includeGreaterThan: ago > .hours(1)) + 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 = "–" + onAgoUpdate?(nil) + caption?.text = "" accessibilityLabel = LocalizedString("Waiting for first run", comment: "Accessibility label describing completion HUD waiting for first run") } 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" } } + + private func formattedTimeAgoString(_ timeString: String, includeGreaterThan: Bool = false) -> NSAttributedString { + let config = UIImage.SymbolConfiguration(pointSize: 11, weight: .semibold) + // ⚠️ arrow.triangle.2.circlepath is deprecated -- replace with "arrow.trianglehead.2.clockwise.rotate.90" once iOS 17 is dropped as a supported platform. + let symbol = UIImage(systemName: "arrow.triangle.2.circlepath", withConfiguration: config) + let tintedSymbol = symbol?.withTintColor(freshnessColor, renderingMode: .alwaysOriginal) + let attachment = NSTextAttachment() + attachment.image = tintedSymbol + attachment.bounds = CGRect(x: 0, y: -2, width: 11, height: 11) + let imageString = NSAttributedString(attachment: attachment) + + let timeAgoString: NSAttributedString + if includeGreaterThan { + timeAgoString = NSAttributedString(string: String(format: LocalizedString(" >%@ ago", comment: "Format string describing the time interval since the last completion date, last cgm or last pump communication. (1: The localized date components"), timeString)) + } else { + timeAgoString = NSAttributedString(string: String(format: LocalizedString(" %@ ago", comment: "Format string describing the time interval since the last completion date, last cgm or last pump communication. (1: The localized date components"), timeString)) + } + + let combined = NSMutableAttributedString() + combined.append(imageString) + combined.append(timeAgoString) + + return combined + } override public func didMoveToWindow() { super.didMoveToWindow() @@ -202,25 +254,3 @@ public final class LoopCompletionHUDView: BaseHUDView { assertTimer() } } - -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)) - } 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)) - } - 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)) - 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)) - } - } -} diff --git a/LoopUI/Views/LoopStateView.swift b/LoopUI/Views/LoopStateView.swift index eedc483de4..c8cf4c7e7c 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 deviceIssue: 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, + deviceIssue: Bool = false + ) { + self.loopStatusColors = loopStatusColors + self.closedLoop = closedLoop + self.freshness = freshness + self.deviceIssue = deviceIssue } +} - private func updateTintColor() { - shapeLayer.strokeColor = tintColor.cgColor +struct WrappedLoopCircleView: View { + + @StateObject var viewModel: WrappedLoopStateViewModel + + var body: some View { + LoopCircleView(animationAllowed: true, closedLoop: viewModel.closedLoop, freshness: viewModel.freshness, deviceIssue: viewModel.deviceIssue) + .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 + } } - - private static let AnimationKey = "com.loudnate.Naterade.breatheAnimation" - - var animated: Bool = false { + + var open = 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.closedLoop = !open + } + } + + var deviceIssue: Bool = false { + didSet { + viewModel.deviceIssue = deviceIssue } } + + 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..2b019e3e5e 100644 --- a/LoopUI/Views/PumpStatusHUDView.swift +++ b/LoopUI/Views/PumpStatusHUDView.swift @@ -7,7 +7,6 @@ // import UIKit -import HealthKit import LoopKit import LoopKitUI @@ -43,7 +42,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 +63,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 +101,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/LoopUI/Views/TreatmentArrowStateView.swift b/LoopUI/Views/TreatmentArrowStateView.swift new file mode 100644 index 0000000000..f6b1bdf9e9 --- /dev/null +++ b/LoopUI/Views/TreatmentArrowStateView.swift @@ -0,0 +1,119 @@ +// +// BasalStateView.swift +// Naterade +// +// Created by Nathan Racklyeft on 5/12/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI + +class WrappedTreatmentArrowViewModel: ObservableObject { + + private lazy var basalRateUnitString = LocalizedString("U/hr", comment: "The format string describing the basal rate unit.") + private lazy var basalRateFormatString = "%1$d %2$@" + + @Published var treatmentArrowState: AutomatedTreatmentState + @Published var tintColor: Color + + var basalStateImageName: String? { + treatmentArrowState.imageName + } + + var basalStateCaptionString: String? { + switch treatmentArrowState { + case .minimumDelivery: return String(format: basalRateFormatString, 0, basalRateUnitString) + default: return nil + } + } + + init(basalDisplayState: AutomatedTreatmentState = .neutralNoOverride, + tintColor: Color = .insulinTintColor + ) { + self.treatmentArrowState = basalDisplayState + self.tintColor = tintColor + } +} + +struct WrappedTreatmentArrowView: View { + + @StateObject var viewModel: WrappedTreatmentArrowViewModel + + var body: some View { + VStack { + if let basalStateImageName = viewModel.basalStateImageName { + Image(systemName: basalStateImageName) + .font(.title) + .foregroundStyle(viewModel.tintColor) + } + if let basalStateCaptionString = viewModel.basalStateCaptionString { + Text(basalStateCaptionString) + .font(.caption2) + .foregroundStyle(.primary) + } + } + .animation(.default, value: viewModel.treatmentArrowState) + } +} + +class BasalRateHostingController: UIHostingController { + init(viewModel: WrappedTreatmentArrowViewModel) { + super.init( + rootView: WrappedTreatmentArrowView( + viewModel: viewModel + ) + ) + } + + required init?(coder aDecoder: NSCoder) { + fatalError() + } +} + + +public final class TreatmentArrowStateView: UIView { + + override init(frame: CGRect) { + super.init(frame: frame) + + setupViews() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + + setupViews() + } + + var automatedTreatmentState: AutomatedTreatmentState = .neutralNoOverride { + didSet { + viewModel.treatmentArrowState = automatedTreatmentState + } + } + + public override func tintColorDidChange() { + super.tintColorDidChange() + viewModel.tintColor = Color(uiColor: tintColor) + } + + private let viewModel = WrappedTreatmentArrowViewModel() + + private func setupViews() { + let hostingController = BasalRateHostingController(viewModel: viewModel) + + hostingController.view.backgroundColor = .clear + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + hostingController.view.frame = CGRect(x: 0, y: 0, width: frame.size.width, height: frame.size.height) + + 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/WatchApp Extension/ComplicationController.swift b/WatchApp Extension/ComplicationController.swift index 9f79aad280..3bacfd933e 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 { @@ -17,12 +18,8 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { // MARK: - Timeline Configuration - func getSupportedTimeTravelDirections(for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationTimeTravelDirections) -> Void) { - handler([.backward]) - } - func getTimelineStartDate(for complication: CLKComplication, withHandler handler: @escaping (Date?) -> Void) { - if let date = ExtensionDelegate.shared().loopManager.activeContext?.glucoseDate { + if let date = LoopDataManager.shared.activeContext?.glucoseDate { handler(date) } else { handler(nil) @@ -30,13 +27,28 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { } func getTimelineEndDate(for complication: CLKComplication, withHandler handler: @escaping (Date?) -> Void) { - if let date = ExtensionDelegate.shared().loopManager.activeContext?.glucoseDate { + if let date = LoopDataManager.shared.activeContext?.glucoseDate { handler(date) } else { handler(nil) } } - + + func complicationDescriptors() async -> [CLKComplicationDescriptor] { + return [ + CLKComplicationDescriptor( + identifier: "glucosegraph", + displayName: "Glucose Graph", + supportedFamilies: [.graphicRectangular, .graphicExtraLarge,] + ), + CLKComplicationDescriptor( + identifier: "glucosegraph", + displayName: "Loop Status", + supportedFamilies: [.circularSmall, .extraLarge, .graphicBezel, .graphicCircular, .graphicCorner, .modularLarge, .modularSmall, .utilitarianLarge, .utilitarianSmall, .utilitarianSmallFlat] + ) + ] + } + func getPrivacyBehavior(for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationPrivacyBehavior) -> Void) { handler(.hideOnLockScreen) } @@ -55,7 +67,8 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { return } - ExtensionDelegate.shared().loopManager.generateChartData { chartData in + Task { @MainActor in + let chartData = await LoopDataManager.shared.generateChartData() self.chartManager.data = chartData completion() } @@ -84,11 +97,11 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { self.log.default("Updating current complication timeline entry") - if let context = ExtensionDelegate.shared().loopManager.activeContext, + if let context = LoopDataManager.shared.activeContext, let template = CLKComplicationTemplate.templateForFamily(complication.family, from: context, at: timelineDate, - recencyInterval: LoopCoreConstants.inputDataRecencyInterval, + recencyInterval: LoopAlgorithm.inputDataRecencyInterval, chartGenerator: self.makeChart) { switch complication.family { @@ -110,7 +123,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { updateChartManagerIfNeeded { let entries: [CLKComplicationTimelineEntry]? - guard let context = ExtensionDelegate.shared().loopManager.activeContext, + guard let context = LoopDataManager.shared.activeContext, let glucoseDate = context.glucoseDate else { handler(nil) @@ -119,7 +132,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 +148,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 deleted file mode 100644 index dce285b9d9..0000000000 --- a/WatchApp Extension/Controllers/ActionHUDController.swift +++ /dev/null @@ -1,298 +0,0 @@ -// -// ActionHUDController.swift -// Loop -// -// Created by Nathan Racklyeft on 5/29/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import WatchKit -import WatchConnectivity -import HealthKit -import LoopKit -import LoopCore -import SwiftUI - - -final class ActionHUDController: HUDInterfaceController { - @IBOutlet var preMealButton: WKInterfaceButton! - @IBOutlet var preMealButtonImage: WKInterfaceImage! - @IBOutlet var preMealButtonBackground: WKInterfaceGroup! - @IBOutlet var overrideButton: WKInterfaceButton! - @IBOutlet var overrideButtonImage: WKInterfaceImage! - @IBOutlet var overrideButtonBackground: WKInterfaceGroup! - @IBOutlet var carbsButton: WKInterfaceButton! - @IBOutlet var carbsButtonImage: WKInterfaceImage! - @IBOutlet var carbsButtonBackground: WKInterfaceGroup! - @IBOutlet var bolusButton: WKInterfaceButton! - @IBOutlet var bolusButtonImage: WKInterfaceImage! - @IBOutlet var bolusButtonBackground: WKInterfaceGroup! - - private lazy var preMealButtonGroup = ButtonGroup(button: preMealButton, image: preMealButtonImage, background: preMealButtonBackground, onBackgroundColor: .carbsColor, offBackgroundColor: .darkCarbsColor, onIconColor: .darkCarbsColor, offIconColor: .carbsColor) - - private lazy var overrideButtonGroup = ButtonGroup(button: overrideButton, image: overrideButtonImage, background: overrideButtonBackground, onBackgroundColor: .overrideColor, offBackgroundColor: .darkOverrideColor, onIconColor: .darkOverrideColor, offIconColor: .overrideColor) - - private lazy var carbsButtonGroup = ButtonGroup(button: carbsButton, image: carbsButtonImage, background: carbsButtonBackground, onBackgroundColor: .carbsColor, offBackgroundColor: .darkCarbsColor, onIconColor: .darkCarbsColor, offIconColor: .carbsColor) - - private lazy var bolusButtonGroup = ButtonGroup(button: bolusButton, image: bolusButtonImage, background: bolusButtonBackground, onBackgroundColor: .insulin, offBackgroundColor: .darkInsulin, onIconColor: .darkInsulin, offIconColor: .insulin) - - @IBOutlet var overrideButtonLabel: WKInterfaceLabel? - - override func willActivate() { - super.willActivate() - - // Update the override button description based on the feature flag; this cannot be done earlier than `-willActivate` (e.g. didSet on the IBOutlet is too soon) - if FeatureFlags.sensitivityOverridesEnabled { - overrideButtonLabel?.setText(NSLocalizedString("Preset", comment: "The text for the Watch button for enabling a custom preset")) - } else { - overrideButtonLabel?.setText(NSLocalizedString("Workout", comment: "The text for the Watch button for enabling workout mode")) - } - - let userActivity = NSUserActivity.forViewLoopStatus() - if #available(watchOSApplicationExtension 5.0, *) { - update(userActivity) - } else { - updateUserActivity(userActivity.activityType, userInfo: userActivity.userInfo, webpageURL: nil) - } - } - - override func update() { - super.update() - - let activeOverrideContext: TemporaryScheduleOverride.Context? - if let override = loopManager.settings.scheduleOverride, override.isActive() { - activeOverrideContext = override.context - } else { - activeOverrideContext = nil - } - - updateForPreMeal(enabled: loopManager.settings.preMealOverride?.isActive() == true) - updateForOverrideContext(activeOverrideContext) - - let isClosedLoop = loopManager.activeContext?.isClosedLoop ?? false - - if !isClosedLoop && FeatureFlags.simpleBolusCalculatorEnabled { - preMealButtonGroup.state = .disabled - overrideButtonGroup.state = .disabled - carbsButtonGroup.state = .disabled - bolusButtonGroup.state = .disabled - } else { - carbsButtonGroup.state = .off - bolusButtonGroup.state = .off - - if loopManager.settings.preMealTargetRange == nil { - preMealButtonGroup.state = .disabled - } else if preMealButtonGroup.state == .disabled { - preMealButtonGroup.state = .off - } - - if !canEnableOverride { - overrideButtonGroup.state = .disabled - } else if overrideButtonGroup.state == .disabled { - overrideButtonGroup.state = .off - } - } - - glucoseFormatter.updateUnit(to: loopManager.displayGlucoseUnit) - } - - private var canEnableOverride: Bool { - if FeatureFlags.sensitivityOverridesEnabled { - return !loopManager.settings.overridePresets.isEmpty - } else { - return loopManager.settings.legacyWorkoutTargetRange != nil - } - } - - private func updateForPreMeal(enabled: Bool) { - if enabled { - preMealButtonGroup.state = .on - } else { - preMealButtonGroup.turnOff() - } - } - - private func updateForOverrideContext(_ context: TemporaryScheduleOverride.Context?) { - switch context { - case nil: - overrideButtonGroup.turnOff() - case .preset?, .custom?: - overrideButtonGroup.state = .on - case .legacyWorkout?: - preMealButtonGroup.turnOff() - overrideButtonGroup.state = .on - case .preMeal?: - assertionFailure() - } - } - - // MARK: - Menu Items - - private var pendingMessageResponses = 0 - - private let glucoseFormatter = QuantityFormatter(for: .milligramsPerDeciliter) - - @IBAction func togglePreMealMode() { - guard let range = loopManager.settings.preMealTargetRange else { - return - } - - let buttonToSelect = loopManager.settings.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), - onSelection: setPreMealEnabled, - selectedButton: buttonToSelect, - selectedButtonTint: .carbsColor) - - presentController(withName: OnOffSelectionController.className, context: viewModel) - } - - func setPreMealEnabled(_ isPreMealEnabled: Bool) { - updateForPreMeal(enabled: isPreMealEnabled) - pendingMessageResponses += 1 - - var settings = loopManager.settings - let overrideContext = settings.scheduleOverride?.context - if isPreMealEnabled { - settings.enablePreMealOverride(for: .hours(1)) - - if !FeatureFlags.sensitivityOverridesEnabled { - settings.clearOverride(matching: .legacyWorkout) - updateForOverrideContext(nil) - } - } else { - settings.clearOverride(matching: .preMeal) - } - - let userInfo = LoopSettingsUserInfo(settings: settings) - do { - try WCSession.default.sendSettingsUpdateMessage(userInfo, 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 - } - - ExtensionDelegate.shared().loopManager.updateContext(context) - case .failure(let error): - if self.pendingMessageResponses == 0 { - ExtensionDelegate.shared().present(error) - self.updateForPreMeal(enabled: isPreMealEnabled) - self.updateForOverrideContext(overrideContext) - } - } - } - }) - } catch { - pendingMessageResponses -= 1 - if pendingMessageResponses == 0 { - updateForPreMeal(enabled: isPreMealEnabled) - updateForOverrideContext(overrideContext) - presentAlert( - withTitle: NSLocalizedString("Send Failed", comment: "The title of the alert controller displayed after a glucose range override send attempt fails"), - message: NSLocalizedString("Make sure your iPhone is nearby and try again", comment: "The recovery message displayed after a glucose range override send attempt fails"), - preferredStyle: .alert, - actions: [.dismissAction()] - ) - } - } - } - - @IBAction func toggleOverride() { - if FeatureFlags.sensitivityOverridesEnabled { - 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 - - 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 - self.sendOverride(override) - }, - selectedButton: buttonToSelect, - selectedButtonTint: .glucose - ) - presentController(withName: OnOffSelectionController.className, context: viewModel) - } - } - - private func formattedGlucoseRangeString(from range: ClosedRange) -> String { - let unit = loopManager.displayGlucoseUnit - glucoseFormatter.updateUnit(to: unit) - let rangeDouble = range.doubleRange(for: unit) - return String( - format: NSLocalizedString( - "%1$@ – %2$@ %3$@", - comment: "Format string for glucose range (1: lower bound)(2: upper bound)(3: unit)" - ), - glucoseFormatter.numberFormatter.string(from: rangeDouble.minValue) ?? String(rangeDouble.minValue), - glucoseFormatter.numberFormatter.string(from: rangeDouble.maxValue) ?? String(rangeDouble.maxValue), - glucoseFormatter.localizedUnitStringWithPlurality() - ) - } - - private func sendOverride(_ override: TemporaryScheduleOverride?) { - updateForOverrideContext(override?.context) - pendingMessageResponses += 1 - - var settings = loopManager.settings - let isPreMealEnabled = settings.preMealOverride?.isActive() == true - if override?.context == .legacyWorkout { - settings.preMealOverride = nil - } - settings.scheduleOverride = override - - let userInfo = LoopSettingsUserInfo(settings: settings) - do { - try WCSession.default.sendSettingsUpdateMessage(userInfo, 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 - } - - ExtensionDelegate.shared().loopManager.updateContext(context) - case .failure(let error): - if self.pendingMessageResponses == 0 { - ExtensionDelegate.shared().present(error) - self.updateForOverrideContext(override?.context) - self.updateForPreMeal(enabled: isPreMealEnabled) - } - } - } - }) - } catch { - pendingMessageResponses -= 1 - if pendingMessageResponses == 0 { - updateForOverrideContext(override?.context) - updateForPreMeal(enabled: isPreMealEnabled) - presentAlert( - withTitle: NSLocalizedString("Send Failed", comment: "The title of the alert controller displayed after a glucose range override send attempt fails"), - message: NSLocalizedString("Make sure your iPhone is nearby and try again", comment: "The recovery message displayed after a glucose range override send attempt fails"), - preferredStyle: .alert, - actions: [.dismissAction()] - ) - } - } - } -} - -extension ActionHUDController: OverrideSelectionControllerDelegate { - func overrideSelectionController(_ controller: OverrideSelectionController, didSelectPreset preset: TemporaryScheduleOverridePreset) { - let override = preset.createOverride(enactTrigger: .local) - sendOverride(override) - } -} diff --git a/WatchApp Extension/Controllers/CarbAndBolusFlowController.swift b/WatchApp Extension/Controllers/CarbAndBolusFlowController.swift deleted file mode 100644 index 102bc98de8..0000000000 --- a/WatchApp Extension/Controllers/CarbAndBolusFlowController.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// CarbAndBolusFlowController.swift -// WatchApp Extension -// -// Created by Michael Pangburn on 4/7/20. -// Copyright © 2020 LoopKit Authors. All rights reserved. -// - -import WatchKit -import SwiftUI -import HealthKit -import LoopCore -import LoopKit - - -final class CarbAndBolusFlowController: WKHostingController, IdentifiableClass { - private lazy var viewModel = { - CarbAndBolusFlowViewModel( - configuration: configuration, - dismiss: { [weak self] in - guard let self = self else { return } - self.willDeactivateObserver = nil - self.dismiss() - } - ) - }() - - private var configuration: CarbAndBolusFlow.Configuration = .carbEntry(nil) - - override var body: CarbAndBolusFlow { - CarbAndBolusFlow(viewModel: viewModel) - } - - private var willDeactivateObserver: AnyObject? { - didSet { - if let oldValue = oldValue { - NotificationCenter.default.removeObserver(oldValue) - } - } - } - - override func awake(withContext context: Any?) { - if let configuration = context as? CarbAndBolusFlow.Configuration { - self.configuration = configuration - } - } - - override func didAppear() { - super.didAppear() - - updateNewCarbEntryUserActivity() - - // If the screen turns off, the screen should be dismissed for safety reasons - willDeactivateObserver = NotificationCenter.default.addObserver(forName: ExtensionDelegate.willResignActiveNotification, object: ExtensionDelegate.shared(), queue: nil, using: { [weak self] (_) in - if let self = self { - WKInterfaceDevice.current().play(.failure) - self.dismiss() - } - }) - } - - override func didDeactivate() { - super.didDeactivate() - - willDeactivateObserver = nil - } -} - -extension CarbAndBolusFlowController: NSUserActivityDelegate { - func updateNewCarbEntryUserActivity() { - update(.forDidAddCarbEntryOnWatch()) - } -} diff --git a/WatchApp Extension/Controllers/CarbEntryListController.swift b/WatchApp Extension/Controllers/CarbEntryListController.swift deleted file mode 100644 index a704a942cd..0000000000 --- a/WatchApp Extension/Controllers/CarbEntryListController.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// CarbEntryListController.swift -// WatchApp Extension -// -// Copyright © 2019 LoopKit Authors. All rights reserved. -// - -import HealthKit -import LoopCore -import LoopKit -import os.log -import WatchKit - -class CarbEntryListController: WKInterfaceController, IdentifiableClass { - @IBOutlet private var table: WKInterfaceTable! - - @IBOutlet private var cobLabel: WKInterfaceLabel! - - @IBOutlet var totalLabel: WKInterfaceLabel! - - @IBOutlet var headerGroup: WKInterfaceGroup! - - private let log = OSLog(category: "CarbEntryListController") - - private lazy var loopManager = ExtensionDelegate.shared().loopManager - - private lazy var carbFormatter: QuantityFormatter = { - let formatter = QuantityFormatter(for: .gram()) - formatter.numberFormatter.numberStyle = .none - return formatter - }() - - private var observers: [Any] = [] { - didSet { - for observer in oldValue { - NotificationCenter.default.removeObserver(observer) - } - } - } - - override func awake(withContext context: Any?) { - table.setNumberOfRows(0, withRowType: TextRowController.className) - loopManager.requestCarbBackfill() - reloadCarbEntries() - updateActiveCarbs() - } - - override func willActivate() { - observers = [ - NotificationCenter.default.addObserver(forName: CarbStore.carbEntriesDidChange, object: loopManager.carbStore, queue: nil) { [weak self] (note) in - self?.log.default("Received carbEntriesDidChange notification: %{public}@. Updating list", String(describing: note.userInfo ?? [:])) - - DispatchQueue.main.async { - self?.reloadCarbEntries() - } - }, - NotificationCenter.default.addObserver(forName: LoopDataManager.didUpdateContextNotification, object: loopManager, queue: nil) { [weak self] (note) in - DispatchQueue.main.async { - self?.updateActiveCarbs() - self?.loopManager.requestCarbBackfill() - } - } - ] - } - - override func didDeactivate() { - observers = [] - } -} - - -extension CarbEntryListController { - private func updateActiveCarbs() { - guard let activeCarbohydrates = loopManager.activeContext?.activeCarbohydrates else { - return - } - - cobLabel.setText(carbFormatter.string(from: activeCarbohydrates)) - } - - private func reloadCarbEntries() { - let start = min(Calendar.current.startOfDay(for: Date()), Date(timeIntervalSinceNow: -loopManager.carbStore.maximumAbsorptionTimeInterval)) - - loopManager.carbStore.getCarbEntries(start: start) { (result) in - switch result { - case .success(let entries): - DispatchQueue.main.async { - self.setCarbEntries(entries) - } - case .failure(let error): - self.log.error("Failed to fetch carb entries: %{public}@", String(describing: error)) - } - } - } - - private func setCarbEntries(_ entries: [StoredCarbEntry]) { - dispatchPrecondition(condition: .onQueue(.main)) - - table.setNumberOfRows(entries.count, withRowType: TextRowController.className) - - var total = 0.0 - - let timeFormatter = DateFormatter() - timeFormatter.dateStyle = .none - timeFormatter.timeStyle = .short - - let unit = loopManager.carbStore.preferredUnit ?? .gram() - - for (index, entry) in entries.reversed().enumerated() { - guard let row = table.rowController(at: index) as? TextRowController else { - continue - } - - total += entry.quantity.doubleValue(for: unit) - - row.textLabel.setText(timeFormatter.string(from: entry.startDate)) - row.detailTextLabel.setText(carbFormatter.string(from: entry.quantity)) - } - - totalLabel.setText(carbFormatter.string(from: HKQuantity(unit: unit, doubleValue: total))) - } -} diff --git a/WatchApp Extension/Controllers/ChartHUDController.swift b/WatchApp Extension/Controllers/ChartHUDController.swift deleted file mode 100644 index f7aa0b0231..0000000000 --- a/WatchApp Extension/Controllers/ChartHUDController.swift +++ /dev/null @@ -1,212 +0,0 @@ -// -// ChartHUDController.swift -// Loop -// -// Created by Bharat Mediratta on 6/26/18. -// Copyright © 2018 LoopKit Authors. All rights reserved. -// - -import WatchKit -import WatchConnectivity -import LoopKit -import HealthKit -import SpriteKit -import os.log -import LoopCore - -final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { - private enum TableRow: Int, CaseIterable { - case iob - case cob - case netBasal - case reservoirVolume - - var title: String { - switch self { - case .iob: - return NSLocalizedString("Active Insulin", comment: "HUD row title for IOB") - case .cob: - return NSLocalizedString("Active Carbs", comment: "HUD row title for COB") - case .netBasal: - return NSLocalizedString("Net Basal Rate", comment: "HUD row title for Net Basal Rate") - case .reservoirVolume: - return NSLocalizedString("Reservoir Volume", comment: "HUD row title for remaining reservoir volume") - } - } - - var isLast: Bool { - return self == TableRow.allCases.last - } - } - - @IBOutlet private weak var table: WKInterfaceTable! - - @IBOutlet private weak var glucoseScene: WKInterfaceSKScene! - private let scene = GlucoseChartScene() - private var timer: Timer? { - didSet { - oldValue?.invalidate() - } - } - private let log = OSLog(category: "ChartHUDController") - private var hasInitialActivation = false - - private var observers: [Any] = [] { - didSet { - for observer in oldValue { - NotificationCenter.default.removeObserver(observer) - } - } - } - - override init() { - super.init() - - glucoseScene.presentScene(scene) - } - - override func awake(withContext context: Any?) { - super.awake(withContext: context) - - table.setNumberOfRows(TableRow.allCases.count, withRowType: HUDRowController.className) - } - - override func didAppear() { - super.didAppear() - - if glucoseScene.isPaused { - log.default("didAppear() unpausing") - glucoseScene.isPaused = false - } else { - log.default("didAppear() not paused") - glucoseScene.isPaused = false - } - - // Force an update when our pixels need to move - let pixelsWide = scene.size.width * WKInterfaceDevice.current().screenScale - let pixelInterval = scene.visibleDuration / TimeInterval(pixelsWide) - - timer = Timer.scheduledTimer(withTimeInterval: pixelInterval, repeats: true) { [weak self] _ in - self?.log.default("Timer fired, triggering update") - self?.scene.setNeedsUpdate() - } - - // These margins are only available after we appear (sadly) - - scene.textInsets.left = max(scene.textInsets.left, systemMinimumLayoutMargins.leading) - scene.textInsets.right = max(scene.textInsets.right, systemMinimumLayoutMargins.trailing) - - for row in TableRow.allCases { - let cell = table.rowController(at: row.rawValue) as! HUDRowController - cell.setContentInset(systemMinimumLayoutMargins) - } - } - - override func willDisappear() { - super.willDisappear() - - log.default("willDisappear") - - timer = nil - } - - override func willActivate() { - super.willActivate() - - observers = [ - NotificationCenter.default.addObserver(forName: GlucoseStore.glucoseSamplesDidChange, object: loopManager.glucoseStore, queue: nil) { [weak self] (note) in - self?.log.default("Received GlucoseSamplesDidChange notification: %{public}@. Updating chart", String(describing: note.userInfo ?? [:])) - - DispatchQueue.main.async { - self?.updateGlucoseChart() - } - } - ] - - if glucoseScene.isPaused { - log.default("willActivate() unpausing") - glucoseScene.isPaused = false - } else { - log.default("willActivate()") - } - - if !hasInitialActivation && UserDefaults.standard.startOnChartPage { - log.default("Switching to startOnChartPage") - becomeCurrentPage() - } - - hasInitialActivation = true - - loopManager.requestGlucoseBackfillIfNecessary() - } - - override func didDeactivate() { - super.didDeactivate() - - observers = [] - - log.default("didDeactivate() pausing") - glucoseScene.isPaused = true - } - - override func update() { - super.update() - - guard let activeContext = loopManager.activeContext else { - return - } - - for row in TableRow.allCases { - let cell = table.rowController(at: row.rawValue) as! HUDRowController - cell.setTitle(row.title) - cell.setIsLastRow(row.isLast) - cell.setContentInset(systemMinimumLayoutMargins) - - let isActiveContextStale = Date().timeIntervalSince(activeContext.creationDate) > LoopCoreConstants.inputDataRecencyInterval - - switch row { - case .iob: - cell.setActiveInsulin(isActiveContextStale ? nil : activeContext.activeInsulin) - case .cob: - cell.setActiveCarbohydrates(isActiveContextStale ? nil : activeContext.activeCarbohydrates) - case .netBasal: - cell.setNetTempBasalDose(isActiveContextStale ? nil : activeContext.lastNetTempBasalDose) - case .reservoirVolume: - cell.setReservoirVolume(isActiveContextStale ? nil : activeContext.reservoirVolume) - } - } - - if glucoseScene.isPaused { - log.default("update() unpausing") - glucoseScene.isPaused = false - } - - updateGlucoseChart() - } - - private func updateGlucoseChart() { - loopManager.generateChartData { chartData in - DispatchQueue.main.async { - self.scene.data = chartData - self.scene.setNeedsUpdate() - } - } - } - - override func table(_ table: WKInterfaceTable, didSelectRowAt rowIndex: Int) { - guard table == self.table, case .cob? = TableRow(rawValue: rowIndex) else { - return - } - - presentController(withName: CarbEntryListController.className, context: nil) - } - - @IBAction func didTapOnChart(_ sender: Any) { - scene.decreaseVisibleDuration() - } - - @IBAction func didDoubleTapOnChart(_ sender: Any) { - scene.increaseVisibleDuration() - } - -} diff --git a/WatchApp Extension/Controllers/HUDInterfaceController.swift b/WatchApp Extension/Controllers/HUDInterfaceController.swift deleted file mode 100644 index b23dc56680..0000000000 --- a/WatchApp Extension/Controllers/HUDInterfaceController.swift +++ /dev/null @@ -1,111 +0,0 @@ -// -// HUDInterfaceController.swift -// WatchApp Extension -// -// Created by Bharat Mediratta on 6/29/18. -// Copyright © 2018 LoopKit Authors. All rights reserved. -// - -import WatchKit -import LoopCore -import LoopKit - -class HUDInterfaceController: WKInterfaceController { - private var activeContextObserver: NSObjectProtocol? - - @IBOutlet weak var loopHUDImage: WKInterfaceImage! - @IBOutlet weak var glucoseLabel: WKInterfaceLabel! - @IBOutlet weak var eventualGlucoseLabel: WKInterfaceLabel! - - var loopManager = ExtensionDelegate.shared().loopManager - - override func willActivate() { - super.willActivate() - - update() - - if activeContextObserver == nil { - activeContextObserver = NotificationCenter.default.addObserver(forName: LoopDataManager.didUpdateContextNotification, object: loopManager, queue: nil) { [weak self] _ in - DispatchQueue.main.async { - self?.update() - } - } - } - - loopManager.requestContextUpdate(completion: { - self.loopManager.requestGlucoseBackfillIfNecessary() - }) - } - - override func didDeactivate() { - super.didDeactivate() - - if let observer = activeContextObserver { - NotificationCenter.default.removeObserver(observer) - } - activeContextObserver = nil - } - - func update() { - guard let activeContext = loopManager.activeContext else { - loopHUDImage.setHidden(true) - return - } - loopHUDImage.setHidden(false) - - let date = activeContext.loopLastRunDate - let isClosedLoop = activeContext.isClosedLoop ?? false - loopHUDImage.setLoopImage(isClosedLoop: isClosedLoop, { - if let date = date { - switch date.timeIntervalSinceNow { - case let t where t > .minutes(-6): - return .fresh - case let t where t > .minutes(-20): - return .aging - default: - return .stale - } - } else { - return .unknown - } - }()) - - if date != nil { - glucoseLabel.setText(NSLocalizedString("– – –", comment: "No glucose value representation (3 dashes for mg/dL)")) - glucoseLabel.setHidden(false) - - let showEventualGlucose = FeatureFlags.showEventualBloodGlucoseOnWatchEnabled - if showEventualGlucose { - eventualGlucoseLabel.setHidden(true) - } - - if let glucose = activeContext.glucose, let glucoseDate = activeContext.glucoseDate, let unit = activeContext.displayGlucoseUnit, glucoseDate.timeIntervalSinceNow > -LoopCoreConstants.inputDataRecencyInterval { - let formatter = NumberFormatter.glucoseFormatter(for: unit) - - if let glucoseValue = formatter.string(from: glucose.doubleValue(for: unit)) { - 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) - } - } - } - - } - - @IBAction func addCarbs() { - presentController(withName: CarbAndBolusFlowController.className, context: CarbAndBolusFlow.Configuration.carbEntry(nil)) - } - - func addCarbs(initialEntry: NewCarbEntry) { - presentController(withName: CarbAndBolusFlowController.className, context: CarbAndBolusFlow.Configuration.carbEntry(initialEntry)) - } - - @IBAction func setBolus() { - presentController(withName: CarbAndBolusFlowController.className, context: CarbAndBolusFlow.Configuration.manualBolus) - } - -} diff --git a/WatchApp Extension/Controllers/HUDRowController.swift b/WatchApp Extension/Controllers/HUDRowController.swift deleted file mode 100644 index d1c8ee5cca..0000000000 --- a/WatchApp Extension/Controllers/HUDRowController.swift +++ /dev/null @@ -1,126 +0,0 @@ -// -// HUDRowController.swift -// WatchApp Extension -// -// Copyright © 2019 LoopKit Authors. All rights reserved. -// - -import HealthKit -import LoopCore -import LoopKit -import WatchKit - -class HUDRowController: NSObject, IdentifiableClass { - @IBOutlet private var textLabel: WKInterfaceLabel! - @IBOutlet private var detailTextLabel: WKInterfaceLabel! - @IBOutlet private var outerGroup: WKInterfaceGroup! - @IBOutlet private var bottomSeparator: WKInterfaceSeparator! -} - -extension HUDRowController { - func setTitle(_ title: String) { - textLabel.setText(title.localizedUppercase) - } - - func setDetail(_ detail: String?) { - detailTextLabel.setText(detail ?? "–") - } - - func setContentInset(_ inset: NSDirectionalEdgeInsets) { - outerGroup.setContentInset(inset.deviceInsets) - } - - func setIsLastRow(_ isLastRow: Bool) { - bottomSeparator.setHidden(isLastRow) - } -} - -extension HUDRowController { - func setActiveInsulin(_ activeInsulin: HKQuantity?) { - guard let activeInsulin = activeInsulin else { - setDetail(nil) - return - } - - let insulinFormatter: QuantityFormatter = { - let insulinFormatter = QuantityFormatter(for: .internationalUnit()) - insulinFormatter.numberFormatter.minimumFractionDigits = 1 - insulinFormatter.numberFormatter.maximumFractionDigits = 1 - - return insulinFormatter - }() - - setDetail(insulinFormatter.string(from: activeInsulin)) - } - - func setActiveCarbohydrates(_ activeCarbohydrates: HKQuantity?) { - guard let activeCarbohydrates = activeCarbohydrates else { - setDetail(nil) - return - } - - let carbFormatter = QuantityFormatter(for: .gram()) - carbFormatter.numberFormatter.maximumFractionDigits = 0 - - setDetail(carbFormatter.string(from: activeCarbohydrates)) - } - - func setNetTempBasalDose(_ tempBasal: Double?) { - guard let tempBasal = tempBasal else { - setDetail(nil) - return - } - - let basalFormatter = NumberFormatter() - basalFormatter.numberStyle = .decimal - basalFormatter.minimumFractionDigits = 1 - basalFormatter.maximumFractionDigits = 3 - basalFormatter.positivePrefix = basalFormatter.plusSign - - let unit = NSLocalizedString( - "U/hr", - comment: "The short unit display string for international units of insulin delivery per hour" - ) - - setDetail(basalFormatter.string(from: tempBasal, unit: unit)) - } - - func setReservoirVolume(_ reservoirVolume: HKQuantity?) { - guard let reservoirVolume = reservoirVolume else { - setDetail(nil) - return - } - - let insulinFormatter: QuantityFormatter = { - let insulinFormatter = QuantityFormatter(for: .internationalUnit()) - insulinFormatter.unitStyle = .long - insulinFormatter.numberFormatter.minimumFractionDigits = 0 - insulinFormatter.numberFormatter.maximumFractionDigits = 0 - - return insulinFormatter - }() - - setDetail(insulinFormatter.string(from: reservoirVolume)) - } -} - - -fileprivate extension NSDirectionalEdgeInsets { - var deviceInsets: UIEdgeInsets { - let left: CGFloat - let right: CGFloat - - switch WKInterfaceDevice.current().layoutDirection { - case .rightToLeft: - right = leading - left = trailing - case .leftToRight: - fallthrough - @unknown default: - left = leading - right = trailing - } - - return UIEdgeInsets(top: top, left: left, bottom: bottom, right: right) - } -} diff --git a/WatchApp Extension/Controllers/NotificationController.swift b/WatchApp Extension/Controllers/NotificationController.swift deleted file mode 100644 index 9b2aaa71da..0000000000 --- a/WatchApp Extension/Controllers/NotificationController.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// NotificationController.swift -// WatchApp Extension -// -// Created by Nathan Racklyeft on 8/29/15. -// Copyright © 2015 Nathan Racklyeft. All rights reserved. -// - -import WatchKit -import Foundation -import UserNotifications - - -final class NotificationController: WKUserNotificationInterfaceController { - - override init() { - super.init() - } - - override func willActivate() { - super.willActivate() - } - - override func didDeactivate() { - super.didDeactivate() - } - - override func didReceive(_ notification: UNNotification, withCompletion completionHandler: @escaping (WKUserNotificationInterfaceType) -> Void) { - completionHandler(.default) - } - -} diff --git a/WatchApp Extension/Controllers/OnOffSelectionController.swift b/WatchApp Extension/Controllers/OnOffSelectionController.swift deleted file mode 100644 index 40d5881079..0000000000 --- a/WatchApp Extension/Controllers/OnOffSelectionController.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// OnOffSelectionController.swift -// WatchApp Extension -// -// Created by Anna Quinlan on 8/20/20. -// Copyright © 2020 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import LoopCore - - -final class OnOffSelectionController: WKHostingController, IdentifiableClass { - - private var viewModel: OnOffSelectionViewModel = OnOffSelectionViewModel(title: "", message: "", onSelection: {_ in }) - - override func awake(withContext context: Any?) { - guard let model = context as? OnOffSelectionViewModel else { - fatalError("OnOffSelectionController invoked without proper context") - } - - model.dismiss = { self.dismiss() } - self.viewModel = model - } - - override var body: OnOffSelectionView { - OnOffSelectionView(viewModel: viewModel) - } -} diff --git a/WatchApp Extension/Controllers/OverrideSelectionController.swift b/WatchApp Extension/Controllers/OverrideSelectionController.swift deleted file mode 100644 index ba79776138..0000000000 --- a/WatchApp Extension/Controllers/OverrideSelectionController.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// OverrideSelectionController.swift -// WatchApp Extension -// -// Created by Michael Pangburn on 1/31/19. -// Copyright © 2019 LoopKit Authors. All rights reserved. -// - -import Foundation -import WatchKit -import LoopKit -import LoopCore -import WatchConnectivity - - -protocol OverrideSelectionControllerDelegate: AnyObject { - func overrideSelectionController(_ controller: OverrideSelectionController, didSelectPreset preset: TemporaryScheduleOverridePreset) -} - - -final class OverrideSelectionController: WKInterfaceController, IdentifiableClass { - - @IBOutlet private var table: WKInterfaceTable! - - private let loopManager = ExtensionDelegate.shared().loopManager - private lazy var presets = loopManager.settings.overridePresets - - weak var delegate: OverrideSelectionControllerDelegate? - - override func awake(withContext context: Any?) { - super.awake(withContext: context) - delegate = context as? OverrideSelectionControllerDelegate - - guard !presets.isEmpty else { - assertionFailure("Instantiating override selection controller without configured presets") - return - } - - configureTable() - } - - private func configureTable() { - table.setRowTypes([OverridePresetRow.className]) - table.setNumberOfRows(presets.count, withRowType: OverridePresetRow.className) - for index in presets.indices { - let row = table.rowController(at: index) as! OverridePresetRow - let preset = presets[index] - row.symbolLabel.setText(preset.symbol) - row.nameLabel.setText(preset.name) - } - } - - override func willActivate() { - super.willActivate() - } - - override func didDeactivate() { - super.didDeactivate() - } - - override func table(_ table: WKInterfaceTable, didSelectRowAt rowIndex: Int) { - let preset = presets[rowIndex] - delegate?.overrideSelectionController(self, didSelectPreset: preset) - dismiss() - } -} diff --git a/WatchApp Extension/Controllers/PresetConfirmHostingController.swift b/WatchApp Extension/Controllers/PresetConfirmHostingController.swift new file mode 100644 index 0000000000..a2a4428f37 --- /dev/null +++ b/WatchApp Extension/Controllers/PresetConfirmHostingController.swift @@ -0,0 +1,17 @@ +// +// PresetConfirmHostingController.swift +// Loop +// +// Created by Pete Schwamb on 9/19/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import WatchKit +import SwiftUI +import LoopCore + +class PresetConfirmHostingController: WKHostingController { + override var body: PresetDetailView { + return PresetDetailView(preset: ExtensionDelegate.shared().loopManager.pendingPreset) + } +} diff --git a/WatchApp Extension/ExtensionDelegate.swift b/WatchApp Extension/ExtensionDelegate.swift index 1ef1d13d75..7d2d9b00f3 100644 --- a/WatchApp Extension/ExtensionDelegate.swift +++ b/WatchApp Extension/ExtensionDelegate.swift @@ -9,15 +9,16 @@ import WatchConnectivity import WatchKit import HealthKit +import LoopAlgorithm import Intents import os import os.log import UserNotifications import LoopKit +import LoopCore +import ClockKit - -final class ExtensionDelegate: NSObject, WKExtensionDelegate { - private(set) lazy var loopManager = LoopDataManager() +class ExtensionDelegate: NSObject, WKApplicationDelegate { private let log = OSLog(category: "ExtensionDelegate") @@ -25,9 +26,11 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { private var notifications: [NSObjectProtocol] = [] static func shared() -> ExtensionDelegate { - return WKExtension.shared().extensionDelegate + return WKApplication.shared().extensionDelegate } + let loopManager = LoopDataManager.shared + override init() { super.init() @@ -40,6 +43,9 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { observers.append(session.observe(\WCSession.activationState) { [weak self] (session, change) in self?.log.default("WCSession.applicationState did change to %d", session.activationState.rawValue) + self?.log.default("WCSession.applicationState did change rootInterfaceController = %{public}@", String(describing: WKApplication.shared().rootInterfaceController)) + self?.log.default("WCSession.applicationState did change visibleInterfaceController = %{public}@", String(describing: WKApplication.shared().visibleInterfaceController)) + DispatchQueue.main.async { self?.completePendingConnectivityTasksIfNeeded() } @@ -79,14 +85,9 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { if WCSession.default.activationState != .activated { WCSession.default.activate() } - - NotificationCenter.default.post(name: type(of: self).didBecomeActiveNotification, object: self) } func applicationWillResignActive() { - UserDefaults.standard.startOnChartPage = (WKExtension.shared().visibleInterfaceController as? ChartHUDController) != nil - - NotificationCenter.default.post(name: type(of: self).willResignActiveNotification, object: self) } // Presumably the main thread? @@ -144,15 +145,11 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { } func handle(_ userActivity: NSUserActivity) { - if #available(watchOSApplicationExtension 5.0, *) { - switch userActivity.activityType { - case NSUserActivity.newCarbEntryActivityType, NSUserActivity.didAddCarbEntryOnWatchActivityType: - if let statusController = WKExtension.shared().visibleInterfaceController as? HUDInterfaceController { - statusController.addCarbs() - } - default: - break - } + switch userActivity.activityType { + case NSUserActivity.newCarbEntryActivityType, NSUserActivity.didAddCarbEntryOnWatchActivityType: + loopManager.bolusViewModel = CarbAndBolusFlowViewModel(configuration: .carbEntry(nil)) + default: + break } } @@ -165,11 +162,18 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { if context.displayGlucoseUnit == nil { let type = HKQuantityType.quantityType(forIdentifier: .bloodGlucose)! loopManager.healthStore.preferredUnits(for: [type]) { (units, error) in - context.displayGlucoseUnit = units[type] - - DispatchQueue.main.async { - self.loopManager.updateContext(context) + defer { + DispatchQueue.main.async { + self.loopManager.updateContext(context) + } } + + guard let unit = units[type] else { + context.displayGlucoseUnit = nil + return + } + + context.displayGlucoseUnit = LoopUnit(from: unit) } } else { DispatchQueue.main.async { @@ -181,8 +185,8 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { private func loopManagerDidUpdateContext() { dispatchPrecondition(condition: .onQueue(.main)) - if WKExtension.shared().applicationState != .active { - WKExtension.shared().scheduleSnapshotRefresh(withPreferredDate: Date(), userInfo: nil) { (error) in + if WKApplication.shared().applicationState != .active { + WKApplication.shared().scheduleSnapshotRefresh(withPreferredDate: Date(), userInfo: nil) { (error) in if let error = error { self.log.error("scheduleSnapshotRefresh error: %{public}@", String(describing: error)) } @@ -201,8 +205,16 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { extension ExtensionDelegate: WCSessionDelegate { func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + log.default("activationDidCompleteWith %{public}@", String(describing: activationState)) + + log.default("activationDidCompleteWith rootInterfaceController = %{public}@", String(describing: WKApplication.shared().rootInterfaceController)) + log.default("activationDidCompleteWith visibleInterfaceController = %{public}@", String(describing: WKApplication.shared().visibleInterfaceController)) + if activationState == .activated { updateContext(session.receivedApplicationContext) + Task { + await loopManager.requestSettingsUpdate() + } } } @@ -219,9 +231,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) @@ -244,15 +256,15 @@ extension ExtensionDelegate: WCSessionDelegate { } } - extension ExtensionDelegate: UNUserNotificationCenterDelegate { - func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { + + log.default("UNNotificationResponse rootInterfaceController = %{public}@", String(describing: WKApplication.shared().rootInterfaceController)) + log.default("UNNotificationResponse visibleInterfaceController = %{public}@", String(describing: WKApplication.shared().visibleInterfaceController)) + switch response.actionIdentifier { case UNNotificationDefaultActionIdentifier: - guard - response.notification.request.identifier == LoopNotificationCategory.missedMeal.rawValue, - let statusController = WKExtension.shared().visibleInterfaceController as? HUDInterfaceController - else { + guard response.notification.request.identifier == LoopNotificationCategory.missedMeal.rawValue else { break } @@ -262,21 +274,55 @@ extension ExtensionDelegate: UNUserNotificationCenterDelegate { let mealTime = userInfo[LoopNotificationUserInfoKey.missedMealTime.rawValue] as? Date, let carbAmount = userInfo[LoopNotificationUserInfoKey.missedMealCarbAmount.rawValue] as? Double { - let missedEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), + let missedEntry = NewCarbEntry(quantity: LoopQuantity(unit: .gram, doubleValue: carbAmount), startDate: mealTime, foodType: nil, absorptionTime: nil) - statusController.addCarbs(initialEntry: missedEntry) + loopManager.bolusViewModel = CarbAndBolusFlowViewModel(configuration: .carbEntry(missedEntry)) // Otherwise, just provide the ability to add carbs } else { - statusController.addCarbs() + loopManager.bolusViewModel = CarbAndBolusFlowViewModel(configuration: .carbEntry(nil)) + } + case NotificationManager.Action.startPreset.rawValue: + // Response contains the preset id and alert id + let userInfo = response.notification.request.content.userInfo + guard let presetIdentifier = userInfo[LoopNotificationUserInfoKey.presetId.rawValue] as? String, + let alertIdentifier = userInfo[LoopNotificationUserInfoKey.alertTypeID.rawValue] as? LoopKit.Alert.AlertIdentifier, + let managerIdentifier = userInfo[LoopNotificationUserInfoKey.managerIDForAlert.rawValue] as? String + else { + log.default("Unable to find keys in userInfo: %{public}@", String(describing: userInfo)) + return + } + log.default("Setting up PendingPresetReminder(presetIdentifier: %{public}@, alertIdentifier: %{public}@), managerIdentifier: %{public}@", presetIdentifier, alertIdentifier, managerIdentifier) + + loopManager.pendingPresetReminder = PendingPresetReminder( + presetIdentifier: presetIdentifier, + alertIdentifier: alertIdentifier, + managerIdentifier: managerIdentifier + ) + + guard let visibleVC = WKApplication.shared().visibleInterfaceController else { + log.error("no visible interface controller for presenting preset reminder!") + return + } + + guard let preset = loopManager.selectablePresets.first(where: { $0.id == presetIdentifier }) else { + log.error("Unable to find preset %{public}@", presetIdentifier) + return } + + visibleVC.presentController(withName: "PresetConfirmHostingController", context: preset) + default: + let userInfo = response.notification.request.content.userInfo + if let alertIdentifier = userInfo[LoopNotificationUserInfoKey.alertTypeID.rawValue] as? LoopKit.Alert.AlertIdentifier, + let managerIdentifier = userInfo[LoopNotificationUserInfoKey.managerIDForAlert.rawValue] as? String + { + await loopManager.sendUserSelectedNotificationActionMessage(alertIdentifier: alertIdentifier, managerIdentifier: managerIdentifier, actionIdentifier: response.actionIdentifier) + } break } - - completionHandler() } func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { @@ -286,22 +332,18 @@ extension ExtensionDelegate: UNUserNotificationCenterDelegate { extension ExtensionDelegate { - static let didBecomeActiveNotification = Notification.Name("com.loopkit.Loop.LoopWatch.didBecomeActive") - - static let willResignActiveNotification = Notification.Name("com.loopkit.Loop.LoopWatch.willResignActive") - /// Global shortcut to present an alert for a specific error out-of-context with a specific interface controller. /// /// - parameter error: The error whose contents to display func present(_ error: Error) { dispatchPrecondition(condition: .onQueue(.main)) - WKExtension.shared().rootInterfaceController?.presentAlert(withTitle: error.localizedDescription, message: (error as NSError).localizedRecoverySuggestion ?? (error as NSError).localizedFailureReason, preferredStyle: .alert, actions: [WKAlertAction.dismissAction()]) + WKApplication.shared().rootInterfaceController?.presentAlert(withTitle: error.localizedDescription, message: (error as NSError).localizedRecoverySuggestion ?? (error as NSError).localizedFailureReason, preferredStyle: .alert, actions: [WKAlertAction.dismissAction()]) } } -fileprivate extension WKExtension { +fileprivate extension WKApplication { var extensionDelegate: ExtensionDelegate! { return delegate as? ExtensionDelegate } diff --git a/WatchApp Extension/Extensions/CLKComplicationTemplate.swift b/WatchApp Extension/Extensions/CLKComplicationTemplate.swift index f49a9f2db0..d2b9933a1a 100644 --- a/WatchApp Extension/Extensions/CLKComplicationTemplate.swift +++ b/WatchApp Extension/Extensions/CLKComplicationTemplate.swift @@ -7,10 +7,10 @@ // import ClockKit -import HealthKit import LoopKit import Foundation import LoopCore +import LoopAlgorithm extension CLKComplicationTemplate { @@ -25,25 +25,29 @@ 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( _ family: CLKComplicationFamily, - glucose: HKQuantity, - unit: HKUnit, + glucose: LoopQuantity, + unit: LoopUnit, glucoseDate: Date?, trend: GlucoseTrend?, - eventualGlucose: HKQuantity?, + glucoseCondition: GlucoseCondition?, + eventualGlucose: LoopQuantity?, at date: Date, loopLastRunDate: Date?, recencyInterval: TimeInterval, @@ -65,7 +69,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 @@ -128,6 +140,7 @@ extension CLKComplicationTemplate { eventualGlucoseText = eventualGlucoseString } + // 106↗108 8:47 PM let format = NSLocalizedString("UtilitarianLargeFlat", tableName: "ckcomplication", comment: "Utilitarian large flat format string (1: Glucose & Trend symbol) (2: Eventual Glucose) (3: Time)") return CLKComplicationTemplateUtilitarianLargeFlat( @@ -161,6 +174,7 @@ extension CLKComplicationTemplate { unit: unit, glucoseDate: glucoseDate, trend: trend, + glucoseCondition: glucoseCondition, eventualGlucose: eventualGlucose, at: date, loopLastRunDate: loopLastRunDate, @@ -177,7 +191,7 @@ extension CLKComplicationTemplate { case .graphicRectangular: if #available(watchOSApplicationExtension 5.0, *) { return CLKComplicationTemplateGraphicRectangularLargeImage( - textProvider: CLKTextProvider(byJoining: [glucoseAndTrendText, timeText], separator: " "), + textProvider: CLKTextProvider(format: "%@ %@", glucoseAndTrendText, timeText), imageProvider: CLKFullColorImageProvider(fullColorImage: makeChart() ?? UIImage()) ) } else { diff --git a/WatchApp Extension/Extensions/EnvironmentValues+GlucoseDisplayUnit.swift b/WatchApp Extension/Extensions/EnvironmentValues+GlucoseDisplayUnit.swift new file mode 100644 index 0000000000..f7a035aab1 --- /dev/null +++ b/WatchApp Extension/Extensions/EnvironmentValues+GlucoseDisplayUnit.swift @@ -0,0 +1,22 @@ +// +// Env.swift +// Loop +// +// Created by Pete Schwamb on 9/9/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopAlgorithm + +@MainActor +private struct GlucoseDisplayUnitKey: @preconcurrency EnvironmentKey { + static let defaultValue: LoopUnit = .milligramsPerDeciliter +} + +extension EnvironmentValues { + var glucoseDisplayUnit: LoopUnit { + get { self[GlucoseDisplayUnitKey.self] } + set { self[GlucoseDisplayUnitKey.self] = newValue } + } +} 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/UIColor.swift b/WatchApp Extension/Extensions/UIColor.swift index 8805741357..8670fdcdb0 100644 --- a/WatchApp Extension/Extensions/UIColor.swift +++ b/WatchApp Extension/Extensions/UIColor.swift @@ -28,6 +28,11 @@ extension UIColor { static let overrideColor = UIColor(named: "workout")! + static let presets = UIColor(named: "presets")! + + static let darkPresets = UIColor(named: "presets-dark")! + + // Equivalent to workoutColor with alpha 0.14 on a black background static let darkOverrideColor = UIColor(named: "workout-dark")! @@ -42,7 +47,9 @@ extension UIColor { static let chartPlatter = HIGWhiteColorDark() static let agingColor = UIColor(named: "warning") ?? HIGYellowColor() - + + static let fresh = UIColor(named: "fresh") ?? .purple + static let staleColor = HIGRedColor() // MARK: - HIG colors diff --git a/WatchApp Extension/Extensions/WCSession.swift b/WatchApp Extension/Extensions/WCSession.swift index 246eff2b2c..3e5f65c018 100644 --- a/WatchApp Extension/Extensions/WCSession.swift +++ b/WatchApp Extension/Extensions/WCSession.swift @@ -8,6 +8,7 @@ import LoopCore import WatchConnectivity +import LoopKit import os.log @@ -26,70 +27,80 @@ enum WCSessionMessageResult { private let log = OSLog(category: "WCSession Extension") extension WCSession { - func sendPotentialCarbEntryMessage(_ carbEntry: PotentialCarbEntryUserInfo, replyHandler: @escaping (WatchContext) -> Void, errorHandler: @escaping (Error) -> Void) throws { - guard activationState == .activated else { - throw MessageError.activation - } - guard isReachable else { - log.default("sendPotentialCarbEntryMessage: Phone is unreachable, taking no action") - return - } - - sendMessage(carbEntry.rawValue, - replyHandler: { reply in - guard let context = WatchContext(rawValue: reply as WatchContext.RawValue) else { - log.error("sendPotentialCarbEntryMessage: could not decode reply: %{public}@", reply) - errorHandler(MessageError.decoding) + func fetchSettings() async throws -> LoopSettingsUserInfo { + try await withCheckedThrowingContinuation { continuation in + sendMessage(SettingsRequestUserInfo().rawValue) { reply in + guard let settings = LoopSettingsUserInfo(rawValue: reply as LoopSettingsUserInfo.RawValue) else { + log.error("fetchSettings: could not decode reply: %{public}@", reply) + continuation.resume(throwing: MessageError.decoding) return } - - replyHandler(context) - }, - errorHandler: { error in - log.error("sendPotentialCarbEntryMessage: message send failed with error: %{public}@", String(describing: error)) - errorHandler(error) + continuation.resume(returning: settings) } - ) + } } - func sendBolusMessage(_ userInfo: SetBolusUserInfo, completionHandler: @escaping (Error?) -> Void) throws { - guard activationState == .activated else { - throw MessageError.activation + func fetchBolusRecommendation(_ carbEntry: NewCarbEntry?) async throws -> WatchContext { + let request = GetBolusRecommendationUserInfo(carbEntry: carbEntry) + let reply = try await sendMessage(request.rawValue) + log.debug("Requesting bolus recommendation with carbEntry: %{public}@", String(describing: carbEntry)) + + guard let context = WatchContext(rawValue: reply as WatchContext.RawValue) else { + log.error("fetchBolusRecommendation: could not decode reply: %{public}@", reply) + throw MessageError.decoding } + log.debug("fetchBolusRecommendation: recommendedBolusDose: %{public}@", String(describing: context.recommendedBolusDose)) - guard isReachable else { - throw MessageError.reachability + return context + } + + func sendBolusMessage(_ userInfo: SetBolusUserInfo) async throws -> WatchContext { + let reply = try await sendMessage(userInfo.rawValue) + guard let context = WatchContext(rawValue: reply as WatchContext.RawValue) else { + log.error("sendBolusMessage: could not decode reply: %{public}@", reply) + throw MessageError.decoding } + return context + } - sendMessage(userInfo.rawValue, - replyHandler: { reply in - completionHandler(nil) - }, - errorHandler: { error in - log.info("sendBolusMessage failure: %{public}@", error.localizedDescription) - completionHandler(error) - } - ) + func sendSetPreset(presetIdentifier: String?, alertIdentifier: String?) async throws { + let _ = try await sendMessage(SetPresetUserInfo(presetIdentifier: presetIdentifier, alertIdentifier: alertIdentifier).rawValue) + } + + func sendAcknowledgeAlert(alertIdentifier: String, managerIdentifier: String) async throws { + let _ = try await sendMessage(AcknowledgeAlertUserInfo(alertIdentifier: alertIdentifier, managerIdentifier: managerIdentifier).rawValue) } - func sendSettingsUpdateMessage(_ userInfo: LoopSettingsUserInfo, completionHandler: @escaping (Result) -> Void) throws { + func sendMessage(_ msg: [String : Any]) async throws -> [String : Any] { guard activationState == .activated else { throw MessageError.activation } - + guard isReachable else { throw MessageError.reachability } - sendMessage(userInfo.rawValue, replyHandler: { (reply) in - if let context = WatchContext(rawValue: reply) { - completionHandler(.success(context)) - } else { - completionHandler(.failure(MessageError.decoding)) - } + return try await withCheckedThrowingContinuation { continuation in + sendMessage(msg, replyHandler: { result in + continuation.resume(returning: result) + }, errorHandler: { error in + continuation.resume(throwing: error) + }) + } + } + + func sendUserSelectedNotificationActionMessage(alertIdentifier: String, managerIdentifier: String, actionIdentifier: String) async { + let msg = NotificationActionSelection( + alertIdentifier: alertIdentifier, + managerIdentifier: managerIdentifier, + actionIdentifier: actionIdentifier + ) + + sendMessage(msg.rawValue, replyHandler: { (reply) in + log.error("Sent notication action selection: ${public}@", actionIdentifier) }, errorHandler: { (error) in - completionHandler(.failure(error)) + log.error("sendUserSelectedNotificationActionMessage failed: ${public}@", String(describing: error)) }) } @@ -159,7 +170,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/Extensions/WatchContext+WatchApp.swift b/WatchApp Extension/Extensions/WatchContext+WatchApp.swift index 6c77afd7c2..0f3b85fb7d 100644 --- a/WatchApp Extension/Extensions/WatchContext+WatchApp.swift +++ b/WatchApp Extension/Extensions/WatchContext+WatchApp.swift @@ -7,31 +7,32 @@ // import Foundation -import HealthKit +import LoopAlgorithm import LoopKit +import LoopCore extension WatchContext { - var activeInsulin: HKQuantity? { + var activeInsulin: LoopQuantity? { guard let value = iob else { return nil } - return HKQuantity(unit: .internationalUnit(), doubleValue: value) + return LoopQuantity(unit: .internationalUnit, doubleValue: value) } - var activeCarbohydrates: HKQuantity? { + var activeCarbohydrates: LoopQuantity? { guard let value = cob else { return nil } - return HKQuantity(unit: .gram(), doubleValue: value) + return LoopQuantity(unit: .gram, doubleValue: value) } - var reservoirVolume: HKQuantity? { + var reservoirVolume: LoopQuantity? { guard let value = reservoir else { return nil } - return HKQuantity(unit: .internationalUnit(), doubleValue: value) + return LoopQuantity(unit: .internationalUnit, doubleValue: value) } } diff --git a/WatchApp Extension/Info.plist b/WatchApp Extension/Info.plist deleted file mode 100644 index e0a8a9a98f..0000000000 --- a/WatchApp Extension/Info.plist +++ /dev/null @@ -1,61 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleDisplayName - WatchApp Extension - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - XPC! - CFBundleShortVersionString - $(LOOP_MARKETING_VERSION) - CFBundleSignature - ???? - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - CLKComplicationPrincipalClass - $(PRODUCT_MODULE_NAME).ComplicationController - CLKComplicationSupportedFamilies - - CLKComplicationFamilyCircularSmall - CLKComplicationFamilyExtraLarge - CLKComplicationFamilyGraphicBezel - CLKComplicationFamilyGraphicCircular - CLKComplicationFamilyGraphicCorner - CLKComplicationFamilyGraphicExtraLarge - CLKComplicationFamilyGraphicRectangular - CLKComplicationFamilyModularLarge - CLKComplicationFamilyModularSmall - CLKComplicationFamilyUtilitarianLarge - CLKComplicationFamilyUtilitarianSmall - CLKComplicationFamilyUtilitarianSmallFlat - - NSExtension - - NSExtensionAttributes - - WKAppBundleIdentifier - $(MAIN_APP_BUNDLE_IDENTIFIER).LoopWatch - - NSExtensionPointIdentifier - com.apple.watchkit - - NSHealthShareUsageDescription - Meal data from the Health database is used to determine glucose effects. Glucose data from the Health database is used for graphing and momentum calculation. Sleep data from the Health database is used to optimize delivery of Apple Watch complication updates during the time you are awake. - NSHealthUpdateUsageDescription - Carbohydrate meal data entered in the app and on the watch is stored in the Health database. Glucose data retrieved from the CGM is stored securely in HealthKit. - RemoteInterfacePrincipalClass - $(PRODUCT_MODULE_NAME).StatusInterfaceController - WKExtensionDelegateClassName - $(PRODUCT_MODULE_NAME).ExtensionDelegate - - diff --git a/WatchApp Extension/Managers/ComplicationChartManager.swift b/WatchApp Extension/Managers/ComplicationChartManager.swift index bfca19ea24..3d4268afa8 100644 --- a/WatchApp Extension/Managers/ComplicationChartManager.swift +++ b/WatchApp Extension/Managers/ComplicationChartManager.swift @@ -8,9 +8,9 @@ import Foundation import UIKit -import HealthKit import WatchKit import LoopKit +import LoopAlgorithm private let textInsets = UIEdgeInsets(top: 2, left: 2, bottom: 2, right: 2) @@ -45,7 +45,7 @@ final class ComplicationChartManager { private var renderedChartImage: UIImage? private var visibleInterval: TimeInterval = .hours(4) - private var unit: HKUnit { + private var unit: LoopUnit { return data?.unit ?? .milligramsPerDeciliter } @@ -123,19 +123,8 @@ final class ComplicationChartManager { let spannedInterval = scaler.dates func drawOverride( - _ override: TemporaryScheduleOverride, - pushingStartTo startDate: Date? = nil, - extendingToChartEnd shouldExtendToChartEnd: Bool + _ override: TemporaryScheduleOverride ) { - var override = override - if let startDate = startDate { - guard startDate < override.scheduledEndDate else { - return - } - - override.scheduledInterval = DateInterval(start: startDate, end: override.scheduledEndDate) - } - guard let overrideHashable = TemporaryScheduleOverrideHashable(override) else { return } @@ -144,7 +133,7 @@ final class ComplicationChartManager { let overrideRect = scaler.rect(for: overrideHashable, unit: unit) context.fill(overrideRect) - if spannedInterval.end > override.scheduledEndDate, shouldExtendToChartEnd { + if spannedInterval.end > override.scheduledEndDate { var extendedOverride = override extendedOverride.duration = .finite(spannedInterval.end.timeIntervalSince(override.startDate)) // Target range already known to be non-nil @@ -155,12 +144,8 @@ final class ComplicationChartManager { } } - if let preMealOverride = data?.activePreMealOverride { - drawOverride(preMealOverride, extendingToChartEnd: true) - } - if let override = data?.activeScheduleOverride { - drawOverride(override, pushingStartTo: data?.activePreMealOverride?.scheduledEndDate, extendingToChartEnd: data?.activePreMealOverride == nil) + drawOverride(override) } } diff --git a/WatchApp Extension/Managers/LoopDataManager.swift b/WatchApp Extension/Managers/LoopDataManager.swift index 579b6a2148..21e367db88 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,25 +12,62 @@ import LoopKit import LoopCore import WatchConnectivity import os.log +import LoopAlgorithm +import UserNotifications +import WatchKit - +@MainActor +@Observable class LoopDataManager { + static let shared = LoopDataManager() + let carbStore: CarbStore - let glucoseStore: GlucoseStore + var glucoseStore: GlucoseStore? + @ObservationIgnored @PersistedProperty(key: "Settings") - private var rawSettings: LoopSettings.RawValue? + private var rawWatchInfo: LoopSettingsUserInfo.RawValue? + + @ObservationIgnored + @PersistedProperty(key: "WatchContext") + private var rawWatchContext: WatchContext.RawValue? // Main queue only - var settings: LoopSettings { + var watchInfo: LoopSettingsUserInfo { didSet { needsDidUpdateContextNotification = true sendDidUpdateContextNotificationIfNecessary() - rawSettings = settings.rawValue + rawWatchInfo = watchInfo.rawValue + } + } + + var pendingPresetReminder: PendingPresetReminder? + + var pendingPreset: SelectablePreset? { + if let presetIdentifier = pendingPresetReminder?.presetIdentifier { + return selectablePresets.first(where: { $0.id == presetIdentifier })! + } else { + return nil } } + var activePreset: SelectablePreset? { + guard let presetId = watchInfo.scheduleOverride?.presetId else { + return nil + } + return selectablePresets.first(where: { $0.id == presetId }) + } + + var glucoseChartScene: GlucoseChartScene = { + let s = GlucoseChartScene() + s.size = WKInterfaceDevice.current().screenBounds.size + return s + }() + + // When set, user will be navigated to carbs/bolus flow + var bolusViewModel: CarbAndBolusFlowViewModel? + // Main queue only var supportedBolusVolumes = UserDefaults.standard.supportedBolusVolumes { didSet { @@ -40,11 +77,12 @@ class LoopDataManager { } } - private let log = OSLog(category: "LoopDataManager") + private let log = OSLog(category: "LoopDosingManager") // Main queue only private(set) var activeContext: WatchContext? { didSet { + rawWatchContext = activeContext?.rawValue needsDidUpdateContextNotification = true sendDidUpdateContextNotificationIfNecessary() } @@ -66,20 +104,27 @@ 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 + syncVersion: 0 ) - glucoseStore = GlucoseStore( - cacheStore: cacheStore, - cacheLength: .hours(4), - provenanceIdentifier: HKSource.default().bundleIdentifier + + self.watchInfo = LoopSettingsUserInfo( + loopSettings: LoopSettings(), + scheduleOverride: nil ) + + Task { + glucoseStore = await GlucoseStore( + cacheStore: cacheStore, + cacheLength: .hours(4) + ) + } - settings = LoopSettings() + if let rawWatchInfo, let watchInfo = LoopSettingsUserInfo(rawValue: rawWatchInfo) { + self.watchInfo = watchInfo + } - if let rawSettings = rawSettings, let storedSettings = LoopSettings(rawValue: rawSettings) { - self.settings = storedSettings + if let rawWatchContext, let watchContext = WatchContext(rawValue: rawWatchContext) { + self.activeContext = watchContext } } } @@ -94,7 +139,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 } @@ -109,10 +156,18 @@ extension LoopDataManager { } } + func sendUserSelectedNotificationActionMessage(alertIdentifier: String, managerIdentifier: String, actionIdentifier: String) async { + await WCSession.default.sendUserSelectedNotificationActionMessage( + alertIdentifier: alertIdentifier, + managerIdentifier: managerIdentifier, + actionIdentifier: actionIdentifier + ) + } + 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 +206,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)) } } @@ -168,6 +225,12 @@ extension LoopDataManager { return true } + func requestSettingsUpdate() async { + if let settings = try? await WCSession.default.fetchSettings() { + self.watchInfo = settings + } + } + func requestContextUpdate(completion: @escaping () -> Void = { }) { try? WCSession.default.sendContextRequestMessage(WatchContextRequestUserInfo(), completionHandler: { (result) in DispatchQueue.main.async { @@ -181,39 +244,108 @@ extension LoopDataManager { } }) } + + func clearOverride() async throws { + var watchInfoUpdate = self.watchInfo + watchInfoUpdate.scheduleOverride = nil + try await WCSession.default.sendSetPreset(presetIdentifier: nil, alertIdentifier: nil) + watchInfo = watchInfoUpdate + } + + func activateOverride(_ override: TemporaryScheduleOverride, alertIdentifierToAcknowledge: String? = nil) async throws { + var watchInfoUpdate = self.watchInfo + watchInfoUpdate.scheduleOverride = override + try await WCSession.default.sendSetPreset(presetIdentifier: override.presetId, alertIdentifier: alertIdentifierToAcknowledge) + watchInfo = watchInfoUpdate + } + + func acknowledgeAlert(alertIdentifier: String, managerIdentifier: String) async throws { + self.log.default("Acknowledging alert %{public}@ : %{public}@", alertIdentifier, managerIdentifier) + try await WCSession.default.sendAcknowledgeAlert(alertIdentifier: alertIdentifier, managerIdentifier: managerIdentifier) + } + + var selectablePresets: [SelectablePreset] { + var presets: [SelectablePreset] = [] + + let settings = watchInfo.loopSettings + + if let preMealTargetRange = settings.preMealTargetRange { + presets.append(.preMeal(range: preMealTargetRange)) + } + + presets.append(contentsOf: settings.overridePresets.map { override in + if override.id.hasPrefix("activity-"), let activityPreset = ActivityPreset(preset: override) { + return .activity(activityPreset) + } else { + return .custom(override) + } + }) + + ActivityPreset.ActivityType.allCases.forEach { activityType in + if !settings.overridePresets.contains(where: { $0.id == activityType.id }) { + presets.append( + .activity( + ActivityPreset( + activityType: activityType, + preset: activityType.completeDefaultPreset + ) + ) + ) + } + } + + return presets + } + + var glucoseValue: String { + guard let activeContext = activeContext, + let glucose = activeContext.glucose, + let unit = activeContext.displayGlucoseUnit else + { + return "- - -" + } + + let formatter = NumberFormatter.glucoseFormatter(for: unit) + + var glucoseValue: String + + if let glucoseCondition = activeContext.glucoseCondition { + glucoseValue = glucoseCondition.localizedDescription + } else { + glucoseValue = formatter.string(from: glucose.doubleValue(for: unit)) ?? "???" + } + + let trend = activeContext.glucoseTrend?.symbol ?? "" + return glucoseValue + trend + } } extension LoopDataManager { - var displayGlucoseUnit: HKUnit { + var displayGlucoseUnit: LoopUnit { activeContext?.displayGlucoseUnit ?? .milligramsPerDeciliter } } extension LoopDataManager { - func generateChartData(completion: @escaping (GlucoseChartData?) -> Void) { + + func generateChartData() async -> GlucoseChartData? { guard let activeContext = activeContext else { - completion(nil) - return + return nil } - glucoseStore.getGlucoseSamples(start: .earliestGlucoseCutoff) { result in - var historicalGlucose: [StoredGlucoseSample]? - switch result { - case .failure(let error): - 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, - historicalGlucose: historicalGlucose, - predictedGlucose: (activeContext.isClosedLoop ?? false) ? activeContext.predictedGlucose?.values : nil - ) - completion(chartData) + var historicalGlucose: [StoredGlucoseSample]? + do { + historicalGlucose = try await glucoseStore?.getGlucoseSamples(start: .earliestGlucoseCutoff) + } catch { + self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) } + let chartData = GlucoseChartData( + unit: activeContext.displayGlucoseUnit, + correctionRange: self.watchInfo.loopSettings.glucoseTargetRangeSchedule, + scheduleOverride: self.watchInfo.scheduleOverride, + historicalGlucose: historicalGlucose, + predictedGlucose: (activeContext.isClosedLoop ?? false) ? activeContext.predictedGlucose?.values : nil + ) + return chartData } } diff --git a/Common/Models/CarbAbsorptionTime.swift b/WatchApp Extension/Models/CarbAbsorptionTime.swift similarity index 100% rename from Common/Models/CarbAbsorptionTime.swift rename to WatchApp Extension/Models/CarbAbsorptionTime.swift diff --git a/WatchApp Extension/Models/GlucoseChartData.swift b/WatchApp Extension/Models/GlucoseChartData.swift index 4ed6bd7ee8..27903af5b6 100644 --- a/WatchApp Extension/Models/GlucoseChartData.swift +++ b/WatchApp Extension/Models/GlucoseChartData.swift @@ -7,17 +7,15 @@ // import Foundation -import HealthKit import LoopKit +import LoopAlgorithm struct GlucoseChartData { - var unit: HKUnit? + var unit: LoopUnit? var correctionRange: GlucoseRangeSchedule? - var preMealOverride: TemporaryScheduleOverride? - var scheduleOverride: TemporaryScheduleOverride? var historicalGlucose: [SampleValue]? { @@ -26,7 +24,7 @@ struct GlucoseChartData { } } - private(set) var historicalGlucoseRange: ClosedRange? + private(set) var historicalGlucoseRange: ClosedRange? var predictedGlucose: [SampleValue]? { didSet { @@ -34,12 +32,11 @@ struct GlucoseChartData { } } - private(set) var predictedGlucoseRange: ClosedRange? + private(set) var predictedGlucoseRange: ClosedRange? - init(unit: HKUnit?, correctionRange: GlucoseRangeSchedule?, preMealOverride: TemporaryScheduleOverride?, scheduleOverride: TemporaryScheduleOverride?, historicalGlucose: [SampleValue]?, predictedGlucose: [SampleValue]?) { + init(unit: LoopUnit?, correctionRange: GlucoseRangeSchedule?, scheduleOverride: TemporaryScheduleOverride?, historicalGlucose: [SampleValue]?, predictedGlucose: [SampleValue]?) { self.unit = unit self.correctionRange = correctionRange - self.preMealOverride = preMealOverride self.scheduleOverride = scheduleOverride self.historicalGlucose = historicalGlucose self.historicalGlucoseRange = historicalGlucose?.quantityRange @@ -47,7 +44,7 @@ struct GlucoseChartData { self.predictedGlucoseRange = predictedGlucose?.quantityRange } - func chartableGlucoseRange(from interval: DateInterval) -> ClosedRange { + func chartableGlucoseRange(from interval: DateInterval) -> ClosedRange { let unit = self.unit ?? .milligramsPerDeciliter // Defaults @@ -59,11 +56,6 @@ struct GlucoseChartData { max = Swift.max(max, correction.value.upperBound.doubleValue(for: unit)) } - if let override = activePreMealOverride?.settings.targetRange { - min = Swift.min(min, override.lowerBound.doubleValue(for: unit)) - max = Swift.max(max, override.upperBound.doubleValue(for: unit)) - } - if let override = activeScheduleOverride?.settings.targetRange { min = Swift.min(min, override.lowerBound.doubleValue(for: unit)) max = Swift.max(max, override.upperBound.doubleValue(for: unit)) @@ -84,8 +76,8 @@ struct GlucoseChartData { min = Swift.max(0, min.floored(to: unit.axisIncrement)) max = max.ceiled(to: unit.axisIncrement) - let lowerBound = HKQuantity(unit: unit, doubleValue: min) - let upperBound = HKQuantity(unit: unit, doubleValue: max) + let lowerBound = LoopQuantity(unit: unit, doubleValue: min) + let upperBound = LoopQuantity(unit: unit, doubleValue: max) return lowerBound...upperBound } @@ -96,16 +88,9 @@ struct GlucoseChartData { } return override } - - var activePreMealOverride: TemporaryScheduleOverride? { - guard let override = preMealOverride, override.isActive() else { - return nil - } - return override - } } -private extension HKUnit { +private extension LoopUnit { var axisIncrement: Double { return chartableIncrement * 25 } diff --git a/WatchApp Extension/Models/GlucoseChartScaler.swift b/WatchApp Extension/Models/GlucoseChartScaler.swift index cb03f8380b..979eeb5e74 100644 --- a/WatchApp Extension/Models/GlucoseChartScaler.swift +++ b/WatchApp Extension/Models/GlucoseChartScaler.swift @@ -8,9 +8,9 @@ import Foundation import CoreGraphics -import HealthKit import LoopKit import WatchKit +import LoopAlgorithm enum CoordinateSystem { @@ -48,14 +48,14 @@ struct GlucoseChartScaler { return CGPoint(x: xCoordinate(for: date), y: yCoordinate(for: glucose)) } - func point(for glucose: SampleValue, unit: HKUnit) -> CGPoint { + func point(for glucose: SampleValue, unit: LoopUnit) -> CGPoint { return point(glucose.startDate, glucose.quantity.doubleValue(for: unit)) } // By default enforce a minimum height so that the range is visible func rect( for range: GlucoseChartValueHashable, - unit: HKUnit, + unit: LoopUnit, minHeight: CGFloat = 2, alignedToScreenScale screenScale: CGFloat = WKInterfaceDevice.current().screenScale ) -> CGRect { @@ -79,7 +79,7 @@ struct GlucoseChartScaler { } extension GlucoseChartScaler { - init(size: CGSize, dateInterval: DateInterval, glucoseRange: ClosedRange, unit: HKUnit, coordinateSystem: CoordinateSystem = .standard) { + init(size: CGSize, dateInterval: DateInterval, glucoseRange: ClosedRange, unit: LoopUnit, coordinateSystem: CoordinateSystem = .standard) { self.dates = dateInterval self.glucoseMin = glucoseRange.lowerBound.doubleValue(for: unit) self.glucoseMax = glucoseRange.upperBound.doubleValue(for: unit) @@ -89,8 +89,8 @@ extension GlucoseChartScaler { } } -extension ClosedRange where Bound == HKQuantity { - fileprivate func span(with unit: HKUnit) -> Double { +extension ClosedRange where Bound == LoopQuantity { + fileprivate func span(with unit: LoopUnit) -> Double { return upperBound.doubleValue(for: unit) - lowerBound.doubleValue(for: unit) } } diff --git a/WatchApp Extension/Models/PendingPresetReminder.swift b/WatchApp Extension/Models/PendingPresetReminder.swift new file mode 100644 index 0000000000..bd796285e1 --- /dev/null +++ b/WatchApp Extension/Models/PendingPresetReminder.swift @@ -0,0 +1,14 @@ +// +// PendingPresetReminder.swift +// Loop +// +// Created by Pete Schwamb on 9/18/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + + +struct PendingPresetReminder: Equatable { + var presetIdentifier: String + var alertIdentifier: String + var managerIdentifier: String +} diff --git a/WatchApp Extension/Scenes/GlucoseChartScene.swift b/WatchApp Extension/Scenes/GlucoseChartScene.swift index 8f69ff5e1d..0c7c6473ac 100644 --- a/WatchApp Extension/Scenes/GlucoseChartScene.swift +++ b/WatchApp Extension/Scenes/GlucoseChartScene.swift @@ -8,7 +8,6 @@ import Foundation import SpriteKit -import HealthKit import LoopKit import WatchKit import os.log @@ -238,7 +237,7 @@ class GlucoseChartScene: SKScene { // Keep track of the nodes we started this pass with so we can expire obsolete nodes at the end var inactiveNodes = nodes - let isOverrideActive = data.activePreMealOverride != nil || data.activeScheduleOverride != nil + let isOverrideActive = data.activeScheduleOverride != nil data.correctionRange?.quantityBetween(start: spannedInterval.start, end: spannedInterval.end).forEach { range in let (sprite, created) = getSprite(forHash: range.chartHashValue) sprite.color = UIColor.glucose.withAlphaComponent(isOverrideActive ? 0.2 : 0.3) @@ -252,8 +251,7 @@ class GlucoseChartScene: SKScene { // extends to the end of the visible window. func plotOverride( _ override: TemporaryScheduleOverride, - pushingStartTo startDate: Date? = nil, - extendingToChartEnd shouldExtendToChartEnd: Bool + pushingStartTo startDate: Date? = nil ) { var override = override if let startDate = startDate { @@ -274,7 +272,7 @@ class GlucoseChartScene: SKScene { sprite1.move(to: scaler.rect(for: overrideHashable, unit: unit), animated: !created) inactiveNodes.removeValue(forKey: overrideHashable.chartHashValue) - if override.scheduledEndDate < spannedInterval.end, shouldExtendToChartEnd { + if override.scheduledEndDate < spannedInterval.end { var extendedOverride = override extendedOverride.duration = .finite(spannedInterval.end.timeIntervalSince(overrideHashable.start)) // Target range already known to be non-nil @@ -287,12 +285,8 @@ class GlucoseChartScene: SKScene { } } - if let preMealOverride = data.activePreMealOverride { - plotOverride(preMealOverride, extendingToChartEnd: true) - } - if let override = data.activeScheduleOverride { - plotOverride(override, pushingStartTo: data.activePreMealOverride?.scheduledEndDate, extendingToChartEnd: data.activePreMealOverride == nil) + plotOverride(override) } data.historicalGlucose?.filter { scaler.dates.contains($0.startDate) }.forEach { diff --git a/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift b/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift index 4737a2336f..ab5ea5800b 100644 --- a/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift +++ b/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift @@ -6,14 +6,14 @@ // import LoopKit -import HealthKit +import LoopAlgorithm protocol GlucoseChartValueHashable { var start: Date { get } var end: Date { get } - var min: HKQuantity { get } - var max: HKQuantity { get } + var min: LoopQuantity { get } + var max: LoopQuantity { get } var chartHashValue: Int { get } } @@ -58,7 +58,7 @@ extension SampleValue { } -extension AbsoluteScheduleValue: GlucoseChartValueHashable where T == ClosedRange { +extension AbsoluteScheduleValue: GlucoseChartValueHashable where T == ClosedRange { var start: Date { return startDate } @@ -67,11 +67,11 @@ extension AbsoluteScheduleValue: GlucoseChartValueHashable where T == ClosedRang return endDate } - var min: HKQuantity { + var min: LoopQuantity { return value.lowerBound } - var max: HKQuantity { + var max: LoopQuantity { return value.upperBound } } @@ -94,11 +94,13 @@ struct TemporaryScheduleOverrideHashable: GlucoseChartValueHashable { return override.activeInterval.end } - var min: HKQuantity { - return override.settings.targetRange!.lowerBound + var min: LoopQuantity { + let effectiveTargetRange = override.effectiveCorrectionRangeDuring(scheduledRange: override.settings.targetRange!) + return effectiveTargetRange.lowerBound } - var max: HKQuantity { - return override.settings.targetRange!.upperBound + var max: LoopQuantity { + let effectiveTargetRange = override.effectiveCorrectionRangeDuring(scheduledRange: override.settings.targetRange!) + return effectiveTargetRange.upperBound } } diff --git a/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift b/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift index 0cc21ca554..1ccb12d976 100644 --- a/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift +++ b/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift @@ -8,13 +8,13 @@ import Foundation import Combine -import HealthKit +import LoopAlgorithm import WatchKit import WatchConnectivity import LoopKit import LoopCore - +@MainActor final class CarbAndBolusFlowViewModel: ObservableObject { enum Error: Swift.Error { case potentialCarbEntryMessageSendFailure @@ -31,7 +31,6 @@ final class CarbAndBolusFlowViewModel: ObservableObject { let interactionStartDate = Date() private var carbEntryUnderConsideration: NewCarbEntry? private var contextUpdateObservation: AnyObject? - private var hasSentConfirmationMessage = false private var contextDate: Date? // MARK: - Constants @@ -40,64 +39,64 @@ final class CarbAndBolusFlowViewModel: ObservableObject { // MARK: - Initialization let configuration: CarbAndBolusFlow.Configuration - private let dismiss: () -> Void init( - configuration: CarbAndBolusFlow.Configuration, - dismiss: @escaping () -> Void + configuration: CarbAndBolusFlow.Configuration ) { - let loopManager = ExtensionDelegate.shared().loopManager - switch configuration { - case .carbEntry: - break - case .manualBolus: - let activeContext = loopManager.activeContext - self.contextDate = activeContext?.creationDate - self._recommendedBolusAmount = Published(initialValue: activeContext?.recommendedBolusDose) - } + let loopManager = LoopDataManager.shared + self.configuration = configuration self._bolusPickerValues = Published( initialValue: BolusPickerValues( supportedVolumes: loopManager.supportedBolusVolumes ?? Self.defaultSupportedBolusVolumes, - maxBolus: loopManager.settings.maximumBolus ?? Self.defaultMaxBolus + maxBolus: loopManager.watchInfo.loopSettings.maximumBolus ?? Self.defaultMaxBolus ) ) - self.configuration = configuration - self.dismiss = dismiss + switch configuration { + case .carbEntry: + break + case .manualBolus: + // If we start out on the manual bolus screen, fetch a fresh recommendation immediately + Task { @MainActor in + await recommendBolus() + } + } contextUpdateObservation = NotificationCenter.default.addObserver( forName: LoopDataManager.didUpdateContextNotification, object: loopManager, queue: nil ) { [weak self] _ in - guard - let self = self, - !self.hasSentConfirmationMessage - else { - return + Task { @MainActor in + self?.handleContextUpdate(loopManager: loopManager) } - - self.bolusPickerValues = BolusPickerValues( - supportedVolumes: loopManager.supportedBolusVolumes ?? Self.defaultSupportedBolusVolumes, - maxBolus: loopManager.settings.maximumBolus ?? Self.defaultMaxBolus - ) + } + } - switch self.configuration { - case .carbEntry: - // If this new context wasn't generated in response to a potential carb entry message, - // recompute the recommended bolus for the carb entry under consideration. - let wasContextGeneratedFromPotentialCarbEntryMessage = loopManager.activeContext?.potentialCarbEntry != nil - if !wasContextGeneratedFromPotentialCarbEntryMessage, let entry = self.carbEntryUnderConsideration { - self.recommendBolus(for: entry) - } - case .manualBolus: - let activeContext = loopManager.activeContext - self.contextDate = activeContext?.creationDate - if self.recommendedBolusAmount != activeContext?.recommendedBolusDose { - self.recommendedBolusAmount = activeContext?.recommendedBolusDose + func handleContextUpdate(loopManager: LoopDataManager) { + + self.bolusPickerValues = BolusPickerValues( + supportedVolumes: loopManager.supportedBolusVolumes ?? Self.defaultSupportedBolusVolumes, + maxBolus: loopManager.watchInfo.loopSettings.maximumBolus ?? Self.defaultMaxBolus + ) + + switch self.configuration { + case .carbEntry: + // If this new context wasn't generated in response to a potential carb entry message, + // recompute the recommended bolus for the carb entry under consideration. + let wasContextGeneratedFromPotentialCarbEntryMessage = loopManager.activeContext?.potentialCarbEntry != nil + if !wasContextGeneratedFromPotentialCarbEntryMessage, let entry = self.carbEntryUnderConsideration { + Task { @MainActor in + await self.recommendBolus(with: entry) } } + case .manualBolus: + let activeContext = loopManager.activeContext + self.contextDate = activeContext?.creationDate + if self.recommendedBolusAmount != activeContext?.recommendedBolusDose { + self.recommendedBolusAmount = activeContext?.recommendedBolusDose + } } } @@ -112,66 +111,48 @@ final class CarbAndBolusFlowViewModel: ObservableObject { recommendedBolusAmount = nil } - func recommendBolus(forGrams grams: Int, eatenAt carbEntryDate: Date, absorptionTime carbAbsorptionTime: CarbAbsorptionTime, lastEntryDate: Date) { + func recommendBolus(forGrams grams: Int, eatenAt carbEntryDate: Date, absorptionTime carbAbsorptionTime: CarbAbsorptionTime, lastEntryDate: Date) async { let entry = NewCarbEntry( date: lastEntryDate, - quantity: HKQuantity(unit: .gram(), doubleValue: Double(grams)), + quantity: LoopQuantity(unit: .gram, doubleValue: Double(grams)), startDate: carbEntryDate, foodType: carbAbsorptionTime.emoji, absorptionTime: absorptionTime(for: carbAbsorptionTime) ) - guard entry.quantity.doubleValue(for: .gram()) > 0 else { + guard entry.quantity.doubleValue(for: .gram) > 0 else { return } carbEntryUnderConsideration = entry - recommendBolus(for: entry) + await recommendBolus(with: entry) } - private func recommendBolus(for entry: NewCarbEntry) { - let potentialEntry = PotentialCarbEntryUserInfo(carbEntry: entry) + private func recommendBolus(with entry: NewCarbEntry? = nil) async { do { isComputingRecommendedBolus = true - try WCSession.default.sendPotentialCarbEntryMessage(potentialEntry, - replyHandler: { [weak self] context in - DispatchQueue.main.async { - let loopManager = ExtensionDelegate.shared().loopManager - loopManager.updateContext(context) - - guard let self = self else { - return - } - - // Only update if this recommendation corresponds to the current carb entry under consideration. - guard context.potentialCarbEntry == self.carbEntryUnderConsideration else { - return - } - - defer { - self.isComputingRecommendedBolus = false - } - - self.contextDate = context.creationDate - - // Don't publish a new value if the recommendation has not changed. - guard self.recommendedBolusAmount != context.recommendedBolusDose else { - return - } - - self.recommendedBolusAmount = context.recommendedBolusDose - } - }, - errorHandler: { error in - DispatchQueue.main.async { [weak self] in - self?.isComputingRecommendedBolus = false - WKInterfaceDevice.current().play(.failure) - ExtensionDelegate.shared().present(error) - } - } - ) + let context = try await WCSession.default.fetchBolusRecommendation(entry) + + // Only update if this recommendation corresponds to the current carb entry under consideration. + guard context.potentialCarbEntry == self.carbEntryUnderConsideration else { + return + } + + defer { + self.isComputingRecommendedBolus = false + } + + self.contextDate = context.creationDate + + // Don't publish a new value if the recommendation has not changed. + guard self.recommendedBolusAmount != context.recommendedBolusDose else { + return + } + + self.recommendedBolusAmount = context.recommendedBolusDose } catch { isComputingRecommendedBolus = false + WKInterfaceDevice.current().play(.failure) self.error = .potentialCarbEntryMessageSendFailure } } @@ -189,49 +170,29 @@ final class CarbAndBolusFlowViewModel: ObservableObject { } } - func addCarbsWithoutBolusing() { + func addCarbsWithoutBolusing() async throws { guard let carbEntry = carbEntryUnderConsideration else { assertionFailure("Attempting to add carbs without a carb entry") return } - sendSetBolusUserInfo(carbEntry: carbEntry, bolus: 0) + try await sendSetBolusUserInfo(carbEntry: carbEntry, bolus: 0) } - func addCarbsAndDeliverBolus(_ bolusAmount: Double) { - sendSetBolusUserInfo(carbEntry: carbEntryUnderConsideration, bolus: bolusAmount) + func addCarbsAndDeliverBolus(_ bolusAmount: Double) async throws { + try await sendSetBolusUserInfo(carbEntry: carbEntryUnderConsideration, bolus: bolusAmount) } - private func sendSetBolusUserInfo(carbEntry: NewCarbEntry?, bolus: Double) { - guard !hasSentConfirmationMessage else { - return - } - self.hasSentConfirmationMessage = true - + private func sendSetBolusUserInfo(carbEntry: NewCarbEntry?, bolus: Double) async throws { let bolus = SetBolusUserInfo(value: bolus, startDate: Date(), contextDate: self.contextDate, carbEntry: carbEntry, activationType: .activationTypeFor(recommendedAmount: recommendedBolusAmount, bolusAmount: bolus)) - do { - try WCSession.default.sendBolusMessage(bolus) { [weak self] (error) in - DispatchQueue.main.async { - if let error = error { - ExtensionDelegate.shared().present(error) - self?.hasSentConfirmationMessage = false - } else { - if bolus.carbEntry != nil { - if bolus.value == 0 { - // Notify for a successful carb entry (sans bolus) - WKInterfaceDevice.current().play(.success) - } - } - } - } + let updatedContext = try await WCSession.default.sendBolusMessage(bolus) + if bolus.carbEntry != nil { + if bolus.value == 0 { + // Notify for a successful carb entry (sans bolus) + WKInterfaceDevice.current().play(.success) } - - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { - self.dismiss() - } - } catch { - self.error = .bolusMessageSendFailure } + LoopDataManager.shared.updateContext(updatedContext) } } diff --git a/WatchApp Extension/View Models/OnOffSelectionViewModel.swift b/WatchApp Extension/View Models/OnOffSelectionViewModel.swift deleted file mode 100644 index c188e59492..0000000000 --- a/WatchApp Extension/View Models/OnOffSelectionViewModel.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// OnOffSelectionViewModel.swift -// WatchApp Extension -// -// Created by Anna Quinlan on 8/20/20. -// Copyright © 2020 LoopKit Authors. All rights reserved. -// - -import SwiftUI - -enum SelectedButton { - case on - case off -} - -class OnOffSelectionViewModel: ObservableObject { - var title: String - var message: String - var onSelection: (Bool) -> Void - var dismiss: (() -> Void)? - var selectedButton: SelectedButton - var selectedButtonTint: UIColor - - init( - title: String, - message: String, - onSelection: @escaping (Bool) -> Void, - dismiss: (() -> Void)? = nil, - selectedButton: SelectedButton = .off, - selectedButtonTint: UIColor = .tintColor - ) { - self.title = title - self.message = message - self.onSelection = onSelection - self.dismiss = dismiss - self.selectedButton = selectedButton - self.selectedButtonTint = selectedButtonTint - } -} diff --git a/WatchApp Extension/Views/ActionButton.swift b/WatchApp Extension/Views/ActionButton.swift index e3b2e67bd9..e926a23904 100644 --- a/WatchApp Extension/Views/ActionButton.swift +++ b/WatchApp Extension/Views/ActionButton.swift @@ -21,7 +21,6 @@ struct ActionButton: View { .animation(nil) }) .buttonStyle(ActionButtonStyle(color: color)) - .animation(.default) .frame(height: 40) } } diff --git a/WatchApp Extension/Views/ActiveOverrideView.swift b/WatchApp Extension/Views/ActiveOverrideView.swift new file mode 100644 index 0000000000..9560e61326 --- /dev/null +++ b/WatchApp Extension/Views/ActiveOverrideView.swift @@ -0,0 +1,156 @@ +// +// ActivePresetView.swift +// Loop +// +// Created by Pete Schwamb on 9/10/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopCore + +struct ActiveOverrideView: View { + @Environment(LoopDataManager.self) var loopManager + @Environment(\.glucoseDisplayUnit) private var glucoseDisplayUnit + + @State private var crownValue: CGFloat = 0 // Tracks Digital Crown rotation + @State private var endingPreset: Bool = false + @State private var lastInteractionTime: Date? // Tracks last crown interaction + + private let resetDelay: TimeInterval = 0.25 // pause for reset + + let override: TemporaryScheduleOverride + var preset: SelectablePreset { + return override.createPreset() + } + + var title: some View { + HStack(spacing: 6) { + if let icon = preset.icon, !icon.isEmpty { + PresetSymbolView(icon) + } + Text(preset.name) + .font(.system(size: 19)) + } + } + + var duration: Text { + if override.isActive() { + if override.context == .preMeal { + return Text(NSLocalizedString("on until carbs added", comment: "The format for the description of a premeal preset end date")) + } else { + switch override.duration { + case .finite: + let endTimeText = DateFormatter.localizedString(from: override.activeInterval.end, dateStyle: .none, timeStyle: .short) + return Text(String(format: NSLocalizedString("on until %@", comment: "The format for the description of a finite custom preset end date"), endTimeText)) + case .indefinite: + return Text(NSLocalizedString("on until turned off", comment: "The format for the description of an indefinite custom preset end date")) + } + } + } else { + let startTimeText = DateFormatter.localizedString(from: override.startDate, dateStyle: .none, timeStyle: .short) + return Text(String(format: NSLocalizedString("starting at %@", comment: "The format for the description of a custom preset start date"), startTimeText)) + } + } + + + private var numberFormatter: NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .percent + return formatter + } + + private var glucoseFormatter: QuantityFormatter { + return QuantityFormatter(for: glucoseDisplayUnit) + } + + var presetDuration: some View { + Group { Text(Image(systemName: "timer")) + duration } + .font(.footnote) + .foregroundColor(.presets) + } + + var descriptionText: Text { + let percent = numberFormatter.string(from: override.settings.insulinNeedsScaleFactor ?? 1)! + var text = Text(percent).bold() + + if let correctionRange = override.settings.targetRange { + text = text + Text(" • ") + text = text + (Text(glucoseFormatter.string(from: correctionRange.lowerBound, includeUnit: false)!) + + Text("-") + + Text(glucoseFormatter.string(from: correctionRange.upperBound, includeUnit: false)!)).bold() + text = text + Text(" " + glucoseDisplayUnit.localizedShortUnitString) + .foregroundStyle(.secondary) + } + return text + } + + var progress: CGFloat { + if endingPreset { + return 1 + } else { + return abs(crownValue) + } + } + + var body: some View { + VStack(spacing: 4) { + title + presetDuration + descriptionText + .padding(.top, 8) + .padding(.bottom, 10) + + Spacer() + + ZStack(alignment: .center) { + if progress == 0 { + Text(endingPreset ? "Ending Preset..." : "Turn Digital Crown to End") + .font(.system(size: 16)) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } else { + CircularProgressWithCheckmark(progress: progress, isComplete: endingPreset) + .animation(.easeInOut(duration: 0.2), value: crownValue) // Smooth animation for progress + .animation(.easeOut(duration: 0.3), value: endingPreset) // Fast animation for completion + } + } + } + .padding() + .focusable() // Required for Digital Crown interaction + .digitalCrownRotation( + $crownValue, + over: -1...1, + sensitivity: .low, + scalingRotationBy: 4 + ) + .onChange(of: crownValue) { (oldValue, newValue) in + lastInteractionTime = Date() + + Task { + try? await Task.sleep(nanoseconds: UInt64(resetDelay * 1_000_000_000)) // Wait for 1 second + if let lastTime = lastInteractionTime, Date().timeIntervalSince(lastTime) >= resetDelay && !endingPreset { + withAnimation { + crownValue = 0 // Reset progress + } + } + } + + if abs(newValue) >= 1 && !endingPreset { + withAnimation(.spring(response: 0.2, dampingFraction: 0.5)) { + endingPreset = true + Task { + do { + try await loopManager.clearOverride() + WKInterfaceDevice.current().play(.directionDown) + } catch { + WKInterfaceDevice.current().play(.failure) + } + } + } + } + } + .navigationBarBackButtonHidden(false) // Ensure back button is visible + } +} diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/BolusArrow.swift b/WatchApp Extension/Views/Carb Entry & Bolus/BolusArrow.swift index 7a8d47657d..431ffda3d4 100644 --- a/WatchApp Extension/Views/Carb Entry & Bolus/BolusArrow.swift +++ b/WatchApp Extension/Views/Carb Entry & Bolus/BolusArrow.swift @@ -31,7 +31,7 @@ struct BolusArrow: View { .padding(.top, 4) // Animate the arrow down off-screen once finished .offset(y: isFinished ? sizeClass.screenSize.height : 0) - .animation(Animation.default.speed(isFinished ? 0.35 : 1.0)) + .animation(.default.speed(isFinished ? 0.35 : 1.0), value: progress) } private var arrow: some View { diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/BolusConfirmationVisual.swift b/WatchApp Extension/Views/Carb Entry & Bolus/BolusConfirmationVisual.swift index 4483d4c38b..7a7015bf0c 100644 --- a/WatchApp Extension/Views/Carb Entry & Bolus/BolusConfirmationVisual.swift +++ b/WatchApp Extension/Views/Carb Entry & Bolus/BolusConfirmationVisual.swift @@ -19,7 +19,7 @@ struct BolusConfirmationVisual: View { Circle() .fill(Color.darkInsulin) .opacity(isFinished ? 0 : 1) - .animation(Animation.default.speed(0.5)) + .animation(Animation.default.speed(0.5), value: progress) .overlay(BolusArrow(progress: progress)) if isFinished { diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/BolusInput.swift b/WatchApp Extension/Views/Carb Entry & Bolus/BolusInput.swift index 4c01e5c4be..c87048f0cf 100644 --- a/WatchApp Extension/Views/Carb Entry & Bolus/BolusInput.swift +++ b/WatchApp Extension/Views/Carb Entry & Bolus/BolusInput.swift @@ -26,12 +26,12 @@ struct BolusInput: View { } private static let amountFormatter: NumberFormatter = { - let formatter = QuantityFormatter(for: .internationalUnit()) + let formatter = QuantityFormatter(for: .internationalUnit) return formatter.numberFormatter }() private static let recommendedAmountFormatter: NumberFormatter = { - let formatter = QuantityFormatter(for: .internationalUnit()) + let formatter = QuantityFormatter(for: .internationalUnit) return formatter.numberFormatter }() diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/CarbAndBolusFlow.swift b/WatchApp Extension/Views/Carb Entry & Bolus/CarbAndBolusFlow.swift index 0c27958fe9..e694105a06 100644 --- a/WatchApp Extension/Views/Carb Entry & Bolus/CarbAndBolusFlow.swift +++ b/WatchApp Extension/Views/Carb Entry & Bolus/CarbAndBolusFlow.swift @@ -7,8 +7,8 @@ // import SwiftUI -import HealthKit import LoopKit +import WatchKit struct CarbAndBolusFlow: View { @@ -32,6 +32,8 @@ struct CarbAndBolusFlow: View { @State private var flowState: FlowState @ObservedObject private var viewModel: CarbAndBolusFlowViewModel @Environment(\.sizeClass) private var sizeClass + @Environment(\.dismiss) private var dismiss + // MARK: - State: Carb Entry // Date the user last changed the carb entry with the UI @@ -62,7 +64,7 @@ struct CarbAndBolusFlow: View { if let entry = entry { _carbEntryDate = State(initialValue: entry.startDate) - let initialCarbAmount = entry.quantity.doubleValue(for: .gram()) + let initialCarbAmount = entry.quantity.doubleValue(for: .gram) _carbAmount = State(initialValue: Int(initialCarbAmount)) } case .manualBolus: @@ -90,6 +92,16 @@ struct CarbAndBolusFlow: View { // Handle error states. .onReceive(viewModel.$error) { self.activeAlert = $0.map(AlertState.communicationError) } .alert(item: $activeAlert, content: alert(for:)) + + // Handoff + .onAppear { + let activity = NSUserActivity.forDidAddCarbEntryOnWatch() + activity.becomeCurrent() + } + + .onReceive(NotificationCenter.default.publisher(for: WKExtension.applicationWillResignActiveNotification)) { _ in + dismiss() + } } } @@ -149,11 +161,13 @@ extension CarbAndBolusFlow { } private func transitionToBolusEntry() { - viewModel.recommendBolus(forGrams: carbAmount, eatenAt: carbEntryDate, absorptionTime: carbAbsorptionTime, lastEntryDate: carbLastEntryDate) withAnimation { flowState = .bolusEntry inputMode = .carbs } + Task { @MainActor in + await viewModel.recommendBolus(forGrams: carbAmount, eatenAt: carbEntryDate, absorptionTime: carbAbsorptionTime, lastEntryDate: carbLastEntryDate) + } } private var topPaddingToPositionInputViews: CGFloat { @@ -173,7 +187,7 @@ extension CarbAndBolusFlow { } else { return 19 } - case .size44mm, .size45mm: + case .size44mm, .size45mm, .size46mm, .size49mm: return 5 } } @@ -219,7 +233,14 @@ extension CarbAndBolusFlow { self.flowState = .bolusConfirmation } } else if case .carbEntry = self.configuration { - self.viewModel.addCarbsWithoutBolusing() + Task { + do { + try await self.viewModel.addCarbsWithoutBolusing() + dismiss() + } catch { + viewModel.error = .bolusMessageSendFailure + } + } } } .offset(y: actionButtonOffsetY) @@ -243,14 +264,21 @@ extension CarbAndBolusFlow { return 0 case .size40mm, .size41mm: return 20 - case .size44mm, .size45mm: + case .size44mm, .size45mm, .size46mm, .size49mm: return 27 } } private var bolusConfirmationView: some View { BolusConfirmationView(progress: $bolusConfirmationProgress, onConfirmation: { - self.viewModel.addCarbsAndDeliverBolus(self.bolusAmount) + Task { + do { + try await self.viewModel.addCarbsAndDeliverBolus(self.bolusAmount) + dismiss() + } catch { + viewModel.error = .bolusMessageSendFailure + } + } }) .padding(.bottom, bolusConfirmationPadding) .transition(.fadeIn(after: 0.35)) @@ -313,6 +341,10 @@ extension CarbAndBolusFlow { return } + if !receivedInitialBolusRecommendation && recommendedBolus == nil { + return + } + if !receivedInitialBolusRecommendation { receivedInitialBolusRecommendation = true diff --git a/WatchApp Extension/Views/CarbList.swift b/WatchApp Extension/Views/CarbList.swift new file mode 100644 index 0000000000..3377ae6d40 --- /dev/null +++ b/WatchApp Extension/Views/CarbList.swift @@ -0,0 +1,96 @@ +// +// CarbList.swift +// Loop +// +// Created by Pete Schwamb on 9/20/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopAlgorithm + +struct CarbList: View { + @Environment(LoopDataManager.self) var loopManager + + var timeFormatter: DateFormatter = { + let timeFormatter = DateFormatter() + timeFormatter.dateStyle = .none + timeFormatter.timeStyle = .short + return timeFormatter + }() + + var carbFormatter: QuantityFormatter = { + let formatter = QuantityFormatter(for: .gram) + formatter.numberFormatter.numberStyle = .none + return formatter + }() + + @State var entries: [StoredCarbEntry] = [] + + private func reloadCarbEntries() async { + let start = min(Calendar.current.startOfDay(for: Date()), Date(timeIntervalSinceNow: -CarbMath.maximumAbsorptionTimeInterval)) + entries = (try? await loopManager.carbStore.getCarbEntries(start: start)) ?? [] + } + + var activeCarbs: String? { + guard let activeContext = loopManager.activeContext, + let activeCarbohydrates = activeContext.activeCarbohydrates + else { + return nil + } + + return carbFormatter.string(from: activeCarbohydrates) + } + + var totalCarbs: String? { + let total = entries.reduce(0, { sum, entry in + return sum + entry.quantity.doubleValue(for: .gram) + }) + + return carbFormatter.string(from: LoopQuantity(unit: .gram, doubleValue: total)) + } + + var body: some View { + List { + Section { + ForEach(entries, id: \.self) { entry in + HStack { + Text(timeFormatter.string(from: entry.startDate)) + Spacer() + Text(carbFormatter.string(from: entry.quantity) ?? "-") + } + } + } header: { + VStack { + HStack { + Text("Active Carbs") + .font(.caption) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Spacer() + Text(activeCarbs ?? "-") + .font(.title3) + .foregroundStyle(.primary) + } + HStack { + Text("Total Carbs") + .font(.caption) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Spacer() + Text(totalCarbs ?? "-") + .font(.title3) + .foregroundStyle(.primary) + } + .padding(.bottom, 4) + } + } + } + .onAppear { + Task { + await reloadCarbEntries() + } + } + } +} diff --git a/WatchApp Extension/Views/ChartPageView.swift b/WatchApp Extension/Views/ChartPageView.swift new file mode 100644 index 0000000000..188cedec38 --- /dev/null +++ b/WatchApp Extension/Views/ChartPageView.swift @@ -0,0 +1,270 @@ +// +// ChartPageView.swift +// Loop +// +// Created by Pete Schwamb on 9/19/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopCore +import LoopAlgorithm +import SpriteKit + +struct ChartPageView: View { + @Environment(\.sizeClass) private var sizeClass + + @Environment(LoopDataManager.self) var loopManager + + @State private var isShowingCarbList: Bool = false + + @ScaledMetric private var iconSize: Double = 26 + + var presetActive: Bool { + return loopManager.watchInfo.scheduleOverride?.isActive() == true + } + + var lastSyncString: String? { + guard loopManager.activeContext?.isClosedLoop == true, let date = loopManager.activeContext?.loopLastRunDate else { + return nil + } + + let ago = min(abs(min(0, date.timeIntervalSinceNow)), TimeInterval.days(7)) + + guard let timeString = ago.truncatedTimeAgoString else { + return nil + } + + if ago > .hours(1) { + return String(format: NSLocalizedString(" >%@ ago", comment: "Format string describing the time interval since the last completion date, last cgm or last pump communication. (1: The localized date components"), timeString) + } else { + return String(format: NSLocalizedString(" %@ ago", comment: "Format string describing the time interval since the last completion date, last cgm or last pump communication. (1: The localized date components"), timeString) + } + } + + private var chartHeight: CGFloat { + switch sizeClass { + case .size38mm: + return 73 + case .size44mm: + return 111 + case .size45mm: + return 115 + default: + return 90 + } + } + + var chartView: some View { + SpriteView(scene: loopManager.glucoseChartScene) + .frame(height: chartHeight) + .ignoresSafeArea() + .gesture( + // Handle double tap + TapGesture(count: 2) + .onEnded { + loopManager.glucoseChartScene.increaseVisibleDuration() + } + ) + .gesture( + // Handle single tap + TapGesture() + .onEnded { + loopManager.glucoseChartScene.decreaseVisibleDuration() + } + ) + } + + var activeInsulin: String? { + guard let activeContext = loopManager.activeContext, + let activeInsulin = activeContext.activeInsulin + else { + return nil + } + + let insulinFormatter: QuantityFormatter = { + let insulinFormatter = QuantityFormatter(for: .internationalUnit) + insulinFormatter.numberFormatter.minimumFractionDigits = 1 + insulinFormatter.numberFormatter.maximumFractionDigits = 1 + + return insulinFormatter + }() + + return insulinFormatter.string(from: activeInsulin) + } + + var activeCarbohydrates: String? { + guard let activeContext = loopManager.activeContext, + let activeCarbohydrates = activeContext.activeCarbohydrates + else { + return nil + } + + let carbFormatter = QuantityFormatter(for: .gram) + carbFormatter.numberFormatter.maximumFractionDigits = 0 + + return carbFormatter.string(from: activeCarbohydrates) + } + + var lastBolus: Text { + guard let lastBolus = loopManager.activeContext?.lastManualBolus else { + return Text("-") + } + + let bolusFormatter = QuantityFormatter(for: .internationalUnit) + bolusFormatter.numberFormatter.minimumFractionDigits = 1 + bolusFormatter.numberFormatter.maximumFractionDigits = 1 + + + let dateFormatter = DateFormatter() + dateFormatter.timeStyle = .short + dateFormatter.dateStyle = .none + + let bolusVolume = bolusFormatter.string(from: LoopQuantity(unit: .internationalUnit, doubleValue: lastBolus.amount))! + let bolusTime = dateFormatter.string(from: lastBolus.startDate) + + return + Text("\(bolusVolume)") + + Text(" at \(bolusTime)").font(.caption).foregroundColor(.secondary) + } + + var netTempBasalDose: String? { + guard let activeContext = loopManager.activeContext, + let tempBasal = activeContext.lastNetTempBasalDose + else { + return nil + } + + let basalFormatter = NumberFormatter() + basalFormatter.numberStyle = .decimal + basalFormatter.minimumFractionDigits = 1 + basalFormatter.maximumFractionDigits = 3 + basalFormatter.positivePrefix = basalFormatter.plusSign + + let unit = NSLocalizedString( + "U/hr", + comment: "The short unit display string for international units of insulin delivery per hour" + ) + + return basalFormatter.string(from: tempBasal, unit: unit) + } + + var reservoirVolume: String? { + guard let activeContext = loopManager.activeContext, + let reservoirVolume = activeContext.reservoirVolume + else { + return nil + } + + let insulinFormatter: QuantityFormatter = { + let insulinFormatter = QuantityFormatter(for: .internationalUnit) + insulinFormatter.unitStyle = .long + insulinFormatter.numberFormatter.minimumFractionDigits = 0 + insulinFormatter.numberFormatter.maximumFractionDigits = 0 + + return insulinFormatter + }() + + return insulinFormatter.string(from: reservoirVolume) + } + + var body: some View { + ScrollView(.vertical) { + TimelineView(.animation) { _ in + LoopHeader() + + chartView + + VStack(spacing: 8) { + if let lastSyncString { + LabelValueRow("Last Loop") { + // ⚠️ arrow.triangle.2.circlepath is deprecated -- replace with "arrow.trianglehead.2.clockwise.rotate.90" once watchOS 10 is dropped as a supported platform. + Text(Image(systemName: "arrow.triangle.2.circlepath")) + + Text(" " + lastSyncString) + } + Divider() + } + LabelValueRow("Active Insulin") { + Text(activeInsulin ?? "-") + } + Divider() + LabelValueRow("Active Carbs") { + Text(activeCarbohydrates ?? "-") + } + .onTapGesture { + isShowingCarbList = true + } + Divider() + LabelValueRow("Last Bolus") { + lastBolus + } + if let currentDelivery = loopManager.activeContext?.insulinDeliveryState { + Divider() + LabelValueRow("Current Delivery") { + Text(currentDelivery.iconImage) + + Text(" " + currentDelivery.shortDescription) + } + } + Divider() + LabelValueRow("Reservoir Volume") { + Text(reservoirVolume ?? "-") + } + } + .padding(.horizontal) + } + } + .font(.system(size: 14, weight: .light)) + .toolbar(.hidden, for: .navigationBar) + .environment(\.glucoseDisplayUnit, loopManager.displayGlucoseUnit) + .onAppear() { + updateGlucoseChart() + } + .onChange(of: loopManager.activeContext?.predictedGlucose) { oldValue, newValue in + updateGlucoseChart() + } + .sheet(isPresented: $isShowingCarbList) { + CarbList() + } + } + + private func updateGlucoseChart() { + Task { @MainActor in + let chartData = await loopManager.generateChartData() + loopManager.glucoseChartScene.data = chartData + loopManager.glucoseChartScene.setNeedsUpdate() + } + } +} + +extension TimeInterval { + /// Formats a time interval as a truncated "time ago" string (e.g., "1 hr", "2 mins") + var truncatedTimeAgoString: String? { + let calendar = Calendar.current + let now = Date() + let past = now.addingTimeInterval(-self) + + let components = calendar.dateComponents([.day, .hour, .minute], from: past, to: now) + if let days = components.day, days > 0 { + return String.localizedStringWithFormat( + NSLocalizedString("%d day", tableName: "LocalizablePlural", bundle: .main, value: "%d day", comment: "Singular/plural day count"), + days + ) + } else if let hours = components.hour, hours > 0 { + return String.localizedStringWithFormat( + NSLocalizedString("%d hr", tableName: "LocalizablePlural", bundle: .main, value: "%d hr", comment: "Singular/plural hour count"), + hours + ) + } else if let minutes = components.minute { + return String.localizedStringWithFormat( + NSLocalizedString("%d min", tableName: "LocalizablePlural", bundle: .main, value: "%d min", comment: "Singular/plural minute count"), + minutes + ) + } else { + return nil + } + } +} + + + diff --git a/WatchApp Extension/Views/CircleTintedButton.swift b/WatchApp Extension/Views/CircleTintedButton.swift new file mode 100644 index 0000000000..3ff29340ee --- /dev/null +++ b/WatchApp Extension/Views/CircleTintedButton.swift @@ -0,0 +1,66 @@ +// +// CircleTintedButton.swift +// Loop +// +// Created by Pete Schwamb on 9/8/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct CircleTintedButton: View { + @Environment(\.sizeClass) private var sizeClass + + var label: String + var image: Image + var foregroundTint: Color + var backgroundTint: Color + var action: () -> Void + + var buttonSize: CGFloat { + if sizeClass.isLarge { + return 64 + } else if sizeClass.isSmall { + return 54 + } else { + return 60 + } + } + + var body: some View { + ZStack { + VStack { + Button(action: action, label: { + image.foregroundStyle(foregroundTint) + }) + .buttonStyle(CircleTintedButtonStyle(tint: backgroundTint)) + .frame(height: buttonSize) + Text(label) + } + } + .frame(maxWidth: .infinity) + } +} + +private struct CircleTintedButtonStyle: ButtonStyle { + var tint: Color + @Environment(\.sizeClass) private var sizeClass + + func makeBody(configuration: Configuration) -> some View { + backgroundShape + .padding(.horizontal, sizeClass.hasRoundedCorners ? 4 : 0) + .overlay(configuration.label) + .padding(configuration.isPressed ? 1 : 0) + .overlay(Color.black.opacity(configuration.isPressed ? 0.35 : 0)) + } + + private var backgroundShape: some View { + Group { + if sizeClass.hasRoundedCorners { + Circle().fill(tint) + } else { + RoundedRectangle(cornerRadius: 6).fill(tint) + } + } + } +} diff --git a/WatchApp Extension/Views/CircularProgressWithCheckmark.swift b/WatchApp Extension/Views/CircularProgressWithCheckmark.swift new file mode 100644 index 0000000000..68cdea1ae9 --- /dev/null +++ b/WatchApp Extension/Views/CircularProgressWithCheckmark.swift @@ -0,0 +1,36 @@ +// +// CircularProgressWithCheckmark.swift +// Loop +// +// Created by Pete Schwamb on 9/23/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + + +import SwiftUI + +struct CircularProgressWithCheckmark: View { + let progress: CGFloat + let isComplete: Bool + + var body: some View { + ZStack { + // Background circle (full gray ring) + Circle() + .stroke(Color.gray.opacity(0.3), lineWidth: 8) + + // Progress arc (blue accent color) + Circle() + .trim(from: 0, to: min(progress, 1.0)) + .stroke(Color.accentColor, style: StrokeStyle(lineWidth: 5, lineCap: .round, lineJoin: .round)) + .rotationEffect(.degrees(-90)) // Start from top + + // Checkmark icon + Image(systemName: "checkmark") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.white) + .opacity(isComplete ? 1 : 0) + } + .frame(width: 45, height: 45) + } +} diff --git a/WatchApp Extension/Views/CompleteOnboardingView.swift b/WatchApp Extension/Views/CompleteOnboardingView.swift new file mode 100644 index 0000000000..4298e4e3ed --- /dev/null +++ b/WatchApp Extension/Views/CompleteOnboardingView.swift @@ -0,0 +1,20 @@ +// +// CompleteOnboardingView.swift +// Loop +// +// Created by Nathaniel Hamming on 2026-02-26. +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct CompleteOnboardingView: View { + + var body: some View { + VStack(alignment: .center) { + Spacer() + Text("Please complete onboarding to use the Tidepool Loop Watch app").multilineTextAlignment(.center) + Spacer() + } + } +} diff --git a/WatchApp Extension/Views/Extensions/AutomatedTreatmentState.swift b/WatchApp Extension/Views/Extensions/AutomatedTreatmentState.swift new file mode 100644 index 0000000000..7e9c6a65c6 --- /dev/null +++ b/WatchApp Extension/Views/Extensions/AutomatedTreatmentState.swift @@ -0,0 +1,45 @@ +// +// AutomatedTreatmentState.swift +// Loop +// +// Created by Pete Schwamb on 10/9/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopKit +import SwiftUI +import LoopCore + +extension InsulinDeliveryWatchState { + var shortDescription: String { + switch self { + case .neutralNoOverride: + return NSLocalizedString("Scheduled", comment: "Title for neutral delivery state") + case .neutralOverride: + return NSLocalizedString("Preset Delivery", comment: "Title for neutral delivery state state with preset adjusting basal") + case .increasedInsulin: + return NSLocalizedString("Increased", comment: "Title for increased insulin delivery state state") + case .decreasedInsulin, .minimumDelivery: + return NSLocalizedString("Decreased", comment: "Title for increased insulin delivery state") + case .suspended: + return NSLocalizedString("Suspended", comment: "Title for increased insulin delivery state state") + case .noDelivery: + return NSLocalizedString("No Delivery", comment: "Title for increased insulin delivery state state") + } + } + + var iconImage: Image { + switch self { + case .neutralNoOverride, .neutralOverride: + Image(systemName: "arrow.right.square.fill") + case .increasedInsulin: + Image(systemName: "arrow.up.square.fill") + case .decreasedInsulin, .minimumDelivery: + Image(systemName: "arrow.down.square.fill") + case .suspended: + Image(systemName: "pause.circle.fill") + case .noDelivery: + Image(systemName: "x.circle.fill") + } + } +} diff --git a/WatchApp Extension/Views/Extensions/Color.swift b/WatchApp Extension/Views/Extensions/Color.swift index 4eb24bc4ff..929b0105fe 100644 --- a/WatchApp Extension/Views/Extensions/Color.swift +++ b/WatchApp Extension/Views/Extensions/Color.swift @@ -16,5 +16,12 @@ extension Color { static let insulin = Color(.insulin) static let darkInsulin = Color(.darkInsulin) + static let presets = Color(.presets) + static let darkPresets = Color(.darkPresets) + + static let fresh = Color(.fresh) + static let aging = Color(.agingColor) + static let stale = Color(.staleColor) + static let defaultWatchButtonGray = Color(white: 35 / 255) } diff --git a/WatchApp Extension/Views/Extensions/Environment+SizeClass.swift b/WatchApp Extension/Views/Extensions/Environment+SizeClass.swift index 1ee3331718..fd74d4771e 100644 --- a/WatchApp Extension/Views/Extensions/Environment+SizeClass.swift +++ b/WatchApp Extension/Views/Extensions/Environment+SizeClass.swift @@ -35,6 +35,12 @@ extension WKInterfaceDevice { // Apple Watch Series 7 case size41mm case size45mm + + // Apple Watch Series 10 + case size46mm + + // Apple Watch Ultra + case size49mm } var sizeClass: SizeClass { @@ -71,15 +77,43 @@ extension WKInterfaceDevice.SizeClass { return CGSize(width: 184, height: 224) case .size45mm: return CGSize(width: 198, height: 242) + case .size46mm: // For some reason, the Series 10 sim is showing different (208x248) + return CGSize(width: 200, height: 244) + case .size49mm: + return CGSize(width: 205, height: 251) } } var hasRoundedCorners: Bool { switch self { - case .size40mm, .size41mm, .size44mm, .size45mm: + case .size40mm, .size41mm, .size44mm, .size45mm, .size46mm, .size49mm: return true case .size38mm, .size42mm: return false } } + + var isSmall: Bool { + switch self { + case .size38mm, .size40mm: return true + default: return false + } + } + + var isLarge: Bool { + switch self { + case .size44mm, .size45mm, .size46mm, .size49mm: return true + default: return false + } + } + + // Recommended horizontal padding per HIG + var recommendedHorizontalPadding: CGFloat { + switch self { + case .size40mm, .size41mm, .size42mm: return 8.5 + case .size44mm, .size45mm, .size46mm, .size49mm: return 9.5 + default: return 0 // Older or unknown + } + } + } diff --git a/WatchApp Extension/Views/Extensions/PeriodicPublisher.swift b/WatchApp Extension/Views/Extensions/PeriodicPublisher.swift index da6641d079..be0613d21f 100644 --- a/WatchApp Extension/Views/Extensions/PeriodicPublisher.swift +++ b/WatchApp Extension/Views/Extensions/PeriodicPublisher.swift @@ -7,7 +7,7 @@ // import Combine - +import Foundation /// A publisher which emits a value at a defined interval, which can be delayed via acknowledgment. final class PeriodicPublisher { diff --git a/WatchApp Extension/Views/LabelValueRow.swift b/WatchApp Extension/Views/LabelValueRow.swift new file mode 100644 index 0000000000..748c97779f --- /dev/null +++ b/WatchApp Extension/Views/LabelValueRow.swift @@ -0,0 +1,33 @@ +// +// LabelValueRow.swift +// Loop +// +// Created by Pete Schwamb on 9/20/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI + + +struct LabelValueRow: View { + let label: LocalizedStringKey + let value: ValueView + + init(_ label: LocalizedStringKey, @ViewBuilder value: () -> ValueView) { + self.label = label + self.value = value() + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(label) + .font(.footnote) + .foregroundStyle(.secondary) + .textCase(.uppercase) + value + .font(.title3) + .foregroundStyle(.primary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/WatchApp Extension/Views/LoopCircleView.swift b/WatchApp Extension/Views/LoopCircleView.swift new file mode 100644 index 0000000000..4c7b3d2974 --- /dev/null +++ b/WatchApp Extension/Views/LoopCircleView.swift @@ -0,0 +1,45 @@ +// +// LoopCircleView.swift +// Loop +// +// Created by Pete Schwamb on 9/8/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit + +public struct LoopCircleView: View { + @Environment(\.isEnabled) private var isEnabled + + private let closedLoop: Bool + private let freshness: LoopCompletionFreshness + private let deviceIssue: Bool + + public init(closedLoop: Bool, freshness: LoopCompletionFreshness, deviceIssue: Bool = false) { + self.closedLoop = closedLoop + self.freshness = freshness + self.deviceIssue = deviceIssue + } + + public var body: some View { + GeometryReader { geometry in + Circle() + .trim(from: closedLoop ? 0 : 0.25, to: 1) + .stroke(loopColor, lineWidth: geometry.size.height / 5) + .rotationEffect(Angle(degrees: closedLoop ? -90 : -135)) + .animation(.default, value: closedLoop) + .animation(.default, value: freshness) + } + } + + private var loopColor: Color { + if !isEnabled { + return .defaultWatchButtonGray + } else if isEnabled && !deviceIssue && freshness == .fresh { + return .fresh + } else { + return .gray + } + } +} diff --git a/WatchApp Extension/Views/LoopHeader.swift b/WatchApp Extension/Views/LoopHeader.swift new file mode 100644 index 0000000000..bc7c0c7669 --- /dev/null +++ b/WatchApp Extension/Views/LoopHeader.swift @@ -0,0 +1,48 @@ +// +// Untitled.swift +// Loop +// +// Created by Pete Schwamb on 9/20/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopCore + +struct LoopHeader: View { + @Environment(LoopDataManager.self) var loopManager + + var freshness: LoopCompletionFreshness { + guard loopManager.activeContext?.isClosedLoop == true else { + return LoopCompletionFreshness(lastCompletion: loopManager.activeContext?.glucoseDate, at: Date()) + } + return LoopCompletionFreshness(lastCompletion: loopManager.activeContext?.loopLastRunDate, at: Date()) + } + + var body: some View { + HStack { + if let activeContext = loopManager.activeContext, + let unit = activeContext.displayGlucoseUnit + { + TimelineView(.animation) { _ in + LoopCircleView(closedLoop: activeContext.isClosedLoop ?? false, freshness: freshness, deviceIssue: loopManager.activeContext?.deviceIssue ?? true) + .frame(width: 22, height: 22) + .padding(.horizontal) + } + + Text(loopManager.glucoseValue) + + Spacer() + + if FeatureFlags.showEventualBloodGlucoseOnWatchEnabled, + let eventualGlucose = activeContext.eventualGlucose, + let eventualGlucoseValue = NumberFormatter.glucoseFormatter(for: unit).string(from: eventualGlucose.doubleValue(for: unit)) + { + Text(eventualGlucoseValue) + } + } + } + .font(.system(size: 24, weight: .light)) + } +} diff --git a/WatchApp Extension/Views/OnOffSelectionView.swift b/WatchApp Extension/Views/OnOffSelectionView.swift deleted file mode 100644 index fc44b51efc..0000000000 --- a/WatchApp Extension/Views/OnOffSelectionView.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// OnOffSelectionView.swift -// WatchApp Extension -// -// Created by Anna Quinlan on 8/20/20. -// Copyright © 2020 LoopKit Authors. All rights reserved. -// - -import SwiftUI - -struct OnOffSelectionView: View { - // MARK: - Initialization - var viewModel: OnOffSelectionViewModel - - // MARK: - View Tree - - var body: some View { - VStack { - Spacer() - titleStack - Spacer() - if viewModel.selectedButton == .on { - buttonStackWithOnSelected - } else if viewModel.selectedButton == .off { - buttonStackWithOffSelected - } - } - } - - var titleStack: some View { - VStack(spacing: 2) { - Text(viewModel.title) - Text(viewModel.message) - } - } - - var buttonStackWithOnSelected: some View { - VStack(spacing: 5) { - onButton - .background(Color(viewModel.selectedButtonTint).cornerRadius(20.0)) - offButton - } - } - - var buttonStackWithOffSelected: some View { - VStack(spacing: 5) { - onButton - offButton - .background(Color(viewModel.selectedButtonTint).cornerRadius(20.0)) - } - } - - var onButton: some View { - Button(action: { - self.viewModel.onSelection(true) - self.viewModel.dismiss?() - }) { - Text("On", comment: "Label for on button") - } - .cornerRadius(20) - } - - var offButton: some View { - Button(action: { - self.viewModel.onSelection(false) - self.viewModel.dismiss?() - }) { - Text("Off", comment: "Label for off button") - } - .cornerRadius(20) - } -} - -struct OnOffSelectionView_Previews: PreviewProvider { - static var previews: some View { - Group { - OnOffSelectionView(viewModel: OnOffSelectionViewModel(title: "Pre-Meal", message: "80-90 mg/dL", onSelection: {_ in print("hi")}, selectedButton: .on, selectedButtonTint: .carbsColor)) - .previewDevice(PreviewDevice(rawValue: "Apple Watch Series 2 - 38mm")) - - OnOffSelectionView(viewModel: OnOffSelectionViewModel(title: "Pre-Meal", message: "80-90 mg/dL", onSelection: {_ in print("hi")}, selectedButton: .off, selectedButtonTint: .carbsColor)) - .previewDevice(PreviewDevice(rawValue: "Apple Watch Series 2 - 42mm")) - - OnOffSelectionView(viewModel: OnOffSelectionViewModel(title: "Workout", message: "180-190 mg/dL", onSelection: {_ in print("hi")}, selectedButton: .on, selectedButtonTint: .glucose)) - .previewDevice(PreviewDevice(rawValue: "Apple Watch Series 4 - 44mm")) - - OnOffSelectionView(viewModel: OnOffSelectionViewModel(title: "Workout", message: "180-190 mg/dL", onSelection: {_ in print("hi")}, selectedButton: .off, selectedButtonTint: .glucose)) - .previewDevice(PreviewDevice(rawValue: "Apple Watch Series 4 - 40mm")) - } - } -} diff --git a/WatchApp Extension/Views/PresetActivateButtonConfirm.swift b/WatchApp Extension/Views/PresetActivateButtonConfirm.swift new file mode 100644 index 0000000000..fe5c72b48f --- /dev/null +++ b/WatchApp Extension/Views/PresetActivateButtonConfirm.swift @@ -0,0 +1,46 @@ +// +// PresetActivateExtraConfirm.swift +// Loop +// +// Created by Pete Schwamb on 9/23/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopCore + +struct PresetActivateButtonConfirm: View { + @Environment(\.sizeClass) private var sizeClass + + let preset: SelectablePreset + @Binding var confirmed: Bool + + private var actionButtonOffsetY: CGFloat { + switch sizeClass { + case .size38mm, .size42mm: + return 0 + case .size40mm, .size41mm: + return 20 + case .size44mm, .size45mm, .size46mm, .size49mm: + return 27 + } + } + + var body: some View { + VStack { + PresetDetailView(preset: preset) + + Spacer() + + ActionButton( + title: Text("Start Preset", comment: "Button text to confirm starting preset before using digital crown"), + color: .accentColor + ) { + confirmed = true + } + + } + .padding() + } +} diff --git a/WatchApp Extension/Views/PresetActivateCrownConfirm.swift b/WatchApp Extension/Views/PresetActivateCrownConfirm.swift new file mode 100644 index 0000000000..03f5ff55db --- /dev/null +++ b/WatchApp Extension/Views/PresetActivateCrownConfirm.swift @@ -0,0 +1,105 @@ +// +// PresetActivateCrownConfirm.swift +// Loop +// +// Created by Pete Schwamb on 9/23/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopCore + +struct PresetActivateCrownConfirm: View { + @Environment(LoopDataManager.self) var loopManager + @Environment(\.sizeClass) private var sizeClass + + @State private var crownValue: CGFloat = 0 // Tracks Digital Crown rotation + @State private var startingPreset: Bool = false + @State private var lastInteractionTime: Date? // Tracks last crown interaction + + private let resetDelay: TimeInterval = 0.25 // pause for reset + + let preset: SelectablePreset + + var progress: CGFloat { + if startingPreset { + return 1 + } else { + return abs(crownValue) + } + } + + var body: some View { + VStack(spacing: 4) { + PresetDetailView(preset: preset) + + Spacer() + + ZStack(alignment: .center) { + if progress == 0 { + Text(startingPreset ? "Starting Preset..." : "Turn Digital Crown to Start") + .font(.system(size: 16)) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } else { + CircularProgressWithCheckmark(progress: progress, isComplete: startingPreset) + .animation(.easeInOut(duration: 0.2), value: crownValue) // Smooth animation for progress + .animation(.easeOut(duration: 0.3), value: startingPreset) // Fast animation for completion + } + } + + } + .padding() + .focusable() // Required for Digital Crown interaction + .digitalCrownRotation( + $crownValue, + over: -1...1, + sensitivity: .low, + scalingRotationBy: 4 + ) + .onDisappear() { + if let reminder = loopManager.pendingPresetReminder, reminder.presetIdentifier == preset.id { + // If this was shown for confirming preset activation from a reminder notification, and we + // are being dismissed, treat the dismissal as an acknowledgement + loopManager.pendingPresetReminder = nil + Task { + try await loopManager.acknowledgeAlert(alertIdentifier: reminder.alertIdentifier, managerIdentifier: reminder.managerIdentifier) + } + } + } + .onChange(of: crownValue) { (oldValue, newValue) in + lastInteractionTime = Date() + + Task { + try? await Task.sleep(nanoseconds: UInt64(resetDelay * 1_000_000_000)) // Wait for 1 second + if let lastTime = lastInteractionTime, Date().timeIntervalSince(lastTime) >= resetDelay && !startingPreset { + withAnimation { + crownValue = 0 // Reset progress + } + } + } + + if abs(newValue) >= 1 && !startingPreset { + withAnimation(.spring(response: 0.2, dampingFraction: 0.5)) { + startingPreset = true + Task { + do { + var alertIdentifier: String? = nil + // If we're starting the preset from a reminder alert, then set alert identifier to acknowledge the alert + if let reminder = loopManager.pendingPresetReminder, reminder.presetIdentifier == preset.id { + alertIdentifier = reminder.presetIdentifier + } + try await loopManager.activateOverride(preset.createOverride(), alertIdentifierToAcknowledge: alertIdentifier) + WKInterfaceDevice.current().play(.success) + } catch { + print("Error! Could not activate preset: \(error)") + WKInterfaceDevice.current().play(.failure) + } + } + } + } + } + .navigationBarBackButtonHidden(false) // Ensure back button is visible + } +} diff --git a/WatchApp Extension/Views/PresetConfirmationView.swift b/WatchApp Extension/Views/PresetConfirmationView.swift new file mode 100644 index 0000000000..e41bc91e39 --- /dev/null +++ b/WatchApp Extension/Views/PresetConfirmationView.swift @@ -0,0 +1,88 @@ +// +// PresetConfirmationView.swift +// Loop +// +// Created by Pete Schwamb on 9/22/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopCore + +struct PresetConfirmationView: View { + @Environment(LoopDataManager.self) var loopManager + @Environment(\.dismiss) private var dismiss + + let preset: SelectablePreset? + + @State private var confirmedViaButton: Bool = false + + enum DisplayState: Equatable { + case confirmingViaButton(SelectablePreset) + case confirmingViaCrown(SelectablePreset) + case activated(TemporaryScheduleOverride) + case oneTimeUseOverrideEnded + } + + var displayState: DisplayState { + if let override = loopManager.watchInfo.scheduleOverride { + return .activated(override) + } else if let preset { + if isConfirmingFromPresetReminder && !confirmedViaButton { + return .confirmingViaButton(preset) + } else { + return .confirmingViaCrown(preset) + } + } else { + return .oneTimeUseOverrideEnded + } + } + + var isConfirmingFromPresetReminder: Bool { + if let reminder = loopManager.pendingPresetReminder, + let preset, + reminder.presetIdentifier == preset.id + { + return true + } + return false + } + + var body: some View { + ZStack { + switch displayState { + case .confirmingViaButton(let preset): + PresetActivateButtonConfirm(preset: preset, confirmed: $confirmedViaButton) + case .confirmingViaCrown(let preset): + PresetActivateCrownConfirm(preset: preset) + case .activated(let override): + ActiveOverrideView(override: override) + case .oneTimeUseOverrideEnded: + // Should not display, as we will dismiss below + Text("One-time use override has ended.") + } + } + .onDisappear { + if isConfirmingFromPresetReminder { + // Treat exiting reminder confirmation as declining + self.loopManager.pendingPresetReminder = nil + } + } + .onChange(of: displayState, { oldValue, newValue in + if case .confirmingViaCrown = oldValue, + case .activated = newValue, + isConfirmingFromPresetReminder + { + // Successfully activated; clear reminder state + self.loopManager.pendingPresetReminder = nil + } + }) + .onChange(of: loopManager.watchInfo.scheduleOverride) { oldValue, newVelue in + if oldValue != nil, newVelue == nil, preset == nil + { + dismiss() + } + } + } +} diff --git a/WatchApp Extension/Views/PresetDetailView.swift b/WatchApp Extension/Views/PresetDetailView.swift new file mode 100644 index 0000000000..879a40bbb9 --- /dev/null +++ b/WatchApp Extension/Views/PresetDetailView.swift @@ -0,0 +1,74 @@ +// +// PresetDetailView.swift +// Loop +// +// Created by Pete Schwamb on 9/9/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopCore + +struct PresetDetailView: View { + @Environment(\.sizeClass) private var sizeClass + + @Environment(\.glucoseDisplayUnit) private var glucoseDisplayUnit + + let preset: SelectablePreset + + var presetTitle: some View { + HStack(spacing: 6) { + if let icon = preset.icon, !icon.isEmpty { + PresetSymbolView(icon) + } + Text(preset.name) + .font(.title3) + .accessibilityIdentifier("text_Preset\(preset.name)") + } + } + + private var numberFormatter: NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .percent + return formatter + } + + private var glucoseFormatter: QuantityFormatter { + return QuantityFormatter(for: glucoseDisplayUnit) + } + + var presetDuration: some View { + Group { Text(Image(systemName: "timer")) + Text(" \(preset.duration.localizedTitle)") } + .font(.footnote) + .foregroundColor(.secondary) + .accessibilityLabel(Text(preset.duration.accessibilityLabel)) + } + + var descriptionText: Text { + let percent = numberFormatter.string(from: preset.insulinNeedsScaleFactor)! + var text = Text(percent).bold() + + if let correctionRange = preset.correctionRange { + text = text + Text(sizeClass.isLarge ? " • " : "\n") + text = text + (Text(glucoseFormatter.string(from: correctionRange.lowerBound, includeUnit: false)!) + + Text("-") + + Text(glucoseFormatter.string(from: correctionRange.upperBound, includeUnit: false)!)).bold() + text = text + Text(" " + glucoseDisplayUnit.localizedShortUnitString) + .foregroundStyle(.secondary) + } + return text + } + + var body: some View { + VStack(spacing: 4) { + presetTitle + presetDuration + descriptionText + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 8) + .padding(.bottom, 10) + } + } +} diff --git a/WatchApp Extension/Views/PresetWatchCard.swift b/WatchApp Extension/Views/PresetWatchCard.swift new file mode 100644 index 0000000000..90367e4330 --- /dev/null +++ b/WatchApp Extension/Views/PresetWatchCard.swift @@ -0,0 +1,151 @@ +// +// PresetWatchCard.swift +// Loop +// +// Created by Pete Schwamb on 9/9/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import SwiftUI +import LoopKit +import LoopCore + +extension Color { + init(presetSymbolTint: PresetSymbol.SymbolTint?) { + guard let presetSymbolTint else { + self = .primary + return + } + + switch presetSymbolTint { + case .preMeal: + self = Color.carbs + } + } +} + +struct PresetSymbolView: View { + + let symbol: PresetSymbol + let iconSize: Double + + init(_ symbol: PresetSymbol, iconSize: Double = 17) { + self.symbol = symbol + self.iconSize = iconSize + } + + var body: some View { + Group { + switch symbol.symbolType { + case .emoji: + Text(symbol.value) + .font(.system(size: UIFontMetrics.default.scaledValue(for: iconSize - 2))) + case .image: + Text(Image(symbol.value)) + .foregroundStyle(Color(presetSymbolTint: symbol.tint)) + .font(.system(size: UIFontMetrics.default.scaledValue(for: iconSize))) + case .systemImage: + Text(Image(systemName: symbol.value)) + .foregroundStyle(Color(presetSymbolTint: symbol.tint)) + .font(.system(size: UIFontMetrics.default.scaledValue(for: iconSize))) + } + } + .fontDesign(.monospaced) + } +} + + +struct PresetWatchCard: View { + + @Environment(\.isEnabled) private var isEnabled + @Environment(\.glucoseDisplayUnit) private var glucoseDisplayUnit + + let presetId: String + let icon: PresetSymbol? + let presetName: String + let duration: PresetDuration + let insulinMultiplier: Double? + let correctionRange: ClosedRange? + let isScheduled: Bool + + init(presetId: String, icon: PresetSymbol?, presetName: String, duration: PresetDuration, insulinMultiplier: Double?, correctionRange: ClosedRange?, isScheduled: Bool) { + self.presetId = presetId + self.icon = icon + self.presetName = presetName + self.duration = duration + self.insulinMultiplier = insulinMultiplier + self.correctionRange = correctionRange + self.isScheduled = isScheduled + } + + private var numberFormatter: NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .percent + return formatter + } + + private var glucoseFormatter: QuantityFormatter { + return QuantityFormatter(for: glucoseDisplayUnit) + } + + + var presetTitle: some View { + HStack(spacing: 6) { + if let icon, !icon.isEmpty { + PresetSymbolView(icon) + } + Text(presetName) + .accessibilityIdentifier("text_Preset\(presetName)") + } + } + + var presetDuration: some View { + Group { Text(Image(systemName: "timer")) + Text(" \(duration.localizedTitle)") } + .font(.footnote) + .foregroundColor(.secondary) + .accessibilityLabel(Text(duration.accessibilityLabel)) + } + + var descriptionText: Text { + let percent = numberFormatter.string(from: insulinMultiplier ?? 1)! + var text = Text(percent).bold() + + if let correctionRange { + text = text + Text(" • ") + text = text + (Text(glucoseFormatter.string(from: correctionRange.lowerBound, includeUnit: false)!) + + Text("-") + + Text(glucoseFormatter.string(from: correctionRange.upperBound, includeUnit: false)!)).bold() + text = text + Text(" " + glucoseDisplayUnit.localizedShortUnitString) + .foregroundStyle(.secondary) + } + return text.font(.footnote) + + } + + var body: some View { + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 12) + .foregroundColor(Color(red: 0.2, green: 0.2, blue: 0.2)) + VStack(alignment: .leading, spacing: 10) { + presetTitle + descriptionText + } + .padding(10) + } + } +} + +extension PresetWatchCard { + init (_ preset: SelectablePreset) { + self.init( + presetId: preset.id, + icon: preset.icon, + presetName: preset.name, + duration: preset.duration, + insulinMultiplier: preset.insulinNeedsScaleFactor, + correctionRange: preset.correctionRange, + isScheduled: preset.isScheduled + ) + } +} diff --git a/WatchApp Extension/Views/PresetsListView.swift b/WatchApp Extension/Views/PresetsListView.swift new file mode 100644 index 0000000000..21f684f130 --- /dev/null +++ b/WatchApp Extension/Views/PresetsListView.swift @@ -0,0 +1,35 @@ +// +// PresetsList.swift +// Loop +// +// Created by Pete Schwamb on 9/9/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopCore + +struct PresetListView: View { + @Environment(LoopDataManager.self) var loopManager + @Environment(\.dismiss) private var dismiss + + let presets: [SelectablePreset] + @Binding var path: NavigationPath + + var body: some View { + ScrollView(.vertical) { + ForEach(presets) { preset in + PresetWatchCard(preset) + .onTapGesture { + path.append(preset) + } + } + .padding() + } + .navigationTitle("Select Preset") + .navigationDestination(for: SelectablePreset.self) { preset in + PresetConfirmationView(preset: preset) + } + } +} diff --git a/WatchApp Extension/Views/PresetsView.swift b/WatchApp Extension/Views/PresetsView.swift new file mode 100644 index 0000000000..af738c2390 --- /dev/null +++ b/WatchApp Extension/Views/PresetsView.swift @@ -0,0 +1,43 @@ +// +// PresetsView.swift +// Loop +// +// Created by Pete Schwamb on 9/22/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopCore + +struct PresetsView: View { + @Environment(LoopDataManager.self) var loopManager + + @State private var path = NavigationPath() + + enum DisplayState: Equatable { + case presetsList + case activeOverride(TemporaryScheduleOverride) + } + + var displayState: DisplayState { + if let override = loopManager.watchInfo.scheduleOverride { + return .activeOverride(override) + } else { + return .presetsList + } + } + + var body: some View { + ZStack { + switch displayState { + case .activeOverride(let override): + PresetConfirmationView(preset: loopManager.selectablePresets.first {$0.id == override.presetId}) + case .presetsList: + NavigationStack(path: $path) { + PresetListView(presets: loopManager.selectablePresets, path: $path) + } + } + } + } +} diff --git a/WatchApp Extension/Views/WatchActionsView.swift b/WatchApp Extension/Views/WatchActionsView.swift new file mode 100644 index 0000000000..369389af6a --- /dev/null +++ b/WatchApp Extension/Views/WatchActionsView.swift @@ -0,0 +1,86 @@ +// +// WatchActionsView.swift +// Loop +// +// Created by Pete Schwamb on 8/15/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + + +import SwiftUI +import LoopKit +import LoopCore + +struct WatchActionsView: View { + @Environment(LoopDataManager.self) var loopManager + + @State private var isShowingPresets: Bool = false + @State private var overrideToShow: TemporaryScheduleOverride? + + var overrideActive: Bool { + return loopManager.watchInfo.scheduleOverride?.isActive() == true + } + + var body: some View { + ScrollView(.vertical) { + LoopHeader() + + HStack(spacing: 0) { + CircleTintedButton( + label: "Carbs", + image: Image("carbs"), + foregroundTint: .carbs, + backgroundTint: .darkCarbs + ) { + loopManager.bolusViewModel = CarbAndBolusFlowViewModel(configuration: .carbEntry(nil)) + } + CircleTintedButton( + label: "Bolus", + image: Image("bolus"), + foregroundTint: .insulin, + backgroundTint: .darkInsulin + ) { + loopManager.bolusViewModel = CarbAndBolusFlowViewModel(configuration: .manualBolus) + } + } + .padding(.bottom, 4) + HStack { + CircleTintedButton( + label: "Presets", + image: Image("presets"), + foregroundTint: overrideActive ? .darkPresets : .presets, + backgroundTint: overrideActive ? .presets : .darkPresets + ) { + if overrideActive { + overrideToShow = loopManager.watchInfo.scheduleOverride + } else { + isShowingPresets = true + } + } + Spacer() + .frame(maxWidth: .infinity) + } + } + .font(.system(size: 14, weight: .light)) + .toolbar(.hidden, for: .navigationBar) + .sheet(isPresented: $isShowingPresets) { + PresetsView() + } + .sheet(isPresented: Binding(get: { + overrideToShow != nil + }, set: { + if !$0 { overrideToShow = nil } + })) { + let preset = loopManager.selectablePresets.first(where: { $0.id == overrideToShow!.presetId }) + PresetConfirmationView(preset: preset) + } + .sheet(isPresented:Binding( + get: { loopManager.bolusViewModel != nil }, + set: { if !$0 { loopManager.bolusViewModel = nil } } + )) { + CarbAndBolusFlow(viewModel: loopManager.bolusViewModel!) + } + .environment(\.glucoseDisplayUnit, loopManager.displayGlucoseUnit) + } + +} diff --git a/WatchApp Extension/en.lproj/ckcomplication.strings b/WatchApp Extension/en.lproj/ckcomplication.strings new file mode 100644 index 0000000000..0aa78a1ee4 --- /dev/null +++ b/WatchApp Extension/en.lproj/ckcomplication.strings @@ -0,0 +1,19 @@ +/* + ckcomplication.strings + Loop + + Created by Nate Racklyeft on 9/18/16. + Copyright © 2016 Nathan Racklyeft. All rights reserved. +*/ + +/* The complication template example glucose and trend string */ +"120↘︎" = "120↘︎"; + +/* The complication template example glucose string */ +"120" = "120"; + +/* The complication template example time string */ +"3MIN" = "3MIN"; + +/* Utilitarian large flat format string (1: Glucose & Trend symbol) (2: Eventual Glucose) (3: Time) */ +"UtilitarianLargeFlat" = "%@%@ %@"; diff --git a/WatchApp/Base.lproj/Interface.storyboard b/WatchApp/Base.lproj/Interface.storyboard deleted file mode 100644 index 03cdd19a1b..0000000000 --- a/WatchApp/Base.lproj/Interface.storyboard +++ /dev/null @@ -1,445 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - - - - - - -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - - - - -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - -
-
- -
- - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
diff --git a/WatchApp/ContentView.swift b/WatchApp/ContentView.swift new file mode 100644 index 0000000000..0600410a77 --- /dev/null +++ b/WatchApp/ContentView.swift @@ -0,0 +1,54 @@ +// +// WatchAppContent.swift +// Loop +// +// Created by Pete Schwamb on 9/21/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopKit +import SwiftUI + +struct ContentView: View { + @Environment(LoopDataManager.self) var loopManager + + @State private var presetToConfirm: SelectablePreset? = nil + @State private var selectedPage = UserDefaults.standard.startOnChartPage ? 1 : 0 + + var body: some View { + VStack { + // TabView for swipeable pages + TabView(selection: $selectedPage) { + WatchActionsView() + .tag(0) + .task { + loopManager.requestContextUpdate {} + } + + ChartPageView() + .tag(1) + .task { + loopManager.requestContextUpdate {} + } + } + .tabViewStyle(.page) + .indexViewStyle(.page(backgroundDisplayMode: .automatic)) + } + .onChange(of: loopManager.pendingPresetReminder) { oldValue, newValue in + if oldValue == nil, newValue != nil { + presetToConfirm = loopManager.pendingPreset + } + } + .sheet(item: $presetToConfirm) { preset in + PresetConfirmationView(preset: preset) + } + .onChange(of: selectedPage, { oldValue, newValue in + UserDefaults.standard.startOnChartPage = selectedPage == 1 + }) + } +} + + +#Preview { + ContentView() +} diff --git a/WatchApp/DerivedAssets.xcassets/Contents.json b/WatchApp/DerivedAssets.xcassets/Contents.json index da4a164c91..73c00596a7 100644 --- a/WatchApp/DerivedAssets.xcassets/Contents.json +++ b/WatchApp/DerivedAssets.xcassets/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/WatchApp/DerivedAssetsBase.xcassets/accent.colorset/Contents.json b/WatchApp/DerivedAssetsBase.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from WatchApp/DerivedAssetsBase.xcassets/accent.colorset/Contents.json rename to WatchApp/DerivedAssetsBase.xcassets/AccentColor.colorset/Contents.json diff --git a/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/Contents.json b/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/Contents.json index 81ddce690d..1043a80496 100644 --- a/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/Contents.json +++ b/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/Contents.json @@ -1,92 +1,140 @@ { "images" : [ { - "size" : "24x24", - "idiom" : "watch", "filename" : "Icon-AppleWatch-24x24@2x.png", - "scale" : "2x", + "idiom" : "watch", "role" : "notificationCenter", + "scale" : "2x", + "size" : "24x24", "subtype" : "38mm" }, { - "size" : "27.5x27.5", - "idiom" : "watch", "filename" : "Icon-AppleWatch-27.5x27.5@2x.png", - "scale" : "2x", + "idiom" : "watch", "role" : "notificationCenter", + "scale" : "2x", + "size" : "27.5x27.5", "subtype" : "42mm" }, { - "size" : "29x29", - "idiom" : "watch", "filename" : "Icon-AppleWatch-Companion-29x29@2x.png", + "idiom" : "watch", "role" : "companionSettings", - "scale" : "2x" + "scale" : "2x", + "size" : "29x29" }, { - "size" : "29x29", - "idiom" : "watch", "filename" : "Icon-AppleWatch-Companion-29x29@3x.png", + "idiom" : "watch", "role" : "companionSettings", - "scale" : "3x" + "scale" : "3x", + "size" : "29x29" }, { - "size" : "40x40", + "filename" : "icon-watchos-33x33@2x.png", "idiom" : "watch", - "filename" : "Icon-AppleWatch-40x40@2x.png", + "role" : "notificationCenter", "scale" : "2x", + "size" : "33x33", + "subtype" : "45mm" + }, + { + "filename" : "Icon-AppleWatch-40x40@2x.png", + "idiom" : "watch", "role" : "appLauncher", + "scale" : "2x", + "size" : "40x40", "subtype" : "38mm" }, { - "size" : "44x44", - "idiom" : "watch", "filename" : "Icon-AppleWatch-44x44@2x.png", - "scale" : "2x", + "idiom" : "watch", "role" : "appLauncher", + "scale" : "2x", + "size" : "44x44", "subtype" : "40mm" }, { - "size" : "50x50", + "filename" : "icon-watchos-46x46@2x.png", "idiom" : "watch", - "filename" : "Icon-AppleWatch-100x100@2x.png", + "role" : "appLauncher", "scale" : "2x", + "size" : "46x46", + "subtype" : "41mm" + }, + { + "filename" : "Icon-AppleWatch-100x100@2x.png", + "idiom" : "watch", "role" : "appLauncher", + "scale" : "2x", + "size" : "50x50", "subtype" : "44mm" }, { - "size" : "86x86", + "filename" : "icon-watchos-51x51@2x.png", "idiom" : "watch", - "filename" : "Icon-AppleWatch-86x86@2x.png", + "role" : "appLauncher", + "scale" : "2x", + "size" : "51x51", + "subtype" : "45mm" + }, + { + "filename" : "icon-watchos-54x54@2x.png", + "idiom" : "watch", + "role" : "appLauncher", "scale" : "2x", + "size" : "54x54", + "subtype" : "49mm" + }, + { + "filename" : "Icon-AppleWatch-86x86@2x.png", + "idiom" : "watch", "role" : "quickLook", + "scale" : "2x", + "size" : "86x86", "subtype" : "38mm" }, { - "size" : "98x98", - "idiom" : "watch", "filename" : "Icon-AppleWatch-98x98@2x.png", - "scale" : "2x", + "idiom" : "watch", "role" : "quickLook", + "scale" : "2x", + "size" : "98x98", "subtype" : "42mm" }, { + "filename" : "Icon-AppleWatch-216x216@2x.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", "size" : "108x108", + "subtype" : "44mm" + }, + { + "filename" : "icon-watchos-117x117@2x.png", "idiom" : "watch", - "filename" : "Icon-AppleWatch-216x216@2x.png", + "role" : "quickLook", "scale" : "2x", + "size" : "117x117", + "subtype" : "45mm" + }, + { + "filename" : "icon-watchos-129x129@2x.png", + "idiom" : "watch", "role" : "quickLook", - "subtype" : "44mm" + "scale" : "2x", + "size" : "129x129", + "subtype" : "49mm" }, { - "size" : "1024x1024", - "idiom" : "watch-marketing", "filename" : "Icon-App-Store-1024.png", - "scale" : "1x" + "idiom" : "watch-marketing", + "scale" : "1x", + "size" : "1024x1024" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-117x117@2x.png b/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-117x117@2x.png new file mode 100644 index 0000000000..3d7e70c949 Binary files /dev/null and b/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-117x117@2x.png differ diff --git a/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-129x129@2x.png b/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-129x129@2x.png new file mode 100644 index 0000000000..e9a4e6db97 Binary files /dev/null and b/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-129x129@2x.png differ diff --git a/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-33x33@2x.png b/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-33x33@2x.png new file mode 100644 index 0000000000..193351df84 Binary files /dev/null and b/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-33x33@2x.png differ diff --git a/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-46x46@2x.png b/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-46x46@2x.png new file mode 100644 index 0000000000..f6a2d35b67 Binary files /dev/null and b/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-46x46@2x.png differ diff --git a/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-51x51@2x.png b/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-51x51@2x.png new file mode 100644 index 0000000000..032b0f6da8 Binary files /dev/null and b/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-51x51@2x.png differ diff --git a/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-54x54@2x.png b/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-54x54@2x.png new file mode 100644 index 0000000000..5d70b71603 Binary files /dev/null and b/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-54x54@2x.png differ diff --git a/WatchApp/DerivedAssetsBase.xcassets/Contents.json b/WatchApp/DerivedAssetsBase.xcassets/Contents.json index da4a164c91..73c00596a7 100644 --- a/WatchApp/DerivedAssetsBase.xcassets/Contents.json +++ b/WatchApp/DerivedAssetsBase.xcassets/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/WatchApp/Info.plist b/WatchApp/Info.plist index 642853115d..3e7b8187eb 100644 --- a/WatchApp/Info.plist +++ b/WatchApp/Info.plist @@ -22,6 +22,12 @@ ???? CFBundleVersion $(CURRENT_PROJECT_VERSION) + CLKComplicationPrincipalClass + $(PRODUCT_MODULE_NAME).ComplicationController + NSHealthShareUsageDescription + Meal data from the Health database is used to determine glucose effects. Glucose data from the Health database is used for graphing and momentum calculation. Sleep data from the Health database is used to optimize delivery of Apple Watch complication updates during the time you are awake. + NSHealthUpdateUsageDescription + Carbohydrate meal data entered in the app and on the watch is stored in the Health database. Glucose data retrieved from the CGM is stored securely in HealthKit. NSUserActivityTypes com.loopkit.Loop.AddCarbEntryOnWatch @@ -31,13 +37,15 @@ UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown + WKApplication + WKCompanionAppBundleIdentifier $(MAIN_APP_BUNDLE_IDENTIFIER) + CFBundleIconName + AppIcon WKSupportsLiveActivityLaunchAttributeTypes GlucoseActivityAttributes - WKWatchKitApp - diff --git a/WatchApp/InfoPlist.xcstrings b/WatchApp/InfoPlist.xcstrings index 00899fdc66..13e0ce32d7 100644 --- a/WatchApp/InfoPlist.xcstrings +++ b/WatchApp/InfoPlist.xcstrings @@ -250,6 +250,30 @@ } } } + }, + "NSHealthShareUsageDescription" : { + "comment" : "Privacy - Health Share Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Meal data from the Health database is used to determine glucose effects. Glucose data from the Health database is used for graphing and momentum calculation. Sleep data from the Health database is used to optimize delivery of Apple Watch complication updates during the time you are awake." + } + } + } + }, + "NSHealthUpdateUsageDescription" : { + "comment" : "Privacy - Health Update Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Carbohydrate meal data entered in the app and on the watch is stored in the Health database. Glucose data retrieved from the CGM is stored securely in HealthKit." + } + } + } } }, "version" : "1.0" diff --git a/WatchApp/LoopWatchApp.swift b/WatchApp/LoopWatchApp.swift new file mode 100644 index 0000000000..cf5ad7d80f --- /dev/null +++ b/WatchApp/LoopWatchApp.swift @@ -0,0 +1,26 @@ +// +// LoopWatchApp.swift +// Loop +// +// Created by Pete Schwamb on 9/21/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// +import SwiftUI + +@main +struct LoopWatchApp: App { + @WKApplicationDelegateAdaptor(ExtensionDelegate.self) private var appDelegate + + var loopManager = LoopDataManager.shared + + var body: some Scene { + WindowGroup { + if loopManager.activeContext?.isOnboardingCompleted != true { + CompleteOnboardingView() + } else { + ContentView() + .environment(loopManager) + } + } + } +}